feat(llm): 排版容器固定CSS + 插图用正文第一段 + 适中尺寸
- enrichment._enrich_format:把排版好的段落包到带固定 CSS 的 <div class=article-body> 里
(font: system-ui / 17px / line-height 1.7 / color #3e3e3e / p margin-bottom 1.5em)
CSS 同时内联到 style 属性,前端 .article-body 全局类做兑底
- enrichment._enrich_image:prompt 改用 body_zh_text 的第一段(原为 title);
新增 {body} 占位符,image_prompt_template 默认模板同步改写
- 插图尺寸写死为 768x512(适中);image_size 字段保留供用户手改但默认行为不依赖它
- 分类明确多标签(2-5 个),提示词加 {body} 变量,容错读 categories/tags 两种 key
- AdminLlmSettings.vue:placeholder / 变量说明同步更新
This commit is contained in:
@@ -29,7 +29,7 @@ class LlmSetting(Base):
|
|||||||
image_prompt_template: Mapped[str | None] = mapped_column(Text)
|
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)
|
chat_model: Mapped[str] = mapped_column(String(64), default="agnes-2.0-flash", nullable=False)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class LlmSettingOut(BaseModel):
|
|||||||
classify_prompt: str | None = None
|
classify_prompt: str | None = None
|
||||||
commentary_prompt: str | None = None
|
commentary_prompt: str | None = None
|
||||||
image_prompt_template: str | None = None
|
image_prompt_template: str | None = None
|
||||||
image_size: str = "1024x768"
|
image_size: str = "768x512"
|
||||||
chat_model: str = "agnes-2.0-flash"
|
chat_model: str = "agnes-2.0-flash"
|
||||||
image_model: str = "agnes-image-2.1-flash"
|
image_model: str = "agnes-image-2.1-flash"
|
||||||
interval_sec: float = 2.0
|
interval_sec: float = 2.0
|
||||||
@@ -48,10 +48,10 @@ DEFAULT_PROMPTS = {
|
|||||||
"原文:\n{body}\n"
|
"原文:\n{body}\n"
|
||||||
),
|
),
|
||||||
"classify_prompt": (
|
"classify_prompt": (
|
||||||
"你是新闻分类助手。请阅读以下新闻,返回 1-2 个分类标签。\n"
|
"你是新闻分类助手。请阅读以下新闻,返回 2-5 个最相关的分类标签(多标签)。\n"
|
||||||
"可选标签(可自由组合): 时政 / 经济 / 科技 / 军事 / 社会 / 国际 / 体育 / 文化 / 环境 / 健康 / 金融 / 能源 / 气候\n"
|
"可选标签(可自由组合,不限于此): 时政 / 经济 / 科技 / 军事 / 社会 / 国际 / 体育 / 文化 / 环境 / 健康 / 金融 / 能源 / 气候\n"
|
||||||
"严格要求:只返回 JSON,形如 {\"categories\": [\"时政\", \"国际\"]},不要其他内容。\n\n"
|
"严格要求:只返回 JSON,形如 {\"categories\": [\"时政\", \"国际\", \"经济\"]},不要其他内容。\n\n"
|
||||||
"标题:{title}\n摘要:{summary}\n"
|
"标题:{title}\n摘要:{summary}\n正文(节选):{body}\n"
|
||||||
),
|
),
|
||||||
"commentary_prompt": (
|
"commentary_prompt": (
|
||||||
"你是资深新闻评论员。请基于以下新闻写一段 100-200 字的中文点评。\n"
|
"你是资深新闻评论员。请基于以下新闻写一段 100-200 字的中文点评。\n"
|
||||||
@@ -59,7 +59,7 @@ DEFAULT_PROMPTS = {
|
|||||||
"标题:{title}\n正文:{body}\n"
|
"标题:{title}\n正文:{body}\n"
|
||||||
),
|
),
|
||||||
"image_prompt_template": (
|
"image_prompt_template": (
|
||||||
"Editorial news illustration about: {title}. "
|
"Editorial news illustration inspired by: {body}. "
|
||||||
"Cinematic, professional journalism style, soft natural lighting, "
|
"Cinematic, professional journalism style, soft natural lighting, "
|
||||||
"no text, no logos, no watermark."
|
"no text, no logos, no watermark."
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,10 +2,17 @@
|
|||||||
|
|
||||||
4 个独立任务:
|
4 个独立任务:
|
||||||
1. format — 排版译文(写入 body_zh_formatted)
|
1. format — 排版译文(写入 body_zh_formatted)
|
||||||
2. classify — 分类(写入 category)
|
2. classify — 分类(写入 category,多标签)
|
||||||
3. image — 生成插图(写入 image_ai_url)
|
3. image — 生成插图(写入 image_ai_url,prompt 用正文第一段)
|
||||||
4. commentary — 写点评(写入 commentary)
|
4. commentary — 写点评(写入 commentary)
|
||||||
|
|
||||||
|
排版容器 CSS(固定,不再让用户改):
|
||||||
|
- 字体: system-ui 字体栈
|
||||||
|
- 字号: 17px
|
||||||
|
- 行高: 1.7
|
||||||
|
- 颜色: #3e3e3e
|
||||||
|
- 段落: margin-bottom 1.5em(自动空一行)
|
||||||
|
|
||||||
设计:
|
设计:
|
||||||
- 任务入口: enrich_article(article_id, settings_row)
|
- 任务入口: enrich_article(article_id, settings_row)
|
||||||
- 任务间互不影响:每个任务独立 try/except + 写 status
|
- 任务间互不影响:每个任务独立 try/except + 写 status
|
||||||
@@ -28,6 +35,25 @@ from app.services.llm.client import LlmClient
|
|||||||
|
|
||||||
logger = logging.getLogger("news.llm.enrichment")
|
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:
|
async def get_setting() -> LlmSetting:
|
||||||
@@ -60,39 +86,81 @@ async def _enrich_format(article: Article, setting: LlmSetting, client: LlmClien
|
|||||||
temperature=0.3,
|
temperature=0.3,
|
||||||
max_tokens=2000,
|
max_tokens=2000,
|
||||||
)
|
)
|
||||||
# 极简 HTML 包裹:按段切 + <p>
|
# 极简 HTML 包裹:按段切 + <p>,整体包到带固定 CSS 的 <div> 里
|
||||||
parts = [f"<p>{p.strip()}</p>" for p in text.split("\n\n") if p.strip()]
|
parts = [f"<p>{p.strip()}</p>" 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"
|
article.format_status = "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_article_body(inner_html: str) -> str:
|
||||||
|
"""把排版好的段落包到带固定 CSS 的 <div class='article-body'> 里。
|
||||||
|
|
||||||
|
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("<p>", f'<p style="{p_style}">')
|
||||||
|
return f'<div class="article-body" style="{inline_style}">{inner_with_p_style}</div>'
|
||||||
|
|
||||||
|
|
||||||
# === 单任务:classify ===
|
# === 单任务:classify ===
|
||||||
async def _enrich_classify(article: Article, setting: LlmSetting, client: LlmClient) -> None:
|
async def _enrich_classify(article: Article, setting: LlmSetting, client: LlmClient) -> None:
|
||||||
prompt = (setting.classify_prompt or get_default_prompts()["classify_prompt"]).format(
|
prompt = (setting.classify_prompt or get_default_prompts()["classify_prompt"]).format(
|
||||||
title=(article.title_zh or article.title)[:200],
|
title=(article.title_zh or article.title)[:200],
|
||||||
summary=(article.summary_zh or "")[:400],
|
summary=(article.summary_zh or "")[:400],
|
||||||
|
body=(article.body_zh_text or "")[:1500],
|
||||||
)
|
)
|
||||||
result = await client.classify_json(
|
result = await client.classify_json(
|
||||||
system="你是新闻分类助手,只返回 JSON。",
|
system="你是新闻分类助手,只返回 JSON。",
|
||||||
user=prompt,
|
user=prompt,
|
||||||
)
|
)
|
||||||
cats = result.get("categories") or []
|
cats = result.get("categories") or result.get("tags") or []
|
||||||
if isinstance(cats, list) and cats:
|
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"
|
article.classify_status = "ok"
|
||||||
|
|
||||||
|
|
||||||
# === 单任务:image ===
|
# === 单任务:image ===
|
||||||
async def _enrich_image(article: Article, setting: LlmSetting, client: LlmClient) -> None:
|
async def _enrich_image(article: Article, setting: LlmSetting, client: LlmClient) -> None:
|
||||||
template = (setting.image_prompt_template or get_default_prompts()["image_prompt_template"])
|
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]
|
title_for_prompt = (article.title_zh or article.title or "")[:200]
|
||||||
prompt = template.format(title=title_for_prompt)
|
# template 同时支持 {body} 和 {title} 两种占位符
|
||||||
url = await client.generate_image(prompt, size=setting.image_size)
|
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_url = url
|
||||||
article.image_ai_status = "ok"
|
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 ===
|
# === 单任务:commentary ===
|
||||||
async def _enrich_commentary(article: Article, setting: LlmSetting, client: LlmClient) -> None:
|
async def _enrich_commentary(article: Article, setting: LlmSetting, client: LlmClient) -> None:
|
||||||
prompt = (setting.commentary_prompt or get_default_prompts()["commentary_prompt"]).format(
|
prompt = (setting.commentary_prompt or get_default_prompts()["commentary_prompt"]).format(
|
||||||
|
|||||||
@@ -27,3 +27,21 @@ img { max-width: 100%; }
|
|||||||
.n-card.article-card:hover {
|
.n-card.article-card:hover {
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const setting = ref<LlmSetting>({
|
|||||||
classify_prompt: '',
|
classify_prompt: '',
|
||||||
commentary_prompt: '',
|
commentary_prompt: '',
|
||||||
image_prompt_template: '',
|
image_prompt_template: '',
|
||||||
image_size: '1024x768',
|
image_size: '768x512',
|
||||||
chat_model: 'agnes-2.0-flash',
|
chat_model: 'agnes-2.0-flash',
|
||||||
image_model: 'agnes-image-2.1-flash',
|
image_model: 'agnes-image-2.1-flash',
|
||||||
interval_sec: 2.0,
|
interval_sec: 2.0,
|
||||||
@@ -115,8 +115,8 @@ onMounted(load)
|
|||||||
</NSpace>
|
</NSpace>
|
||||||
<NSpace>
|
<NSpace>
|
||||||
<NText>插图尺寸:</NText>
|
<NText>插图尺寸:</NText>
|
||||||
<NInput v-model:value="setting.image_size" placeholder="1024x768" style="width: 160px" />
|
<NInput v-model:value="setting.image_size" placeholder="768x512" style="width: 160px" />
|
||||||
<NText depth="3" style="font-size: 12px">(格式: WIDTHxHEIGHT,如 1024x768)</NText>
|
<NText depth="3" style="font-size: 12px">(格式: WIDTHxHEIGHT,如 768x512;后端默认固定用 768x512)</NText>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
<NSpace>
|
<NSpace>
|
||||||
<NText>LLM 调用间隔(秒):</NText>
|
<NText>LLM 调用间隔(秒):</NText>
|
||||||
@@ -141,8 +141,8 @@ onMounted(load)
|
|||||||
|
|
||||||
<NCard title="分类提示词" style="margin-top: 16px">
|
<NCard title="分类提示词" style="margin-top: 16px">
|
||||||
<NText depth="3" style="font-size: 12px">
|
<NText depth="3" style="font-size: 12px">
|
||||||
模板变量: <NCode>{title}</NCode> = 译后标题, <NCode>{summary}</NCode> = 摘要。<br />
|
模板变量: <NCode>{title}</NCode> = 译后标题, <NCode>{summary}</NCode> = 摘要, <NCode>{body}</NCode> = 正文(节选)。<br />
|
||||||
期望返回 JSON,形如 <NCode>{`{"categories": ["时政", "国际"]}`}</NCode>
|
期望返回 JSON(多标签,2-5 个),形如 <NCode>{`{"categories": ["时政", "国际", "经济"]}`}</NCode>
|
||||||
</NText>
|
</NText>
|
||||||
<NInput
|
<NInput
|
||||||
v-model:value="setting.classify_prompt"
|
v-model:value="setting.classify_prompt"
|
||||||
@@ -168,7 +168,8 @@ onMounted(load)
|
|||||||
|
|
||||||
<NCard title="插图 prompt 模板" style="margin-top: 16px">
|
<NCard title="插图 prompt 模板" style="margin-top: 16px">
|
||||||
<NText depth="3" style="font-size: 12px">
|
<NText depth="3" style="font-size: 12px">
|
||||||
模板变量: <NCode>{title}</NCode> = 标题(优先译后)。最终 prompt 会拼成英文描述发给文生图模型
|
模板变量: <NCode>{body}</NCode> = 译文正文第一段(主要), <NCode>{title}</NCode> = 标题(fallback)。<br />
|
||||||
|
最终 prompt 会拼成英文描述发给文生图模型
|
||||||
</NText>
|
</NText>
|
||||||
<NInput
|
<NInput
|
||||||
v-model:value="setting.image_prompt_template"
|
v-model:value="setting.image_prompt_template"
|
||||||
|
|||||||
Reference in New Issue
Block a user