feat: 优化回忆录内容处理和章节分类逻辑

- 更新 get_system_prompt 函数,增强对话内容的核心信息提炼和分类能力,确保只保留与人生经历相关的实质内容。
- 修改 _classify_chapter_category 函数,增加对无实质回忆录价值内容的处理,返回 None 以跳过无效段落。
- 在 Android 客户端中,更新章节阅读视图以移除内嵌章节标题,提升排版一致性。
- 新增 TextUtils 工具函数,专门用于移除 LLM 生成的内嵌章节标题,确保正文内容的流畅性。
This commit is contained in:
penghanyuan
2026-03-02 19:47:32 +01:00
parent 4a331428f7
commit 8b4a058640
5 changed files with 89 additions and 32 deletions

View File

@@ -49,22 +49,41 @@ def get_system_prompt() -> str:
return """你是一位专业的传记作家和文字编辑,擅长将口语化的对话内容整理成优雅的书面语回忆录章节。
你的任务:
1. 接收对话段落文本(口语化)
2. 识别内容主题,归类到对应章节(童年/教育/事业/家庭/信念/总结)
3. 将口语化表达改写为书面语,保持原意和情感
4. 生成合适的章节标题和段落结构
5. 提取关键信息,形成连贯的叙述
6. 建议插图位置(在描述场景、人物、地点的地方)
1. 接收对话段落文本(口语化,可能来自语音转写
2. **先提炼对话中与人生经历相关的核心内容**,过滤掉无关信息
3. 识别内容主题,归类到对应章节(童年/教育/事业/家庭/信念/总结)
4. 将口语化表达改写为书面语,保持原意和情感
5. 生成合适的章节标题和段落结构
6. 提取关键信息,形成连贯的叙述
7. 建议插图位置(在描述场景、人物、地点的地方)
改写原则:
- 保持用户的真实声音和情感
- 使用优雅但不失亲切的书面语
## 内容筛选原则(最重要)
对话中往往夹杂大量与回忆录无关的噪音,你必须严格筛选,只保留有价值的内容:
应该保留的内容:
- 具体的人生事件、经历、故事
- 提到的人物及其关系(家人、朋友、同事、恩师等)
- 地点、时间、场景描写
- 用户的情感表达、内心感受
- 人生感悟、价值观、信念
- 具体的细节(食物、声音、画面等)
应该过滤掉的内容:
- 语气词、填充词(嗯、啊、那个、就是说、对对对、然后呢等)
- 对话中的寒暄、问候(你好、谢谢、好的等)
- 用户与AI助手之间的交互指令你帮我、我想问、你说得对等
- 重复、冗余的表述(取核心含义即可)
- 与个人经历完全无关的闲聊内容
## 改写原则
- 保持用户的真实情感
- 使用优雅但不失亲切的书面语,不要直接引用对话原话
- 适当添加过渡句,使段落连贯
- 保留生动的细节和对话
- 去除口语中的"""那个"填充词
- 保留生动的细节,但将口语表达改写为书面叙述
- 去除口语中的填充词和无意义重复
- 保持时间顺序和逻辑清晰
章节分类规则
## 章节分类规则
- 童年相关 → "童年与成长背景"
- 学校、老师、同学 → "教育经历与青年时期"
- 工作、职业、成就 → "主要成就与巅峰时刻""崭露头角"
@@ -79,7 +98,7 @@ def get_chapter_classification_prompt(segments_text: str) -> str:
"""获取章节分类的提示词"""
return f"""{get_system_prompt()}
请分析以下对话内容,判断应该归类到哪个章节类别:
请分析以下对话内容,**忽略其中的语气词、寒暄和无关对话**,只关注涉及人生经历的实质内容,判断应该归类到哪个章节类别:
- childhood: 童年与成长背景
- education: 教育经历与青年时期
- career_early: 崭露头角(早期事业)
@@ -92,7 +111,8 @@ def get_chapter_classification_prompt(segments_text: str) -> str:
对话内容:
{segments_text}
请只返回章节类别childhood不要返回其他内容。"""
请只返回章节类别childhood不要返回其他内容。
如果对话内容中没有任何与人生经历相关的实质内容,返回 none。"""
def get_text_rewrite_prompt(segments_text: str, chapter_category: str, existing_content: str = "") -> str:
@@ -148,7 +168,7 @@ def get_state_extraction_prompt(user_message: str, current_stage: str, stage_slo
return f"""{get_system_prompt()}
你需要从用户话语中抽取结构化信息,并判断用户实际在谈论哪个人生阶段。
你需要从用户话语中**先提炼与人生经历相关的核心内容**,然后抽取结构化信息,并判断用户实际在谈论哪个人生阶段。
系统当前跟踪的阶段:{current_stage}
该阶段可填 slots{slot_keys}
@@ -170,11 +190,12 @@ def get_state_extraction_prompt(user_message: str, current_stage: str, stage_slo
}}
要求:
1. **detected_stage 必须根据用户话语的实际内容判断**,不要默认沿用系统当前阶段。用户可能在聊不同阶段的事情。
2. slots 的 key 必须属于 detected_stage 对应的 slot 列表
3. slots 只填写确实提到的内容
4. snippet 保持用户原话风格50 字以内
5. 如果没有明确内容slots 为空对象
1. **先忽略话语中的语气词、填充词、寒暄、与AI的交互指令等无关内容**,只关注涉及人生经历的实质信息
2. **detected_stage 必须根据用户话语的实际内容判断**,不要默认沿用系统当前阶段。用户可能在聊不同阶段的事情
3. slots 的 key 必须属于 detected_stage 对应的 slot 列表
4. slots 只填写确实提到的、与人生经历相关的实质内容
5. **snippet 应是提炼后的核心信息**去除语气词和冗余表达50 字以内
6. 如果用户话语中没有任何与人生经历相关的实质内容如纯粹的寒暄、指令、语气词slots 为空对象
"""
@@ -266,14 +287,23 @@ def get_narrative_prompt(
{new_content}
{context_section}
要求:
## 第一步:提炼核心内容
在改写之前,请先从对话内容中提炼出与人生经历相关的核心信息:
- 提取具体的事件、人物、地点、时间、感受
- 丢弃语气词嗯、啊、那个、就是说、寒暄你好、谢谢、与AI的交互你帮我整理一下、对对对你说得对、无意义的重复
- 如果对话内容中几乎没有与人生经历相关的实质内容,请输出空字符串
## 第二步:改写为叙述
基于提炼后的核心内容进行文学改写:
1. 使用第一人称叙述
2. 保留少量原话(引用)
2. **不要直接引用对话原话**,将所有内容改写为流畅的书面叙述
3. **只输出新内容的改写结果**,不要重复已有内容
4. 如果有衔接上下文,确保新内容与之自然衔接(语气、时间线连贯)
5. 语气自然,有情绪
6. 在适合配图的地方插入图片占位符
7. 如果有用户的基本信息(出生地、成长地等),在叙述中自然融入地域文化和时代背景
8. **不要将对话中的交互性语言(如"我跟你说""你知道吗")写入叙述**
9. **不要在正文中插入章节标题或分类标签**(如"章节:信念与价值观""## 童年与成长背景"等),章节标题由系统单独管理
## 图片占位符格式
在描述场景、人物、重要时刻的段落后,插入图片占位符,格式为:
@@ -291,6 +321,6 @@ def get_narrative_prompt(
- 单独占一行,不要嵌入段落中
- 不要使用括号或星号等其他格式
只输出新对话内容的改写结果(包含图片占位符)。
只输出新对话内容的改写结果(包含图片占位符)。如果对话中没有值得记录的人生经历内容,输出空字符串。
"""

