", f'
') return f'
"""LLM 智能增强服务(翻译后调)。 4 个独立任务: 1. format — 排版译文(写入 body_zh_formatted) 2. classify — 分类(写入 category,多标签) 3. image — 生成插图(写入 image_ai_url,prompt 用正文第一段) 4. commentary — 写点评(写入 commentary) 排版容器 CSS(固定,不再让用户改): - 字体: system-ui 字体栈 - 字号: 17px - 行高: 1.7 - 颜色: #3e3e3e - 段落: margin-bottom 1.5em(自动空一行) 设计: - 任务入口: enrich_article(article_id, settings_row) - 任务间互不影响:每个任务独立 try/except + 写 status - 全部任务共走 LlmClient 的全局限速 - 若设置 enabled=False,只跳过(不调 LLM) - 用户提示词模板可能不包含全部占位符,用 _safe_format 容错 """ from __future__ import annotations import asyncio import logging from typing import Any, Mapping from sqlalchemy import select from app.database import AsyncSessionLocal from app.models.article import Article from app.models.llm_setting import LlmSetting from app.schemas.llm import get_default_prompts from app.services.llm.client import LlmClient logger = logging.getLogger("news.llm.enrichment") # === 排版容器固定 CSS(项目级固定,不再让用户改)=== # 同时内联到 body_zh_formatted 的容器 div 的 style 属性上, # 保证分享/邮件/导出场景下样式不丢;前端全局 .article-body 类做兜底。 ARTICLE_BODY_FONT_FAMILY = ( "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, " "'Helvetica Neue', sans-serif" ) ARTICLE_BODY_FONT_SIZE = "17px" ARTICLE_BODY_LINE_HEIGHT = "1.7" ARTICLE_BODY_COLOR = "#3e3e3e" ARTICLE_BODY_P_MARGIN_BOTTOM = "1.5em" # === 插图默认尺寸(适中,不再用 1024x768)=== # 写死到 enrichment 里,行为稳定;setting.image_size 仍可由用户在 UI 改, # 但默认行为不依赖它,避免意外被改成很大。 DEFAULT_IMAGE_SIZE = "768x512" DEFAULT_IMAGE_FIRST_PARA_CHARS = 400 # 提取第一段最多用这么多字 DEFAULT_IMAGE_MAX_TAGS = 5 # 分类标签上限(多标签) class _SafeDict(dict): """missing 返回 {key} 本身(占位符原样保留),不抛 KeyError。""" def __missing__(self, key: str) -> str: # type: ignore[override] return "{" + key + "}" def _safe_format(template: str, vars_: Mapping[str, Any]) -> str: """用 _SafeDict 跑 str.format,缺失的占位符保留原样而不是 KeyError。 用途:数据库里用户已存的 prompt 模板可能是旧版的(只支持部分占位符), 新代码传了更多变量也不应崩。 """ try: return template.format_map(_SafeDict(vars_)) except (KeyError, IndexError) as e: # 极端情况(比如 {} 这种非法占位符)兜底 logger.warning("_safe_format 解析失败,按原文返回: %s", e) return template # === 获取当前设置(行锁 + 缓存刷新)=== async def get_setting() -> LlmSetting: """读 llm_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() row = LlmSetting( id=1, format_prompt=defaults["format_prompt"], classify_prompt=defaults["classify_prompt"], commentary_prompt=defaults["commentary_prompt"], image_prompt_template=defaults["image_prompt_template"], ) session.add(row) await session.commit() await session.refresh(row) return row # === 单任务:format === async def _enrich_format(article: Article, setting: LlmSetting, client: LlmClient) -> None: template = setting.format_prompt or get_default_prompts()["format_prompt"] prompt = _safe_format(template, {"body": (article.body_zh_text or "")[:6000]}) text = await client.chat( system="你是中文新闻排版助手,只输出排版后的纯文本。", user=prompt, temperature=0.3, max_tokens=2000, ) # 极简 HTML 包裹:按段切 +
,整体包到带固定 CSS 的
{p.strip()}
" for p in text.split("\n\n") if p.strip()] if not parts: article.body_zh_formatted = None else: article.body_zh_formatted = _wrap_article_body("\n".join(parts)) article.format_status = "ok" def _wrap_article_body(inner_html: str) -> str: """把排版好的段落包到带固定 CSS 的", f'
') return f'