Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: FastAPI CI (uv + make)

on:
push:
branches: [main]
pull_request:
branches: [main]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
ci:
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/ai
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11' # 可改成 3.12

- name: Install uv
run: pipx install uv

- name: Cache uv and venv
uses: actions/cache@v4
with:
path: |
~/.cache/uv
apps/ai/.venv
key: uv-${{ runner.os }}-${{ hashFiles('apps/ai/pyproject.toml', 'apps/ai/uv.lock') }}

- name: Sync deps (with dev extras)
run: uv sync --extra dev --frozen || uv sync --extra dev

- name: Run all checks
run: make check-all
37 changes: 31 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,31 @@
.PHONY: lint lint-fix format test typecheck check-all
# ---- Config ----
SHELL := /bin/bash
.ONESHELL:
.DEFAULT_GOAL := help

CODE = .
# 可在 CI 里覆盖:make test PYTHON=3.12
PYTHON ?= python3
CODE ?= .
TEST_DIR ?= tests # 如果你确实放在 app/test,就写 TEST_DIR=app/test
MYPY_TARGET ?= $(CODE)/app
PYTEST_OPTS ?= -q --maxfail=1 --disable-warnings
COV ?= 1 # 设为 0 关闭覆盖率
COV_OPTS := $(if $(filter $(COV),1),--cov=$(CODE) --cov-report=xml,)

# ---- Phonies ----
.PHONY: help sync lint lint-fix format test typecheck check-all

help:
@echo "make sync 安装/同步依赖(uv sync)"
@echo "make lint Ruff 代码检查"
@echo "make lint-fix Ruff 自动修复"
@echo "make format Ruff 格式化"
@echo "make typecheck MyPy 类型检查"
@echo "make test Pytest 测试(含覆盖率,CI 默认开启)"
@echo "make check-all lint + typecheck + test"

sync:
uv sync --frozen || uv sync # 有 uv.lock 就严格锁定;没有则创建

lint:
uv run ruff check $(CODE)
Expand All @@ -11,11 +36,11 @@ lint-fix:
format:
uv run ruff format $(CODE)

test:
uv run pytest $(CODE)/app/test/

typecheck:
uv run mypy $(CODE)/app
uv run mypy $(MYPY_TARGET)

test:
uv run pytest $(TEST_DIR) $(PYTEST_OPTS) $(COV_OPTS)

check-all: lint typecheck test
@echo "✅ All checks passed!"
Empty file added app/__init__.py
Empty file.
5 changes: 3 additions & 2 deletions app/api/call.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from fastapi import APIRouter, HTTPException
from typing import Any, Dict
from pydantic import BaseModel, Field, ValidationError
from models.call import Message, CallSkeleton
from services.redis_service import get_call_skeleton
Expand Down Expand Up @@ -133,7 +134,7 @@ async def ai_conversation(data: ConversationInput):
if updated_state["last_llm_response"]
else "Sorry, system is busy, please try again later."
)

# Apply service placeholder replacement to ensure voice responses have correct service names
print(f"🔍 [API_ENDPOINT] Pre-replacement response: '{ai_message}'")
ai_message = cs_agent._replace_service_placeholders(ai_message, updated_state)
Expand All @@ -148,7 +149,7 @@ async def ai_conversation(data: ConversationInput):
should_hangup = updated_state.get("conversation_complete", False)

# 7. Return AI response with hangup signal if conversation is complete
response_data = {"aiResponse": ai_response}
response_data: Dict[str, Any] = {"aiResponse": ai_response}

if should_hangup:
response_data["shouldHangup"] = True
Expand Down
Empty file added app/client/__init__.py
Empty file.
12 changes: 5 additions & 7 deletions app/infrastructure/redis_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,20 @@


@lru_cache
def get_redis() -> Redis:
def get_redis() -> Redis[str]:
if settings.redis_url:
return Redis.from_url(
settings.redis_url,
decode_responses=True,
socket_timeout=settings.redis_socket_timeout,
health_check_interval=30,
retry_on_error=[ConnectionError],
socket_timeout=float(settings.redis_socket_timeout),
health_check_interval=30.0,
)

