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
BIN
View File
Binary file not shown.
+3
View File
@@ -31,6 +31,9 @@ class AIProvider(ABC):
) -> Dict[str, Any]:
pass
async def chat(self, message: str, history: list = None, system_prompt: str = None) -> Dict[str, Any]:
raise NotImplementedError
@property
@abstractmethod
def name(self) -> str:
+104
View File
@@ -0,0 +1,104 @@
from typing import Optional, List, Tuple
import re
FAQ = [
{
"keywords": ["有哪些功能", "能做什么", "有什么功能", "功能介绍", "可以做什么"],
"answer": "TradeMate(外贸小助手)主要有以下功能模块:\n\n"
"1. **翻译** — 多语言翻译,支持外贸专业术语\n"
"2. **客户管理** — 添加、编辑、分类客户,跟进记录\n"
"3. **产品管理** — 产品库管理,支持分类和搜索\n"
"4. **报价单** — 在线生成报价单,导出 PDF\n"
"5. **营销文案** — AI 生成营销文案和开发信\n"
"6. **WhatsApp 集成** — 发送消息、管理对话\n"
"7. **数据看板** — 销售漏斗、客户分析\n\n"
"你想了解哪个功能的详细用法?",
},
{
"keywords": ["添加客户", "新增客户", "创建客户", "怎么加客户", "客户录入"],
"answer": "添加客户很简单:\n\n"
"1. 点击底部导航栏 **「客户」** 进入客户列表\n"
"2. 点击右上角 **「+」** 按钮\n"
"3. 填写客户信息(名称、电话、邮箱、公司、国家等)\n"
"4. 点击 **保存** 即可\n\n"
"你也可以直接对我说「添加客户张三,电话13800138000」,我会帮你自动提取信息并创建客户。",
},
{
"keywords": ["生成报价单", "创建报价单", "怎么报价", "如何报价"],
"answer": "生成报价单的步骤:\n\n"
"1. 点击底部导航栏 **「报价」** 进入报价单列表\n"
"2. 点击 **「新建报价单」**\n"
"3. 选择客户(可从客户管理导入)\n"
"4. 添加产品(从产品库选择或手动输入)\n"
"5. 设置价格、数量、折扣等\n"
"6. 点击 **生成**,系统会自动生成报价单\n"
"7. 可以导出为 PDF 发送给客户",
},
{
"keywords": ["导出客户", "导出数据", "怎么导出", "下载客户"],
"answer": "导出客户数据的方法:\n\n"
"1. 进入 **「客户」** 页面\n"
"2. 点击右上角菜单,选择 **「导出」**\n"
"3. 支持 CSV 和 Excel 两种格式\n"
"4. 选择导出范围(全部/当前筛选)\n"
"5. 点击确认即可下载\n\n"
"导出的文件包含客户名称、电话、邮箱、公司、国家等字段。",
},
{
"keywords": ["营销文案", "营销", "开发信", "怎么写开发信", "营销文案生成"],
"answer": "生成营销文案的步骤:\n\n"
"1. 进入 **「营销」** 页面\n"
"2. 点击 **「新建营销文案」**\n"
"3. 选择产品和目标客户\n"
"4. 选择风格(专业/友好/促销等)和语言\n"
"5. AI 会自动生成多版文案供你选择\n"
"6. 你可以编辑修改后直接使用或保存",
},
{
"keywords": ["FOB", "CIF", "贸易术语", "EXW", "DDP"],
"answer": "**FOBFree On Board,离岸价)**\n"
"卖方负责将货物运至装运港并装上船,风险在装运港越过船舷时转移给买方。\n\n"
"**CIFCost, Insurance and Freight,到岸价)**\n"
"卖方承担运费和保险费,将货物运至目的港,风险在装运港越过船舷时转移。\n\n"
"主要区别:\n"
"- FOB:买方负责运费和保险\n"
"- CIF:卖方负责运费和保险\n\n"
"选择哪种取决于你和客户的谈判约定。",
},
{
"keywords": ["忘记密码", "重置密码", "修改密码"],
"answer": "目前 TradeMate 支持通过手机号验证码重置密码:\n\n"
"1. 在登录页面点击 **「忘记密码」**\n"
"2. 输入注册手机号\n"
"3. 点击 **获取验证码**\n"
"4. 输入验证码后设置新密码\n\n"
"如有问题请联系管理员。",
},
{
"keywords": ["怎么登录", "无法登录", "登录不了"],
"answer": "登录方式:\n\n"
"1. **手机号登录** — 输入手机号和密码\n"
"2. **微信登录** — 点击微信图标快速登录\n"
"3. **游客模式** — 无需注册即可体验部分功能\n\n"
"如果无法登录,请检查网络连接,或尝试重置密码。",
},
]
def match_faq(query: str) -> Optional[str]:
query_lower = query.lower().strip()
best_score = 0
best_answer = None
for item in FAQ:
score = 0
for kw in item["keywords"]:
if kw.lower() in query_lower:
score += 1
if score > best_score:
best_score = score
best_answer = item["answer"]
if best_score >= 1:
return best_answer
return None
+2 -1
View File
@@ -5,5 +5,6 @@ from .local import LocalProvider
from .spark import SparkProvider
from .sensenova import SensenovaProvider
from .opencode_go import OpencodeGoProvider
from .nvidia import NvidiaProvider
__all__ = ["OpenAIProvider", "ClaudeProvider", "DeepLProvider", "LocalProvider", "SparkProvider", "SensenovaProvider", "OpencodeGoProvider"]
__all__ = ["OpenAIProvider", "ClaudeProvider", "DeepLProvider", "LocalProvider", "SparkProvider", "SensenovaProvider", "OpencodeGoProvider", "NvidiaProvider"]
+50
View File
@@ -0,0 +1,50 @@
from typing import Dict, Any, Optional, List
from app.ai.providers.openai import OpenAIProvider, SYSTEM_PROMPTS
import logging
import time
import httpx
logger = logging.getLogger(__name__)
class NvidiaProvider(OpenAIProvider):
def __init__(self, api_key: str, model: str = "stepfun-ai/step-3.5-flash", base_url: str = "https://integrate.api.nvidia.com/v1"):
super().__init__(
api_key=api_key,
model=model,
base_url=base_url,
http_client=httpx.AsyncClient(timeout=httpx.Timeout(60.0)),
)
self._name = f"nvidia-{model}"
async def chat(self, message: str, history: list = None, system_prompt: str = None) -> Dict[str, Any]:
t0 = time.time()
system = system_prompt or SYSTEM_PROMPTS["chat"]
messages = [{"role": "system", "content": system}]
if history:
for h in history[-10:]:
messages.append(h)
messages.append({"role": "user", "content": message})
t1 = time.time()
kwargs = {
"model": self.model,
"messages": messages,
"max_tokens": 300,
"temperature": 0.3,
}
resp = await self.client.chat.completions.create(**kwargs)
t2 = time.time()
content = resp.choices[0].message.content or ""
if not content and hasattr(resp.choices[0].message, "reasoning"):
content = resp.choices[0].message.reasoning
t3 = time.time()
logger.info(
f"NVIDIA timing: build_msgs={t1-t0:.1f}s api_call={t2-t1:.1f}s process={t3-t2:.1f}s "
f"chars_in={sum(len(m.get('content','')) for m in messages)} chars_out={len(content)}"
)
return {"reply": content, "provider": self.name, "model": self.model}
+31 -1
View File
@@ -14,11 +14,21 @@ SYSTEM_PROMPTS = {
"marketing content that drives action. Adapt to the target audience's culture. "
"Return ONLY the copy, no explanations.",
"extract": "You extract structured data from text. Return ONLY valid JSON matching the requested schema.",
"chat": "你是 TradeMate(外贸小助手)的 AI 助手。你的职责是帮助外贸从业者解答关于本工具使用的问题,以及提供外贸业务建议。\n"
"你可以回答的问题包括:\n"
"- 功能介绍:翻译、客户管理、产品管理、报价单、营销文案、WhatsApp 集成等\n"
"- 使用帮助:如何添加客户、如何生成报价单、如何导出数据等\n"
"- 外贸知识:贸易术语(FOB、CIF 等)、谈判技巧、跟进策略等\n\n"
"回答要求:\n"
"- 简洁扼要,用中文回答\n"
"- 涉及操作步骤时用数字列表说明\n"
"- 不确定的问题不要编造,直接说需要查证\n"
"- 语气友好专业",
}
class OpenAIProvider(AIProvider):
def __init__(self, api_key: str, model: str = "gpt-4o", base_url: Optional[str] = None):
def __init__(self, api_key: str, model: str = "gpt-4o", base_url: Optional[str] = None, http_client=None):
try:
from openai import AsyncOpenAI
except ImportError:
@@ -29,6 +39,8 @@ class OpenAIProvider(AIProvider):
kwargs = {"api_key": api_key}
if base_url:
kwargs["base_url"] = base_url
if http_client:
kwargs["http_client"] = http_client
self.client = AsyncOpenAI(**kwargs)
self.model = model
self._name = f"openai-{model}"
@@ -92,6 +104,24 @@ class OpenAIProvider(AIProvider):
except json.JSONDecodeError:
return {"data": {}, "confidence": 0.0, "provider": self.name, "error": "parse_failed"}
async def chat(self, message: str, history: list = None, system_prompt: str = None) -> Dict[str, Any]:
system = system_prompt or SYSTEM_PROMPTS["chat"]
messages = [{"role": "system", "content": system}]
if history:
for h in history[-10:]:
messages.append(h)
messages.append({"role": "user", "content": message})
kwargs = {
"model": self._cheap_model,
"messages": messages,
"max_tokens": 2000,
"temperature": 0.7,
}
resp = await self.client.chat.completions.create(**kwargs)
content = resp.choices[0].message.content or ""
return {"reply": content, "provider": self.name, "model": self.model}
async def _call(self, system: str, prompt: str, max_tokens: int = 3000, response_format: Optional[Dict] = None, model: Optional[str] = None) -> str:
kwargs = {
"model": model or self.model,
+15 -1
View File
@@ -1,6 +1,6 @@
from typing import Dict, Any, Optional, List
from app.ai.base import AIProvider
from app.ai.providers import OpenAIProvider, ClaudeProvider, DeepLProvider, LocalProvider, SparkProvider, SensenovaProvider, OpencodeGoProvider
from app.ai.providers import OpenAIProvider, ClaudeProvider, DeepLProvider, LocalProvider, SparkProvider, SensenovaProvider, OpencodeGoProvider, NvidiaProvider
from app.config import settings
from app.ai.trade_corpus import TradeCorpus
import logging
@@ -45,6 +45,17 @@ class AIRouter:
except Exception as e:
logger.warning(f"OpencodeGo init failed: {e}")
if settings.NVIDIA_API_KEY:
try:
self.providers["nvidia"] = NvidiaProvider(
api_key=settings.NVIDIA_API_KEY,
model=settings.NVIDIA_MODEL,
base_url=settings.NVIDIA_BASE_URL,
)
logger.info("Nvidia provider ready")
except Exception as e:
logger.warning(f"Nvidia init failed: {e}")
if settings.ANTHROPIC_API_KEY:
try:
self.providers["anthropic"] = ClaudeProvider(api_key=settings.ANTHROPIC_API_KEY)
@@ -132,6 +143,9 @@ class AIRouter:
async def extract(self, text: str, schema: Dict[str, Any]) -> Dict[str, Any]:
return await self.execute("extract", "extract_info", text, schema)
async def chat(self, message: str, history: list = None, system_prompt: str = None) -> Dict[str, Any]:
return await self.execute("chat", "chat", message, history, system_prompt)
_router_instance = None
+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,
}
+6 -1
View File
@@ -45,7 +45,11 @@ class Settings(BaseSettings):
OPENCODE_GO_API_KEY: Optional[str] = None
OPENCODE_GO_BASE_URL: str = "https://opencode.ai/zen/go/v1"
OPENCODE_GO_MODEL: str = "deepseek-v4-flash"
OPENCODE_GO_MODEL: str = "minimax-m2.7"
NVIDIA_API_KEY: Optional[str] = None
NVIDIA_BASE_URL: str = "https://integrate.api.nvidia.com/v1"
NVIDIA_MODEL: str = "stepfun-ai/step-3.5-flash"
WHATSAPP_API_TOKEN: Optional[str] = None
WHATSAPP_PHONE_NUMBER_ID: Optional[str] = None
@@ -72,6 +76,7 @@ class Settings(BaseSettings):
"marketing": {"primary": "opencode_go", "fallback": ["sensenova", "openai", "local"]},
"extract": {"primary": "opencode_go", "fallback": ["sensenova", "openai"]},
"quotation": {"primary": "opencode_go", "fallback": ["sensenova", "openai"]},
"chat": {"primary": "nvidia", "fallback": ["opencode_go", "openai", "sensenova"]},
}
FREE_DAILY_TRANSLATE_CHARS: int = 5000
+2
View File
@@ -298,6 +298,8 @@ class AdminService:
SystemConfig(key="feature_registration", value={"enabled": True}, description="新用户注册开关"),
SystemConfig(key="free_daily_limits", value={"translate_chars": 5000, "replies": 20, "marketing": 5, "customers": 5, "products": 1, "quotations": 3}, description="免费版每日配额"),
SystemConfig(key="pro_daily_limits", value={"translate_chars": 50000, "replies": 200, "marketing": 50, "customers": 100, "products": 20, "quotations": 30}, description="Pro 版每日配额"),
SystemConfig(key="ai_assistant_prompt", value="你是 TradeMate(外贸小助手)的 AI 助手。你的职责是帮助外贸从业者解答关于本工具使用的问题,以及提供外贸业务建议。\n你可以回答的问题包括:\n- 功能介绍:翻译、客户管理、产品管理、报价单、营销文案、WhatsApp 集成等\n- 使用帮助:如何添加客户、如何生成报价单、如何导出数据等\n- 外贸知识:贸易术语(FOB、CIF 等)、谈判技巧、跟进策略等\n\n回答要求:\n- 简洁扼要,用中文回答\n- 涉及操作步骤时用数字列表说明\n- 不确定的问题不要编造,直接说需要查证\n- 语气友好专业", description="AI 助手系统提示词"),
SystemConfig(key="ai_assistant_quick_questions", value=["TradeMate 有哪些功能?", "如何添加客户?", "如何生成报价单?", "怎么导出客户数据?", "营销文案怎么生成?", "什么是 FOB、CIF"], description="AI 助手快捷提问列表"),
]
for cfg in defaults:
self.db.add(cfg)