重构回忆录为 story-first / markdown-first 架构并整合图片意图与前端 UI 修复

本次 squash merge 将 codex-story-first-image-intent 的整体改动合入 development,核心内容包括:

1. 后端数据与迁移:新增 stories、story_versions、story_image_intents、chapter_cover_intents、assets 等模型与 Alembic 迁移,建立 story-first、markdown-first、asset-first 的主数据链路。

2. 生成与任务链:引入 StoryBuilderOrchestrator、ChapterComposerOrchestrator、story_image_tasks、chapter_cover_tasks,图片生成从正文占位符改为结构化 intent -> asset -> markdown 回填。

3. 并发与一致性:为 story/chapter intent 增加 claim_token、claimed_at、attempt_count,采用数据库原子 claim 为主、Redis 锁为辅,避免重复生成、锁误删和 processing 卡死。

4. Memoir 读写路径:章节 canonical_markdown 成为正文真源,列表/详情接口补齐 markdown、cover_asset、word_count 等字段,PDF 与 asset 解析链路同步升级。

5. Memory / Retrieval:扩展 transcript ingest、chunking、evidence 检索与 story 聚合基础设施,为后续 story-first RAG 与多 agent 编排提供底座。

6. App 端体验:章节页继续走 MarkdownRenderer 阅读链,同时吸收 fix3-19 的跨平台 UI glitch 修复;更新对话页、首页、文案资源与章节列表映射逻辑。

7. 测试与文档:补充 asset resolver、story image task、章节封面派发、markdown 映射等回归测试,并加入图片占位符退役设计文档。
This commit is contained in:
Kevin
2026-03-20 10:30:07 +08:00
parent 13e3124b85
commit 7f57f96c25
67 changed files with 4751 additions and 832 deletions

View File

@@ -7,11 +7,15 @@ from app.agents.memoir.extraction_agent import ExtractionAgent, ExtractionResult
from app.agents.memoir.classification_agent import ClassificationAgent
from app.agents.memoir.narrative_agent import NarrativeAgent
from app.agents.memoir.placeholder_agent import inject_placeholders
from app.agents.memoir.story_builder_orchestrator import StoryBuilderOrchestrator
from app.agents.memoir.chapter_composer_orchestrator import ChapterComposerOrchestrator
__all__ = [
"MemoryAgent",
"BackgroundTaskRunner",
"MemoirOrchestrator",
"StoryBuilderOrchestrator",
"ChapterComposerOrchestrator",
"ExtractionAgent",
"ExtractionResult",
"ClassificationAgent",

View File

@@ -0,0 +1,106 @@
"""
ChapterComposerOrchestrator — 读取 stories/evidence生成章节 markdown。
Agent 只产出结构化结果,不直接写 DB。
"""
from __future__ import annotations
from typing import Any
from app.core.logging import get_logger
logger = get_logger(__name__)
class ChapterComposerOrchestrator:
"""
生成章节大纲和章节 markdown。
仅返回 markdown不落库。
"""
def compose_chapter_markdown(
self,
*,
title: str,
category: str,
evidence: dict,
existing_markdown: str = "",
user_profile: str = "",
birth_year: int | None = None,
llm: Any = None,
) -> str:
"""
从 evidence 生成章节 markdown。
若有 existing_markdown 则追加/合并。
返回 markdown 正文,不写 DB。
"""
from app.agents.memoir.narrative_agent import NarrativeAgent
chunks = evidence.get("relevant_chunks", [])
facts = evidence.get("relevant_facts", [])
new_content = self._format_evidence_for_prompt(chunks, facts)
agent = NarrativeAgent()
narrative = agent.generate_narrative(
stage=category,
slots={},
new_content=new_content,
existing_content=existing_markdown,
user_profile=user_profile,
birth_year=birth_year,
llm=llm,
)
return self._to_markdown(narrative)
def _format_evidence_for_prompt(self, chunks: list, facts: list) -> str:
"""将 evidence 格式化为 prompt 输入。"""
parts = []
for c in chunks[:10]:
content = (
c.get("content", "")
if isinstance(c, dict)
else getattr(c, "content", "")
)
if content:
parts.append(content.strip())
for f in facts[:5]:
if isinstance(f, dict):
subj = f.get("subject", "")
pred = f.get("predicate", "")
obj = f.get("object_json", "")
if subj or pred:
parts.append(f"{subj} {pred} {obj}")
else:
parts.append(
f"{getattr(f, 'subject', '')} {getattr(f, 'predicate', '')}"
)
return "\n\n".join(parts) if parts else ""
def _to_markdown(self, narrative: str) -> str:
"""将 narrativeJSON 或纯文本)转为 markdown。正文不含占位符。"""
if not narrative or not narrative.strip():
return ""
if narrative.strip().startswith("{") and "paragraphs" in narrative:
import json
try:
data = json.loads(narrative)
paras = data.get("paragraphs", [])
if isinstance(paras, list):
parts = []
for p in paras:
if isinstance(p, dict):
text = p.get("content", p.get("text", ""))
else:
text = str(p)
if text.strip():
parts.append(text.strip())
md = "\n\n".join(parts)
else:
md = narrative
except json.JSONDecodeError:
md = narrative
else:
md = narrative.strip()
return md

View File

@@ -14,7 +14,6 @@ from app.agents.memoir.prompts import (
STAGE_TO_ORDER,
get_chapter_classification_prompt,
get_text_rewrite_prompt,
inject_image_placeholder_template,
)
from app.features.memoir.memoir_images.json_payload import extract_json_payload
@@ -78,15 +77,12 @@ class MemoryAgent:
)
content = content.strip()
result = json.loads(extract_json_payload(content))
result["content"] = inject_image_placeholder_template(
result.get("content") or ""
)
return result
except json.JSONDecodeError:
raw = response.content if hasattr(response, "content") else str(response)
return {
"title": CHAPTER_CATEGORIES.get(chapter_category, "章节"),
"content": inject_image_placeholder_template(raw),
"content": raw,
"summary": "",
"image_suggestions": [],
}

