"""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=[], ) return LlmSettingOut.model_validate(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.model_validate(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 + 端点通。""" async with AsyncSessionLocal() as session: row = (await session.execute(select(LlmSetting).where(LlmSetting.id == 1))).scalar_one_or_none() chat_model = row.chat_model if row else "agnes-2.0-flash" client = LlmClient(chat_model=chat_model) if not client.is_configured(): return TestResponse(ok=False, configured=False, detail="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}") 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}")