return Redis(
host=settings.redis_host,
port=settings.redis_port,
db=settings.redis_db,
decode_responses=True,
socket_timeout=settings.redis_socket_timeout,
health_check_interval=30,
retry_on_error=[ConnectionError],
socket_timeout=float(settings.redis_socket_timeout),
health_check_interval=30.0,
)
3 changes: 1 addition & 2 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,9 @@ async def root():
"send_email_with_google_calendar",
"send_email_with_outlook_calendar",
],

)

mcp.mount_sse(app,mount_path=f"{settings.api_prefix}/mcp")
mcp.mount_sse(app, mount_path=f"{settings.api_prefix}/mcp")

if __name__ == "__main__":
import uvicorn
Expand Down
83 changes: 49 additions & 34 deletions app/services/call_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,30 +170,44 @@ def _replace_service_placeholders(

if extracted_service:
extracted_lower = extracted_service.lower()
print(f"🔍 [PLACEHOLDER_REPLACEMENT] Looking for extracted service: '{extracted_service}'")

print(
f"🔍 [PLACEHOLDER_REPLACEMENT] Looking for extracted service: '{extracted_service}'"
)

# First try exact match
for service in available_services:
if service["name"].lower() == extracted_lower:
selected_service = service
print(f"✅ [PLACEHOLDER_REPLACEMENT] Exact match found: {service['name']}")
print(
f"✅ [PLACEHOLDER_REPLACEMENT] Exact match found: {service['name']}"
)
break

# If no exact match, try partial matching
if not selected_service:
for service in available_services:
service_name_lower = service["name"].lower()
# Check if extracted service contains service name or vice versa
if (extracted_lower in service_name_lower or
service_name_lower in extracted_lower or
if (
extracted_lower in service_name_lower
or service_name_lower in extracted_lower
or
# Also check word-level matching
any(word in service_name_lower for word in extracted_lower.split())):
any(
word in service_name_lower
for word in extracted_lower.split()
)
):
selected_service = service
print(f"✅ [PLACEHOLDER_REPLACEMENT] Partial match found: {service['name']} for '{extracted_service}'")
print(
f"✅ [PLACEHOLDER_REPLACEMENT] Partial match found: {service['name']} for '{extracted_service}'"
)
break

if not selected_service:
print(f"⚠️ [PLACEHOLDER_REPLACEMENT] No match found for service: '{extracted_service}'")
print(
f"⚠️ [PLACEHOLDER_REPLACEMENT] No match found for service: '{extracted_service}'"
)

if selected_service:
# Replace 2-brace patterns
Expand All @@ -204,7 +218,7 @@ def _replace_service_placeholders(
response_text = response_text.replace(
"{{{{selected_service_name}}}}", selected_service["name"]
)

price_text = (
f"{selected_service['price']}"
if selected_service.get("price")
Expand Down Expand Up @@ -260,13 +274,14 @@ def _generate_closing_message(
f"on {service_time}. Your booking is confirmed and we will send you a confirmation shortly. "
f"Thank you for choosing our service today. Have a great day and goodbye!"
)

def __init__(self, api_key=None):
"""Initialize customer service system"""
if api_key:
self.client = OpenAI(api_key=api_key)
else:
self.client = OpenAI(api_key=settings.openai_api_key)

# Initialize speech corrector
self.speech_corrector = SimplifiedSpeechCorrector(api_key=api_key)

Expand Down Expand Up @@ -502,7 +517,7 @@ async def process_address_collection(
if existing_address and all(existing_components.values()):
state["address_complete"] = True
state["current_step"] = "collect_service"

# Create natural transition message thanking user and introducing services
available_services = state.get("available_services", [])
services_list = ""
Expand All @@ -513,17 +528,17 @@ async def process_address_collection(
else "Price on request"
)
services_list += f"{i}. {service['name']} for {price_text}. "

transition_message = f"Thank you for providing your information! Now, here are our available services: {services_list.strip()}. Which service would you like to book today?"

# Update the response to include the transition message
state["last_llm_response"] = {
"response": transition_message,
"info_extracted": {"confirmed": True},
"info_complete": True,
"analysis": "Address confirmed, transitioning to service selection"
"analysis": "Address confirmed, transitioning to service selection",
}

print(f"✅ Address confirmed and completed: {existing_address}")
print("🔄 Created transition message to service selection")
return state
Expand Down Expand Up @@ -849,7 +864,7 @@ def _complete_booking_failed(
"response": closing_message,
"info_extracted": {},
"info_complete": True,
"analysis": "Booking failed, time collection unsuccessful"
"analysis": "Booking failed, time collection unsuccessful",
}
print("⚠️ Partial booking completed, time collection failed")

Expand Down Expand Up @@ -1023,7 +1038,7 @@ def save_to_file(self, state: CustomerServiceState, filename: Optional[str] = No
}

try:
with open(filename, 'w') as f:
with open(filename, "w") as f:
json.dump(save_data, f, indent=2, ensure_ascii=False)
print(f"💾 Conversation saved to: {filename}")
return filename
Expand Down Expand Up @@ -1072,7 +1087,7 @@ async def start_conversation(
"service_available": True,
"time_available": True,
}

print(f"🤖 {initial_message}")
return state

Expand All @@ -1094,6 +1109,7 @@ def _extract_first_json_blob(text: str) -> Optional[dict]:
# Try to find the first {...} JSON object in the text and parse it
try:
import re as _re

match = _re.search(r"\{[\s\S]*\}", text)
if not match:
return None
Expand All @@ -1102,8 +1118,6 @@ def _extract_first_json_blob(text: str) -> Optional[dict]:
return None




# Removed print_latest_assistant - no longer needed with simplified design


Expand All @@ -1118,13 +1132,15 @@ async def main() -> None:

# Initialize CustomerServiceState for standalone testing
state: CustomerServiceState = create_default_customer_service_state()
state.update({
"available_services": [
{"id": "cleaning", "name": "房屋清洁", "price": 100.0},
{"id": "repair", "name": "维修服务", "price": 200.0},
{"id": "garden", "name": "园艺服务", "price": 150.0}
]
})
state.update(
{
"available_services": [
{"id": "cleaning", "name": "房屋清洁", "price": 100.0},
{"id": "repair", "name": "维修服务", "price": 200.0},
{"id": "garden", "name": "园艺服务", "price": 150.0},
]
}
)

print("🤖 AI Customer Service Assistant Started (LangGraph + Redis Integration)")
print("💡 Type 'quit' or 'exit' to exit conversation")
Expand All @@ -1133,7 +1149,7 @@ async def main() -> None:
# Initial greeting
state["last_user_input"] = "" # Trigger initial greeting
state = await cs_agent.process_customer_workflow(state, call_sid=None)

if state.get("last_llm_response"):
print(f"🤖 AI: {state['last_llm_response']['response']}")

Expand All @@ -1142,7 +1158,7 @@ async def main() -> None:
try:
# Get user input
user_input = input("\n👤 You: ").strip()

# Check for exit commands
if user_input.lower() in ["quit", "exit"]:
print("👋 Thank you for using AI customer service assistant, goodbye!")
Expand All @@ -1154,7 +1170,7 @@ async def main() -> None:
# Set user input and process
state["last_user_input"] = user_input
state = await cs_agent.process_customer_workflow(state, call_sid=None)

# Display AI response
if state.get("last_llm_response"):
ai_response = state["last_llm_response"]["response"]
Expand All @@ -1181,6 +1197,5 @@ async def main() -> None:

if __name__ == "__main__":
import asyncio
asyncio.run(main())


asyncio.run(main())
Loading
Loading