feat: make article summary prompt independent from daily digest
- Add outputs/prompts/article-summary-prompt.txt with deep knowledge note schema (core_conclusion, main_argument, key_methods, etc.) - Add ArticleSummaryResult Pydantic model for the new schema - Add validate_article_summary_payload() validator - Add optional validator param to run_loop_payload(); defaults to existing validate_llm_result_payload so daily digest is unaffected - Update article_summary.py: new prompt path, new validator, new Markdown output with Chinese section headers
This commit is contained in:
@@ -7,7 +7,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -183,7 +183,10 @@ def run_loop_payload(
|
||||
model: str | None,
|
||||
api_url: str | None,
|
||||
output_path: Path | None = None,
|
||||
validator: Callable[[dict[str, Any], dict[str, Any] | None], ValidationReport] | None = None,
|
||||
) -> tuple[int, dict[str, Any] | None, ValidationReport | None]:
|
||||
# validator 为 None 时使用日报摘要默认校验器;传入自定义 validator 时使用传入的
|
||||
resolved_validator = validator if validator is not None else validate_llm_result_payload
|
||||
prompt_template = load_text(prompt_path)
|
||||
if output_path is not None:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -223,7 +226,7 @@ def run_loop_payload(
|
||||
if output_path is not None:
|
||||
save_json(output_path, result_payload)
|
||||
|
||||
report = validate_llm_result_payload(result_payload, extracted_payload)
|
||||
report = resolved_validator(result_payload, extracted_payload)
|
||||
_save_attempt_artifact(
|
||||
output_path,
|
||||
f"attempt-{attempt}.validation.json",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# 单篇文章深度沉淀的结构化输出模型,与日报摘要的 LlmSummaryResult 完全独立。
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, HttpUrl
|
||||
|
||||
|
||||
ArticleSummaryCategory = Literal["资讯", "方法论", "工具实践", "观点评论"]
|
||||
|
||||
|
||||
class ArticleSummaryResult(BaseModel):
|
||||
title: str
|
||||
url: HttpUrl
|
||||
core_conclusion: str
|
||||
main_argument: str
|
||||
key_methods: list[str] = []
|
||||
important_details: list[str] = []
|
||||
reusable_insights: list[str] = []
|
||||
keywords: list[str] = []
|
||||
topics: list[str] = []
|
||||
category: ArticleSummaryCategory
|
||||
worth_keeping: bool
|
||||
reason: str
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# 单篇文章深度沉淀的校验器,与日报摘要 validator 完全独立。
|
||||
# 只校验 ArticleSummaryResult schema,不做日报摘要的业务规则检查。
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from summary_mcp.models.article_summary_result import ArticleSummaryResult
|
||||
from summary_mcp.validators.llm_result import ValidationReport
|
||||
|
||||
|
||||
def validate_article_summary_payload(
|
||||
result_payload: dict[str, Any],
|
||||
extracted_payload: dict[str, Any] | None = None,
|
||||
) -> ValidationReport:
|
||||
# 校验 LLM 输出是否符合 ArticleSummaryResult schema
|
||||
# extracted_payload 暂未使用,保留参数与 run_loop_payload validator 签名一致
|
||||
try:
|
||||
parsed = ArticleSummaryResult.model_validate(result_payload)
|
||||
except ValidationError as exc:
|
||||
errors = [
|
||||
".".join(str(part) for part in error["loc"]) + ": " + error["msg"]
|
||||
for error in exc.errors()
|
||||
]
|
||||
return ValidationReport(valid=False, errors=errors)
|
||||
|
||||
# keywords 和 topics 不能重叠
|
||||
keyword_overlap = set(parsed.keywords) & set(parsed.topics)
|
||||
errors: list[str] = []
|
||||
if keyword_overlap:
|
||||
errors.append(f"`keywords` and `topics` must not overlap: {sorted(keyword_overlap)}")
|
||||
|
||||
return ValidationReport(
|
||||
valid=not errors,
|
||||
errors=errors,
|
||||
warnings=[],
|
||||
normalized_result=parsed.model_dump(mode="json"),
|
||||
)
|
||||
@@ -5,12 +5,13 @@ from pathlib import Path
|
||||
from typing import Iterable, Mapping, Sequence
|
||||
|
||||
from summary_mcp.core.summary_loop import run_loop_payload
|
||||
from summary_mcp.validators.article_summary import validate_article_summary_payload
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
OUTPUT_ROOT = REPO_ROOT / "outputs"
|
||||
FRESHRSS_OUTPUT_ROOT = OUTPUT_ROOT / "freshrss"
|
||||
DEFAULT_PROMPT_PATH = OUTPUT_ROOT / "prompts" / "llm-summary-prompt.txt"
|
||||
DEFAULT_PROMPT_PATH = OUTPUT_ROOT / "prompts" / "article-summary-prompt.txt"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -181,28 +182,58 @@ def summarize_selected_articles(
|
||||
model=resolved_model,
|
||||
api_url=resolved_api_url,
|
||||
output_path=None,
|
||||
validator=validate_article_summary_payload,
|
||||
)
|
||||
if summary_exit_code != 0 or summary_payload is None:
|
||||
continue
|
||||
|
||||
# Render a simple Markdown file summarizing the article.
|
||||
# Render a Markdown knowledge note using the new article summary schema.
|
||||
title = article.get("title") or summary_payload.get("title") or item_id
|
||||
url = article.get("url")
|
||||
summary_text = summary_payload.get("summary") or ""
|
||||
highlights = summary_payload.get("highlights") or []
|
||||
category = summary_payload.get("category") or ""
|
||||
core_conclusion = summary_payload.get("core_conclusion") or ""
|
||||
main_argument = summary_payload.get("main_argument") or ""
|
||||
key_methods = summary_payload.get("key_methods") or []
|
||||
important_details = summary_payload.get("important_details") or []
|
||||
reusable_insights = summary_payload.get("reusable_insights") or []
|
||||
keywords = summary_payload.get("keywords") or []
|
||||
topics = summary_payload.get("topics") or []
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append(f"# {title}")
|
||||
if url:
|
||||
lines.append("")
|
||||
lines.append(f"Source: {url}")
|
||||
lines.append("")
|
||||
if summary_text:
|
||||
lines.append(summary_text)
|
||||
if url:
|
||||
lines.append(f"Source: {url}")
|
||||
if category:
|
||||
lines.append(f"Category: {category}")
|
||||
lines.append("")
|
||||
if core_conclusion:
|
||||
lines.append("## 核心结论")
|
||||
lines.append(core_conclusion)
|
||||
lines.append("")
|
||||
if highlights:
|
||||
lines.append("## Highlights")
|
||||
lines.extend(f"- {h}" for h in highlights)
|
||||
if main_argument:
|
||||
lines.append("## 主要论点")
|
||||
lines.append(main_argument)
|
||||
lines.append("")
|
||||
if key_methods:
|
||||
lines.append("## 关键方法 / 机制")
|
||||
lines.extend(f"- {m}" for m in key_methods)
|
||||
lines.append("")
|
||||
if important_details:
|
||||
lines.append("## 重要细节")
|
||||
lines.extend(f"- {d}" for d in important_details)
|
||||
lines.append("")
|
||||
if reusable_insights:
|
||||
lines.append("## 可复用启发")
|
||||
lines.extend(f"- {i}" for i in reusable_insights)
|
||||
lines.append("")
|
||||
if keywords:
|
||||
lines.append("## 关键词")
|
||||
lines.append("、".join(keywords))
|
||||
lines.append("")
|
||||
if topics:
|
||||
lines.append("## 主题")
|
||||
lines.append("、".join(topics))
|
||||
lines.append("")
|
||||
|
||||
safe_title = "-".join(
|
||||
|
||||
Reference in New Issue
Block a user