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
This commit is contained in:
TradeMate Dev
2026-05-20 09:39:22 +08:00
parent 4755cc75ba
commit f8a23855d2
20 changed files with 744 additions and 5 deletions
+135
View File
@@ -0,0 +1,135 @@
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,
}