diff --git a/backend/app/models/llm_setting.py b/backend/app/models/llm_setting.py index 3d6369e..3d81653 100644 --- a/backend/app/models/llm_setting.py +++ b/backend/app/models/llm_setting.py @@ -29,7 +29,7 @@ class LlmSetting(Base): image_prompt_template: Mapped[str | None] = mapped_column(Text) # === 插图参数 === - image_size: Mapped[str] = mapped_column(String(16), default="1024x768", nullable=False) + image_size: Mapped[str] = mapped_column(String(16), default="768x512", nullable=False) # === 模型 === chat_model: Mapped[str] = mapped_column(String(64), default="agnes-2.0-flash", nullable=False) diff --git a/backend/app/schemas/llm.py b/backend/app/schemas/llm.py index fabc4ea..15b53af 100644 --- a/backend/app/schemas/llm.py +++ b/backend/app/schemas/llm.py @@ -13,7 +13,7 @@ class LlmSettingOut(BaseModel): classify_prompt: str | None = None commentary_prompt: str | None = None image_prompt_template: str | None = None - image_size: str = "1024x768" + image_size: str = "768x512" chat_model: str = "agnes-2.0-flash" image_model: str = "agnes-image-2.1-flash" interval_sec: float = 2.0 @@ -48,10 +48,10 @@ DEFAULT_PROMPTS = { "原文:\n{body}\n" ), "classify_prompt": ( - "你是新闻分类助手。请阅读以下新闻,返回 1-2 个分类标签。\n" - "可选标签(可自由组合): 时政 / 经济 / 科技 / 军事 / 社会 / 国际 / 体育 / 文化 / 环境 / 健康 / 金融 / 能源 / 气候\n" - "严格要求:只返回 JSON,形如 {\"categories\": [\"时政\", \"国际\"]},不要其他内容。\n\n" - "标题:{title}\n摘要:{summary}\n" + "你是新闻分类助手。请阅读以下新闻,返回 2-5 个最相关的分类标签(多标签)。\n" + "可选标签(可自由组合,不限于此): 时政 / 经济 / 科技 / 军事 / 社会 / 国际 / 体育 / 文化 / 环境 / 健康 / 金融 / 能源 / 气候\n" + "严格要求:只返回 JSON,形如 {\"categories\": [\"时政\", \"国际\", \"经济\"]},不要其他内容。\n\n" + "标题:{title}\n摘要:{summary}\n正文(节选):{body}\n" ), "commentary_prompt": ( "你是资深新闻评论员。请基于以下新闻写一段 100-200 字的中文点评。\n" @@ -59,7 +59,7 @@ DEFAULT_PROMPTS = { "标题:{title}\n正文:{body}\n" ), "image_prompt_template": ( - "Editorial news illustration about: {title}. " + "Editorial news illustration inspired by: {body}. " "Cinematic, professional journalism style, soft natural lighting, " "no text, no logos, no watermark." ), diff --git a/backend/app/services/llm/enrichment.py b/backend/app/services/llm/enrichment.py index 0bf10a7..25ea80e 100644 --- a/backend/app/services/llm/enrichment.py +++ b/backend/app/services/llm/enrichment.py @@ -2,10 +2,17 @@ 4 个独立任务: 1. format — 排版译文(写入 body_zh_formatted) - 2. classify — 分类(写入 category) - 3. image — 生成插图(写入 image_ai_url) + 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 @@ -28,6 +35,25 @@ 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 # 分类标签上限(多标签) + # === 获取当前设置(行锁 + 缓存刷新)=== async def get_setting() -> LlmSetting: @@ -60,39 +86,81 @@ async def _enrich_format(article: Article, setting: LlmSetting, client: LlmClien temperature=0.3, max_tokens=2000, ) - # 极简 HTML 包裹:按段切 +

+ # 极简 HTML 包裹:按段切 +

,整体包到带固定 CSS 的

