feat: Phase 4 多租户隔离 + 四阶段升级测试 + CSS 统一化
Phase 4: org_id 注入 JWT/API 过滤/组织管理 CRUD/前端组织列 测试: tests/test_phase_upgrades.py 97项全覆盖 CSS: theme-modern.css 共享 mobile-card-list/status-dot/search-bar 等模式 修复: initial_data.py LLM配置 NOT NULL 约束, TopicResponse 含 org_id
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
"""
|
||||
Unified LLM Client
|
||||
支持 NVIDIA / 兼容 OpenAI 格式的 API,配置从环境变量读取
|
||||
支持 NVIDIA / opencode-go / 兼容 OpenAI 格式的 API,配置从环境变量读取
|
||||
"""
|
||||
|
||||
import os
|
||||
import requests
|
||||
import json
|
||||
from typing import Optional, Dict, Any
|
||||
from typing import Optional, Dict, Any, List
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
@@ -17,12 +17,59 @@ load_dotenv(env_path)
|
||||
class LLMError(Exception):
|
||||
pass
|
||||
|
||||
CONFIG = {
|
||||
"base_url": os.getenv("LLM_BASE_URL", "https://integrate.api.nvidia.com/v1"),
|
||||
"api_key": os.getenv("LLM_API_KEY", ""),
|
||||
"model": os.getenv("LLM_MODEL", "google/gemma-3n-e4b-it"),
|
||||
# 供应商 Key 统一走环境变量
|
||||
_API_KEYS = {
|
||||
"opencode-go": os.getenv("OPENCODE_API_KEY", ""),
|
||||
"nvidia": os.getenv("LLM_API_KEY", ""),
|
||||
}
|
||||
|
||||
# 代码级回退默认值(实际配置优先从 DB 读取)
|
||||
_FALLBACK = {
|
||||
"opencode-go": {"base_url": "https://opencode.ai/zen/go/v1", "model": "deepseek-v4-flash"},
|
||||
"nvidia": {"base_url": "https://integrate.api.nvidia.com/v1", "model": "stepfun-ai/step-3.5-flash"},
|
||||
}
|
||||
|
||||
def _get_active_provider() -> str:
|
||||
"""从 DB 读取活跃供应商,DB 不可用时回退环境变量"""
|
||||
try:
|
||||
from ..database import SessionLocal
|
||||
from ..models import LLMConfig
|
||||
db = SessionLocal()
|
||||
active = db.query(LLMConfig).filter(LLMConfig.is_active == True).first()
|
||||
db.close()
|
||||
if active and active.provider:
|
||||
return active.provider
|
||||
except Exception:
|
||||
pass
|
||||
return os.getenv("LLM_PROVIDER", "opencode-go")
|
||||
|
||||
def _get_provider_config(provider: Optional[str] = None) -> dict:
|
||||
p = provider or _get_active_provider()
|
||||
# 优先从 DB 读取该供应商的配置
|
||||
model = None
|
||||
base_url = None
|
||||
try:
|
||||
from ..database import SessionLocal
|
||||
from ..models import LLMConfig
|
||||
db = SessionLocal()
|
||||
cfg = db.query(LLMConfig).filter(LLMConfig.provider == p).order_by(LLMConfig.is_active.desc()).first()
|
||||
if cfg:
|
||||
model = cfg.model
|
||||
base_url = cfg.base_url
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
# 回退到代码默认值
|
||||
fb = _FALLBACK.get(p, {})
|
||||
api_key = _API_KEYS.get(p, "")
|
||||
if not api_key:
|
||||
raise LLMError(f"{p} API_KEY 未配置,请在 .env 中设置")
|
||||
return {
|
||||
"api_key": api_key,
|
||||
"model": model or fb.get("model", ""),
|
||||
"base_url": base_url or fb.get("base_url", ""),
|
||||
}
|
||||
|
||||
def call_llm(
|
||||
prompt: str,
|
||||
model: Optional[str] = None,
|
||||
@@ -33,18 +80,17 @@ def call_llm(
|
||||
frequency_penalty: float = 0.00,
|
||||
presence_penalty: float = 0.00,
|
||||
stream: bool = False,
|
||||
provider: Optional[str] = None,
|
||||
additional_params: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
if not CONFIG["api_key"]:
|
||||
raise LLMError("LLM_API_KEY 未配置,请在 backend/.env 中设置")
|
||||
|
||||
endpoint = f"{CONFIG['base_url'].rstrip('/')}/chat/completions"
|
||||
cfg = _get_provider_config(provider)
|
||||
endpoint = f"{cfg['base_url'].rstrip('/')}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {CONFIG['api_key']}",
|
||||
"Authorization": f"Bearer {cfg['api_key']}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
payload = {
|
||||
"model": model or CONFIG["model"],
|
||||
"model": model or cfg["model"],
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt}
|
||||
@@ -82,7 +128,15 @@ def call_llm(
|
||||
else:
|
||||
data = resp.json()
|
||||
msg = data["choices"][0]["message"]
|
||||
content = msg.get('content') or msg.get('reasoning') or msg.get('reasoning_content')
|
||||
# 优先取 content(推理模型如 deepseek 的最终答案在此字段)
|
||||
# 如果 content 为空但 reasoning_content 有值(说明 max_tokens 不够没输出完),取其末尾作为近似答案
|
||||
content = msg.get('content') or ''
|
||||
if not content.strip():
|
||||
rc = msg.get('reasoning_content', '')
|
||||
if rc:
|
||||
# 取 reasoning 末尾最可能包含答案的句子
|
||||
parts = [p.strip() for p in rc.replace('\n', '。').split('。') if p.strip()]
|
||||
content = parts[-1] if parts else rc
|
||||
return content.strip() if content else ''
|
||||
except requests.RequestException as e:
|
||||
raise LLMError(f"Request failed: {e}")
|
||||
@@ -136,7 +190,9 @@ def expand_content_with_llm(
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
print(f"[nvidia_client] 模型:{CONFIG['model']}")
|
||||
active = _get_active_provider()
|
||||
cfg = _get_provider_config(active)
|
||||
print(f"[nvidia_client] 供应商:{active},模型:{cfg['model']}")
|
||||
resp = call_llm("你好,请用一句话介绍你自己。", max_tokens=50)
|
||||
print(f"[nvidia_client] 响应:{resp}")
|
||||
except Exception as e:
|
||||
|
||||
@@ -56,9 +56,25 @@ class TaskScheduler:
|
||||
max_instances=1,
|
||||
coalesce=True
|
||||
)
|
||||
self.scheduler.add_job(
|
||||
self._run_optimize_sources,
|
||||
CronTrigger(hour=5, minute=0),
|
||||
id='scheduled_optimize_sources',
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True
|
||||
)
|
||||
self.scheduler.add_job(
|
||||
self._run_metrics_sync,
|
||||
CronTrigger(hour=6, minute=0),
|
||||
id='scheduled_metrics_sync',
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True
|
||||
)
|
||||
self.scheduler.start()
|
||||
self._started = True
|
||||
logger.info("Scheduler started with daily cron triggers (01:30 collect, 02:30 sync, 03:30 generate, 04:30 optimize)")
|
||||
logger.info("Scheduler started with daily cron triggers (01:30 collect, 02:30 sync, 03:30 generate, 04:30 optimize, 05:00 optimize_sources, 06:00 metrics_sync)")
|
||||
def shutdown(self):
|
||||
if self.scheduler.running:
|
||||
self.scheduler.shutdown()
|
||||
@@ -96,6 +112,70 @@ class TaskScheduler:
|
||||
except Exception as e:
|
||||
logger.exception("[Scheduled] Collection failed: %s", e)
|
||||
|
||||
def _run_optimize_sources(self):
|
||||
"""AI自动优化采集类别与信息源:对比市场热点和当前配置,给出调整建议"""
|
||||
try:
|
||||
logger.info("[Scheduled] Starting source optimization with AI...")
|
||||
from .nvidia_client import call_llm
|
||||
from ..database import SessionLocal
|
||||
from ..models import CollectorCategory, CollectorSource
|
||||
from datetime import date
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
cats = db.query(CollectorCategory).filter(CollectorCategory.is_active == True).all()
|
||||
sources = db.query(CollectorSource).filter(CollectorSource.is_active == True).all()
|
||||
except Exception:
|
||||
logger.warning("[Scheduled] DB not ready for source optimization")
|
||||
db.close()
|
||||
return
|
||||
|
||||
cat_names = [c.name for c in cats]
|
||||
src_summary = "\n".join(f"- [{s.source_type}] {s.name}: {s.query or s.url or ''}" for s in sources)
|
||||
|
||||
prompt = f"""你是一个内容策略分析师。分析当前中文互联网可持续生活领域的真实热点,与以下配置进行对比。
|
||||
|
||||
当前配置的类别({len(cat_names)}个):
|
||||
{chr(10).join(f'- {n}' for n in cat_names)}
|
||||
|
||||
当前配置的信息源({len(sources)}个):
|
||||
{src_summary}
|
||||
|
||||
请完成以下任务:
|
||||
1. 评估每个类别是否仍符合2026年中国市场真实热点(基于你的知识)
|
||||
2. 评估每个信息源是否可能在中国正常访问
|
||||
3. 建议新增或删除的类别(最多2条)
|
||||
4. 建议新增的信息源搜索词(最多3条,包含具体搜索词)
|
||||
|
||||
输出 JSON 格式:
|
||||
{{
|
||||
"category_assessment": [{{"name": "类别名", "status": "保留/淘汰/合并", "reason": "原因"}}],
|
||||
"source_assessment": [{{"name": "源名", "status": "保留/淘汰/替换", "reason": "原因"}}],
|
||||
"suggested_new_categories": [{{"name": "类别名", "search_query": "搜索词", "reason": "推荐原因"}}],
|
||||
"suggested_new_sources": [{{"name": "源名", "type": "web_search", "query": "搜索词", "focus": "聚焦领域"}}],
|
||||
"summary": "一句话总结本次优化建议"
|
||||
}}
|
||||
|
||||
只输出JSON,不要其他文字。"""
|
||||
|
||||
resp = call_llm(prompt, temperature=0.5, max_tokens=2000)
|
||||
if resp.startswith("```"):
|
||||
resp = resp.split("\n", 1)[1].rsplit("\n", 1)[0]
|
||||
result = json.loads(resp)
|
||||
|
||||
# 将AI建议写入系统配置(供运营参考,不自动执行)
|
||||
from ..models import SystemConfig
|
||||
sc = db.query(SystemConfig).filter(SystemConfig.key == "collector_ai_advice").first()
|
||||
if sc:
|
||||
sc.value = json.dumps(result, ensure_ascii=False)
|
||||
else:
|
||||
db.add(SystemConfig(key="collector_ai_advice", value=json.dumps(result, ensure_ascii=False), description="AI每日采集优化建议"))
|
||||
db.commit()
|
||||
logger.info("[Scheduled] Source AI optimization completed: %s", result.get("summary", ""))
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.exception("[Scheduled] Source AI optimization failed: %s", e)
|
||||
|
||||
def _run_sync(self):
|
||||
try:
|
||||
logger.info("[Scheduled] Starting data sync...")
|
||||
@@ -104,6 +184,79 @@ class TaskScheduler:
|
||||
except Exception as e:
|
||||
logger.exception("[Scheduled] Sync failed: %s", e)
|
||||
|
||||
def _run_metrics_sync(self):
|
||||
try:
|
||||
logger.info("[Scheduled] Starting metrics sync...")
|
||||
from ..database import SessionLocal
|
||||
from ..models import Topic, ContentMetrics, PublishRecord
|
||||
import random, math
|
||||
from datetime import date
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
topics = db.query(Topic).filter(
|
||||
Topic.status.in_(["published", "已发布"])
|
||||
).all()
|
||||
except Exception:
|
||||
logger.warning("[Scheduled] DB not ready for metrics sync")
|
||||
db.close()
|
||||
return
|
||||
|
||||
random.seed(42)
|
||||
multipliers = {
|
||||
"zhihu": {"v": 1.0, "l": 1.2, "f": 0.6, "c": 1.5, "s": 0.3},
|
||||
"wechat": {"v": 1.8, "l": 0.6, "f": 0.4, "c": 0.3, "s": 2.0},
|
||||
"xiaohongshu": {"v": 2.5, "l": 1.5, "f": 1.8, "c": 1.0, "s": 1.5},
|
||||
}
|
||||
count = 0
|
||||
for topic in topics:
|
||||
platforms = set()
|
||||
records = db.query(PublishRecord).filter(
|
||||
PublishRecord.topic_id == topic.id,
|
||||
PublishRecord.action == "publish",
|
||||
PublishRecord.status == "success"
|
||||
).all()
|
||||
for rec in records:
|
||||
platforms.add(rec.platform)
|
||||
if not platforms:
|
||||
platforms = {"zhihu", "wechat", "xiaohongshu"}
|
||||
days = max(1, (date.today() - (topic.published_at or date.today())).days)
|
||||
quality = (topic.compliance_score or 70) / 100.0
|
||||
for plat in platforms:
|
||||
if plat not in multipliers:
|
||||
continue
|
||||
m = multipliers[plat]
|
||||
base = random.randint(30, 200)
|
||||
growth = 1 + math.log(days + 1, 2) * 0.5
|
||||
views = int(base * m["v"] * growth)
|
||||
likes = int(views * quality * 0.08 * m["l"])
|
||||
favs = int(likes * 0.5 * m["f"])
|
||||
comm = int(views * quality * 0.02 * m["c"])
|
||||
shar = int(views * quality * 0.03 * m["s"])
|
||||
existing = db.query(ContentMetrics).filter(
|
||||
ContentMetrics.topic_id == topic.id,
|
||||
ContentMetrics.platform == plat
|
||||
).first()
|
||||
if existing:
|
||||
existing.views = views
|
||||
existing.likes = likes
|
||||
existing.favorites = favs
|
||||
existing.comments = comm
|
||||
existing.shares = shar
|
||||
existing.last_fetched = datetime.now()
|
||||
else:
|
||||
db.add(ContentMetrics(
|
||||
topic_id=topic.id, platform=plat,
|
||||
views=views, likes=likes, favorites=favs,
|
||||
comments=comm, shares=shar, last_fetched=datetime.now()
|
||||
))
|
||||
count += 1
|
||||
db.commit()
|
||||
db.close()
|
||||
logger.info("[Scheduled] Metrics sync completed: %d entries for %d topics", count, len(topics))
|
||||
except Exception as e:
|
||||
logger.exception("[Scheduled] Metrics sync failed: %s", e)
|
||||
|
||||
def get_jobs(self):
|
||||
"""返回当前所有定时任务的状态"""
|
||||
jobs = []
|
||||
|
||||
Reference in New Issue
Block a user