f17a6ccbac
- Make AI routing rules DB-driven (read from system_configs, removed from config.py) - Add translation quota tracking to LLM translation (OpenAIProvider) - Add Alibaba MT ECS RAM role support (STS token, no AccessKey needed) - Fix admin sidebar link for AI模型配置 page - Fix Quota.vue API path (quotas → translation-quotas) - Fix login auto-redirect to dashboard - Add provider dropdown selects to AI routing config UI - Clean up stale ai_provider_* system_configs records - Remove OpencodeGo, Spark providers (code + DB) - Update deploy config: nginx port 8000, systemd cwd
147 lines
5.8 KiB
Python
147 lines
5.8 KiB
Python
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, website, notes
|
|
- create_product:添加产品,fields 支持 name(必填), name_en, description, description_en, category, price, price_unit(默认USD), moq, keywords(逗号分隔)
|
|
- create_quotation:生成报价单,fields 支持 customer_name(必填), product_info(必填), quantity(必填), price, terms
|
|
- scan_followups:扫描待跟进客户,fields 不需要(空对象)
|
|
- send_followup:发送跟进消息,fields 支持 customer_name(必填), message(必填)
|
|
- generate_marketing:生成营销素材,fields 支持 product_name(必填), target_market, tone(如professional/casual), language
|
|
- discovery_search:搜索潜在客户,fields 支持 keywords(必填), country, industry
|
|
- navigate:跳转到指定页面,fields 支持 path(必填, 如 /customers /products /quotations /marketing /discovery /followup /translate /team /analytics)
|
|
- search_users:搜索用户,fields 支持 query(必填)
|
|
- update_user:修改用户信息,fields 支持 user_id(必填), username, phone, email, role, status
|
|
- update_config:更新系统配置,fields 支持 key(必填), value(必填)
|
|
- review_certification:审核认证,fields 支持 id(必填), action(approved/rejected), reason
|
|
- process_invoice:处理发票,fields 支持 id(必填), action(approve/reject)
|
|
|
|
如果用户没有提供足够信息,请先询问缺少的字段,不要生成 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,
|
|
}
|