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:
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user