Files
diary-news/backend/app/schemas/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

125 lines
5.7 KiB
Python

"""LLM 设置相关 Pydantic schemas。"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class LlmSettingOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
format_prompt: str | None = None
classify_prompt: str | None = None
commentary_prompt: str | None = None
image_prompt_template: str | None = None
image_size: str = "768x512"
chat_model: str = "agnes-2.0-flash"
image_model: str = "agnes-image-2.1-flash"
interval_sec: float = 2.0
enabled: bool = True
# 全局屏蔽分类标签;与 sources.blocklist_tags 合并后注入 classify prompt
blocklist_tags: list[str] = []
# === Angel(Agnes)凭据 — DB 存,优先于 .env ===
# 安全:不回传明文,只回 agnes_api_key_set (True = DB 已有 key)
agnes_api_key_set: bool = False
agnes_base_url_override: str = "" # 留空 = 用 .env
# 美团大模型(LongCat,OpenAI 兼容)双 provider 评论
# 安全:不回传 api_key 真值,只回传 meituan_api_key_set 表示"是否已配置"
meituan_api_key_set: bool = False
meituan_base_url: str = "https://api.longcat.chat/openai/v1"
meituan_chat_model: str = "LongCat-2.0-Preview"
meituan_interval_sec: float = 2.0
meituan_enabled: bool = True
meituan_commentary_prompt: str | None = None
updated_at: datetime | None = None
@classmethod
def from_row(cls, row) -> "LlmSettingOut":
"""从 LlmSetting 构造,key 字段转 bool。"""
return cls(
format_prompt=row.format_prompt,
classify_prompt=row.classify_prompt,
commentary_prompt=row.commentary_prompt,
image_prompt_template=row.image_prompt_template,
image_size=row.image_size,
chat_model=row.chat_model,
image_model=row.image_model,
interval_sec=row.interval_sec,
enabled=row.enabled,
blocklist_tags=row.blocklist_tags or [],
# === key 安全转换 ===
agnes_api_key_set=bool(row.agnes_api_key),
agnes_base_url_override=row.agnes_base_url_override or "",
meituan_api_key_set=bool(row.meituan_api_key),
meituan_base_url=row.meituan_base_url,
meituan_chat_model=row.meituan_chat_model,
meituan_interval_sec=row.meituan_interval_sec,
meituan_enabled=row.meituan_enabled,
meituan_commentary_prompt=row.meituan_commentary_prompt,
updated_at=row.updated_at,
)
class LlmSettingUpdate(BaseModel):
"""PATCH — 全部字段 optional,只更新传入的。"""
format_prompt: str | None = None
classify_prompt: str | None = None
commentary_prompt: str | None = None
image_prompt_template: str | None = None
image_size: str | None = Field(default=None, pattern=r"^\d{2,4}x\d{2,4}$")
chat_model: str | None = Field(default=None, min_length=1, max_length=64)
image_model: str | None = Field(default=None, min_length=1, max_length=64)
interval_sec: float | None = Field(default=None, ge=0.0, le=60.0)
enabled: bool | None = None
blocklist_tags: list[str] | None = None
# === Angel provider 字段 ===
agnes_api_key: str | None = Field(default=None, max_length=512)
agnes_base_url_override: str | None = Field(default=None, max_length=255)
# 美团 provider 字段(api_key 可更新;None/空 = 不修改;显式传空字符串 = 清空)
meituan_api_key: str | None = Field(default=None, max_length=512)
meituan_base_url: str | None = Field(default=None, max_length=255)
meituan_chat_model: str | None = Field(default=None, max_length=64)
meituan_interval_sec: float | None = Field(default=None, ge=0.0, le=60.0)
meituan_enabled: bool | None = None
meituan_commentary_prompt: str | None = None
# === 默认提示词(模板,用户可改)===
DEFAULT_PROMPTS = {
"format_prompt": (
"你是中文新闻排版助手。请将以下译文改写为适合网页阅读的版式,要求:\n"
"1. 保留所有事实信息,不要增删内容\n"
"2. 按段落拆分(2-4 句一段),段间空行\n"
"3. 关键人物/机构/数字用 **加粗**\n"
"4. 如有并列要点,转为编号列表(1. 2. 3.)\n"
"5. 不要使用 # 标题,不要外层 markdown 代码块\n"
"6. 直接输出排版后的纯文本\n\n"
"原文:\n{body}\n"
),
"classify_prompt": (
"你是新闻分类助手。请阅读以下新闻,返回 2-5 个最相关的分类标签(多标签)。\n"
"可选标签(可自由组合,不限于此): 时政 / 经济 / 科技 / 军事 / 社会 / 国际 / 体育 / 文化 / 环境 / 健康 / 金融 / 能源 / 气候\n"
"黑名单分类(若新闻属于或主要围绕这些领域,务必将 drop 设为 true): {blocklist}\n"
"严格要求:只返回 JSON,形如 {\"categories\": [\"时政\", \"国际\", \"经济\"], \"drop\": false},"
"若新闻属于或主要围绕黑名单中的任何分类,将 drop 设为 true 并把该分类放入 categories。"
"不要其他内容。\n\n"
"标题:{title}\n摘要:{summary}\n正文(节选):{body}\n"
),
"commentary_prompt": (
"你是资深新闻评论员。请基于以下新闻写一段 100-200 字的中文点评。\n"
"要求:客观、有深度、避免空洞套话,给出具体观察或背景。\n\n"
"标题:{title}\n正文:{body}\n"
),
"image_prompt_template": (
"Editorial news illustration inspired by: {body}. "
"Cinematic, professional journalism style, soft natural lighting, "
"no text, no logos, no watermark."
),
}
def get_default_prompts() -> dict[str, str]:
return dict(DEFAULT_PROMPTS)