里 parts = [f"

{p.strip()}

" for p in text.split("\n\n") if p.strip()] - article.body_zh_formatted = "\n".join(parts) or None + 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 的
里。 + + CSS 同时内联到 style 属性(分享/导出样式不丢)+ class 名(前端全局类可覆盖)。 + """ + inline_style = ( + f"font-family:{ARTICLE_BODY_FONT_FAMILY};" + f"font-size:{ARTICLE_BODY_FONT_SIZE};" + f"line-height:{ARTICLE_BODY_LINE_HEIGHT};" + f"color:{ARTICLE_BODY_COLOR};" + ) + # 段落样式也内联,保证 v-html 渲染时一定生效 + p_style = f"margin:0 0 {ARTICLE_BODY_P_MARGIN_BOTTOM} 0;" + inner_with_p_style = inner_html.replace("

", f'

') + return f'

{inner_with_p_style}
' + + # === 单任务:classify === async def _enrich_classify(article: Article, setting: LlmSetting, client: LlmClient) -> None: prompt = (setting.classify_prompt or get_default_prompts()["classify_prompt"]).format( title=(article.title_zh or article.title)[:200], summary=(article.summary_zh or "")[:400], + body=(article.body_zh_text or "")[:1500], ) result = await client.classify_json( system="你是新闻分类助手,只返回 JSON。", user=prompt, ) - cats = result.get("categories") or [] + cats = result.get("categories") or result.get("tags") or [] if isinstance(cats, list) and cats: - article.category = ",".join(str(c).strip() for c in cats[:3])[:32] + # 多标签(2-5 个),逗号分隔存到 category 字段(已有索引) + joined = ",".join(str(c).strip() for c in cats[:DEFAULT_IMAGE_MAX_TAGS] if str(c).strip()) + article.category = joined[:64] or None article.classify_status = "ok" # === 单任务:image === async def _enrich_image(article: Article, setting: LlmSetting, client: LlmClient) -> None: template = (setting.image_prompt_template or get_default_prompts()["image_prompt_template"]) - # 默认用 title_zh(若有),否则用原文 title + # 用正文第一段作为 prompt(英文 prompt 走 title 仍可工作,所以 title 也带上作 fallback) + first_para = _first_paragraph(article.body_zh_text or "", max_chars=DEFAULT_IMAGE_FIRST_PARA_CHARS) + if not first_para: + first_para = (article.title_zh or article.title or "")[:200] title_for_prompt = (article.title_zh or article.title or "")[:200] - prompt = template.format(title=title_for_prompt) - url = await client.generate_image(prompt, size=setting.image_size) + # template 同时支持 {body} 和 {title} 两种占位符 + try: + prompt = template.format(body=first_para, title=title_for_prompt) + except (KeyError, IndexError): + # 用户改坏了 template,fallback 用 {title} 模式 + prompt = template.format(title=title_for_prompt) + url = await client.generate_image(prompt, size=DEFAULT_IMAGE_SIZE) article.image_ai_url = url article.image_ai_status = "ok" +def _first_paragraph(text: str, max_chars: int) -> str: + """取正文第一段(按 \\n\\n 切)。如果首段超长就截断。""" + if not text: + return "" + for p in text.split("\n\n"): + p = p.strip() + if p: + return p[:max_chars] + return "" + + # === 单任务:commentary === async def _enrich_commentary(article: Article, setting: LlmSetting, client: LlmClient) -> None: prompt = (setting.commentary_prompt or get_default_prompts()["commentary_prompt"]).format( diff --git a/frontend/src/style.css b/frontend/src/style.css index 93d3316..9a50048 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -27,3 +27,21 @@ img { max-width: 100%; } .n-card.article-card:hover { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); } + +/* === 文章正文容器(项目级固定 CSS,排版版译文用)=== + * 后端 enrichment 也会在容器 div 上内联同样的属性;这里做兜底, + * 万一 inline style 被 sanitizer 剥掉,样式仍生效。 + * 与 backend/app/services/llm/enrichment.py 里的常量保持一致。 + */ +.article-body { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; + font-size: 17px; + line-height: 1.7; + color: #3e3e3e; +} +.article-body p { + margin: 0 0 1.5em 0; +} +.article-body p:last-child { + margin-bottom: 0; +} diff --git a/frontend/src/views/AdminLlmSettings.vue b/frontend/src/views/AdminLlmSettings.vue index 7f8eb70..9520f22 100644 --- a/frontend/src/views/AdminLlmSettings.vue +++ b/frontend/src/views/AdminLlmSettings.vue @@ -14,7 +14,7 @@ const setting = ref({ classify_prompt: '', commentary_prompt: '', image_prompt_template: '', - image_size: '1024x768', + image_size: '768x512', chat_model: 'agnes-2.0-flash', image_model: 'agnes-image-2.1-flash', interval_sec: 2.0, @@ -115,8 +115,8 @@ onMounted(load) 插图尺寸: - - (格式: WIDTHxHEIGHT,如 1024x768) + + (格式: WIDTHxHEIGHT,如 768x512;后端默认固定用 768x512) LLM 调用间隔(秒): @@ -141,8 +141,8 @@ onMounted(load) - 模板变量: {title} = 译后标题, {summary} = 摘要。
- 期望返回 JSON,形如 {`{"categories": ["时政", "国际"]}`} + 模板变量: {title} = 译后标题, {summary} = 摘要, {body} = 正文(节选)。
+ 期望返回 JSON(多标签,2-5 个),形如 {`{"categories": ["时政", "国际", "经济"]}`}
- 模板变量: {title} = 标题(优先译后)。最终 prompt 会拼成英文描述发给文生图模型 + 模板变量: {body} = 译文正文第一段(主要), {title} = 标题(fallback)。
+ 最终 prompt 会拼成英文描述发给文生图模型