docs: update project docs and clean up redundant files
- PROGRESS.md: update to 2026-05-29 with security hardening (T-005), 4-frontend architecture, AI provider refactoring, discovery features, landing page/referral/quota, desktop layout, admin AI management - AGENTS.md: add AI provider list (Alibaba/NVIDIA, removed Claude/DeepL/Local), DB-driven config, CSRF/rate-limit/CORS notes, admin_ai reload quirk - .env.example: sync with actual config, replace deprecated providers with current Sensenova/OpencodeGo/NVIDIA/Spark/Alibaba - docs/PROJECT_STATUS.md: archive (fully superseded by PROGRESS.md) - Remove generated JS files (_bing_search.js, _batch_search.js) - Remove empty directories (data/corpus, data/models) - Remove backend/.coverage (test artifact) - Fix services/.gitignore to cover _bing_search.js - Include pending AI provider DB admin feature (admin_ai, AIProvider model, AIProviders.vue, migration) and T-008 test report
This commit is contained in:
@@ -1,11 +1,8 @@
|
||||
from .openai import OpenAIProvider
|
||||
from .claude import ClaudeProvider
|
||||
from .deepl import DeepLProvider
|
||||
from .local import LocalProvider
|
||||
from .spark import SparkProvider
|
||||
from .sensenova import SensenovaProvider
|
||||
from .opencode_go import OpencodeGoProvider
|
||||
from .nvidia import NvidiaProvider
|
||||
from .alibaba import AlibabaMTProvider
|
||||
|
||||
__all__ = ["OpenAIProvider", "ClaudeProvider", "DeepLProvider", "LocalProvider", "SparkProvider", "SensenovaProvider", "OpencodeGoProvider", "NvidiaProvider", "AlibabaMTProvider"]
|
||||
__all__ = ["OpenAIProvider", "SparkProvider", "SensenovaProvider", "OpencodeGoProvider", "NvidiaProvider", "AlibabaMTProvider"]
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
from typing import Dict, Any, Optional
|
||||
import json
|
||||
from app.ai.base import AIProvider
|
||||
|
||||
|
||||
SYSTEM_PROMPTS = {
|
||||
"marketing": "You are a world-class copywriter for international trade. Write persuasive, "
|
||||
"culturally-adapted marketing content that converts. You excel at storytelling "
|
||||
"and emotional appeal in business contexts.",
|
||||
"reply": "You are a senior international sales representative with 20 years of experience. "
|
||||
"Your replies are warm, professional, and strategically move the conversation "
|
||||
"toward closing the deal.",
|
||||
"translate": "You are a professional translator specializing in trade documents. "
|
||||
"Preserve all numbers, terms, and formatting. Translate meaning, not words.",
|
||||
"extract": "Extract structured data from text. Return ONLY valid JSON.",
|
||||
}
|
||||
|
||||
|
||||
class ClaudeProvider(AIProvider):
|
||||
def __init__(self, api_key: str, model: str = "claude-sonnet-4-20250514"):
|
||||
try:
|
||||
from anthropic import AsyncAnthropic
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"anthropic SDK is required for ClaudeProvider. "
|
||||
"Install it with: pip install anthropic"
|
||||
)
|
||||
self.client = AsyncAnthropic(api_key=api_key)
|
||||
self.model = model
|
||||
self._name = f"claude-sonnet"
|
||||
self._pricing = {"input": 0.003, "output": 0.015}
|
||||
|
||||
async def translate(self, text: str, source_lang: Optional[str], target_lang: str, context: Optional[str] = None) -> Dict[str, Any]:
|
||||
system = SYSTEM_PROMPTS["translate"]
|
||||
if context:
|
||||
system += f"\nContext: {context}"
|
||||
prompt = f"Translate to {target_lang}:\n\n{text}"
|
||||
content = await self._call(system, prompt)
|
||||
return {"translated_text": content, "provider": self.name}
|
||||
|
||||
async def reply(self, inquiry: str, context: Optional[Dict[str, Any]] = None, tone: str = "professional", preference_context: Optional[str] = None) -> Dict[str, Any]:
|
||||
system = SYSTEM_PROMPTS["reply"]
|
||||
if preference_context:
|
||||
system += f"\nUser writing preference: {preference_context}"
|
||||
context_str = ""
|
||||
if context:
|
||||
for k, v in context.items():
|
||||
if v:
|
||||
context_str += f"{k}: {v}\n"
|
||||
prompt = f"{context_str}\nCustomer says:\n{inquiry}\n\nYour reply ({tone} tone):"
|
||||
content = await self._call(system, prompt)
|
||||
return {"reply": content, "provider": self.name}
|
||||
|
||||
async def generate_marketing(self, product_info: Dict[str, Any], target: str, style: str = "professional", language: str = "en", preference_context: Optional[str] = None) -> Dict[str, Any]:
|
||||
system = SYSTEM_PROMPTS["marketing"]
|
||||
if preference_context:
|
||||
system += f"\nUser preference: {preference_context}"
|
||||
info = json.dumps(product_info, ensure_ascii=False, indent=2)
|
||||
prompt = f"Product:\n{info}\n\nTarget: {target}\nStyle: {style}\nLanguage: {language}\n\nWrite marketing copy:"
|
||||
content = await self._call(system, prompt, max_tokens=1500)
|
||||
return {"content": content, "provider": self.name}
|
||||
|
||||
async def extract_info(self, text: str, schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
system = SYSTEM_PROMPTS["extract"]
|
||||
prompt = f"Schema:\n{json.dumps(schema, indent=2)}\n\nText:\n{text}\n\nJSON:"
|
||||
content = await self._call(system, prompt, max_tokens=1000)
|
||||
try:
|
||||
data = json.loads(content)
|
||||
return {"data": data, "confidence": 0.9, "provider": self.name}
|
||||
except json.JSONDecodeError:
|
||||
return {"data": {}, "confidence": 0.0, "provider": self.name, "error": "parse_failed"}
|
||||
|
||||
async def _call(self, system: str, prompt: str, max_tokens: int = 1000) -> str:
|
||||
resp = await self.client.messages.create(
|
||||
model=self.model,
|
||||
system=system,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.7,
|
||||
)
|
||||
return resp.content[0].text
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def cost_per_1k_tokens(self) -> float:
|
||||
return (self._pricing["input"] + self._pricing["output"]) / 2
|
||||
|
||||
@property
|
||||
def supports_streaming(self) -> bool:
|
||||
return True
|
||||
@@ -1,51 +0,0 @@
|
||||
from typing import Dict, Any, Optional
|
||||
import httpx
|
||||
from app.ai.base import AIProvider
|
||||
|
||||
|
||||
class DeepLProvider(AIProvider):
|
||||
def __init__(self, api_key: str, endpoint: str = "https://api.deepl.com/v2"):
|
||||
self.api_key = api_key
|
||||
self.endpoint = endpoint
|
||||
self._name = "deepl"
|
||||
self._cost_per_char = 0.000006
|
||||
|
||||
async def translate(self, text: str, source_lang: Optional[str], target_lang: str, context: Optional[str] = None) -> Dict[str, Any]:
|
||||
params = {
|
||||
"auth_key": self.api_key,
|
||||
"text": text,
|
||||
"target_lang": target_lang.upper()[:2],
|
||||
}
|
||||
if source_lang and source_lang != "auto":
|
||||
params["source_lang"] = source_lang.upper()[:2]
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(f"{self.endpoint}/translate", data=params, timeout=15)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
t = data["translations"][0]
|
||||
return {
|
||||
"translated_text": t["text"],
|
||||
"provider": self.name,
|
||||
"detected_source_lang": t.get("detected_source_language", source_lang),
|
||||
"char_count": len(text),
|
||||
"cost": len(text) * self._cost_per_char,
|
||||
}
|
||||
|
||||
async def reply(self, inquiry: str, context: Optional[Dict[str, Any]] = None, tone: str = "professional") -> Dict[str, Any]:
|
||||
raise NotImplementedError("DeepL does not support reply generation")
|
||||
|
||||
async def generate_marketing(self, product_info: Dict[str, Any], target: str, style: str = "professional", language: str = "en") -> Dict[str, Any]:
|
||||
raise NotImplementedError("DeepL does not support marketing generation")
|
||||
|
||||
async def extract_info(self, text: str, schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raise NotImplementedError("DeepL does not support info extraction")
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def cost_per_1k_tokens(self) -> float:
|
||||
return self._cost_per_char * 1000
|
||||
@@ -1,60 +0,0 @@
|
||||
from typing import Dict, Any, Optional
|
||||
import json, httpx
|
||||
from app.ai.base import AIProvider
|
||||
|
||||
|
||||
class LocalProvider(AIProvider):
|
||||
def __init__(self, model_url: str = "http://localhost:8001", model_name: str = "gemma-3-8b"):
|
||||
self.model_url = model_url.rstrip("/")
|
||||
self.model_name = model_name
|
||||
self._name = f"local-{model_name}"
|
||||
|
||||
async def translate(self, text: str, source_lang: Optional[str], target_lang: str, context: Optional[str] = None) -> Dict[str, Any]:
|
||||
prompt = f"Translate{ f' from {source_lang}' if source_lang else ''} to {target_lang}:\n{text}\n\nTranslation:"
|
||||
result = await self._generate(prompt)
|
||||
return {"translated_text": result, "provider": self.name, "cost": 0.0}
|
||||
|
||||
async def reply(self, inquiry: str, context: Optional[Dict[str, Any]] = None, tone: str = "professional", preference_context: Optional[str] = None) -> Dict[str, Any]:
|
||||
prompt = ""
|
||||
if preference_context:
|
||||
prompt += f"[User prefers: {preference_context}]\n"
|
||||
if context:
|
||||
prompt += "\n".join(f"{k}: {v}" for k, v in context.items() if v) + "\n"
|
||||
prompt += f"Customer: {inquiry}\n\nWrite a {tone} reply:"
|
||||
result = await self._generate(prompt)
|
||||
return {"reply": result, "provider": self.name, "cost": 0.0}
|
||||
|
||||
async def generate_marketing(self, product_info: Dict[str, Any], target: str, style: str = "professional", language: str = "en", preference_context: Optional[str] = None) -> Dict[str, Any]:
|
||||
info = json.dumps(product_info, ensure_ascii=False)
|
||||
prompt = ""
|
||||
if preference_context:
|
||||
prompt += f"[User prefers: {preference_context}]\n"
|
||||
prompt += f"Product: {info}\nTarget: {target}\nStyle: {style}\nLanguage: {language}\n\nMarketing copy:"
|
||||
result = await self._generate(prompt, max_tokens=800)
|
||||
return {"content": result, "provider": self.name, "cost": 0.0}
|
||||
|
||||
async def extract_info(self, text: str, schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
prompt = f"Extract JSON from text matching schema:\nSchema: {json.dumps(schema)}\n\nText: {text}\n\nJSON:"
|
||||
result = await self._generate(prompt, max_tokens=500)
|
||||
try:
|
||||
return {"data": json.loads(result), "confidence": 0.7, "provider": self.name, "cost": 0.0}
|
||||
except json.JSONDecodeError:
|
||||
return {"data": {}, "confidence": 0.0, "provider": self.name, "cost": 0.0, "error": "parse_failed"}
|
||||
|
||||
async def _generate(self, prompt: str, max_tokens: int = 500) -> str:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{self.model_url}/v1/completions",
|
||||
json={"model": self.model_name, "prompt": prompt, "max_tokens": max_tokens, "temperature": 0.7, "stream": False},
|
||||
timeout=60,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["choices"][0]["text"].strip()
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def cost_per_1k_tokens(self) -> float:
|
||||
return 0.0
|
||||
+94
-78
@@ -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, NvidiaProvider, AlibabaMTProvider
|
||||
from app.ai.providers import SparkProvider, SensenovaProvider, OpencodeGoProvider, NvidiaProvider, AlibabaMTProvider
|
||||
from app.config import settings
|
||||
from app.ai.trade_corpus import TradeCorpus
|
||||
import logging
|
||||
@@ -13,95 +13,111 @@ class AIRouter:
|
||||
self.providers: Dict[str, AIProvider] = {}
|
||||
self.routing_rules = settings.AI_ROUTING
|
||||
self.corpus = TradeCorpus()
|
||||
self._init_providers()
|
||||
|
||||
def _init_providers(self):
|
||||
if settings.OPENAI_API_KEY:
|
||||
try:
|
||||
self.providers["openai"] = OpenAIProvider(api_key=settings.OPENAI_API_KEY)
|
||||
logger.info("OpenAI provider ready")
|
||||
except Exception as e:
|
||||
logger.warning(f"OpenAI init failed: {e}")
|
||||
async def reload_from_db(self, db_session) -> int:
|
||||
from app.models.ai_provider import AIProvider
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db_session.execute(
|
||||
select(AIProvider).where(AIProvider.enabled == True).order_by(AIProvider.priority)
|
||||
)
|
||||
rows = result.scalars().all()
|
||||
|
||||
new_providers: Dict[str, AIProvider] = {}
|
||||
for p in rows:
|
||||
inst = self._build_provider(p)
|
||||
if inst:
|
||||
key = p.id.hex if hasattr(p.id, 'hex') else str(p.id)
|
||||
new_providers[key] = inst
|
||||
new_providers[p.name] = inst
|
||||
new_providers[p.provider_type] = inst
|
||||
|
||||
if new_providers:
|
||||
self.providers = new_providers
|
||||
logger.info(f"Loaded {len(rows)} AI providers from DB")
|
||||
else:
|
||||
logger.warning("No enabled AI providers found in DB")
|
||||
|
||||
return len(rows)
|
||||
|
||||
async def seed_from_env(self, db_session) -> int:
|
||||
from app.models.ai_provider import AIProvider
|
||||
|
||||
count = 0
|
||||
seeds = []
|
||||
|
||||
if settings.SENSENOVA_API_KEY:
|
||||
try:
|
||||
self.providers["sensenova"] = SensenovaProvider(
|
||||
api_key=settings.SENSENOVA_API_KEY,
|
||||
model=settings.SENSENOVA_MODEL,
|
||||
base_url=settings.SENSENOVA_BASE_URL,
|
||||
)
|
||||
logger.info("Sensenova provider ready")
|
||||
except Exception as e:
|
||||
logger.warning(f"Sensenova init failed: {e}")
|
||||
|
||||
seeds.append(AIProvider(
|
||||
name="Sensenova (商汤)", provider_type="sensenova",
|
||||
api_key=settings.SENSENOVA_API_KEY,
|
||||
base_url=settings.SENSENOVA_BASE_URL,
|
||||
model_name=settings.SENSENOVA_MODEL, priority=0, enabled=True,
|
||||
))
|
||||
if settings.OPENCODE_GO_API_KEY:
|
||||
try:
|
||||
self.providers["opencode_go"] = OpencodeGoProvider(
|
||||
api_key=settings.OPENCODE_GO_API_KEY,
|
||||
model=settings.OPENCODE_GO_MODEL,
|
||||
base_url=settings.OPENCODE_GO_BASE_URL,
|
||||
)
|
||||
logger.info("OpencodeGo provider ready")
|
||||
except Exception as e:
|
||||
logger.warning(f"OpencodeGo init failed: {e}")
|
||||
|
||||
seeds.append(AIProvider(
|
||||
name="OpencodeGo", provider_type="opencode_go",
|
||||
api_key=settings.OPENCODE_GO_API_KEY,
|
||||
base_url=settings.OPENCODE_GO_BASE_URL,
|
||||
model_name=settings.OPENCODE_GO_MODEL, priority=1, enabled=True,
|
||||
))
|
||||
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)
|
||||
logger.info("Claude provider ready")
|
||||
except Exception as e:
|
||||
logger.warning(f"Claude init failed: {e}")
|
||||
|
||||
if settings.DEEPL_API_KEY:
|
||||
try:
|
||||
self.providers["deepl"] = DeepLProvider(api_key=settings.DEEPL_API_KEY)
|
||||
logger.info("DeepL provider ready")
|
||||
except Exception as e:
|
||||
logger.warning(f"DeepL init failed: {e}")
|
||||
|
||||
seeds.append(AIProvider(
|
||||
name="NVIDIA", provider_type="nvidia",
|
||||
api_key=settings.NVIDIA_API_KEY,
|
||||
base_url=settings.NVIDIA_BASE_URL,
|
||||
model_name=settings.NVIDIA_MODEL, priority=2, enabled=True,
|
||||
))
|
||||
if settings.IFLYTEK_API_KEY:
|
||||
try:
|
||||
self.providers["spark"] = SparkProvider(
|
||||
api_key=settings.IFLYTEK_API_KEY,
|
||||
model=settings.IFLYTEK_MODEL,
|
||||
base_url=settings.IFLYTEK_API_BASE,
|
||||
)
|
||||
logger.info("Spark provider ready")
|
||||
except Exception as e:
|
||||
logger.warning(f"Spark init failed: {e}")
|
||||
|
||||
seeds.append(AIProvider(
|
||||
name="讯飞 Spark", provider_type="spark",
|
||||
api_key=settings.IFLYTEK_API_KEY,
|
||||
base_url=settings.IFLYTEK_API_BASE,
|
||||
model_name=settings.IFLYTEK_MODEL, priority=3, enabled=True,
|
||||
))
|
||||
if settings.ALIBABA_ACCESS_KEY_ID and settings.ALIBABA_ACCESS_KEY_SECRET:
|
||||
try:
|
||||
self.providers["alibaba-mt"] = AlibabaMTProvider(
|
||||
access_key_id=settings.ALIBABA_ACCESS_KEY_ID,
|
||||
access_key_secret=settings.ALIBABA_ACCESS_KEY_SECRET,
|
||||
)
|
||||
logger.info("Alibaba MT provider ready")
|
||||
except Exception as e:
|
||||
logger.warning(f"Alibaba MT init failed: {e}")
|
||||
seeds.append(AIProvider(
|
||||
name="阿里翻译", provider_type="alibaba-mt",
|
||||
api_key=settings.ALIBABA_ACCESS_KEY_ID,
|
||||
api_secret=settings.ALIBABA_ACCESS_KEY_SECRET,
|
||||
model_name="alibaba-mt", priority=4, enabled=True,
|
||||
))
|
||||
|
||||
if settings.LOCAL_MODEL_ENABLED:
|
||||
try:
|
||||
self.providers["local"] = LocalProvider(model_url=settings.LOCAL_MODEL_URL)
|
||||
logger.info("Local provider ready")
|
||||
except Exception as e:
|
||||
logger.warning(f"Local init failed: {e}")
|
||||
for p in seeds:
|
||||
db_session.add(p)
|
||||
count += 1
|
||||
if count:
|
||||
await db_session.commit()
|
||||
logger.info(f"Seeded {count} AI providers from .env into DB")
|
||||
return count
|
||||
|
||||
def schedule_reload(self):
|
||||
self._needs_reload = True
|
||||
logger.info("AI router scheduled for reload on next call")
|
||||
|
||||
def _build_provider(self, p) -> Optional[AIProvider]:
|
||||
try:
|
||||
t = p.provider_type
|
||||
if t == "sensenova":
|
||||
return SensenovaProvider(api_key=p.api_key, model=p.model_name, base_url=p.base_url)
|
||||
elif t == "opencode_go":
|
||||
return OpencodeGoProvider(api_key=p.api_key, model=p.model_name, base_url=p.base_url)
|
||||
elif t == "nvidia":
|
||||
return NvidiaProvider(api_key=p.api_key, model=p.model_name, base_url=p.base_url)
|
||||
elif t == "spark":
|
||||
return SparkProvider(api_key=p.api_key, model=p.model_name, base_url=p.base_url)
|
||||
elif t == "alibaba-mt":
|
||||
return AlibabaMTProvider(access_key_id=p.api_key, access_key_secret=p.api_secret or "")
|
||||
else:
|
||||
logger.warning(f"Unknown provider type: {t}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to build provider {p.name}: {e}")
|
||||
return None
|
||||
|
||||
def get_providers_for_task(self, task_type: str) -> List[AIProvider]:
|
||||
rules = self.routing_rules.get(
|
||||
task_type,
|
||||
{"primary": "openai", "fallback": ["local"]},
|
||||
{"primary": "sensenova", "fallback": ["opencode_go"]},
|
||||
)
|
||||
ordered = []
|
||||
seen = set()
|
||||
|
||||
Reference in New Issue
Block a user