Files
diary-news/backend/app/api/admin_llm.py
xiaji aaf728f3f4 feat(admin): Angel(Agnes) provider 凭据 DB 化 + 安全 key_set 字段
- llm_settings.agnes_api_key           TEXT   (DB key 优先,.env 兜底)
- llm_settings.agnes_base_url_override VARCHAR (留空 = 用 .env)
- alembic 0005_agnes_key 迁移
- LlmSettingOut.agnes_api_key_set (bool) 替代直接回传 key
- LlmSettingUpdate 加 agnes_api_key / agnes_base_url_override(可空可清空)
- providers.get_angel_client 改用 DB key 优先
- enrichment.py 改为 get_angel_client() 工厂调用(热改 key 不需重启)
- /admin/llm/settings/test 走 get_angel_client(测的是 DB 里的 key)
- 前端 AdminLlmSettings 在'总开关 + 模型'卡里加 Angel api_key 输入框 +
  base_url 覆盖 + 已配置/未配置指示灯 + 清空按钮
- 顶部'测连接'按钮复用(测的就是 Angel)
2026-06-12 20:43:54 +08:00

170 lines
6.7 KiB
Python

"""Admin LLM 设置(仅 owner)。
- GET /admin/llm-settings — 读当前设置(单行)
- PUT /admin/llm-settings — 更新(可只传部分字段)
- POST /admin/llm-settings/reset — 恢复默认提示词
- POST /admin/llm-settings/test — 测一次连通性(发个最小 chat 请求)
- POST /admin/llm-enrich/{article_id} — 手动触发某篇的 LLM 增强
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from app.core.deps import require_owner
from app.database import AsyncSessionLocal
from app.models.article import Article
from app.models.llm_setting import LlmSetting
from app.schemas.llm import LlmSettingOut, LlmSettingUpdate, get_default_prompts
from app.services.llm.client import LlmClient
logger = logging.getLogger("news.admin_llm")
router = APIRouter(prefix="/admin/llm", tags=["admin-llm"], dependencies=[Depends(require_owner)])
@router.get("/settings", response_model=LlmSettingOut)
async def get_settings():
async with AsyncSessionLocal() as session:
row = (await session.execute(select(LlmSetting).where(LlmSetting.id == 1))).scalar_one_or_none()
if row is None:
# 返回默认值(不写库)
defaults = get_default_prompts()
return LlmSettingOut(
format_prompt=defaults["format_prompt"],
classify_prompt=defaults["classify_prompt"],
commentary_prompt=defaults["commentary_prompt"],
image_prompt_template=defaults["image_prompt_template"],
blocklist_tags=[],
meituan_api_key_set=False,
)
return LlmSettingOut.from_row(row)
@router.put("/settings", response_model=LlmSettingOut)
async def update_settings(body: LlmSettingUpdate):
async with AsyncSessionLocal() as session:
row = (await session.execute(select(LlmSetting).where(LlmSetting.id == 1))).scalar_one_or_none()
if row is None:
row = LlmSetting(id=1, **get_default_prompts())
session.add(row)
await session.flush()
# 只更新传入的字段
update_data = body.model_dump(exclude_unset=True)
for k, v in update_data.items():
setattr(row, k, v)
await session.commit()
await session.refresh(row)
return LlmSettingOut.from_row(row)
class ResetResponse(BaseModel):
reset: bool
detail: str = ""
@router.post("/settings/reset", response_model=ResetResponse)
async def reset_settings():
"""恢复默认提示词。"""
async with AsyncSessionLocal() as session:
row = (await session.execute(select(LlmSetting).where(LlmSetting.id == 1))).scalar_one_or_none()
defaults = get_default_prompts()
if row is None:
row = LlmSetting(id=1, **defaults)
session.add(row)
else:
row.format_prompt = defaults["format_prompt"]
row.classify_prompt = defaults["classify_prompt"]
row.commentary_prompt = defaults["commentary_prompt"]
row.image_prompt_template = defaults["image_prompt_template"]
await session.commit()
return ResetResponse(reset=True, detail="已恢复默认提示词")
class TestResponse(BaseModel):
ok: bool
detail: str = ""
configured: bool
@router.post("/settings/test", response_model=TestResponse)
async def test_connection():
"""最小测试:发一个 'hi' chat 请求,确认 key + 端点通。
优先用 llm_settings 表里的 agnes_api_key / agnes_base_url_override,
都没有再 fallback 到 .env 里的 agnes_api_key / agnes_base_url。
"""
from app.services.llm.providers import get_angel_client
async with AsyncSessionLocal() as session:
row = (await session.execute(select(LlmSetting).where(LlmSetting.id == 1))).scalar_one_or_none()
if row is None:
return TestResponse(ok=False, configured=False, detail="LLM 设置未初始化")
# 用工厂:DB key 优先,.env 兜底
client = get_angel_client(row)
if not client.is_configured():
return TestResponse(
ok=False, configured=False,
detail="Angel api_key 未配置(请在设置页填 key,或在 .env 配 AGNES_API_KEY)",
)
try:
reply = await client.chat(
system="你是测试助手,只用 1 个词回答 OK 或 FAIL。",
user="ping",
temperature=0.0,
max_tokens=10,
)
return TestResponse(ok=True, configured=True, detail=f"reply={reply[:50]!r}")
except Exception as e:
return TestResponse(ok=False, configured=True, detail=f"{type(e).__name__}: {e}")
@router.post("/settings/test-meituan", response_model=TestResponse)
async def test_meituan_connection():
"""最小测试:发一个 'hi' chat 请求,确认美团大模型 LongCat 端点通。"""
from app.services.llm.providers import get_meituan_client
async with AsyncSessionLocal() as session:
row = (await session.execute(select(LlmSetting).where(LlmSetting.id == 1))).scalar_one_or_none()
if row is None:
return TestResponse(ok=False, configured=False, detail="美团 provider 未配置(api_key 空)")
client = get_meituan_client(row)
if client is None:
return TestResponse(ok=False, configured=False, detail="美团 MEITUAN_API_KEY 未配置")
try:
reply = await client.chat(
system="你是测试助手,只用 1 个词回答 OK 或 FAIL。",
user="ping",
temperature=0.0,
max_tokens=10,
)
return TestResponse(ok=True, configured=True, detail=f"reply={reply[:50]!r}")
except Exception as e:
return TestResponse(ok=False, configured=True, detail=f"{type(e).__name__}: {e}")
class EnrichTriggerResponse(BaseModel):
triggered: bool
detail: str = ""
results: dict[str, str] | None = None
@router.post("/enrich/{article_id}", response_model=EnrichTriggerResponse)
async def trigger_enrich(article_id: int):
"""手动触发某篇的 4 项 LLM 增强(同步等待,不会丢在后台)。"""
from app.services.llm.enrichment import enrich_article
async with AsyncSessionLocal() as session:
row = (await session.execute(select(Article).where(Article.id == article_id))).scalar_one_or_none()
if not row:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Article not found")
try:
results = await enrich_article(article_id)
return EnrichTriggerResponse(triggered=True, detail=f"done for {article_id}", results=results)
except Exception as e:
logger.exception("manual enrich failed for %s", article_id)
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, f"{type(e).__name__}: {e}")