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:
Binary file not shown.
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user