Files
trade-assistant/backend/app/api/v1/ai_assistant.py
T
TradeMate Dev f8a23855d2 feat: AI assistant phase 2 - configurable prompt, action operations, FAQ matching, NVIDIA provider
- Admin-configurable AI prompt/quick questions from system_configs DB
- GET /api/v1/ai/quick-questions endpoint for fetching quick questions
- Local FAQ matching for instant responses (avoid AI calls for common Qs)
- AI action extraction: "add customer" intent detected, structured data returned
- Frontend action confirmation card with editable fields, calls customer API on confirm
- NVIDIA provider (stepfun-ai/step-3.5-flash) for faster chat vs deepseek-v4-flash
- Fixed httpx client timeout preventing backend hangs
- Added log_usage calls for auth events (register/login/guest/wechat)
- Admin tabs (users/stats/logs/config) fully functional with real backend
- AiAssistant component added to all tabbar pages
2026-05-20 09:39:22 +08:00

136 lines
4.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.ai.router import get_ai_router
from app.ai.local_faq import match_faq
from app.api.v1.deps import get_current_user_id
from app.models.system_config import SystemConfig
from app.services.admin import AdminService
import logging
import time
import re
import json
logger = logging.getLogger(__name__)
router = APIRouter()
ACTION_INSTRUCTIONS = """
当用户想要执行操作时(如添加客户、创建产品、生成报价单等),请执行以下步骤:
1. 从用户消息中提取所有必要的信息
2. 在回复末尾附上 JSON 格式的动作块,格式如下:
```actions
[{"type": "create_customer", "label": "添加客户", "fields": {"name": "...", "phone": "...", "email": "...", "company": "...", "country": "...", "notes": "..."}}]
```
支持的 action type
- create_customer:添加客户,fields 支持 name(必填), phone, email, company, country, notes
- create_product:添加产品(开发中)
如果用户没有提供足够信息,请先询问缺少的字段,不要生成 action。
如果用户明确表示要执行操作但缺少信息,生成 action 但标注缺失的字段。
"""
class ChatRequest(BaseModel):
message: str
history: Optional[List[Dict[str, str]]] = None
@router.get("/quick-questions")
async def get_quick_questions(
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(SystemConfig).where(SystemConfig.key == "ai_assistant_quick_questions")
)
cfg = result.scalar_one_or_none()
return cfg.value if cfg and cfg.value else []
@router.post("/chat")
async def chat(
data: ChatRequest,
request: Request,
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
t_start = time.time()
if not data.message.strip():
raise HTTPException(status_code=422, detail="Message is required")
t0 = time.time()
prompt_config = await db.execute(
select(SystemConfig).where(SystemConfig.key == "ai_assistant_prompt")
)
quick_config = await db.execute(
select(SystemConfig).where(SystemConfig.key == "ai_assistant_quick_questions")
)
t1 = time.time()
prompt_row = prompt_config.scalar_one_or_none()
base_prompt = prompt_row.value if prompt_row else None
quick_row = quick_config.scalar_one_or_none()
quick_questions = quick_row.value if quick_row else None
t2 = time.time()
action_keywords = ["帮我添加", "帮我创建", "帮我新增", "帮我新建", "帮我录入", "帮.+加", "添加客户.*电话", "创建客户.*电话"]
needs_action = any(re.search(k, data.message) for k in action_keywords)
system_prompt = base_prompt or ""
if needs_action:
system_prompt += "\n\n" + ACTION_INSTRUCTIONS
faq_answer = match_faq(data.message)
if faq_answer and not needs_action:
reply = faq_answer
provider = "local-faq"
actions = []
t4 = time.time()
logger.info(
f"CHAT [{user_id[:8]}] FAQ match | "
f"db={t1-t0:.2f}s orm={t2-t1:.2f}s total={t4-t_start:.2f}s"
)
else:
t3 = time.time()
ai = get_ai_router()
result = await ai.chat(data.message, data.history or [], system_prompt)
t4 = time.time()
reply = result.get("reply", "")
provider = result.get("provider_used", "")
actions = []
action_match = re.search(r'```actions\s*\n(.*?)\n```', reply, re.DOTALL)
if action_match:
try:
actions = json.loads(action_match.group(1))
reply = reply[:action_match.start()].strip()
except (json.JSONDecodeError, Exception) as e:
logger.warning(f"Failed to parse actions: {e}")
logger.info(
f"CHAT [{user_id[:8]}] AI | "
f"db={t1-t0:.2f}s orm={t2-t1:.2f}s setup={t3-t2:.2f}s ai={t4-t3:.2f}s total={t4-t_start:.2f}s"
)
client_ip = request.client.host if request.client else None
await AdminService(db).log_usage(
user_id, "ai.chat",
{"message": data.message[:200], "reply_length": len(reply), "provider": provider},
ip=client_ip,
)
logger.info(f"CHAT [{user_id[:8]}] done total={time.time()-t_start:.2f}s")
return {
"reply": reply,
"provider": provider,
"quick_questions": quick_questions,
"actions": actions,
}