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:
@@ -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:
|
||||
|
||||
@@ -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": "**FOB(Free On Board,离岸价)**\n"
|
||||
"卖方负责将货物运至装运港并装上船,风险在装运港越过船舷时转移给买方。\n\n"
|
||||
"**CIF(Cost, 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
|
||||
@@ -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"]
|
||||
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user