fix(memoir): 改善 story 合并决策,少生碎片篇

以前模型只看到很短预览,还容易被引导成新建 story。现在优先用已有摘要、
按需带正文片段,并区分「像续写同一主题」和「像换了一件事」;
beliefs/summary 更鼓励接着写, career/童年等仍可按新事件新开。
This commit is contained in:
Kevin
2026-04-03 11:02:05 +08:00
parent 545d5a4ae0
commit b853b986dd
7 changed files with 715 additions and 49 deletions

View File

@@ -436,6 +436,40 @@ def get_narrative_merge_json_prompt(
"""
def story_route_merge_hint_for_category(chapter_category: str) -> str:
"""按章节类目的 append/new 倾向(与 StoryRouteAgent 路由提示共用)。"""
cc = (chapter_category or "").strip()
if cc in ("beliefs", "summary"):
return (
"### 本章类别路由倾向(强主题容器)\n"
"- 多条短感悟、同一价值维度、同一总结脉络的补充 → **优先 append_story**"
"选最匹配的一条候选 id。\n"
"- 仅在用户明确讲述**与所有候选主题明显不相关**、且可独立成篇的长经历时,才用 new_story。"
)
if cc == "family":
return (
"### 本章类别路由倾向(家庭)\n"
"- 原则性反思、关系模式、相处之道的补充 → **倾向 append_story**。\n"
"- **明确的新事件链**(新场景、新时间线、不同人物组合的新经历)→ 可 new_story。"
)
if cc in (
"childhood",
"education",
"career_early",
"career_achievement",
"career_challenge",
):
return (
"### 本章类别路由倾向(经历叙事)\n"
"- 以具体事件链为主:**不同事件 / 时期 / 地点** → 可 new_story。\n"
"- 明显是**同一段经历的续叙、补充细节** → append_story。"
)
return (
"### 本章类别路由倾向(一般)\n"
"- 同时参考「主题连续性」与「事件切换」两类信号做判断。"
)
def get_story_route_prompt(
*,
chapter_category: str,
@@ -448,15 +482,24 @@ def get_story_route_prompt(
「故事」= 可独立讲述的一段人生经历;进入本步的批次已归入具体 chapter category
(含模型返回 none 或零散档案启发式时映射的 summary
"""
return f"""你是回忆录编辑助手。根据本批用户口述与候选故事列表,决定:
- append_story内容明显延续、补充某一已有故事的主题与时间线且能对应到具体 candidate id
- new_story新话题、新人生阶段片段或与所有候选故事都不够贴合
merge_hint = story_route_merge_hint_for_category(chapter_category)
return f"""你是回忆录编辑助手。根据本批用户口述与【候选故事】决定 append_story 或 new_story。
**JSON 输出**:接口已启用 `response_format=json_object`,只输出下面 schema 的一个合法 JSON 对象,不要 markdown。
「故事」在此指:**可独立讲述的一段人生经历**——单一主题或同一事件链;不要假设本批里包含多个互不相关的故事(多段由系统其它步骤处理)。
## 两层决策标准(必须先在心里过一遍)
1. **主题连续性信号**:价值观、关系模式、长期总结、同一反思维度;口述是否像在**同一主题容器**里加厚?
2. **事件切换信号**:是否出现**新人物组合、新地点、新时间段、新事件因果链**,与候选正文明显是**另一段经历**
**路由边界(必须遵守)**:仅根据下方「本批口述合并文本」判断 new_story 与 append_story不得将系统检索摘要、记忆摘录、图谱事实或其它非用户口述材料当作本批口述内容来匹配候选故事
- 类别 **beliefs / summary**:更重主题连续性;除非事件切换信号极强,否则倾向 append
- 类别 **career_* / childhood / education**:更重事件链;不同事件可 new同一经历续聊则 append。
- 类别 **family**:两类信号兼顾——原则/关系反思倾向 append明确新事件链可 new。
{merge_hint}
**路由边界(必须遵守)**:仅根据下方「本批口述合并文本」判断;不得将系统检索摘要、记忆摘录等当作本批口述内容来匹配候选。
**候选故事说明**:列表项可能含 `summary` 或 `body_for_route`(正文摘要);仅含 `preview` 者为索引项,信息不全。**append 时优先匹配带 summary 或 body 的条目**;索引项仅作候选 id 备忘。
当前章节(写作容器):
- category: {chapter_category}
@@ -465,7 +508,7 @@ def get_story_route_prompt(
【本批口述合并文本】
{batch_transcript}
【候选故事】(仅允许在 append 时选择其中的 idid 必须原样复制)
【候选故事】append 时 target_story_id 必须来自下列 id原样复制)
{candidate_stories_json}
## 输出 JSON仅此一个对象不要 markdown
@@ -476,7 +519,8 @@ def get_story_route_prompt(
}}
规则:
- 若无法自信匹配某一候选,选 new_story
- **不要**只因「不太确定」就选 new_story在主题可并入某一候选时应 append_story
- 仅当口述与**所有**候选在两层标准下都明显不兼容时,才选 new_story。
"""
@@ -488,17 +532,28 @@ def get_story_batch_plan_prompt(
candidate_stories_json: str,
) -> str:
"""同一章节类别下多 segment划分为若干写入单元每单元 new 或 append。输出严格 JSON。"""
merge_hint = story_route_merge_hint_for_category(chapter_category)
return f"""你是回忆录编辑助手。下面同一章节类别下有一批**按时间顺序**的用户口述片段(每段有 id 与文本)。
**JSON 输出**:接口已启用 `response_format=json_object`,只输出下面 schema 的一个合法 JSON 对象,不要 markdown。
## 两层决策标准(每一块都要应用)
1. **主题连续性信号**:价值观、关系模式、长期总结、同一反思维度。
2. **事件切换信号**:新人物组合、新地点、新时间段、新事件因果链。
各类别倾向与单段路由一致beliefs/summary 重主题连续性career/childhood/education 重事件链family 兼顾。
{merge_hint}
## 「故事」定义(必须遵守)
一段「故事」= **可独立讲述的一段人生经历**:单一主题或同一事件链,能单独成篇。若话题切换、时间线跳到另一件事、人物/主线明显变化,应作为**新的故事**new_story而不是塞进同一段 append
一段「故事」= **可独立讲述的一段人生经历**。**同一主题容器内的连续口述**应并入同一块 append而不是切碎成多个 new_story
## 任务
将本批 segment **划分为连续若干块**(每块包含至少一个 segment顺序不能打乱每个 segment 必须恰好属于一块)。对每一块决定:
- **append_story**内容明显延续、补充**某一已有候选故事**的主题与时间线,且能对应到具体 candidate id
- **new_story**新话题、与所有候选故事都不够贴合、或应独立成篇的片段
将本批 segment **划分为连续若干块**(每块至少一个 segment顺序不能打乱每个 segment 必须恰好属于一块)。对每一块决定:
- **append_story**与某一候选在两层标准下可合并,且能对应到具体 candidate id
- **new_story**该块与**所有**候选都明显不兼容,或确认为独立新经历
**候选故事说明**:条目可能含 `summary`/`body_for_route`;仅 `preview` 者为索引项。**优先用带摘要/正文的条目做 append 目标**。
当前章节(写作容器):
- category: {chapter_category}
@@ -507,7 +562,7 @@ def get_story_batch_plan_prompt(
【本批口述片段】JSON 数组,顺序即口述顺序)
{segments_json}
【候选故事】(仅允许在 append 时选择其中的 idid 必须原样复制)
【候选故事】append 时 target_story_id 必须来自下列 id原样复制)
{candidate_stories_json}
## 输出 JSON仅此一个对象不要 markdown
@@ -524,7 +579,7 @@ def get_story_batch_plan_prompt(
规则:
- `units` 中所有 `segment_ids` 拼接后,必须**不重不漏**地覆盖本批全部 id且顺序与【本批口述片段】数组一致
- 若无法自信匹配某一候选,对该块选 new_story
- **不要**仅因不确定就对整块选 new_story能并入候选时应 append_story
"""

View File

@@ -13,6 +13,8 @@ from app.agents.memoir.prompts import (
get_story_batch_plan_prompt,
get_story_route_prompt,
)
from app.agents.memoir.story_route_payload import build_route_candidate_json
from app.core.config import settings
from app.core.langchain_llm import invoke_json_object
from app.core.logging import get_logger
from app.features.story.models import Story
@@ -63,40 +65,6 @@ class StoryRouteDecision(BaseModel):
return str(v)
def _build_candidate_json(
stories: list[Story],
*,
preview_chars: int = 220,
story_meta: dict[str, dict[str, int]] | None = None,
) -> str:
"""story_meta: story_id -> { char_count, version_count },供路由感知篇幅与版本数。"""
rows: list[dict[str, Any]] = []
meta = story_meta or {}
for s in stories:
md = (s.canonical_markdown or "").strip().replace("\n", " ")
preview = md[:preview_chars] + ("" if len(md) > preview_chars else "")
links: list[str] = []
for cl in getattr(s, "chapter_links", None) or []:
ch = getattr(cl, "chapter", None)
if ch is None:
continue
cat = getattr(ch, "category", None) or ""
tit = getattr(ch, "title", None) or ""
links.append(f"{tit}({cat})")
row: dict[str, Any] = {
"id": s.id,
"title": s.title,
"preview": preview,
"linked_chapters": links,
}
m = meta.get(str(s.id))
if m:
row["char_count"] = int(m.get("char_count", 0))
row["version_count"] = int(m.get("version_count", 0))
rows.append(row)
return json.dumps(rows, ensure_ascii=False, indent=2)
def _build_segments_json_for_plan(
segments: list[tuple[str, str]], *, text_preview_chars: int = 4000
) -> str:
@@ -157,7 +125,7 @@ class StoryRouteAgent:
new_story_title=None,
reason="no_llm",
)
payload = _build_candidate_json(candidate_stories, story_meta=story_meta)
payload = build_route_candidate_json(candidate_stories, story_meta, settings)
prompt = get_story_route_prompt(
chapter_category=chapter_category,
chapter_title=chapter_title,
@@ -211,7 +179,7 @@ class StoryRouteAgent:
"""
if not llm or len(segments) < 2:
return None
payload = _build_candidate_json(candidate_stories, story_meta=story_meta)
payload = build_route_candidate_json(candidate_stories, story_meta, settings)
segments_json = _build_segments_json_for_plan(segments)
prompt = get_story_batch_plan_prompt(
chapter_category=chapter_category,

View File

@@ -0,0 +1,230 @@
"""
Story 路由:候选故事 JSON 载荷summary 优先、预算裁剪、固定排序)。
供 StoryRouteAgent 与单测复用。
"""
from __future__ import annotations
import json
from datetime import timezone
from typing import Any, TYPE_CHECKING
if TYPE_CHECKING:
from app.core.config import Settings
from app.features.story.models import Story
def _linked_chapters(s: Story) -> list[str]:
links: list[str] = []
for cl in getattr(s, "chapter_links", None) or []:
ch = getattr(cl, "chapter", None)
if ch is None:
continue
cat = getattr(ch, "category", None) or ""
tit = getattr(ch, "title", None) or ""
links.append(f"{tit}({cat})")
return links
def _updated_at_iso(s: Story) -> str:
ua = getattr(s, "updated_at", None)
if ua is None:
return ""
if ua.tzinfo is None:
ua = ua.replace(tzinfo=timezone.utc)
return ua.isoformat()
def _has_usable_summary(s: Story, summary_min_len: int) -> bool:
t = (getattr(s, "summary", None) or "").strip()
return len(t) >= summary_min_len
def _truncate_body_for_route(
md: str,
*,
body_max_chars: int,
head_chars: int,
tail_chars: int,
) -> str:
"""单篇正文进入路由 prompt 的裁剪:尽量全文,否则 head+tail。"""
m = (md or "").strip()
if not m:
return ""
if len(m) <= body_max_chars:
return m
hc = max(1, min(head_chars, body_max_chars // 2))
tc = max(1, min(tail_chars, body_max_chars // 2))
mid_omit = len(m) - hc - tc
if mid_omit <= 0:
return m[:body_max_chars]
return f"{m[:hc]}\n…(中间省略 {mid_omit} 字)…\n{m[-tc:]}"
def sort_stories_for_route(
stories: list[Story],
story_meta: dict[str, dict[str, int]],
*,
summary_min_chars: int,
) -> list[Story]:
"""has_summary(desc) → updated_at(desc) → version_count(desc) → char_count(desc) → id(asc)"""
def key(s: Story) -> tuple:
sid = str(s.id)
m = story_meta.get(sid) or {}
vc = int(m.get("version_count", 0))
cc = int(m.get("char_count", 0))
ua = getattr(s, "updated_at", None)
ts = 0.0
if ua is not None:
if ua.tzinfo is None:
ua = ua.replace(tzinfo=timezone.utc)
ts = ua.timestamp()
return (
not _has_usable_summary(s, summary_min_chars),
-ts,
-vc,
-cc,
sid,
)
return sorted(stories, key=key)
def _build_full_row(
s: Story,
story_meta: dict[str, dict[str, int]],
*,
summary_min_chars: int,
body_max_chars: int,
head_chars: int,
tail_chars: int,
) -> dict[str, Any]:
sid = str(s.id)
meta = story_meta.get(sid) or {}
canon = (s.canonical_markdown or "").strip()
char_count = int(meta.get("char_count", len(canon)))
version_count = int(meta.get("version_count", 0))
row: dict[str, Any] = {
"id": s.id,
"title": s.title,
"char_count": char_count,
"version_count": version_count,
"updated_at": _updated_at_iso(s),
"linked_chapters": _linked_chapters(s),
}
if _has_usable_summary(s, summary_min_chars):
row["summary"] = (getattr(s, "summary", None) or "").strip()
return row
body = _truncate_body_for_route(
canon,
body_max_chars=body_max_chars,
head_chars=head_chars,
tail_chars=tail_chars,
)
if body:
row["body_for_route"] = body
return row
def _build_index_row(
s: Story,
story_meta: dict[str, dict[str, int]],
*,
preview_chars: int,
) -> dict[str, Any]:
sid = str(s.id)
meta = story_meta.get(sid) or {}
canon = (s.canonical_markdown or "").strip().replace("\n", " ")
preview = canon[:preview_chars] + ("" if len(canon) > preview_chars else "")
char_count = int(meta.get("char_count", len((s.canonical_markdown or "").strip())))
return {
"id": s.id,
"title": s.title,
"char_count": char_count,
"preview": preview,
}
def _rows_json_len(rows: list[dict[str, Any]]) -> int:
return len(json.dumps(rows, ensure_ascii=False))
def apply_total_budget_downgrade(
rows: list[dict[str, Any]],
*,
stories_by_id: dict[str, Story],
story_meta: dict[str, dict[str, int]],
total_max_chars: int,
index_preview_chars: int,
) -> list[dict[str, Any]]:
"""从列表尾部(低优先级)起将整行降级为索引行,直到 JSON 总长不超过预算。"""
out = [dict(r) for r in rows]
def _is_index_row(r: dict[str, Any]) -> bool:
return "preview" in r and "summary" not in r and "body_for_route" not in r
while _rows_json_len(out) > total_max_chars:
replaced = False
for i in range(len(out) - 1, -1, -1):
sid = str(out[i].get("id", ""))
st = stories_by_id.get(sid)
if st is None or _is_index_row(out[i]):
continue
out[i] = _build_index_row(
st,
story_meta,
preview_chars=index_preview_chars,
)
replaced = True
break
if not replaced:
break
return out
def build_route_candidate_rows(
stories: list[Story],
story_meta: dict[str, dict[str, int]] | None,
settings: "Settings",
) -> list[dict[str, Any]]:
"""排序 + 完整候选行(尚未做总预算降级)。"""
meta = story_meta or {}
summary_min = int(settings.story_route_summary_min_chars)
ordered = sort_stories_for_route(stories, meta, summary_min_chars=summary_min)
body_max = int(settings.story_route_candidate_body_max_chars)
head_c = int(settings.story_route_long_body_head_chars)
tail_c = int(settings.story_route_long_body_tail_chars)
rows: list[dict[str, Any]] = []
for s in ordered:
rows.append(
_build_full_row(
s,
meta,
summary_min_chars=summary_min,
body_max_chars=body_max,
head_chars=head_c,
tail_chars=tail_c,
)
)
by_id = {str(s.id): s for s in ordered}
total_max = int(settings.story_route_candidate_total_max_chars)
index_prev = int(settings.story_route_index_preview_chars)
return apply_total_budget_downgrade(
rows,
stories_by_id=by_id,
story_meta=meta,
total_max_chars=total_max,
index_preview_chars=index_prev,
)
def build_route_candidate_json(
stories: list[Story],
story_meta: dict[str, dict[str, int]] | None,
settings: "Settings",
) -> str:
rows = build_route_candidate_rows(stories, story_meta, settings)
return json.dumps(rows, ensure_ascii=False, indent=2)

View File

@@ -217,6 +217,15 @@ class Settings(BaseSettings):
# Append 硬上限canonical 字符数、版本数(超限强制 new_story
story_append_max_canonical_chars: int = Field(default=12000, ge=1000, le=500_000)
story_append_max_versions: int = Field(default=20, ge=1, le=500)
# StoryRouteAgent候选 JSON 预算(保守默认,可调大)
story_route_candidate_body_max_chars: int = Field(default=1600, ge=200, le=8000)
story_route_candidate_total_max_chars: int = Field(
default=16_000, ge=2000, le=100_000
)
story_route_long_body_head_chars: int = Field(default=700, ge=100, le=4000)
story_route_long_body_tail_chars: int = Field(default=700, ge=100, le=4000)
story_route_summary_min_chars: int = Field(default=30, ge=0, le=500)
story_route_index_preview_chars: int = Field(default=80, ge=20, le=500)
# Evidence 检索 top_k大批次 unit 时降低检索量
evidence_top_k_default: int = Field(default=10, ge=1, le=50)
evidence_top_k_large_batch: int = Field(default=5, ge=1, le=50)