View File

@@ -97,7 +97,7 @@ class MemoirOrchestrator:
continue
category_to_segments.setdefault(chapter_category, []).append(segment)
# 2) 按 category 调用 process_category内含 NarrativeAgent、PlaceholderInject、持久化
# 2) 按 category 调用 process_category叙事生成、持久化、封面入队标记
for chapter_category, category_segments in category_to_segments.items():
if not acquire_lock(chapter_category):
logger.warning(

View File

@@ -57,8 +57,8 @@ _IMAGE_PLACEHOLDER_ANY_BRACES_RE = re.compile(
def inject_image_placeholder_template(content: str) -> str:
"""
入库前对章节正文做占位符处理:用正则匹配所有图片占位符位置,拼上固定模板
支持任意层数花括号,输出统一为四层大括号 + 固定模板 + 描述
对正文中的 IMAGE 占位符拼上固定风格模板(四层花括号)
**线上写路径已不使用**;保留供离线迁移脚本处理历史数据
"""
if not content or not content.strip():
return content
@@ -92,7 +92,6 @@ def get_system_prompt() -> str:
4. 将口语化表达改写为书面语,保持原意和情感
5. 生成合适的章节标题和段落结构
6. 提取关键信息,形成连贯的叙述
7. 建议插图位置(在描述场景、人物、地点的地方)
## 内容筛选原则(最重要)
对话中往往夹杂大量与回忆录无关的噪音,你必须严格筛选,只保留有价值的内容:
@@ -171,24 +170,14 @@ def get_text_rewrite_prompt(
请按照以下格式返回 JSON
{{
"title": "章节标题",
"content": "改写后的书面语内容(包含图片占位符)",
"content": "改写后的书面语内容",
"summary": "章节摘要50字以内"
}}
要求:
1. 标题要简洁有力,能概括章节主题
2. 内容要流畅自然,保持原意和情感
3. 如果已有章节内容,请将新内容与已有内容自然融合
4. 在内容中适当位置插入图片占位符
## 图片占位符格式(必须严格遵守)
- **唯一合法格式**:开头恰好四个左花括号、结尾恰好四个右花括号,中间为 IMAGE:具体描述。即:{{{{IMAGE:具体的图片描述}}}}
- 禁止使用两层 {{ }}、六层 {{{{{{ }}}}}} 或任意其它层数,否则会在手机端显示异常。
- 占位符单独占一行,描述要具体、有画面感。系统会在入库前自动拼上统一风格模板,你只需写场景描述即可。
正确示例(仅此格式):
{{{{IMAGE:南方小镇的青石板路,两旁是白墙黑瓦的老房子}}}}
{{{{IMAGE:奶奶坐在院子里的藤椅上,手里摇着蒲扇}}}}"""
3. 如果已有章节内容,请将新内容与已有内容自然融合"""
def get_state_extraction_prompt(
@@ -350,30 +339,11 @@ def get_narrative_prompt(
3. **只输出新内容的改写结果**,不要重复已有内容
4. 如果有衔接上下文,确保新内容与之自然衔接(语气、时间线连贯)
5. 语气自然,有情绪
6. 在适合配图的地方插入图片占位符
7. 如果有用户的基本信息(出生地、成长地等),在叙述中自然融入地域文化和时代背景
6. 如果有用户的基本信息(出生地、成长地等),在叙述中自然融入地域文化和时代背景
8. **不要将对话中的交互性语言(如"我跟你说""你知道吗")写入叙述**
9. **不要在正文中插入章节标题或分类标签**(如"章节:信念与价值观""## 童年与成长背景"等),章节标题由系统单独管理
## 图片占位符格式(必须严格遵守)
- **唯一合法格式**:开头恰好四个左花括号、结尾恰好四个右花括号,即:{{{{IMAGE:具体的图片描述}}}}
- 禁止两层 {{ }}、六层 {{{{{{ }}}}}} 或其它层数,否则会在手机端显示多余花括号。
- 占位符单独占一行,描述要具体、有画面感。系统会在入库前自动拼上统一风格模板,你只需写场景描述即可。
正确示例(仅此格式):
- {{{{IMAGE:南方小镇的青石板路,两旁是白墙黑瓦的老房子}}}}
- {{{{IMAGE:奶奶坐在院子里的藤椅上,手里摇着蒲扇}}}}
- {{{{IMAGE:少年背着书包站在火车站台上,回望身后的小镇}}}}
- {{{{IMAGE:泛黄的大学录取通知书,压在一摞旧课本下}}}}
图片占位符要求:
- 描述要具体、有画面感,便于后续生成或匹配图片
- 每 200-300 字左右可以插入一个
- 单独占一行,不要嵌入段落中
- 不要使用括号或星号等其他格式
- **花括号必须且仅能为四层**{{{{}}}} 各四个,不多不少
只输出新对话内容的改写结果(包含图片占位符)。如果对话中没有值得记录的人生经历内容,输出空字符串。
只输出新对话内容的改写结果。如果对话中没有值得记录的人生经历内容,输出空字符串。
"""
@@ -415,19 +385,18 @@ def get_narrative_json_prompt(
1. 从对话中提炼与人生经历相关的核心内容过滤语气词、寒暄、与AI的交互
2. 使用第一人称,改写为流畅的书面叙述,不要直接引用对话原话
3. 只输出新内容的改写,不要重复已有内容
4. 每 200-300 字左右一个段落,每个段落配一张图
4. 每 200-300 字左右一个段落
5. 如有衔接上下文,确保新内容与之自然衔接
## 输出格式(严格 JSON
{{
"paragraphs": [
{{"content": "段落正文", "image_description": "该段配图的场景描述,具体有画面感"}},
{{"content": "段落正文"}},
...
]
}}
- content: 本段纯正文,不含占位符
- image_description: 该段配图的场景描述,具体、有画面感,便于生成图片。示例:南方小镇的青石板路,两旁是白墙黑瓦的老房子
- content: 本段纯正文
如果对话中没有值得记录的人生经历内容,输出:{{"paragraphs": []}}
"""

View File

@@ -0,0 +1,107 @@
"""
StoryBuilderOrchestrator — 组织 evidence调用 StorySynthesisAgent产出 story markdown。
Agent 只产出结构化结果,不直接写 DB。
"""
from __future__ import annotations
from typing import Any
from app.core.logging import get_logger
logger = get_logger(__name__)
class StoryBuilderOrchestrator:
"""
判断新增 story、补充现有 story、合并重复 story。
组织 evidence bundle生成或更新 story markdown。
仅返回结构化输出,不落库。
"""
def build_story_markdown(
self,
*,
evidence: dict,
stage: str,
story_type: str | None = None,
existing_markdown: str = "",
user_profile: str = "",
birth_year: int | None = None,
llm: Any = None,
) -> str:
"""
从 evidence 生成 story markdown。
若有 existing_markdown 则做补充/合并。
返回 markdown 正文,不写 DB。
"""
from app.agents.memoir.narrative_agent import NarrativeAgent
chunks = evidence.get("relevant_chunks", [])
facts = evidence.get("relevant_facts", [])
new_content = self._format_evidence_for_prompt(chunks, facts)
agent = NarrativeAgent()
markdown = agent.generate_narrative(
stage=stage,
slots={},
new_content=new_content,
existing_content=existing_markdown,
user_profile=user_profile,
birth_year=birth_year,
llm=llm,
)
return self._to_markdown(markdown)
def _format_evidence_for_prompt(self, chunks: list, facts: list) -> str:
"""将 evidence 格式化为 prompt 输入。"""
parts = []
for c in chunks[:10]:
content = (
c.get("content", "")
if isinstance(c, dict)
else getattr(c, "content", "")
)
if content:
parts.append(content.strip())
for f in facts[:5]:
if isinstance(f, dict):
subj = f.get("subject", "")
pred = f.get("predicate", "")
obj = f.get("object_json", "")
if subj or pred:
parts.append(f"{subj} {pred} {obj}")
else:
parts.append(
f"{getattr(f, 'subject', '')} {getattr(f, 'predicate', '')}"
)
return "\n\n".join(parts) if parts else ""
def _to_markdown(self, narrative: str) -> str:
"""将 narrativeJSON 或纯文本)转为 markdown。正文不包含占位符图片意图由 StoryImageIntentExtractor 提取。"""
if not narrative or not narrative.strip():
return ""
if narrative.strip().startswith("{") and "paragraphs" in narrative:
import json
try:
data = json.loads(narrative)
paras = data.get("paragraphs", [])
if isinstance(paras, list):
parts = []
for p in paras:
if isinstance(p, dict):
text = p.get("content", p.get("text", ""))
else:
text = str(p)
if text.strip():
parts.append(text.strip())
md = "\n\n".join(parts)
else:
md = narrative
except json.JSONDecodeError:
md = narrative
else:
md = narrative.strip()
return md