View File

@@ -98,16 +98,20 @@ def _detect_stage(user_message: str, fallback_stage: str) -> str:
return fallback_stage
def _classify_chapter_category(text: str, fallback_stage: str, llm=None) -> str:
def _classify_chapter_category(text: str, fallback_stage: str, llm=None) -> str | None:
"""
将内容分类到 8 个章节类别之一。
优先使用 LLM失败则按 5-stage 关键词映射到默认类别。
如果 LLM 判定内容无实质回忆录价值,返回 None。
"""
if llm:
try:
prompt = get_chapter_classification_prompt(text)
response = llm.invoke(prompt)
category = response.content.strip().lower()
if category == "none":
logger.info(f"LLM 判定内容无回忆录价值,跳过: {text[:80]}...")
return None
if category in CHAPTER_CATEGORIES:
return category
except Exception as e:
@@ -264,6 +268,9 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]):
# 8-category 章节分类
chapter_category = _classify_chapter_category(text, detected_stage, llm)
if chapter_category is None:
logger.info(f"段落无回忆录价值,跳过: segment_id={segment.id}")
continue
category_to_segments.setdefault(chapter_category, []).append(segment)
# 按 8 分类生成章节内容

View File

@@ -48,10 +48,11 @@ fun ChapterReadingView(
modifier = Modifier.padding(bottom = 24.dp)
)
// 正文内容(支持 Markdown移除{{IMAGE}}占位符如果没有图片)
val processedContent = TextUtils.removeImagePlaceholders(
chapter.content,
hasImages = chapter.images.isNotEmpty()
val processedContent = TextUtils.removeInlineChapterHeadings(
TextUtils.removeImagePlaceholders(
chapter.content,
hasImages = chapter.images.isNotEmpty()
)
)
MarkdownText(
content = processedContent,

View File

@@ -64,10 +64,11 @@ fun FullTextReadingView(
modifier = Modifier.padding(bottom = 16.dp)
)
// 章节内容(支持 Markdown移除{{IMAGE}}占位符如果没有图片)
val processedContent = TextUtils.removeImagePlaceholders(
chapter.content,
hasImages = chapter.images.isNotEmpty()
val processedContent = TextUtils.removeInlineChapterHeadings(
TextUtils.removeImagePlaceholders(
chapter.content,
hasImages = chapter.images.isNotEmpty()
)
)
MarkdownText(
content = processedContent,

View File

@@ -66,6 +66,24 @@ object TextUtils {
return quotePattern.findAll(text).map { it.groupValues[1] }.toList()
}
/**
* 移除 LLM 生成的内嵌章节标题(如 "章节:信念与价值观"、"## 章节:童年与成长背景"
* 这些标题已由 UI 单独渲染,混在正文中会导致排版突兀
*/
fun removeInlineChapterHeadings(content: String?): String {
if (content.isNullOrBlank()) return ""
return content
// markdown 标题格式: #{1,6} 章节[:]...
.replace(Regex("^#{1,6}\\s*章节[:].*$", RegexOption.MULTILINE), "")
// 粗体格式: **章节:...**
.replace(Regex("\\*\\*章节[:].+?\\*\\*"), "")
// 纯文本格式: 独立一行的 "章节:..."
.replace(Regex("^章节[:].+$", RegexOption.MULTILINE), "")
// 清理移除后产生的多余空行
.replace(Regex("\n{3,}"), "\n\n")
.trim()
}
/**
* 移除图片占位符
* 如果没有图片,移除所有{{{{IMAGE:...}}}}和{{IMAGE:...}}格式的占位符