feat(memory,conversation): 记忆富化/证据包、时间线幂等字段与对话分段全链路
数据库 - 新增迁移 0003:timeline_events.memory_source_id 外键 → memory_sources,便于按 ingest 源做时间线幂等 后端 - 记忆 - 新增 ingest 后 LLM 富化(摘要/事实/时间线),可配置开关与最大字符数 - 新增证据包组装:合并 chunk、摘要、事实、时间线、故事等检索结果;支持空 query 时是否仍带 rolling 等开关 - repo/retriever/service/router/schemas/summarizer/timeline/extractor 等扩展;文档 memory-retrieval.md 更新 后端 - 对话 WS - 增加 PING/PONG;分段 ASR 日志与空音频处理;转写失败与「无助手回复」错误提示更明确 - 助手多段回复持久化使用统一分隔符,与分段逻辑一致 后端 - Agent - reply_limits:按 [SPLIT] 与段落拆段,并保证非空 fallback,供 WS 与 TTS 多段下发 后端 - 回忆录任务 - transcript ingest 记录 source_id;任务成功结?
This commit is contained in:
@@ -1,16 +1,16 @@
|
||||
"""
|
||||
ClassificationAgent:将内容分类到 8 个章节类别,或判定无价值返回 None。
|
||||
对应现有逻辑:_classify_chapter_category
|
||||
ClassificationAgent:将内容分类到 8 个章节类别之一。
|
||||
|
||||
返回 None 表示本段不进入回忆录 Story/章节流水线;与 StoryRoute 中「可独立讲述的一段人生经历」
|
||||
(见 prompts.get_story_route_prompt)在标准上对齐:零散档案点不进 Story,记忆与 slot 抽取仍由上游完成。
|
||||
原「LLM 返回 none / 零散档案启发式」不再跳过 Story:统一映射为 ``summary`` 章节,
|
||||
仍走叙事流水线落库;与 StoryRoute 仍兼容(批次内 new/append 规划不变)。
|
||||
Memory ingest 由 Celery 任务在批次级先行完成,与分类结果独立。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
from app.agents.memoir.prompts import (
|
||||
CHAPTER_CATEGORIES,
|
||||
@@ -22,6 +22,9 @@ from app.features.memoir.memoir_images.json_payload import extract_json_payload
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# 模型判定 none 或启发式命中零散档案时,仍写入回忆录正文所用的兜底章节
|
||||
_SUMMARY_FALLBACK_CATEGORY = "summary"
|
||||
|
||||
# 与「仅档案句式」组合使用;过短但明显为叙事句的仍交 LLM 判断
|
||||
_FRAGMENT_SHORT_MAX_LEN = 48
|
||||
|
||||
@@ -67,8 +70,8 @@ def _detect_stage(text: str, fallback_stage: str) -> str:
|
||||
|
||||
def _looks_like_fragment_only(text: str) -> bool:
|
||||
"""
|
||||
保守启发式:明显为档案点/标签句,不足以作为 Story 叙事单元。
|
||||
与 get_chapter_classification_prompt 中「应返回 none」的情形一致;误判风险通过窄正则控制。
|
||||
保守启发式:明显为档案点/标签句。
|
||||
命中时仍进回忆录正文,章节映射为 ``summary``(与 LLM 返回 none 一致)。
|
||||
"""
|
||||
s = (text or "").strip()
|
||||
if not s:
|
||||
@@ -107,26 +110,30 @@ def _parse_category_from_llm_response(raw: str) -> str:
|
||||
|
||||
|
||||
class ClassificationAgent:
|
||||
"""将内容分类到 8 个章节类别之一,或判定无价值返回 None"""
|
||||
"""将内容分类到 8 个章节类别之一;none/零散档案映射为 ``summary`` 仍进 Story。"""
|
||||
|
||||
def classify(
|
||||
self,
|
||||
text: str,
|
||||
fallback_stage: str,
|
||||
llm: Any,
|
||||
) -> Optional[str]:
|
||||
*,
|
||||
segment_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
分类到 8 个章节类别之一。
|
||||
若 LLM 判定内容不足以独立成篇(none)或启发式判定为零散档案点,返回 None。
|
||||
LLM 返回 none 或启发式为零散档案时,返回 ``summary``(仍走回忆录流水线)。
|
||||
llm 需支持 .invoke(prompt) 同步调用。
|
||||
"""
|
||||
if _looks_like_fragment_only(text):
|
||||
logger.debug(
|
||||
"零散档案/极短标签句,跳过回忆录 Story: text_len={} text={}",
|
||||
logger.info(
|
||||
"event=chapter_classification_summary_fallback reason=fragment_heuristic "
|
||||
"segment_id={} text_len={} category={}",
|
||||
segment_id or "",
|
||||
len(text or ""),
|
||||
text or "",
|
||||
_SUMMARY_FALLBACK_CATEGORY,
|
||||
)
|
||||
return None
|
||||
return _SUMMARY_FALLBACK_CATEGORY
|
||||
|
||||
if llm:
|
||||
try:
|
||||
@@ -139,12 +146,14 @@ class ClassificationAgent:
|
||||
)
|
||||
category = _parse_category_from_llm_response(raw)
|
||||
if category == "none":
|
||||
logger.debug(
|
||||
"LLM 判定内容不足以成篇,跳过: text_len={} text={}",
|
||||
logger.info(
|
||||
"event=chapter_classification_summary_fallback reason=llm_none "
|
||||
"segment_id={} text_len={} category={}",
|
||||
segment_id or "",
|
||||
len(text or ""),
|
||||
text or "",
|
||||
_SUMMARY_FALLBACK_CATEGORY,
|
||||
)
|
||||
return None
|
||||
return _SUMMARY_FALLBACK_CATEGORY
|
||||
if category in CHAPTER_CATEGORIES:
|
||||
return category
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
FidelityCheckAgent:比较「用户口述」与叙事 JSON 输出,判定是否存在明显编造或越界。
|
||||
失败时由流水线回退为口述正文(见 story_pipeline_sync)。
|
||||
续写合并(append)时传入 `existing_canonical_markdown`,将已有故事正文一并视为允许来源。
|
||||
失败时由流水线回退(见 story_pipeline_sync):续写为「已有 + 口述」,新建为口述原文。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -43,6 +44,7 @@ class FidelityCheckAgent:
|
||||
oral_text: str,
|
||||
narrative_json: str,
|
||||
llm: Any,
|
||||
existing_canonical_markdown: str | None = None,
|
||||
) -> bool:
|
||||
if not llm or not settings.memoir_fidelity_check_enabled:
|
||||
return True
|
||||
@@ -50,8 +52,32 @@ class FidelityCheckAgent:
|
||||
gen = (narrative_json or "").strip()
|
||||
if not oral or not gen:
|
||||
return True
|
||||
existing = (existing_canonical_markdown or "").strip()
|
||||
_log_suspicious_years_not_in_oral(oral, gen)
|
||||
prompt = f"""你是事实核对员。比较下面两段文字。
|
||||
if existing:
|
||||
prompt = f"""你是事实核对员。当前为**续写合并**:模型需要把「已有故事正文」与「本轮口述」合成一篇,生成稿**允许且应当**保留已有正文中的事实(可改写语序、合并段落),并融入本轮口述中的新事实。
|
||||
|
||||
【用户本轮口述】(本段亲口补充)
|
||||
{oral[:8000]}
|
||||
|
||||
【已有故事正文】(已落库、允许在生成稿中出现或改写;出现于此处的内容**不算**本轮编造)
|
||||
{existing[:12000]}
|
||||
|
||||
【模型生成的 JSON 叙事】
|
||||
{gen[:16000]}
|
||||
|
||||
判断:生成稿是否出现**既明显不在本轮口述、也明显不在已有故事正文**的具体人名、地名、时间、数字、事件经过、对话,或把摘录/档案里才有的信息写成了用户亲口经历?
|
||||
若内容可归因于「已有故事」或「本轮口述」的合理整理,pass=true。
|
||||
若存在无法归因的明显编造或越界,pass=false。
|
||||
|
||||
**JSON 输出**:只输出一个合法 JSON 对象。
|
||||
{{"pass": true, "reason": null}}
|
||||
或
|
||||
{{"pass": false, "reason": "一句话说明"}}
|
||||
|
||||
只输出 JSON,不要其它文字。"""
|
||||
else:
|
||||
prompt = f"""你是事实核对员。比较下面两段文字。
|
||||
|
||||
【用户口述】(亲历内容)
|
||||
{oral[:8000]}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
NarrativeAgent:生成创意标题和叙事改写。
|
||||
对应现有逻辑:get_creative_title_json_prompt、get_narrative_json_prompt
|
||||
叙事正文走 `get_narrative_json_prompt` / `get_narrative_merge_json_prompt`(传记作家式书面语 + 事实边界)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -91,6 +91,7 @@ class MemoirOrchestrator:
|
||||
text=text,
|
||||
fallback_stage=detected_stage,
|
||||
llm=llm,
|
||||
segment_id=segment.id,
|
||||
)
|
||||
if agent_summary_enabled():
|
||||
logger.info(
|
||||
@@ -108,13 +109,6 @@ class MemoirOrchestrator:
|
||||
segment.id,
|
||||
list((result.slots or {}).keys()),
|
||||
)
|
||||
if chapter_category is None:
|
||||
logger.debug(
|
||||
"段落无回忆录价值,跳过: segment_id={} transcript={}",
|
||||
segment.id,
|
||||
getattr(segment, "user_input_text", None) or "",
|
||||
)
|
||||
continue
|
||||
category_to_segments.setdefault(chapter_category, []).append(segment)
|
||||
|
||||
return PreparedMemoirBatches(
|
||||
|
||||
@@ -130,29 +130,67 @@ def get_memoir_editor_system_prompt() -> str:
|
||||
"""
|
||||
|
||||
|
||||
def get_memoir_fidelity_system_prompt() -> str:
|
||||
"""叙事/标题生成专用:准确性优先,禁止编造事实(与 get_memoir_editor_system_prompt 分离)。"""
|
||||
return """你是回忆录编辑助手,任务是把用户口述整理为第一人称书面叙述。
|
||||
|
||||
## 事实边界(必须遵守,优先于文采)
|
||||
def _memoir_fidelity_core_rules() -> str:
|
||||
"""事实边界 1–4 条(与文体第 5 条拆分,供 story 叙事与标题等复用)。"""
|
||||
return """## 事实边界(必须遵守,优先于文采)
|
||||
1. **正文只能展开「本段用户口述」区块中的内容**。若输入中有「相关记忆摘录」等参考区,其中信息**不得**写成本人本轮亲口经历的细节;最多用一两句作主题衔接,且不得引入摘录里才有的具体人名、地点、时间、对话、数字。
|
||||
2. **禁止编造**:不得新增用户未提及的具体人物姓名、对话原文、地点、时间、事件经过、因果、数字;不得推断性心理描写或「典型年代场景」填充。
|
||||
3. **禁止为凑字数扩写**:材料短则输出短;段落数量与长度随材料而定。
|
||||
4. 允许:去除口语赘词与寒暄、调整语序、合并重复指代、把口语改为书面语;**不得**用虚构细节「让文章更好看」。
|
||||
5. **叙述风格平实**:少用抒情、比喻与文学铺陈;像清楚记事,不要写成散文。
|
||||
4. 允许:去除口语赘词与寒暄、调整语序、合并重复指代、把口语改为书面语;**不得**用虚构细节「让文章更好看」。"""
|
||||
|
||||
## 用户档案与阶段信息
|
||||
|
||||
def _memoir_fidelity_user_profile_rules() -> str:
|
||||
return """## 用户档案与阶段信息
|
||||
- 「用户基本信息」「时间参考」仅可使用其中**已写明**的条目;不得把档案中的出生地等写进正文,除非用户在本段口述里已提及或明确关联。"""
|
||||
|
||||
|
||||
def get_narrative_editor_system_prompt() -> str:
|
||||
"""叙事改写:准确性系统提示 + 可执行文体约束(不用 get_memoir_editor_system_prompt 中的「过渡句/生动细节」泛化指令)。"""
|
||||
return f"""{get_memoir_fidelity_system_prompt()}
|
||||
def get_memoir_fidelity_system_prompt() -> str:
|
||||
"""叙事/标题生成专用:准确性优先,禁止编造事实(与 get_memoir_editor_system_prompt 分离)。"""
|
||||
return f"""你是回忆录编辑助手,任务是把用户口述整理为第一人称书面叙述。
|
||||
|
||||
## 文体(在严守事实的前提下)
|
||||
- 使用第一人称、**平实书面语**(少修辞、少感叹);不要直接引用对话原话。
|
||||
- 不使用 Markdown 标题(#、##)、不使用表格。
|
||||
- 如有「衔接上下文」,仅保持语气与时间线连贯,不重复已有段落全文。"""
|
||||
{_memoir_fidelity_core_rules()}
|
||||
5. **叙述风格平实**:少用抒情、比喻与文学铺陈;像清楚记事,不要写成散文。
|
||||
|
||||
{_memoir_fidelity_user_profile_rules()}"""
|
||||
|
||||
|
||||
def get_memoir_fidelity_facts_only_prompt() -> str:
|
||||
"""与 `get_memoir_fidelity_system_prompt` 相同的事实 1–4 条,第 5 条改为允许传记作家式文采(仍禁止编造)。"""
|
||||
return f"""你是回忆录编辑助手,任务是把用户口述整理为第一人称书面叙述。
|
||||
|
||||
{_memoir_fidelity_core_rules()}
|
||||
5. **文体**:在遵守第 1–4 条的前提下,可将口语改写为**优雅、连贯的回忆录书面语**(适当过渡句,保留并书面化用户已提及的细节与情感);文采服务于真实内容,**不得**用虚构描写替代或填补事实。
|
||||
|
||||
{_memoir_fidelity_user_profile_rules()}"""
|
||||
|
||||
|
||||
def _memoir_editor_narrative_style_block() -> str:
|
||||
"""与 `get_memoir_editor_system_prompt` 对齐的传记作家改写要点(用于写入 chapter 的 story 正文)。"""
|
||||
return """## 传记作家文体(须同时遵守上文「事实边界」)
|
||||
你是一位专业的传记作家和文字编辑,擅长将口语化的对话内容整理成优雅的书面语回忆录章节。
|
||||
|
||||
### 提炼与筛选
|
||||
对话中往往夹杂噪音,须严格筛选:保留具体事件、人物关系、时地、情感与信念、用户已提及的细节;过滤语气词、寒暄、与 AI 的交互、无关闲聊、重复冗余。
|
||||
|
||||
### 改写原则
|
||||
- 保持用户的真实情感
|
||||
- 使用优雅但不失亲切的书面语,不要直接引用对话原话
|
||||
- 适当添加过渡句,使段落连贯
|
||||
- 保留生动的细节,但将口语表达改写为书面叙述
|
||||
- 去除口语中的填充词和无意义重复
|
||||
- 保持时间顺序和逻辑清晰
|
||||
|
||||
### 输出格式约束
|
||||
- 使用第一人称
|
||||
- 不使用 Markdown 标题(#、##)、不使用表格
|
||||
- 如有「衔接上下文」,仅保持语气与时间线连贯,不重复已有段落全文"""
|
||||
|
||||
|
||||
def get_narrative_editor_system_prompt() -> str:
|
||||
"""故事/章节叙事:传记作家式书面语 + 事实边界(chapter 直接展示 story 时使用)。"""
|
||||
return f"""{get_memoir_fidelity_facts_only_prompt()}
|
||||
|
||||
{_memoir_editor_narrative_style_block()}"""
|
||||
|
||||
|
||||
def _short_classification_edit_prefix() -> str:
|
||||
@@ -209,7 +247,9 @@ childhood, education, career_early, career_achievement, career_challenge, family
|
||||
|
||||
**JSON 输出**:`response_format=json_object`,只输出:
|
||||
{{"category": "childhood|education|career_early|career_achievement|career_challenge|family|beliefs|summary|none"}}
|
||||
不要其它文字。"""
|
||||
不要其它文字。
|
||||
|
||||
若你返回 **none**,服务端会将本段映射到 **summary** 章节并仍写入回忆录正文(不落库丢弃)。"""
|
||||
|
||||
|
||||
def get_state_extraction_prompt(
|
||||
@@ -378,7 +418,7 @@ def get_narrative_prompt(
|
||||
|
||||
## 步骤
|
||||
1. 从「本段用户口述」提炼可写事实;丢弃语气词、寒暄、与 AI 的交互。
|
||||
2. 改写为第一人称书面叙述:可调整语序与用词,**不得**新增事实。
|
||||
2. 改写为第一人称书面叙述(优雅、连贯,可适当过渡;可调整语序与用词),**不得**新增事实。
|
||||
3. 若材料中无值得记录的人生经历内容,输出空字符串。
|
||||
|
||||
## 格式
|
||||
@@ -428,7 +468,7 @@ def get_narrative_json_prompt(
|
||||
1. **只展开「本段用户口述」**;若有参考摘录区,不得把摘录中的具体事实写成本轮亲历经历(见系统说明)。
|
||||
2. 过滤语气词、寒暄、与 AI 的交互;不重复已有故事全文;本批只写同一主题/事件链。
|
||||
3. 段落数量与每段长度**随材料而定**,禁止为凑字数编造。
|
||||
4. 使用第一人称、**平实书面语**,少修辞;不要直接引用原话;不要用 `#`、`##`、表格。
|
||||
4. 使用第一人称、**优雅书面语**(可适当过渡与铺陈,须基于口述事实);不要直接引用原话;不要用 `#`、`##`、表格。
|
||||
|
||||
## 输出格式(严格 JSON)
|
||||
{{
|
||||
@@ -504,7 +544,7 @@ def get_narrative_merge_json_prompt(
|
||||
1. 输出为**完整故事正文**(不是仅写本段):`paragraphs` 须包含重组后的**全文**。
|
||||
2. **禁止编造**:不得新增用户未在「已有」或「本段」中出现的人名、地点、时间、对话、数字。
|
||||
3. 若本段与旧文完全重复或无新信息,可仅输出与旧文等价重组后的正文(不得无故缩短到明显少于旧文)。
|
||||
4. 使用第一人称、平实书面语;不要用 `#`、`##`、表格。
|
||||
4. 使用第一人称、**优雅书面语**(与系统说明中的传记作家文体一致);不要用 `#`、`##`、表格。
|
||||
|
||||
## 输出格式(严格 JSON)
|
||||
{{
|
||||
@@ -527,8 +567,8 @@ def get_story_route_prompt(
|
||||
) -> str:
|
||||
"""Celery 批次:判断写入新 story 还是追加已有 story。输出严格 JSON。
|
||||
|
||||
「故事」= 可独立讲述的一段人生经历;进入本步的批次已满足 get_chapter_classification_prompt
|
||||
中章节级分类(非 none),二者语义一致。
|
||||
「故事」= 可独立讲述的一段人生经历;进入本步的批次已归入具体 chapter category
|
||||
(含模型返回 none 或零散档案启发式时映射的 summary)。
|
||||
"""
|
||||
return f"""你是回忆录编辑助手。根据本批用户口述与候选故事列表,决定:
|
||||
- append_story:内容明显延续、补充某一已有故事的主题与时间线,且能对应到具体 candidate id
|
||||
@@ -636,12 +676,13 @@ def format_narrative_user_content(oral_text: str, evidence_text: str = "") -> st
|
||||
def format_evidence_chunks_for_prompt(evidence: dict) -> str:
|
||||
"""将 retrieve_evidence / retrieve_evidence_sync 结果格式化为简短文本,供叙事 prompt 使用。
|
||||
|
||||
仅包含实际返回的 chunks、confirmed facts、timeline;不包含 relevant_summaries / relevant_stories
|
||||
(当前管线多为空列表,避免模型误以为有摘要或故事全文可用)。
|
||||
包含 chunks、摘要(若有)、confirmed facts、timeline、故事摘要(若有)。
|
||||
"""
|
||||
chunks = evidence.get("relevant_chunks") or []
|
||||
summaries = evidence.get("relevant_summaries") or []
|
||||
facts = evidence.get("relevant_facts") or []
|
||||
timeline = evidence.get("timeline_hints") or []
|
||||
stories = evidence.get("relevant_stories") or []
|
||||
parts: list[str] = []
|
||||
for c in chunks[:10]:
|
||||
content = (
|
||||
@@ -649,6 +690,13 @@ def format_evidence_chunks_for_prompt(evidence: dict) -> str:
|
||||
)
|
||||
if content:
|
||||
parts.append(content.strip())
|
||||
for s in summaries[:3]:
|
||||
if isinstance(s, dict):
|
||||
st = (s.get("content") or "").strip()
|
||||
stype = (s.get("summary_type") or "").strip()
|
||||
if st:
|
||||
label = f"[摘要:{stype}]" if stype else "[摘要]"
|
||||
parts.append(f"{label} {st}")
|
||||
for f in facts[:5]:
|
||||
if isinstance(f, dict):
|
||||
subj = f.get("subject", "")
|
||||
@@ -668,6 +716,12 @@ def format_evidence_chunks_for_prompt(evidence: dict) -> str:
|
||||
)
|
||||
if line:
|
||||
parts.append(line)
|
||||
for st in stories[:3]:
|
||||
if isinstance(st, dict):
|
||||
title = (st.get("title") or "").strip()
|
||||
summ = (st.get("summary") or "").strip()
|
||||
if title or summ:
|
||||
parts.append(" ".join(x for x in (title, summ) if x))
|
||||
return "\n\n".join(parts) if parts else ""
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user