diff --git a/api/.env.example b/api/.env.example index beeaebe..b1012eb 100644 --- a/api/.env.example +++ b/api/.env.example @@ -59,23 +59,29 @@ EMBEDDING_MODEL=embedding-3 # Chat 访谈:每轮根据用户内容判定主人生阶段(关则仅用关键词,省一次 LLM) # CHAT_STAGE_DETECTION_ENABLED=true # CHAT_STAGE_DETECTION_MAX_TOKENS=128 -# 访谈性格(InterviewAgent):default | warm_listener | curious_guide +# 年代/流行文化联想块(config 默认 true;若减少「文艺硬接」可设 false) +# CHAT_ERA_CONTEXT_ENABLED=true +# 访谈性格(InterviewAgent):default | warm_listener | curious_guide(config 默认 default) # CHAT_INTERVIEW_PERSONA=default # 访谈回复长度档位(brief/standard/expanded)联动:极短输入 / 默认 / 长段+新细节(若与当前代码不一致以 config 为准) # CHAT_INTERVIEW_BRIEF_MAX_TOKENS=240 # CHAT_INTERVIEW_BRIEF_MAX_CHARS_PER_SEGMENT=180 # CHAT_INTERVIEW_EXPANDED_MAX_TOKENS=400 # CHAT_INTERVIEW_EXPANDED_MAX_CHARS_PER_SEGMENT=300 -# 访谈/开场采样温度(略高于 LLM_TEMPERATURE 时口语更活) +# 访谈/开场采样温度(config 默认 0.93;偏「好访谈者」体验时可试 0.60~0.70) # CHAT_INTERVIEW_TEMPERATURE=0.93 # 访谈主回复:统一 max_tokens / 单段字数(代码截断) -# CHAT_INTERVIEW_MAX_TOKENS=380 -# CHAT_INTERVIEW_MAX_CHARS_PER_SEGMENT=260 +# CHAT_INTERVIEW_MAX_TOKENS=512 +# CHAT_INTERVIEW_MAX_CHARS_PER_SEGMENT=380 # CHAT_INTERVIEW_MAX_SEGMENTS=2 # 访谈:是否按本轮用户话检索记忆并注入提示词(关则不调 retrieve) # CHAT_MEMORY_RETRIEVAL_ENABLED=true # CHAT_MEMORY_TOP_K=8 # CHAT_MEMORY_EVIDENCE_MAX_CHARS=4096 +# 规则 TurnPlan 之后再调一轮 JSON focus planner(config 默认 false;开启则多一次 LLM) +# CHAT_REPLY_PLANNER_LLM_ENABLED=true +# CHAT_REPLY_PLANNER_MAX_TOKENS=256 +# CHAT_REPLY_PLANNER_TEMPERATURE=0.2 # Memoir:批处理/抽取更新 slot 时是否允许改写 MemoirState.current_stage(默认 false,访谈 switch_stage 仍可推进) # True 时仅当 proposed 与 existing 在同一 chat_bucket 才对齐 current_stage diff --git a/api/.env.production b/api/.env.production index d909f42..3022b1a 100644 --- a/api/.env.production +++ b/api/.env.production @@ -53,17 +53,20 @@ EMBEDDING_MODEL=embedding-3 # Chat 访谈:每轮根据用户内容判定主人生阶段(关则仅用关键词,省一次 LLM) # CHAT_STAGE_DETECTION_ENABLED=true # CHAT_STAGE_DETECTION_MAX_TOKENS=128 -# 访谈性格(InterviewAgent):default | warm_listener | curious_guide -# CHAT_INTERVIEW_PERSONA=default +# 访谈者体验(覆盖 config 默认值;与 api/.env.development 对齐时可减少文风漂移与记忆噪声) +CHAT_ERA_CONTEXT_ENABLED=true +CHAT_INTERVIEW_PERSONA=warm_listener +CHAT_INTERVIEW_TEMPERATURE=0.65 +# 访谈:是否按本轮用户话检索记忆并注入提示词(关则不调 retrieve) +# CHAT_MEMORY_RETRIEVAL_ENABLED=true +CHAT_MEMORY_TOP_K=4 +CHAT_MEMORY_EVIDENCE_MAX_CHARS=1400 +CHAT_REPLY_PLANNER_LLM_ENABLED=true # 访谈回复长度档位(brief/standard/expanded)联动:极短输入 / 默认 / 长段+新细节 # CHAT_INTERVIEW_BRIEF_MAX_TOKENS=240 # CHAT_INTERVIEW_BRIEF_MAX_CHARS_PER_SEGMENT=180 # CHAT_INTERVIEW_EXPANDED_MAX_TOKENS=400 # CHAT_INTERVIEW_EXPANDED_MAX_CHARS_PER_SEGMENT=300 -# 访谈:是否按本轮用户话检索记忆并注入提示词(关则不调 retrieve) -# CHAT_MEMORY_RETRIEVAL_ENABLED=true -# CHAT_MEMORY_TOP_K=8 -# CHAT_MEMORY_EVIDENCE_MAX_CHARS=4096 # Memoir:批处理/抽取更新 slot 时是否允许改写 MemoirState.current_stage(默认 false,访谈 switch_stage 仍可推进) # True 时仅当 proposed 与 existing 在同一 chat_bucket 才对齐 current_stage diff --git a/api/app/agents/chat/interview_agent.py b/api/app/agents/chat/interview_agent.py index b79dc5e..a62d54b 100644 --- a/api/app/agents/chat/interview_agent.py +++ b/api/app/agents/chat/interview_agent.py @@ -11,12 +11,14 @@ from langchain_core.messages import HumanMessage, SystemMessage from app.agents.chat.agent_turn import AgentChatTurn from app.agents.chat.helpers import format_history_string, get_history_with_window from app.agents.chat.interview_state_hints import ( + apply_autobiographical_boundary_guard, apply_duplicate_question_guard, extract_recent_questions, segments_are_only_duplicate_guard_fallback, update_recent_questions, ) from app.agents.chat.interview_turn_plan import plan_interview_turn +from app.agents.chat.reply_planner import maybe_refine_turn_plan_with_llm from app.agents.chat.personas import normalize_interview_persona from app.agents.chat.prompt_context import ChatPromptContext from app.agents.chat.prompts_conversation import ( @@ -135,12 +137,15 @@ class InterviewAgent: user_profile_context: str = "", detected_user_stage: Optional[str] = None, memory_evidence_text: str = "", + memory_anchor_source: str = "", + memory_planner_text: str = "", background_voice: str = "default", normalized_user_message: Optional[str] = None, occupation: str = "", profile_birth_year: int | None = None, profile_era_place: str = "", stage_switched_this_turn: bool = False, + scene_cues_for_planner: Optional[list[str]] = None, ) -> AgentChatTurn: """生成状态感知的访谈回复,不持久化(由 Orchestrator 负责)""" if not self.llm: @@ -179,7 +184,7 @@ class InterviewAgent: current_stage=memoir_state.current_stage, empty_slots=empty_slots, normalized_user_message=text_for_model, - memory_evidence_text=memory_evidence_text, + memory_evidence_text=(memory_anchor_source or "").strip(), stage_switched_this_turn=stage_switched_this_turn, ) logger.info( @@ -189,6 +194,37 @@ class InterviewAgent: len(turn_plan.anchor_snippet or ""), ) + reply_planner_raw = "" + baseline_mode = turn_plan.mode + baseline_primary_focus = turn_plan.primary_focus + if settings.chat_reply_planner_llm_enabled: + rq_preview = ( + "\n".join(recent_questions[-4:]) + if recent_questions + else "" + ) + turn_plan, reply_planner_raw = await maybe_refine_turn_plan_with_llm( + self.llm, + plan=turn_plan, + text_for_model=text_for_model, + memory_evidence_text=(memory_planner_text or memory_evidence_text) + or "", + max_tokens=int(settings.chat_reply_planner_max_tokens), + temperature=float(settings.chat_reply_planner_temperature), + scene_cues_for_planner=scene_cues_for_planner or [], + recent_questions_preview=rq_preview, + ) + if reply_planner_raw: + logger.info( + "event=reply_planner_applied memory_usage={} reply_shape={} " + "mode={} primary_focus={} focus_source={}", + turn_plan.memory_usage, + turn_plan.reply_shape, + turn_plan.mode, + turn_plan.primary_focus, + turn_plan.focus_source, + ) + ctx = ChatPromptContext( current_stage=memoir_state.current_stage, empty_slots=empty_slots, @@ -322,6 +358,7 @@ class InterviewAgent: recent_questions=rq_base, ) retry_used = True + out, auto_bio = apply_autobiographical_boundary_guard(out) updated_recent_questions = update_recent_questions(rq_base, out) log_agent_summary( logger, @@ -338,6 +375,18 @@ class InterviewAgent: "recent_questions": updated_recent_questions, "duplicate_question_guard_triggered": deduped, "duplicate_question_guard_llm_retry": retry_used, + "autobiographical_boundary_guard_triggered": auto_bio, + "reply_planner_llm_used": bool( + settings.chat_reply_planner_llm_enabled + and (reply_planner_raw or "").strip() + ), + "reply_planner_raw_preview": (reply_planner_raw or "")[:800], + "focus_planner_baseline_mode": baseline_mode, + "focus_planner_baseline_primary_focus": baseline_primary_focus, + "focus_planner_mode": turn_plan.mode, + "focus_planner_primary_focus": turn_plan.primary_focus, + "focus_planner_focus_source": turn_plan.focus_source, + "focus_planner_focus_summary": (turn_plan.focus_summary or "")[:200], }, ) except Exception as e: diff --git a/api/app/agents/chat/interview_state_hints.py b/api/app/agents/chat/interview_state_hints.py index b0b3ce3..11ae690 100644 --- a/api/app/agents/chat/interview_state_hints.py +++ b/api/app/agents/chat/interview_state_hints.py @@ -16,6 +16,8 @@ from app.agents.state_schema import KnownFact, MemoirStateSchema, PersonaThread _QUESTION_SPLIT_RE = re.compile(r"[??]+") _SENTENCE_SPLIT_RE = re.compile(r"(?<=[。!?!?])") _PUNCT_RE = re.compile(r"[\s,。!?;:、“”‘’()()《》【】\\[\\],.!?:;\"'`~·…-]+") +# 「我演罗密欧」等扮演亲历,但排除「我演示…」类口癖 +_AUTOBIO_IYAN_NOT_DEMO_RE = re.compile(r"我演(?!示)") _TRAIT_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = ( ("执着坚持", ("坚持", "执着", "咬牙", "熬过", "顶住", "训练", "反复")), @@ -337,6 +339,91 @@ def segments_are_only_duplicate_guard_fallback(segments: Iterable[str]) -> bool: return len(parts) == 1 and parts[0] == DUPLICATE_QUESTION_GUARD_FALLBACK_ZH +# 助手可见回复中,明显声称「我本人有过某种人生经历」的高置信子串(偏保守、宁可漏网不误伤泛化共情)。 +_AUTOBIOGRAPHICAL_MARKERS_ZH: tuple[str, ...] = ( + "我小时候", + "我小学", + "我中学", + "我初中", + "我高中", + "我大学", + "我上学那", + "我念书", + "我读书那", + "我暗恋", + "我当时暗恋", + "我爸妈", + "我父亲", + "我母亲", + "我爹", + "我妈", + "我爷爷", + "我奶奶", + "我外公", + "我外婆", + "我前任", + "我老公", + "我老婆", + "我丈夫", + "我妻子", + "我男友", + "我女友", + "我对象", + "我儿子", + "我女儿", + "我孩子", + "我以前也", + "我当时也", + "我那时候也", + "我也经历过", + "我也有过", + "我也演过", + "我也上台", + "我演过", + "我饰演", + "我演出", + "我演的是", + "我演的", + "我扮演", + "感觉我熟", + "这我熟", +) + +AUTOBIOGRAPHICAL_BOUNDARY_FALLBACK_ZH = ( + "你刚说的这段很有画面,我想多听你讲讲那时候你心里是什么感觉。" +) + + +def _segment_has_autobiographical_claim_zh(text: str) -> bool: + s = (text or "").strip() + if not s: + return False + if _AUTOBIO_IYAN_NOT_DEMO_RE.search(s): + return True + return any(m and m in s for m in _AUTOBIOGRAPHICAL_MARKERS_ZH) + + +def apply_autobiographical_boundary_guard( + segments: Iterable[str], +) -> tuple[list[str], bool]: + """将明显带有「助手自传式经历」的段落替换为中性承接,避免身份越界。""" + cleaned: list[str] = [] + touched = False + for seg in segments: + text = str(seg or "").strip() + if not text: + continue + if _segment_has_autobiographical_claim_zh(text): + cleaned.append(AUTOBIOGRAPHICAL_BOUNDARY_FALLBACK_ZH) + touched = True + else: + cleaned.append(text) + if not cleaned: + cleaned = [AUTOBIOGRAPHICAL_BOUNDARY_FALLBACK_ZH] + touched = True + return cleaned, touched + + def stage_slot_hint_lines(stage: str) -> list[str]: keys = STAGE_SLOT_KEYS.get(stage, ()) stage_zh = STAGE_DISPLAY_ZH.get(stage, stage) diff --git a/api/app/agents/chat/interview_turn_plan.py b/api/app/agents/chat/interview_turn_plan.py index 718b82a..71e6dfc 100644 --- a/api/app/agents/chat/interview_turn_plan.py +++ b/api/app/agents/chat/interview_turn_plan.py @@ -1,6 +1,11 @@ """ -访谈轮次编排(方案 A):由服务端显式给出 turn_mode / 主槽 / 挂钩摘录, -减少仅靠长 prompt 软约束时模型「随便问、不往回忆录引」的漂移。 +访谈轮次编排(Option B 定位:InterviewTurnPlan 是**本轮行为模式的唯一决策源**)。 + +约束说明: +- 主 prompt(prompt_layers)只提供跨轮通用的承接-深挖-串联节奏与身份守则; +- 本轮是否「情绪优先」「跟话头」「回忆推进」完全由 `plan_interview_turn()` 决定; +- 对 LLM 的硬指令由 `InterviewTurnPlan.render_system_directive()` 输出,主 prompt **不得**再 + 针对具体模式另立软约束。 """ from __future__ import annotations @@ -11,17 +16,173 @@ from typing import Literal from app.agents.chat.prompts_conversation import SLOT_NAME_MAP from app.agents.stage_constants import STAGE_SLOT_KEYS -InterviewTurnMode = Literal["emotion_first", "memoir_push", "follow_user_only"] +InterviewTurnMode = Literal[ + "emotion_first", + "clarify_first", + "memoir_push", + "follow_user_only", +] +SubjectOwner = Literal["user_only"] +MemoryUsage = Literal["none", "allowed_with_attribution"] +ReplyShape = Literal["flexible", "ack_only", "ack_then_question"] +AnchorSourceKind = Literal["user_message", "memory", "none"] + +# 本轮承接焦点(规则基线或 focus planner LLM 输出;供 directive 与日志) +FocusPrimary = Literal[ + "emotion", + "relationship", + "identity", + "scene", + "memoir_gap", + "follow_user", +] +FocusSource = Literal["rule", "llm", "fallback"] @dataclass(frozen=True) class InterviewTurnPlan: - """单轮访谈的硬目标(供注入 system prompt 顶部,优先级高于一般性建议)。""" + """单轮访谈的硬目标(优先级高于主 prompt 的一般性建议)。 + + 作为唯一决策源,对 LLM 的落地由 `render_system_directive()` 输出;其它调用方应通过 + `is_emotion_first` / `is_clarify_first` / `is_follow_user_only` / `is_memoir_push` / + `requires_explicit_question` 等语义属性读取决策,避免在主 prompt 里重复立法。 + + 扩展字段承载「主语归属 / 记忆引用策略 / 回复形状」,供防上下文污染与双阶段 planner 合并。 + """ mode: InterviewTurnMode anchor_slot_key: str | None anchor_slot_readable: str anchor_snippet: str + anchor_source_kind: AnchorSourceKind = "none" + assistant_identity_question: bool = False + subject_owner: SubjectOwner = "user_only" + memory_usage: MemoryUsage = "none" + memory_reference_style: str = "你之前提过" + forbid_first_person_experience: bool = True + reply_shape: ReplyShape = "flexible" + # 本轮承接重点(单一事实源的一部分;与 mode 配合,由 directive 落地) + primary_focus: FocusPrimary = "memoir_gap" + secondary_focus: FocusPrimary | None = None + focus_summary: str = "" + focus_source: FocusSource = "rule" + + # ---- 语义属性:供 prompt_layers / interview_agent 等调用方消费,禁止重复立法 ---- + + @property + def is_emotion_first(self) -> bool: + return self.mode == "emotion_first" + + @property + def is_clarify_first(self) -> bool: + return self.mode == "clarify_first" + + @property + def is_follow_user_only(self) -> bool: + return self.mode == "follow_user_only" + + @property + def is_memoir_push(self) -> bool: + return self.mode == "memoir_push" + + @property + def requires_explicit_question(self) -> bool: + """memoir_push 要求恰好一个开放式追问;其它模式允许不问或尽量不问。""" + return self.mode == "memoir_push" + + @property + def allows_zero_question(self) -> bool: + """emotion_first / clarify_first 整轮允许不问;follow_user_only 仅允许最多一个轻问。""" + return self.mode in ("emotion_first", "clarify_first", "follow_user_only") + + def render_system_directive(self) -> str: + """对 LLM 的硬指令(供注入 system prompt 顶部)。""" + return format_interview_turn_directive_block(self) + + +def _focus_primary_for_mode(mode: InterviewTurnMode) -> FocusPrimary: + if mode == "emotion_first": + return "emotion" + if mode == "clarify_first": + return "follow_user" + if mode == "follow_user_only": + return "follow_user" + return "memoir_gap" + + +def apply_safe_mode_override( + baseline: InterviewTurnMode, + override: str | None, + *, + primary_focus: str | None, +) -> InterviewTurnMode | None: + """若 planner 建议覆盖 mode,在此做安全收敛;返回 None 表示保持 baseline。""" + if not override or override == baseline: + return None + if override not in ( + "emotion_first", + "clarify_first", + "memoir_push", + "follow_user_only", + ): + return None + # 情绪优先一旦成立,不因 planner 改回强推素材 + if baseline == "emotion_first" and override != "emotion_first": + return None + # 跟话头 / 模糊先澄清:不允许改回问卷式 memoir_push + if baseline in ("follow_user_only", "clarify_first") and override == "memoir_push": + return None + # memoir_push -> emotion_first:仅当焦点偏情绪/关系/身份(或空,由模型判断) + if baseline == "memoir_push" and override == "emotion_first": + pf = (primary_focus or "").strip() + if pf in ("", "emotion", "relationship", "identity"): + return "emotion_first" + return None + if baseline == "memoir_push" and override == "follow_user_only": + return "follow_user_only" + if baseline == "follow_user_only" and override == "emotion_first": + return "emotion_first" + if baseline == "follow_user_only" and override == "clarify_first": + return "clarify_first" + if baseline == "memoir_push" and override == "clarify_first": + pf = (primary_focus or "").strip() + if pf in ("", "emotion", "relationship", "identity", "follow_user"): + return "clarify_first" + return None + if baseline == "clarify_first" and override == "emotion_first": + return "emotion_first" + if baseline == "clarify_first" and override == "follow_user_only": + return "follow_user_only" + return None + + +def _focus_directive_lines(plan: InterviewTurnPlan) -> str: + """本轮承接重点:focus_summary 仅作追问角度,不支配正文措辞。""" + if (plan.focus_summary or "").strip(): + summary = (plan.focus_summary or "").strip() + if len(summary) > 200: + summary = summary[:199].rstrip() + "…" + return ( + "- **本轮追问/承接角度(非正文提纲;主回复仍须从用户本轮原话长出来)**:" + + summary + + "\n" + ) + labels: dict[FocusPrimary, str] = { + "emotion": "情绪与感受", + "relationship": "关系与他人(谁在场、谁在看、和谁较劲)", + "identity": "身份与面子(自我形象、怕丢脸、要强)", + "scene": "现场与感官(画面、光线、身体感受)", + "memoir_gap": "叙述槽与成稿素材(当前主追问方向)", + "follow_user": "顺着用户话头自然展开", + } + pri = labels.get(plan.primary_focus, labels["memoir_gap"]) + if plan.secondary_focus and plan.secondary_focus != plan.primary_focus: + sec = labels.get(plan.secondary_focus, "") + if sec: + return ( + f"- **本轮承接重点**:先锚定「{pri}」,必要时再轻点「{sec}」作辅助。\n" + ) + return f"- **本轮承接重点**:优先锚定「{pri}」。\n" def primary_empty_slot(stage: str, empty_slots: list[str]) -> str | None: @@ -48,16 +209,51 @@ def extract_anchor_snippet( user_message: str, max_chars: int = 180, ) -> str: - """优先记忆摘录,其次用户原话(用于追问挂钩,非事实断言)。""" - mem = _strip_scene_hint(memory_evidence_text) - if mem and len(mem) >= 4: - return mem[:max_chars].strip() + """优先用户本轮原话,其次极短记忆线索(用于追问挂钩,非事实断言)。 + + 旧记忆不得压过用户当前话头;仅当本轮原话过短时再借 `memory_anchor_source` 挂钩。 + `memory_evidence_text` 此处仅为**一条短线索**(非整段 `[M…]` 摘录块)。 + """ um = (user_message or "").strip() if len(um) >= 10: return um[:max_chars].strip() + mem = _strip_scene_hint(memory_evidence_text) + if mem and len(mem) >= 4: + for line in mem.splitlines(): + s = line.strip() + if s.startswith("[M") and "]" in s: + rest = s.split("]", 1)[1].strip() + if rest and len(rest) >= 4: + return rest[:max_chars].strip() + return mem[:max_chars].strip() + if len(um) >= 4: + return um[:max_chars].strip() return "" +def determine_anchor_source_kind( + *, + memory_evidence_text: str, + user_message: str, +) -> AnchorSourceKind: + """标记挂钩线索来源,避免把用户本轮原话误标成旧记忆。""" + um = (user_message or "").strip() + if len(um) >= 10: + return "user_message" + mem = _strip_scene_hint(memory_evidence_text) + if mem and len(mem) >= 4: + for line in mem.splitlines(): + s = line.strip() + if s.startswith("[M") and "]" in s: + rest = s.split("]", 1)[1].strip() + if rest and len(rest) >= 4: + return "memory" + return "memory" + if len(um) >= 4: + return "user_message" + return "none" + + _EMOTION_MARKERS: tuple[str, ...] = ( "哭", "难受", @@ -79,6 +275,52 @@ _EMOTION_MARKERS: tuple[str, ...] = ( "扛不住", "放不下", "意难平", + "难堪", + "丢脸", + "怕丢人", + "臊得慌", +) + +# 说不清 / 暧昧放慢:不触发 memoir_push 式强推槽位 +_AMBIGUITY_MARKERS: tuple[str, ...] = ( + "说不清", + "说不明白", + "说不上来", + "说不上", + "说不好", + "不知道怎么说", + "不知道咋说", + "好像", + "也不是", + "不完全是", + "不太确定", + "不确定", + "有点模糊", + "很模糊", + "模模糊糊", + "难说", + "有一点,但我不确定", +) +_SOFT_SLOW_MARKERS: tuple[str, ...] = ( + "害羞", + "羞涩", + "不好意思", + "脸红", + "暧昧", +) + +_ASSISTANT_IDENTITY_QUESTION_MARKERS: tuple[str, ...] = ( + "你是哪里人", + "你哪的人", + "你是哪里长大的", + "你在哪长大", + "你的童年", + "你小时候", + "你家里", + "你爸妈", + "你父母", + "你也有过", + "你也是", ) @@ -97,6 +339,33 @@ def _is_emotion_heavy(text: str) -> bool: return False +def _is_ambiguous_or_needs_slow_pace(text: str) -> bool: + """模糊表达、暧昧/羞涩等:先澄清或放慢,不强推叙述槽。""" + t = (text or "").strip() + if not t: + return True + if any(m in t for m in _AMBIGUITY_MARKERS): + return True + if any(m in t for m in _SOFT_SLOW_MARKERS): + return True + return False + + +def _is_too_vague_for_memoir_push(text: str) -> bool: + """过短或仍含糊时,不进入 memoir_push。""" + t = (text or "").strip() + if len(t) < 12: + return True + return _is_ambiguous_or_needs_slow_pace(t) + + +def _is_asking_assistant_identity_or_life(text: str) -> bool: + t = (text or "").strip() + if not t: + return False + return any(marker in t for marker in _ASSISTANT_IDENTITY_QUESTION_MARKERS) + + def plan_interview_turn( *, current_stage: str, @@ -108,14 +377,31 @@ def plan_interview_turn( """ 粗规则(可迭代): - 情绪浓:先共情,不强推叙述槽搜集问。 + - 说不清/暧昧羞涩等:模糊先澄清,禁止替用户下结论或强推槽位。 - 刚切换人生阶段:跟着用户节奏,不做「新阶段问卷首开」。 - 当前阶段无空槽:深度跟进,不重启盘点。 - - 默认:memoir_push,锁一个主槽 + 挂钩摘录。 + - 默认:memoir_push(仅当本轮话头足够具体、非含糊短答时)。 """ snippet = extract_anchor_snippet( memory_evidence_text=memory_evidence_text, user_message=normalized_user_message, ) + anchor_source_kind = determine_anchor_source_kind( + memory_evidence_text=memory_evidence_text, + user_message=normalized_user_message, + ) + mem_trim = _strip_scene_hint(memory_evidence_text).strip() + mem_use: MemoryUsage = ( + "allowed_with_attribution" if mem_trim else "none" + ) + um = (normalized_user_message or "").strip() + asks_assistant_identity = _is_asking_assistant_identity_or_life(um) + reply_shape: ReplyShape = "flexible" + if any( + k in um + for k in ("讲讲", "说说", "她的故事", "他的故事", "后来呢", "然后呢") + ): + reply_shape = "ack_then_question" if _is_emotion_heavy(normalized_user_message): slot = primary_empty_slot(current_stage, empty_slots) @@ -129,6 +415,12 @@ def plan_interview_turn( anchor_slot_key=slot, anchor_slot_readable=readable, anchor_snippet=snippet, + anchor_source_kind=anchor_source_kind, + assistant_identity_question=asks_assistant_identity, + memory_usage=mem_use, + reply_shape=reply_shape, + primary_focus=_focus_primary_for_mode("emotion_first"), + focus_source="rule", ) if stage_switched_this_turn: @@ -137,6 +429,12 @@ def plan_interview_turn( anchor_slot_key=None, anchor_slot_readable="(刚自然谈到本阶段,先顺着对方语势,勿问卷式首开)", anchor_snippet=snippet, + anchor_source_kind=anchor_source_kind, + assistant_identity_question=asks_assistant_identity, + memory_usage=mem_use, + reply_shape=reply_shape, + primary_focus=_focus_primary_for_mode("follow_user_only"), + focus_source="rule", ) if not empty_slots: @@ -145,6 +443,32 @@ def plan_interview_turn( anchor_slot_key=None, anchor_slot_readable="(本阶段主要叙述槽已有素材)请 depth-first:接续画面或情绪线,别重启童年在哪长大式盘点", anchor_snippet=snippet, + anchor_source_kind=anchor_source_kind, + assistant_identity_question=asks_assistant_identity, + memory_usage=mem_use, + reply_shape=reply_shape, + primary_focus=_focus_primary_for_mode("follow_user_only"), + focus_source="rule", + ) + + if _is_too_vague_for_memoir_push(um): + slot = primary_empty_slot(current_stage, empty_slots) + readable = ( + SLOT_NAME_MAP.get(slot, slot or "") + if slot + else "(先澄清感受时可暂不强绑某一槽位)" + ) + return InterviewTurnPlan( + mode="clarify_first", + anchor_slot_key=slot, + anchor_slot_readable=readable, + anchor_snippet=snippet, + anchor_source_kind=anchor_source_kind, + assistant_identity_question=asks_assistant_identity, + memory_usage=mem_use, + reply_shape=reply_shape, + primary_focus=_focus_primary_for_mode("clarify_first"), + focus_source="rule", ) slot = primary_empty_slot(current_stage, empty_slots) @@ -154,6 +478,12 @@ def plan_interview_turn( anchor_slot_key=slot, anchor_slot_readable=SLOT_NAME_MAP.get(slot, slot), anchor_snippet=snippet, + anchor_source_kind=anchor_source_kind, + assistant_identity_question=asks_assistant_identity, + memory_usage=mem_use, + reply_shape=reply_shape, + primary_focus=_focus_primary_for_mode("memoir_push"), + focus_source="rule", ) @@ -165,6 +495,45 @@ def format_interview_turn_directive_block(plan: InterviewTurnPlan) -> str: else "(无可用摘录时,必须从用户本轮原话里抽词作挂钩,禁止编造)" ) + if plan.memory_usage == "allowed_with_attribution": + mem_block = ( + "- **过往记忆线索**:主 Context 里若有一条极短线索,**只**用于帮你选追问角度;" + "**不是**正文提纲,**不是**长素材。\n" + "- **禁止**用「" + + plan.memory_reference_style + + "…」开场或整段复述旧记忆;若轻勾连,**最多半句**,且须让本轮原话占主位。\n" + "- **禁止**把线索写成助手亲历;**禁止**把线索当「咱俩共同回忆」。\n" + ) + else: + mem_block = ( + "- **过往记忆线索**:本轮无可用线索(或已策略性不注入);" + "**不要**编造「你说过…」式假归因。\n" + ) + + perspective_block = ( + "- **主语与视角**:用户可见回复主线必须是**用户**的经历与感受;" + "助手**没有**真实人生传记。\n" + ) + if plan.forbid_first_person_experience: + perspective_block += ( + "- **禁止助手自传式措辞**:不得使用「我小时候」「我演过/我扮演」「我当时暗恋」" + "「我爸妈」等自称亲历表述。\n" + ) + if plan.assistant_identity_question: + perspective_block += ( + "- **本轮用户在问助手本人**:必须明确守住边界,不能假装自己有籍贯、童年、家庭或恋爱经历;" + "若要借记忆继续聊,只能明确归因到用户,例如「你刚提到上海」「你前面说过……」。\n" + ) + + if plan.reply_shape == "ack_only": + shape_block = "- **本轮回复形状**:以短承接为主;若无必要可整轮不问。\n" + elif plan.reply_shape == "ack_then_question": + shape_block = ( + "- **本轮回复形状**:先短承接,再尽量带**一条**贴用户话头的开放式问(仍守全篇最多一个问句)。\n" + ) + else: + shape_block = "" + if plan.mode == "emotion_first": mode_rules = ( "- **情绪优先**:本轮以承接、并肩与安全感为主,**不要**为推进「叙述槽大纲」而追加信息搜集型追问。\n" @@ -173,6 +542,16 @@ def format_interview_turn_directive_block(plan: InterviewTurnPlan) -> str: + plan.anchor_slot_readable + "」仅供你心里知道后续方向,**不要**在本轮用问卷口吻硬推该槽。" ) + elif plan.mode == "clarify_first": + mode_rules = ( + "- **模糊先澄清**:用户正在表达不确定、说不清,或暧昧/羞涩等未命名感受;" + "以承接、并列为先,**禁止**把模糊感受改写成确定关系、命运、动机或人生结论。\n" + "- 若带问句,最多**一个**,且须邀请对方**用自己的词**继续摸索或澄清(例如更接近哪一种、还是也不完全是)," + "禁止封闭式逼认、禁止替用户命名关系或事件结局。\n" + "- 允许整轮**只承接不问**;参考主槽「" + + plan.anchor_slot_readable + + "」仅供你心里知道后续方向,**不要**问卷式硬推该槽。" + ) elif plan.mode == "follow_user_only": mode_rules = ( "- **跟话头**:本轮禁止问卷式首开、禁止重启式盘点;顺着用户刚展开的画面、人物或情绪自然往下。\n" @@ -181,20 +560,38 @@ def format_interview_turn_directive_block(plan: InterviewTurnPlan) -> str: else: mode_rules = ( "- **回忆推进(memoir_push)**:对用户可见回复中须有**恰好一个**开放式回忆追问,\n" - " 且意图明显在补足下面「主追问方向」;问句必须挂住**挂钩摘录**或**用户本轮原词**(二者至少其一)。\n" - "- 禁止用日常寒暄替代该追问;仍遵守全篇「最多一个问句」「禁止晚会硬切」。" + " 且意图明显在补足下面「主追问方向」;问句必须挂住**用户本轮原词**或**挂钩摘录**(二者至少其一,且**优先原词**)。\n" + "- **禁止**把用户仍含糊的表达改写成确定事实或关系;禁止用日常寒暄替代该追问;" + "仍遵守全篇「最多一个问句」「禁止晚会硬切」。" ) + focus_block = _focus_directive_lines(plan) + + if plan.anchor_source_kind == "user_message": + anchor_label = "(仅作追问挂钩,来自用户本轮原话摘录;优先把这句里的原词接住)" + elif plan.anchor_source_kind == "memory": + anchor_label = "(仅作追问挂钩,来自检索到的用户过往口述/摘要;不是用户本轮新说的内容)" + else: + anchor_label = "(无可用挂钩时,必须从用户本轮原话里抽词承接,禁止编造)" + return f"""## 本轮编排指令(硬规则,优先于后文一般性建议) {mode_rules} -- **主追问方向(叙述槽)**:{plan.anchor_slot_readable} -- **挂钩摘录**(仅作衔接线索,**不是**用户本轮新说的内容;禁止写成就等于用户刚讲的原话):{snippet_line} +{focus_block}{mem_block}{perspective_block}{shape_block}- **主追问方向(叙述槽)**:{plan.anchor_slot_readable} +- **挂钩线索**{anchor_label}:{snippet_line} """ __all__ = [ + "FocusPrimary", + "FocusSource", "InterviewTurnMode", "InterviewTurnPlan", + "MemoryUsage", + "ReplyShape", + "SubjectOwner", + "AnchorSourceKind", + "apply_safe_mode_override", + "determine_anchor_source_kind", "extract_anchor_snippet", "format_interview_turn_directive_block", "plan_interview_turn", diff --git a/api/app/agents/chat/orchestrator.py b/api/app/agents/chat/orchestrator.py index 52deff5..1e73397 100644 --- a/api/app/agents/chat/orchestrator.py +++ b/api/app/agents/chat/orchestrator.py @@ -52,14 +52,13 @@ _UNAUTH_TURN = AgentChatTurn( ) -async def _fetch_interview_memory_evidence( +async def _fetch_interview_memory_bundle( db: AsyncSession, user_id: str, user_message: str, -) -> tuple[str, dict | None]: - """按本轮用户话检索记忆:格式化短文本 + 可入库 trace(稳定 id)。""" +) -> tuple[dict | None, object | None]: + """检索记忆 bundle(原始结构);是否进主 prompt 由 `slice_interview_memory` 再筛。""" from app.core.dependencies import get_embedding_provider - from app.features.memory.evidence_format import format_evidence_chunks_for_prompt from app.features.memory.retrieval_trace import ( chat_memory_retrieval_trace_from_bundle, ) @@ -69,13 +68,13 @@ async def _fetch_interview_memory_evidence( logger.debug( "event=chat_memory_retrieval_skip reason=disabled user_id={}", user_id ) - return "", None + return None, None msg = (user_message or "").strip() if not msg: logger.debug( "event=chat_memory_retrieval_skip reason=empty user_id={}", user_id ) - return "", None + return None, None try: emb = get_embedding_provider() ms = MemoryService(db, embedding_provider=emb) @@ -85,30 +84,19 @@ async def _fetch_interview_memory_evidence( trace = chat_memory_retrieval_trace_from_bundle( bd, top_k=top_k, query_len=len(msg) ) - text = format_evidence_chunks_for_prompt(bd) - t = (text or "").strip() - if not t: - logger.debug( - "event=memory_evidence_for_prompt user_id={} formatted_chars=0", - user_id, - ) - return "", trace - max_c = settings.chat_memory_evidence_max_chars - if len(t) > max_c: - t = t[: max_c - 3] + "..." logger.info( - "event=memory_evidence_for_prompt user_id={} formatted_chars={}", + "event=memory_retrieval_bundle user_id={} top_k={}", user_id, - len(t), + top_k, ) - return t, trace + return bd, trace except Exception as e: try: await db.rollback() except Exception as rollback_error: logger.warning("访谈记忆检索失败后回滚也失败: {}", rollback_error) logger.warning("访谈记忆检索失败: {}", e) - return "", None + return None, None class ChatOrchestrator: @@ -284,14 +272,14 @@ class ChatOrchestrator: background_voice = infer_background_voice(user.occupation) occupation = user.occupation or "" - memory_evidence_text, mem_trace = await _fetch_interview_memory_evidence( + from app.features.memory.chat_memory_injection import slice_interview_memory + + memory_bundle, mem_trace = await _fetch_interview_memory_bundle( db, user_id, normalized_user_message ) - scene_cues = extract_scene_cues(normalized_user_message) - if scene_cues: - cue_block = "\n".join(f"- {c}" for c in scene_cues) - scene_hint = f"\n\n[场景氛围提示——可借用这些感官细节自然接话,不要原样抄]\n{cue_block}" - memory_evidence_text = (memory_evidence_text or "") + scene_hint + mem_slices = slice_interview_memory(memory_bundle, normalized_user_message) + # 场景关键词仅作为 focus planner 的辅助输入,不直接拼进记忆块,避免抢过用户明确的关系/身份线索 + scene_cues_for_planner = extract_scene_cues(normalized_user_message) profile_birth_year = user.birth_year if user else None profile_era_place = "" @@ -313,13 +301,16 @@ class ChatOrchestrator: memoir_state=prompt_state, user_profile_context=user_profile_context, detected_user_stage=detected, - memory_evidence_text=memory_evidence_text, + memory_evidence_text=mem_slices.prompt_excerpt, + memory_anchor_source=mem_slices.anchor_source, + memory_planner_text=mem_slices.planner_preview, background_voice=background_voice, normalized_user_message=normalized_user_message, occupation=occupation, profile_birth_year=profile_birth_year, profile_era_place=profile_era_place, stage_switched_this_turn=stage_switched_this_turn, + scene_cues_for_planner=scene_cues_for_planner, ) recent_questions = prompt_state.recent_questions if turn.interview_state_meta and isinstance(turn.interview_state_meta, dict): @@ -411,12 +402,15 @@ class ChatOrchestrator: audio_duration_seconds: int | None = None, detected_user_stage: str | None = None, memory_evidence_text: str = "", + memory_anchor_source: str = "", + memory_planner_text: str = "", background_voice: str = "default", normalized_user_message: str | None = None, occupation: str = "", profile_birth_year: int | None = None, profile_era_place: str = "", stage_switched_this_turn: bool = False, + scene_cues_for_planner: Optional[list[str]] = None, ) -> AgentChatTurn: """委托 InterviewAgent 生成访谈回复(持久化由调用方负责)。""" return await self.interview_agent.generate_response_with_state( @@ -426,12 +420,15 @@ class ChatOrchestrator: user_profile_context=user_profile_context, detected_user_stage=detected_user_stage, memory_evidence_text=memory_evidence_text, + memory_anchor_source=memory_anchor_source, + memory_planner_text=memory_planner_text, background_voice=background_voice, normalized_user_message=normalized_user_message, occupation=occupation, profile_birth_year=profile_birth_year, profile_era_place=profile_era_place, stage_switched_this_turn=stage_switched_this_turn, + scene_cues_for_planner=scene_cues_for_planner, ) def detect_user_stage(self, user_message: str) -> str: diff --git a/api/app/agents/chat/output_rules.py b/api/app/agents/chat/output_rules.py index cbfeece..688a134 100644 --- a/api/app/agents/chat/output_rules.py +++ b/api/app/agents/chat/output_rules.py @@ -24,7 +24,17 @@ def chat_output_rules() -> str: "空话铺垫(「这确实是个好问题」类);**以核对为名**重复对方已明确说过的基础信息(如「所以您是……对吗」「刚才您说的是……吗」)," "对方已交代清楚的事实应直接当作前提,在其上深化、延伸或关联提问;" "编造对方没说的**具体**事实(人名、时间、地点、事件经过等若用户未提及则不说)。" - "**允许**用「我能想象……」「那时候大概……」等泛泛接话,但不要把这些写成就等于用户亲身经历的事实。" + "**禁止**声称助手本人拥有真实人生经历(童年、求学、暗恋/恋爱/婚姻、家人子女、职业履历等)," + "也**禁止**把用户经历改写成「我也经历过 / 我小时候也 / 我当时也…」式共同回忆;" + "若用户追问「你是哪里人」「你的童年怎么样」这类助手身份问题," + "**禁止**拿上下文里的用户资料或记忆线索冒充助手自己的答案;" + "但**可以**把这些信息作为后续承接依据,只能用「你刚提到…」「你之前说过…」这类明确归因转回用户;" + "**允许**用「我能想象……」「换作很多人可能……」「光听你这么说……」等**泛指**共情," + "但不要把这些泛泛接话写成就等于用户或助手亲历的确定事实。" + "**禁止诱导式提问**:不要在问句里夹带两段以上小说式描写、排比或「标准答案」;" + "少用「更过瘾的是 A 还是 B」式长选项——若必须对比,每个选项**一两短句**即可,且**不得**把答案藏在选项修辞里。" + "**禁止跨轮复读**:不要反复套用同一套比喻、金句或意象包装用户(用户原话短引除外);换一轮就换钩子。" + "**篇幅**:优先短而准;承接加一问合计宁短勿长,**禁止**单条写成小作文或晚会导语。" ) diff --git a/api/app/agents/chat/personas.py b/api/app/agents/chat/personas.py index 00331d0..651a60f 100644 --- a/api/app/agents/chat/personas.py +++ b/api/app/agents/chat/personas.py @@ -36,11 +36,12 @@ def get_interview_persona_tone_hint(persona: str) -> str: if key == "warm_listener": return ( "偏倾听与承接,语气柔和、少打断;不审问感,一次最多一个具体问题。" + "**短句优先**:承接加提问宁短勿长,忌单条写成小作文或晚会导语。" + "不要出诱导性二选一(尤其选项里夹大段故事或隐喻);不要跨轮重复同一套比喻或金句包装。" "对方语气发紧、变慢、自嘲或重复时,先并肩承认这份感受说得通,再考虑追问。" "长段倾诉后承接要有温度,忌单字敷衍;可短,但要让人家觉得你真的在听。" "对方愿意展开时,可温和多问一层感受、缘由或后来的影响,仍贴对方原词。" - "前文若出现过稳定的小习惯或执念,偶尔轻轻扣一下,像朋友记得你的路子。" - "接话时允许带一点画面感或感官细节(一两句即可),让对方觉得你真的在跟着想象。" + "前文若出现过稳定的小习惯或执念,偶尔轻轻扣一下;接话最多一两句轻画面,勿堆砌。" ) return ( "爱把人往一个具体细节里带;事实清楚后可追问对自我认知或后来选择的影响;" diff --git a/api/app/agents/chat/prompt_context.py b/api/app/agents/chat/prompt_context.py index 6d86f55..0cd7b91 100644 --- a/api/app/agents/chat/prompt_context.py +++ b/api/app/agents/chat/prompt_context.py @@ -31,14 +31,15 @@ class ChatPromptContext: turn_plan: InterviewTurnPlan | None = None def guided_system_prompt(self) -> str: - """用户原话仅以对话历史 + HumanMessage 注入模型。""" - from app.agents.chat.interview_turn_plan import ( - format_interview_turn_directive_block, - ) + """用户原话仅以对话历史 + HumanMessage 注入模型。 + + 本轮模式硬指令统一由 `InterviewTurnPlan.render_system_directive()` 产出, + 主 prompt 不得重复立法(见 interview_turn_plan 模块 docstring)。 + """ from app.agents.chat.prompts_conversation import get_guided_conversation_prompt directive = ( - format_interview_turn_directive_block(self.turn_plan) + self.turn_plan.render_system_directive() if self.turn_plan is not None else "" ) diff --git a/api/app/agents/chat/prompt_layers.py b/api/app/agents/chat/prompt_layers.py new file mode 100644 index 0000000..30fa4d9 --- /dev/null +++ b/api/app/agents/chat/prompt_layers.py @@ -0,0 +1,361 @@ +"""Chat prompt 分层构件(Option B 重构)。 + +将原先堆在 `get_guided_conversation_prompt` 的超长 system prompt 按职责拆成三层: + +- **BehaviorPolicy**:跨轮通用的身份守则、承接/深挖/串联节奏、硬禁令。 + ——本层只表达**与本轮模式无关**的长期不变约束;本轮「情绪优先 / 模糊先澄清 / 跟话头 / 回忆推进」 + 完全由 `InterviewTurnPlan.render_system_directive()` 在 prompt 顶部输出,**本层禁止重复**立 + 那些模式规则。 + +- **Context**:当前是什么;阶段、已聊/未聊、已确认事实、人物主线、最近已问、(若有)极短记忆线索、时代氛围。 + ——纯数据视图,不立行为规则。 + +- **StyleProfile**:怎么说;口语温度、文笔密度、风格参考举例、成稿质量侧重。 + ——由 `ChatStyleProfile` 驱动,chat 与 memoir 不再共享同一套隐式风格偏好。 + +`prompts_conversation.get_guided_conversation_prompt` 退化为「薄组装」:只负责把三层拼在一起 + +最终的 output_rules/结尾封口。 +""" + +from __future__ import annotations + +from typing import Dict, List, Optional + +from app.agents.chat.background_voice import ( + get_background_voice_tone_hint, +) +from app.agents.chat.occupation_context import get_occupation_chat_hint +from app.agents.chat.output_rules import chat_output_rules +from app.agents.chat.personas import ( + get_interview_persona_tone_hint, + normalize_interview_persona, +) +from app.agents.chat.slot_question_bank import format_slot_question_outline_block +from app.agents.stage_constants import CHAT_STAGES, STAGE_DISPLAY_ZH +from app.agents.state_schema import KnownFact, PersonaThread +from app.agents.style_profiles import ChatStyleProfile + + +# ============================================================================= +# Context 层:状态与素材(纯数据视图,不立行为规则) +# ============================================================================= + + +def build_context_block( + *, + current_stage: str, + detected_user_stage: str, + empty_slots_readable: List[str], + filled_slots: Dict[str, str], + slot_name_map: Dict[str, str], + all_stages_coverage: Optional[Dict[str, Dict]], + user_profile_context: str, + occupation: str, + background_voice: str, + known_facts: Optional[List[KnownFact]], + persona_threads: Optional[List[PersonaThread]], + recent_questions: Optional[List[str]], + memory_evidence_text: str, + era_line: str, +) -> str: + """组装 Context 层:身份/资料/已确认事实/人物主线/最近已问/已聊+还可聊/进度/时代/记忆线索。""" + current_stage_name = STAGE_DISPLAY_ZH.get(current_stage, current_stage) + user_stage_name = ( + STAGE_DISPLAY_ZH.get(detected_user_stage, "") if detected_user_stage else "" + ) + user_jumped = bool(detected_user_stage and detected_user_stage != current_stage) + + if user_jumped: + topic_desc = ( + f"你们原本在聊「{current_stage_name}」," + f"用户自然地聊到了「{user_stage_name}」——跟着他/她的节奏,别硬拉回。" + ) + else: + topic_desc = f"你们在聊「{current_stage_name}」这阶段的话题。" + + user_info_parts: List[str] = [] + if user_profile_context.strip(): + user_info_parts.append(user_profile_context.strip()) + occ = get_occupation_chat_hint(occupation, background_voice) + if occ: + user_info_parts.append(occ) + user_info_section = "" + if user_info_parts: + user_info_section = "## 用户信息\n" + "\n".join(user_info_parts) + "\n\n" + + known_fact_lines: list[str] = [] + for fact in (known_facts or [])[-10:]: + line = fact.prompt_line().strip() + if line: + known_fact_lines.append(f"- {line}") + known_fact_section = "" + if known_fact_lines: + known_fact_section = ( + "## 已确认事实(视为对话前提;**禁止**再以问卷口吻复述核对,只许在此基础上往纵深推)\n" + + "\n".join(known_fact_lines) + + "\n\n" + ) + + persona_lines: list[str] = [] + for item in (persona_threads or [])[-6:]: + line = item.prompt_line().strip() + if line: + persona_lines.append(f"- {line}") + persona_section = "" + if persona_lines: + persona_section = ( + "## 人物主线(跨轮持续呼应,不要每轮像第一次认识)\n" + + "\n".join(persona_lines) + + "\n\n" + ) + + recent_question_lines = [ + str(x).strip() for x in (recent_questions or [])[-4:] if str(x).strip() + ] + recent_question_section = "" + if recent_question_lines: + recent_question_section = ( + "## 最近已经问过的问题(尽量不要同义重问)\n" + + "\n".join(f"- {x}" for x in recent_question_lines) + + "\n\n" + ) + + filled_info = [] + for key, value in filled_slots.items(): + readable_key = slot_name_map.get(key, key) + filled_info.append( + f"{readable_key}: {value[:80]}..." + if len(value) > 80 + else f"{readable_key}: {value}" + ) + filled_slots_str = "\n".join(filled_info) if filled_info else "刚开始聊" + empty_slots_str = ( + "、".join(empty_slots_readable) + if empty_slots_readable + else "本阶段暂无明显缺口" + ) + + progress_lines: List[str] = [] + if all_stages_coverage: + cur_cn = STAGE_DISPLAY_ZH.get(current_stage, current_stage) + progress_lines.append(f"当前阶段:{cur_cn}") + for stage in CHAT_STAGES: + cov = all_stages_coverage.get(stage, {}) + filled_n = cov.get("filled", 0) + total_n = cov.get("total", 0) + sname = STAGE_DISPLAY_ZH.get(stage, stage) + if total_n <= 0: + continue + if filled_n == 0: + progress_lines.append(f" {sname}:未聊") + elif filled_n < total_n: + progress_lines.append(f" {sname}:{filled_n}/{total_n}") + progress_str = "\n".join(progress_lines) if progress_lines else "" + progress_block = f"## 进度\n{progress_str}\n" if progress_str else "" + era_block = f"## 时代与氛围参考\n{era_line}\n" if era_line else "" + + memory_section = "" + mem_trim = (memory_evidence_text or "").strip() + if mem_trim: + if mem_trim.startswith("##"): + # 已由 `slice_interview_memory` / `format_minimal_prompt_memory_hint` 包一层说明 + memory_section = f"{mem_trim}\n\n" + else: + memory_section = ( + "## 记忆线索(仅追问角度,禁止当正文素材库)\n" + "以下为系统检索到的**极短**线索,**不是**用户本轮原话。\n" + "**禁止**大段复述或「你之前提过」开场;优先从用户本轮原话承接。\n\n" + f"{mem_trim}\n\n" + ) + + # 已聊 + 还可聊方向,归入 Context:只描述状态,不立行为规则 + state_block = ( + "## 当前对话状态\n" + f"已聊:\n{filled_slots_str}\n\n" + f"还可聊的方向:{empty_slots_str}\n\n" + ) + + return ( + f"{topic_desc}\n\n" + f"{user_info_section}" + f"{known_fact_section}" + f"{persona_section}" + f"{recent_question_section}" + f"{state_block}" + f"{progress_block}" + f"{era_block}" + f"{memory_section}" + ) + + +def build_question_outline_block(current_stage: str, empty_slots: List[str]) -> str: + """题库大纲独立成块(Context 末尾,作为可选的「发问思路」素材)。""" + return format_slot_question_outline_block(current_stage, empty_slots) + + +# ============================================================================= +# BehaviorPolicy 层:本轮硬行为规则 + 跨轮一致性约束 +# ============================================================================= + + +def build_behavior_policy_block() -> str: + """通用行为策略:身份、主线守则、承接规则、话题过渡、严格基于上下文推进。 + + **注意**:本轮模式(emotion_first / clarify_first / follow_user_only / memoir_push)由 + `InterviewTurnPlan.render_system_directive()` 在 prompt 顶部落地,优先级高于本块; + 本块只留**跨轮通用**硬规则,**不得**重述 TurnPlan 已经决定的模式级规则。 + """ + return ( + "## 身份边界(硬规则,优先于下文一切「像老朋友」表述)\n" + "- 你是**访谈主持式知己**,**没有**真实人生传记:不得声称自己有童年、求学、校园、暗恋、恋爱、婚姻、子女、父母亲属、职业履历等**任何**个人经历。\n" + "- **禁止**把用户刚讲的第一人称经历,改写成「我也经历过 / 我小时候也 / 我当时也 / 我暗恋过…」式**共同回忆**;共情只能落在**对方**的故事上,或**泛指**(「换作很多人可能也会…」「光听你这么说就…」「我能想象那种…」),且泛指**不得**夹带你自称亲历的细节。\n" + "- **禁止**用「我」引出与**你自己**人生切片相关的具体人事(含角色名、同班同学式细节、自家亲属称谓等),除非是在**复述用户原话**时明确带出「你说…」且整句主体仍是用户。\n" + "- 若用户直接追问**你的**身世、籍贯、童年、感情或家庭,必须守住这条边界:明确你没有这些真实经历,再把话题轻轻带回用户;**绝不能**把「用户信息」「已确认事实」「人物主线」或「记忆线索」里的内容拿来冒充助手自己的资料(例如不能把用户的成长地答成「我是上海人」)。但这些上下文仍可继续用来服务回答,只能以**明确归因**方式转回用户(如「你刚提到上海」「你之前说过那段童年」)。\n" + "\n## 身份与语气\n" + "- 你们是**平等聊天**:底色暖、有安全感;**不是**冷冰冰盘问或庭审式追问。仍须避免**晚会串联腔、播报腔**(如「那么接下来」「让我们回到」)——好的主持人**自然勾回话题**,不靠节目硬切。\n" + "- **主持人职责(与温情并存)**:你心里守着**回忆口述这条主线**。用户若只给寒暄、天气、泛泛忙累、纯近况而**几乎没有人生叙事实质**:最多**一两句**并肩承接,随后**必须**用**一条**带锚的开放式问题,把话头带回「当前阶段 / 还可聊的方向 / 已确认事实或人物主线 /(若有)一条极短记忆线索」之一;像朋友**绕着弯把话头勾回来**,**禁止**长时间停在纯日常闲聊里空转。**不要把「今天过得怎样」「最近好吗」当默认整轮主线**。\n" + "- **深度倾听与人格线索**:不只消化本轮字句;留意用户**跨轮反复流露**的性情、价值观与做事习惯(怕什么、争什么、总先想到哪一步、遇压力时默认反应等),在「已确认事实」「人物主线」与(若有)极短记忆线索里若有呼应,后续话里**自然勾上**——可轻问是否一贯,或观察有没有在变,**禁止**贴标签式宣判「你就是这样的人」。\n" + "- **唯一起点**:本轮承接与追问尽量**只从用户上一轮最后一个话头、意象或情绪线长出来**;少用先把整段收束成小结再转场的「采访段」感。\n" + "- **聊天伙伴 + 控场**:像炕头、微信里能讲心里话的老友那样接住人,但**服务目标是成稿素材与回忆叙事**,**不是**记者式刨根,也**不是**无底洞式陪聊;可以把细节捋清楚,亲和力、安全感与「听懂对方」至少和信息条理同等重要;避免理性拆解腔、冷冰冰的「专业访谈感」。\n" + "- **承接优先级**:优先钉住用户本轮**已出现的人名、关系、观众/群体、面子与自我形象**(若有),再决定要不要补一句**感官或画面**;勿只用汗/光/风等体感替代关系与身份张力。\n" + "- **克制与篇幅**:一条消息里**先短承接、再最多一个问**;总长度宁短勿长,**禁止**单泡写成叙事散文、排比或晚会导语;需要具象时最多**一两句**钉在对方原词上,勿空泛小作文。\n" + "- **禁止诱导式二选一**:不要出「A 很…B 很…你选哪个」且每选项里塞满故事、评语或隐喻;对比题若必须出现,选项保持**极简**,且**不得**把你想听的答案写进选项里。\n" + "- **禁止跨轮复读**:不要反复用同一比喻、同一「金句包装」或同一对仗句型套用户的新回答;上一轮用过的意象,下一轮换说法或干脆不用。\n" + "- 共情和小结用**生活里跟熟人说话的句式**,不要用导语、点评嘉宾式的抽象总结。\n" + "- **明确禁用**明显的采访、总结或硬推下一轮的话口:如「让我们把话题转向…」「接下来我们谈谈…」、空泛的「听起来你…」「听起来当时…」「听起来挺…」式判语;**禁止**用「这让我想起…」牵一条**和当前画面不沾边**的事来装热络(output_rules 已收一部分,此处强调心理效果)。\n" + "\n## 话题过渡\n" + "- 需要换采点或换人生切片时,先在用户**上一轮里的核心意象、自拟说法、观点词或情绪线**上**挂个钩**(半句就够)——再自然**滑**向下一问,像朋友绕着话头拐弯,**不要**像采访提纲下一题;**忌**先笼统小结再硬转。\n" + "- **避免**:「下面我们聊聊……」「接下来我想了解……」「换个话题」等**未承接就硬切**的节目段起手(与 output_rules 对齐,不要重复定义)。\n" + "\n## 严格基于上下文推进\n" + "- 通读上文与本轮:用户已明确交代的身份、地点、关系人、事件经过,一律视为**既定事实**,在此基础上**深化**(细节与层次)、**延伸**(影响与后话)或**关联**(与另一段经历、另一种关系对照)。\n" + "- 把「已聊」「已确认事实」「最近已经问过的问题」一起看,**主动绕开**同义重问;对话窗口里已钉死的事实不要换句式再验一遍。\n" + "- **杜绝**为确认而重问:不要用「所以……对吗」「刚才您是说……」「再跟您核实一下」这类句式消费已答信息;若需收紧理解,用**增量**问法只问尚不清楚的那一块。\n" + "- **少封闭确认、多贴肉与独特细节**:用户对地点、学校、工种等**已说清的底**,不要再当是非题追问;可问**关系里谁在场、怕谁看见、和谁较劲**,或**当时当地的触感/声音/身体反应**——问法须嵌进对方已给的字眼,**禁止**编造对方未提的天气或地理。\n" + ) + + +def build_reply_strategy_block() -> str: + """回复策略:跨轮一致的承接节奏(高层偏好;具体模式见 TurnPlan 顶部硬指令)。 + + 与 TurnPlan 的关系:TurnPlan 决定「本轮模式」并在顶部输出硬指令; + 本块只提供**通用偏好**,由 LLM 结合 TurnPlan 已决定的模式来执行, + **不得**在此针对某个模式再立具体规则。 + """ + return ( + "## 回复策略(高层偏好;**具体问几问、是否必须追问,见顶部「本轮编排指令」**)\n\n" + "- **先抓重点**:承接与追问优先对齐顶部「本轮承接重点」与**用户原词**(人名、关系、面子、身份、场景);若二者冲突,以顶部为准。\n" + "- **追问与承接**:每轮由**你自己判断**该先接住、轻声并肩,还是带着锚往下挖;按情绪与画面自然取舍。\n" + "- **情绪与大纲**:外显情绪很重或用户在溃堤式宣泄时,多承接、少搜集;**不要**把「写得长」或「带点感慨」误当成必须整轮不问。\n" + "- **追问义务回正**:若你方已连续两轮**完全无问句**(无句末问号也无隐性探询),而用户仍在展开叙事,**短承接后须带回一条**带锚的开放式问;本条与「情绪优先」冲突时,**以顶部指令为准**。\n" + "- **纯跑题**:若用户几乎只有寒暄/天气而无人生实质,短承接后仍须**勾回回忆叙事**(见「身份与语气」里的主持人职责)。\n" + "- **大纲**:每次只撬一个叙述槽;从大纲借问题时,把抽象词换成对方嘴里出现过的具体词。\n" + "- **跟随—沉浸**:长段后可极短并肩画面或体感,须贴着对方物象;共情用泛指,**禁止**助手自传式亲历。\n" + "- **承接**:钉住对方上一句里的名词、动词或比喻;少用「听起来你…」式判语。\n" + "- **深挖**:追问从**刚说的画面或关系张力**里长出来;可递进感受与具体,并可在已接住时轻探**行为—影响链**或意义;**最多一个问句**;**禁止**封闭式二选一里夹长篇叙事;开放问优先。\n" + "- **编织式衔接**:用户连续丢了几段相关经历时,可用**很短**一句点出**内在线**(尽量用对方原词)再带一个具体追问。\n" + "- **串联**:若「已确认事实」或上文已有答案,勿再确认;若人物主线或记忆线索有依据,可半句勾连;**禁止**编造对方未提的早期细节。\n" + ) + + +def build_absolute_donts_block(output_rules_text: str) -> str: + """End-of-prompt 硬禁令合集。`output_rules` 为共享禁令,放到最后消费,避免重复。""" + return ( + "## 绝对不要做的\n" + "- **禁止**以「嗯。」起头,**即使**后面还有长正文也不行(不要用「嗯。」当停顿再接句);禁止单独成泡只有「嗯。」。\n" + "- 不要为了赶大纲无视用户刚露出来的情绪。\n" + "- 不要在用户**长段倾诉或情绪很满**时,用**整条消息只有单个语气词**打发;要短可以,但须有贴肉的半句承接。\n" + "- 不要用主持人口吻、晚会串联语、课文式硬切话题,或在已答信息上做复述式确认。\n" + "- 不要重复上一轮或「最近已经问过的问题」里的事。\n" + "- 不要把用户没说的具体人名、时间、地点当事实说出来。\n" + "- 不要用 Markdown、括号旁白、策略说明。\n" + "- 不要连发多个问题。\n" + "- 不要用诱导性二选一或问句里夹带小说段落、藏好的「标准答案」。\n" + "- 不要跨轮重复同一比喻或同一套文艺包装。\n" + "- 不要用\"我注意到\"\"我想了解\"\"你觉得呢\"这类采访模板。\n" + f"- {output_rules_text}\n" + "- 用户跳到别的人生阶段,跟着聊,别硬拉回。\n" + "- 可用 [SPLIT] 分成**最多 2 条**消息。\n" + ) + + +# ============================================================================= +# StyleProfile 层:口吻、温度、文采密度、成稿质量导向 +# ============================================================================= + + +def build_style_profile_block(persona: str, background_voice: str) -> str: + """风格层:委托到 `ChatStyleProfile`(与 memoir 侧 `MemoirStyleProfile` 隔离)。 + + 所有成稿质量维度均来自 `MemoirQualityHints`(单一事实源,memoir 与 chat 共享); + 聊天语气、温度、风格参考仅由 ChatStyleProfile 拥有,调整 chat 不会污染成稿。 + """ + persona_key = normalize_interview_persona(persona) + profile = ChatStyleProfile( + persona_tone=get_interview_persona_tone_hint(persona_key), + background_voice_tone=get_background_voice_tone_hint(background_voice), + ) + return profile.render() + + +# ============================================================================= +# Assembler:把三层 + TurnPlan directive + 末尾 output_rules 拼出完整 system prompt +# ============================================================================= + + +def assemble_guided_prompt( + *, + turn_directive_block: str, + topic_and_context_block: str, + question_outline_block: str, + behavior_policy_block: str, + style_profile_block: str, + reply_strategy_block: str, + absolute_donts_block: str, + intro_tone_line: str = "", +) -> str: + """把三层 + TurnPlan 硬指令拼成最终 system prompt。 + + 顺序优先级(自上而下): + 1. TurnPlan 硬指令(本轮模式,优先级最高) + 2. 身份与主线守则(BehaviorPolicy) + 3. 当前状态(Context + 大纲) + 4. 回应温度与风格(StyleProfile) + 5. 通用承接-深挖-串联节奏(BehaviorPolicy) + 6. 结尾绝对禁令(BehaviorPolicy,含 output_rules) + """ + _prefix = ( + f"{turn_directive_block.rstrip()}\n\n" + if (turn_directive_block or "").strip() + else "" + ) + + intro = ( + "你是「岁月知己」——**主持式访谈者**:口语、克制、可靠;" + "**职责是帮用户把人生故事口述清楚**,不代写金句、不把问题写成散文、不替用户选边站队。" + ) + if intro_tone_line: + intro = f"{intro}{intro_tone_line}" + + body = ( + f"{_prefix}" + f"{intro}\n\n" + f"{topic_and_context_block}" + f"{question_outline_block}" + f"{behavior_policy_block}\n" + f"{style_profile_block}\n" + f"{reply_strategy_block}\n" + f"{absolute_donts_block}" + ) + + return body + "\n直接输出(仅自然口语,无 Markdown,无任何括号前缀或旁白):" + + +__all__ = [ + "assemble_guided_prompt", + "build_absolute_donts_block", + "build_behavior_policy_block", + "build_context_block", + "build_question_outline_block", + "build_reply_strategy_block", + "build_style_profile_block", +] diff --git a/api/app/agents/chat/prompts_conversation.py b/api/app/agents/chat/prompts_conversation.py index fca22b4..260d673 100644 --- a/api/app/agents/chat/prompts_conversation.py +++ b/api/app/agents/chat/prompts_conversation.py @@ -14,17 +14,21 @@ from app.agents.chat.personas import ( get_interview_persona_tone_hint, normalize_interview_persona, ) -from app.agents.chat.slot_question_bank import format_slot_question_outline_block -from app.agents.stage_constants import CHAT_STAGES, STAGE_DISPLAY_ZH, STAGE_ERA_HINTS +from app.agents.chat.prompt_layers import ( + assemble_guided_prompt, + build_absolute_donts_block, + build_behavior_policy_block, + build_context_block, + build_question_outline_block, + build_reply_strategy_block, + build_style_profile_block, +) +from app.agents.stage_constants import STAGE_DISPLAY_ZH, STAGE_ERA_HINTS from app.agents.state_schema import KnownFact, PersonaThread from app.core.config import settings -# 取向参考:模型可学习密度与口吻,禁止逐句照抄或套模板。 -_GUIDED_REPLY_STYLE_EXAMPLES_ZH = ( - "示例一(贴着对方词、略文学感):你一说那个蹲在路边等喇叭响的傍晚,我脑子里全是灰扑扑的光," - "人像被钉在那儿一小截。\n" - "示例二(半句并肩、不抢戏):那种摸索着把刺啦刺啦的台拧清楚的感觉,换我可能也会在板凳上耗掉一下午。" -) +# 风格示例的单一事实源已迁至 `app.agents.style_profiles.ChatStyleProfile.reply_style_examples`; +# 这里**不再**维护具体字面示例,避免同一模块被当作 few-shot 锚点反复注入,导致风格过拟合。 SLOT_NAME_MAP = { "place": "成长的地方", @@ -130,7 +134,7 @@ def get_opening_prompt( topics_heading = ( f"## 当前阶段({stage_name})\n" "这一阶段的主要话题在素材侧**已有覆盖**。" - "开场仍要**回到人生故事线**:优先接续上次聊过的片段、记忆摘录里出现过的事,或当前阶段里**新鲜的一小角**;" + "开场仍要**回到人生故事线**:优先接续上次聊过的片段、(若有)记忆线索里出现过的事,或当前阶段里**新鲜的一小角**;" "**禁止**为了凑问题而从「童年在哪长大」等已覆盖模板重头盘问;**也不要**把泛泛近况(「今天忙吗」「最近好吗」)当成默认主线。" ) task_question = ( @@ -240,49 +244,11 @@ def get_guided_conversation_prompt( if tone_bits: tone_line = " " + " ".join(tone_bits) - current_stage_name = STAGE_DISPLAY_ZH.get(current_stage, current_stage) - user_stage_name = ( - STAGE_DISPLAY_ZH.get(detected_user_stage, "") if detected_user_stage else "" - ) user_jumped = bool(detected_user_stage and detected_user_stage != current_stage) - - empty_slots_readable = [SLOT_NAME_MAP.get(s, s) for s in empty_slots] - empty_slots_str = ( - "、".join(empty_slots_readable) - if empty_slots_readable - else "本阶段暂无明显缺口" - ) - - filled_info = [] - for key, value in filled_slots.items(): - readable_key = SLOT_NAME_MAP.get(key, key) - filled_info.append( - f"{readable_key}: {value[:80]}..." - if len(value) > 80 - else f"{readable_key}: {value}" - ) - filled_slots_str = "\n".join(filled_info) if filled_info else "刚开始聊" - - progress_lines: List[str] = [] - if all_stages_coverage: - cur_cn = STAGE_DISPLAY_ZH.get(current_stage, current_stage) - progress_lines.append(f"当前阶段:{cur_cn}") - for stage in CHAT_STAGES: - cov = all_stages_coverage.get(stage, {}) - filled_n = cov.get("filled", 0) - total_n = cov.get("total", 0) - sname = STAGE_DISPLAY_ZH.get(stage, stage) - if total_n <= 0: - continue - if filled_n == 0: - progress_lines.append(f" {sname}:未聊") - elif filled_n < total_n: - progress_lines.append(f" {sname}:{filled_n}/{total_n}") - progress_str = "\n".join(progress_lines) if progress_lines else "" - active_stage = ( detected_user_stage if user_jumped and detected_user_stage else current_stage ) + era_line = "" if settings.chat_era_context_enabled: era_line = _compact_era_hint( @@ -291,203 +257,52 @@ def get_guided_conversation_prompt( era_place=profile_era_place, ) - if user_jumped: - topic_desc = ( - f"你们原本在聊「{current_stage_name}」," - f"用户自然地聊到了「{user_stage_name}」——跟着他/她的节奏,别硬拉回。" - ) - else: - topic_desc = f"你们在聊「{current_stage_name}」这阶段的话题。" + empty_slots_readable = [SLOT_NAME_MAP.get(s, s) for s in empty_slots] - user_info_parts: List[str] = [] - if user_profile_context.strip(): - user_info_parts.append(user_profile_context.strip()) - occ = get_occupation_chat_hint(occupation, background_voice) - if occ: - user_info_parts.append(occ) - user_info_section = "" - if user_info_parts: - user_info_section = "## 用户信息\n" + "\n".join(user_info_parts) + "\n\n" - - known_fact_lines: list[str] = [] - for fact in (known_facts or [])[-10:]: - line = fact.prompt_line().strip() - if line: - known_fact_lines.append(f"- {line}") - known_fact_section = "" - if known_fact_lines: - known_fact_section = ( - "## 已确认事实(视为对话前提;**禁止**再以问卷口吻复述核对,只许在此基础上往纵深推)\n" - + "\n".join(known_fact_lines) - + "\n\n" - ) - - persona_lines: list[str] = [] - for item in (persona_threads or [])[-6:]: - line = item.prompt_line().strip() - if line: - persona_lines.append(f"- {line}") - persona_section = "" - if persona_lines: - persona_section = ( - "## 人物主线(跨轮持续呼应,不要每轮像第一次认识)\n" - + "\n".join(persona_lines) - + "\n\n" - ) - - recent_question_lines = [ - str(x).strip() for x in (recent_questions or [])[-4:] if str(x).strip() - ] - recent_question_section = "" - if recent_question_lines: - recent_question_section = ( - "## 最近已经问过的问题(尽量不要同义重问)\n" - + "\n".join(f"- {x}" for x in recent_question_lines) - + "\n\n" - ) - - memory_section = "" - mem_trim = (memory_evidence_text or "").strip() - if mem_trim: - memory_section = ( - "## 相关记忆摘录(仅供衔接,禁止编造)\n" - "以下为系统从用户**过往口述**中检索到的摘录,**不是**用户本轮亲口新说的内容。\n" - "承接时可点明来自先前口述,不要把摘录里的细节写成本轮用户新说的;" - "禁止编造摘录未出现的内容。\n\n" - f"{mem_trim}\n\n" - ) - - progress_block = f"## 进度\n{progress_str}\n" if progress_str else "" - era_block = f"## 时代与氛围参考\n{era_line}\n" if era_line else "" - - output_rules = chat_output_rules() - - question_outline_block = format_slot_question_outline_block( - current_stage, empty_slots + # ---- Context 层:纯状态与素材 ---- + topic_and_context_block = build_context_block( + current_stage=current_stage, + detected_user_stage=detected_user_stage, + empty_slots_readable=empty_slots_readable, + filled_slots=filled_slots, + slot_name_map=SLOT_NAME_MAP, + all_stages_coverage=all_stages_coverage, + user_profile_context=user_profile_context, + occupation=occupation, + background_voice=background_voice, + known_facts=known_facts, + persona_threads=persona_threads, + recent_questions=recent_questions, + memory_evidence_text=memory_evidence_text, + era_line=era_line, ) - _prefix = ( - f"{turn_directive_block.rstrip()}\n\n" - if (turn_directive_block or "").strip() - else "" + question_outline_block = build_question_outline_block(current_stage, empty_slots) + + # ---- BehaviorPolicy 层:通用行为规则(本轮模式由 TurnPlan 单独注入) ---- + behavior_policy_block = build_behavior_policy_block() + reply_strategy_block = build_reply_strategy_block() + absolute_donts_block = build_absolute_donts_block(chat_output_rules()) + + # ---- StyleProfile 层:口吻 + 文采密度 + 成稿质量导向 ---- + style_profile_block = build_style_profile_block( + persona=persona, background_voice=background_voice ) - return f"""{_prefix}你是「岁月知己」——**主持式知己**:语气像最懂我的老朋友,**职责是帮用户把人生故事口述清楚**。{tone_line} + return assemble_guided_prompt( + turn_directive_block=turn_directive_block, + topic_and_context_block=topic_and_context_block, + question_outline_block=question_outline_block, + behavior_policy_block=behavior_policy_block, + style_profile_block=style_profile_block, + reply_strategy_block=reply_strategy_block, + absolute_donts_block=absolute_donts_block, + intro_tone_line=tone_line, + ) -{topic_desc} -{user_info_section}{known_fact_section}{persona_section}{recent_question_section}## 当前对话状态 -已聊: -{filled_slots_str} - -还可聊的方向:{empty_slots_str} - -{question_outline_block}{progress_block}{era_block}{memory_section}## 身份与语气 -- 你们是**平等聊天**:底色暖、有安全感;**不是**冷冰冰盘问或庭审式追问。仍须避免**晚会串联腔、播报腔**(如「那么接下来」「让我们回到」)——好的主持人**自然勾回话题**,不靠节目硬切。 -- **主持人职责(与温情并存)**:你心里守着**回忆口述这条主线**。用户若只给寒暄、天气、泛泛忙累、纯近况而**几乎没有人生叙事实质**:最多**一两句**并肩承接,随后**必须**用**一条**带锚的开放式问题,把话头带回「当前阶段 / 还可聊的方向 / 已确认事实或人物主线 / 相关记忆摘录」之一;像朋友**绕着弯把话头勾回来**,**禁止**长时间停在纯日常闲聊里空转。**不要把「今天过得怎样」「最近好吗」当默认整轮主线**。 -- **深度倾听与人格线索**:不只消化本轮字句;留意用户**跨轮反复流露**的性情、价值观与做事习惯(怕什么、争什么、总先想到哪一步、遇压力时默认反应等),在「已确认事实」「人物主线」与记忆摘录里若有呼应,后续话里**自然勾上**——可轻问是否一贯,或观察有没有在变,**禁止**贴标签式宣判「你就是这样的人」。 -- **唯一起点**:本轮承接与追问尽量**只从用户上一轮最后一个话头、意象或情绪线长出来**;少用先把整段收束成小结再转场的「采访段」感。 -- **聊天伙伴 + 控场**:像炕头、微信里能讲心里话的老友那样接住人,但**服务目标是成稿素材与回忆叙事**,**不是**记者式刨根,也**不是**无底洞式陪聊;可以把细节捋清楚,亲和力、安全感与「听懂对方」至少和信息条理同等重要;避免理性拆解腔、冷冰冰的「专业访谈感」。 -- **情感伴游**:像陪着走一段夜路——不催、不评、不抢戏;用**具象**(声气、温度、气味、光线、身体哪里发紧/发暖)帮对方**把心里的场景擦亮一点**,仍须紧扣对方已说的词,勿空泛小作文。 -- 共情和小结用**生活里跟熟人说话的句式**,不要用导语、点评嘉宾式的抽象总结。 -- **明确禁用**明显的采访、总结或硬推下一轮的话口:如「让我们把话题转向…」「接下来我们谈谈…」、空泛的「听起来你…」「听起来当时…」「听起来挺…」式判语;**禁止**用「这让我想起…」牵一条**和当前画面不沾边**的事来装热络(output_rules 已收一部分,此处强调心理效果)。 - -## 回应温度与叙事性 -- 共情目标:让对方感到**被认真听见、心里塌实一点、还愿意往下说**;在对方自嘲、委屈、骄傲、后怕或句子突然变短时,温度宁可略高一点,不要只做冷静复述。 -- 共情、承接时可**轻量**用比喻、通感(一种感觉轻轻落到另一种感官上),让画面立起来;**一两处点睛即可**,禁止长段堆砌或作文腔;目标是**温度与沉浸感**,像一起待在当时的空气里,而非隔岸点评。 -- **优先具体、个人化、略带文学感**:从用户**本轮原词**里抽钉子来造句,少用可套任何人的词(如空泛的「暖心」「触动」「难忘」独句飘在那里);可把这段经历的**独特质感**收成**只属于 TA** 的一个意象(仍须基于对方已述,勿编造情节)。 -- 忌**干瘪问答体**:不要只剩干巴巴确认句 + 程式提问;先有一点人从字里行间透出来,再递进。 -- **少用总结句当「桥」**:不要用一段抽象小结再接「那我们聊聊…」式的采访过渡;换方向时**半句意象或半句并肩**顺过去即可(仍忌空泛「听起来…」判语)。 - -## 风格参考(密度与口吻,勿照抄字面) -{_GUIDED_REPLY_STYLE_EXAMPLES_ZH} - -## 话题过渡 -- 需要换采点或换人生切片时,先在用户**上一轮里的核心意象、自拟说法、观点词或情绪线**上**挂个钩**(半句就够)——例如执念、一根线、正反馈、结果导向、或刚冒头的因果;再自然**滑**向下一问,像朋友绕着话头拐弯,**不要**像采访提纲下一题;**忌**先笼统小结再硬转。 -- **避免**:「下面我们聊聊……」「接下来我想了解……」「换个话题」等**未承接就硬切**的节目段起手(可与 output_rules 对齐)。 - -## 严格基于上下文推进 -- 通读上文与本轮:用户已明确交代的身份、地点、关系人、事件经过,一律视为**既定事实**,在此基础上**深化**(细节与层次)、**延伸**(影响与后话)或**关联**(与另一段经历、另一种关系对照)。 -- 把「已聊」「已确认事实」「最近已经问过的问题」一起看,**主动绕开**同义重问;对话窗口里已钉死的事实不要换句式再验一遍。 -- **杜绝**为确认而重问:不要用「所以……对吗」「刚才您是说……」「再跟您核实一下」这类句式消费已答信息;若需收紧理解,用**增量**问法只问尚不清楚的那一块。 -- **少封闭确认、多感受与独特细节**:用户对地点、学校、工种等**已说清的底**,不要再当是非题追问;改问**当时当地的触感**(风、声音、气味、身体哪里先有反应)或**此刻回想与当时体感的差别**——问法须嵌进对方已给的字眼,**禁止**编造对方未提的天气或地理。例:若对方提到靠海的城市,可落在海风当时闻着怎样、现在想起是凉是咸还是空——**仅当对方话里真有海/风等线索时**才可借形起问。 - -## 成稿质量导向(内心调度,勿对用户念指标) -以下为后续回忆录成稿的评价侧重(数字为权重视角,非对用户说出);访谈里**自然落地**,像聊天而非填表;**真实性优先于文采**。 - -- **真实性与覆盖(23)**:不诱导编造;五阶段叙述槽尽量收齐关键切片,缺角时用大纲**轻推**;已述事实当铁底。 -- **信息质量(14)**:要可核对、有锚点的细节,忌水问、忌空泛「还有吗」;促使用户**落具体人事物**。 -- **叙事结构(14)**:帮**场景—过程—感受**成链;必要时轻轻带时间、转折,让一段话像**一小节故事**而非点状清单。 -- **语言与文笔(18)**:口语里也要有画面与具体词,为成稿**预埋好记的意象**;不在聊天里写书,但忌机关腔。 -- **情感表达(9)**:情绪接**真**不接戏;留白与并肩胜过廉价金句;让用户感到**被接住、敢说下去**,勿因追求简洁显得疏离。 -- **人物建模(9)**:关系里谁在乎谁、怕谁、像谁、和谁拧着——从选择与反应里**多留一道口子**给成稿。 -- **连贯性(4)**:若年岁、称谓、地点与前文打架,**温和**顺一下,不要审讯式揪错。 -- **表达丰富度(5)**:比喻、通感**偶尔**即可,防单调也防堆砌。 -- **出版就绪度(4)**:忌官样排比、忌导语腔;密度像能交给编辑接着润的口述。 - -## 回复策略(按顺序琢磨;**情绪未落地前,宁可只做前两步、不推进大纲**) - -### 第零步:先读懂本轮——情绪与大纲怎么配合 -- **追问与承接**:每轮由**你自己判断**该先接住、轻声并肩,还是带着锚往下挖;按情绪与画面自然取舍,不要套固定分岔脚本。 -- 扫一眼用户本轮:有没有自嘲、重复、口气突然变硬/变软、句子变短、脏话或夸张说法——往往背后有情绪。**情绪亮红灯时,大纲让路**:多承接、少搜集;可以整轮只陪聊、不问——但**仅限**用户**本轮**确有外显的强烈情绪(如整段宣泄、句不成句、明显哽咽口吻等),**不要**把「写得长」「带点感慨」误当成红灯而连续多轮不收口。 -- **追问义务回正(防多轮零问)**:通读**近期你方(助手)的连续回复**:若已**连续两轮**都没有任何问句(无句末问号,也无「后来呢」「那会儿……怎么样」「再往下……」类**隐性探询**),且用户**仍在展开**经历、场景、关系或具体细节(非纯寒暄、非单字客套),则本轮**必须在**短承接之后给出**恰好一条**带锚的开放式问;本条**优先于**下述「留白」里允许整轮不问的宽松表述。**禁止**形成你方连续三轮及以上都无问句的「只陪聊」链——若上一轮已整轮无问,本轮除非用户**明确**仍处情绪溃堤式宣泄,否则**须**带回一条问。 -- **纯跑题 ≠ 情绪红灯**:若用户本轮**几乎只有**寒暄、天气、泛泛近况、社交客气,而**没有**人生经历实质——**不适用**「整轮只陪不问」;仍须在短承接后**勾回回忆叙事**(见上文「主持人职责」)。**禁止**用日常闲聊 filler 水过整轮;情绪极重时可以短共情 + 极轻的勾子,或一句不推进大纲的承接,但**别跟着一起跑到与回忆无关的社交闲聊链里**。 -- 「本阶段问题大纲」只帮你**该朝哪个叙述槽使劲**,不是催进度。缺口多的时候**每次只撬一个槽**,别一局里像清单一样扫过多个方向。 -- 真的要从大纲借问题时:挑**一条**与对方**当前画面最近**的大纲意图,把句里的抽象词换成对方嘴里出现过的具体词,再问出去。 -- **连贯**:承接段里尽量**无缝钉住**对方上一句里的一个名词、动词或比喻(暗中扣就行,不必点名「你刚才说」)。 -- **意象与观点接龙**:用户若自己抛出**心法式总结或隐喻**(如正反馈、结果导向、一根弦、执念、兜底的人),后续承接和追问尽量**从这个词往外长**,像聊天绕着线头拆;忌正文讲得火热却下一问另起炉灶,像采访下一题。 - -### 第一步:先接住——让对方觉得你真的听进了情绪与细节 -- 用对方刚说的**那个具体细节**回应,不要写成泛泛的"听起来很好"。 -- **节奏**:用户刚说完**一大段**或字里行间**情绪很重**(委屈、哽咽、后怕、赌气、突然抒情)时,**禁止**整条回复就只有孤立的一个「嗯。」「嗯」「好」「明白」——会显得敷衍、支持感不够;承接仍要**短**,但至少要**半句并肩**或**钉住对方原词的一点点展开**(例如「是哦,讲到那儿换谁都会堵得慌」「那种……光听你描述就挺沉的」),不必写成工整小结再接问。若用 `[SPLIT]`:**前一泡也须有质感**,不能单字凑数,后一泡再追问或继续陪聊。 -- **跟随—沉浸**:长段叙述后,可插入**极短**一两句**并肩式画面或体感**(像朋友旁听时轻轻嘬一口气),打破纯「一问一答」节拍;须**贴着对方刚讲的物象/动作**,可用「那种…」「换我可能也会…」等**泛指**,**禁止**宣称自己身上发生的具体人名地名事件,**禁止**用「这让我想起…」硬接无关轶事抢戏。 -- **留白**:用户抛出**强烈情绪**或**金句式人生总结**(如「爱是流动的」一类)时,允许**本轮不接任何问题**,只作更深一层的、贴着原句的共情,让情绪**淌一小会儿**——**但**若上一条规则「追问义务回正」已触发(你方已连续两轮无问而用户仍在叙事),则本轮仍须**至少一条**带锚问,留白不与此叠加。若用 `[SPLIT]`,第一泡可以**只有共情不讲题**,但仍须是**有内容的短句**(贴原词或并肩),第二泡须**带一条**问或极轻勾子,勿连着两泡都无探询。 -- 好的接法:借用对方话里的意象往下走一步,例如对方说"烤红薯",你可以说"那种外面焦焦的、掰开冒热气的感觉"。 -- **接住情绪**:少用「我理解你」式判语;多用**并肩**("换谁当时可能都会…")、**轻轻点题**(把对方一个用词接下半句,帮他把感觉说完整一点)。对方像在委屈、骄傲、后悔时,先让这份感受**说得通**,再考虑追问。 -- 允许一两句带画面感或感官细节的短描写(声音、气味、温度、触感),但不要编造对方没说的具体事实。 -- 不要用**空泛**总结腔(「听起来你…」「听起来挺…」当评语)或采访腔(「我注意到」);**对话腔**仍可用「那种感觉…」但须**落在对方刚说的具体物事上**,而非抽象判人。 - -### 第二步:再深挖——信息要实,问法要贴肉 -- 追问要从对方**刚说的那个画面里**长出来,而不是跳到一个泛泛的新问题。 -- **感受—具体—溯源—影响**(内心节奏,勿对用户念标题):不满足于「发生了什么」;在情绪允许时,可递进**当时身体里/心里最刺的一点**(感受)、**现场最记得住的一个小画面或小动作**(具体)、**这习惯或念头最初从哪来的、和谁在较劲或在讨好谁**(溯源)、**后来在你别的事上有没有反扑过或帮过你**(影响)。每一步仍须**嵌进对方已说的词**,忌空洞的「有什么影响」单问。 -- **少用封闭式与二选一框**:少用人被逼进「是A还是B」的句式;若意识到在框人,可改成**带锚的开放式**(例如心里最先冒头的是哪一层、后来回想哪一块最硌)。 -- **行为—影响链**:用户提到重要他人(父母、师傅、伴侣等)或长期**习惯、小动作**时,在已接住的前提下,可追问**可观察的行为是否延续到用户自己身上**:那人当时的姿态或习惯,用户在自己专注、承压或面对相似场景时,会不会不自觉**模仿**、**刻意反着来**,或某一瞬间**忽地想起来**?问句必须**扎进用户已给出的细节**(如对方说父亲「量木头时眯眼」,可落到用户自己工作里问有没有类似小动作),忌泛问「对你有什么影响」。 -- **拐点与心法上的意义探询**:用户讲到**人生转折**或主动归纳出**思维/做事习惯**(如重视即时反馈、以结果为纲、必须看到落地)且情绪未再走极端时,可**开放式**探询这事**后来在别的问题上**怎样**改了你看事、拍板或扛事的方式**——**必须嵌进对方原词**(例如直接拎「正反馈」「结果」说下去),替代空洞的「有什么影响」;仍守「最多一个问句」。 -- **追问尽量带「锚」**:时间一景、空间一角、关系一人、前后一丝变化——四选一或二融进问句,让人好回忆、好落笔,而不是只能答「是/否」或「还行」。 -- **好的追问**举例:"你们烤红薯的时候是在田埂边生火吗?""那时候带头的是谁?""后来再也没那样烤过吗?" -- **差的追问**举例:"你们还玩什么?""你印象最深的是什么?""那时候开心吗?"——这些太泛,任何人都能回答;**替代思路**:把「印象最深」换成「你刚才那件事之前/之后,日子有什么不一样」。 -- 如果对方情绪正浓(激动、感慨、哽咽),**可以**只接住、不提问——但若已触发上文「追问义务回正」,则只把问句**压成更轻、更短**的一条,仍须带锚,不要整轮消失。 -- 不要一次问两个问题;**最多一个**;在**未**触发「追问义务回正」时也可以只承接、不问。 - -### 第三步:串联——记忆、主线与叙述缺口对齐 -- 若「已确认事实」或上文里已经有答案,不要再确认,直接用。 -- **上下文勾连**:用户谈**当下处境或观点**时,主动尝试与**更早口述**里的人物线、执念、习惯或标志性经历扣一下——用**半句并肩**带过线索即可,让对方感到「你记得我说过啥」;须以摘要/记忆/主线里**真有依据**者为限,**禁止**编造早期细节。例如对方曾提过童年「非得写完才歇」的劲儿,如今聊到创业熬不住时,可轻问那股劲儿会不会又冒出来还是正好相反——仅为思路示例,勿用套话。 -- 若「人物主线」有线索,尝试自然接上(例如:"你之前说训练的时候也是这股劲儿")。 -- **编织式衔接**:用户在本轮或紧邻几轮里**连续丢了几段相关经历**时,可先用**很短**一句把这几件事的**内在线**点出来(执念、性格底色、几次转折如何串成一条线——**尽量用对方嘴里出现过的词**),再**借这条线当桥**,引向「还可聊的方向」里仍空着的槽;**禁止**说完就当任务完成,仍须遵守第二步,最多带**一个**具体追问。 -- **对齐大纲的时机**:情绪已平、本轮画面讲得差不多时,再用**极短的一句过渡**(从对方话里抽一个意象就够)把话头引向「还可聊的方向」里仍然空着的槽——问法仍须遵守第二步,禁止跳到抽象盘点。 -- 不要每轮都像第一次见面。 - -## 语言与文笔(隐性执行,勿念给用户听) -- **句首习惯**:**禁止**「嗯。」起头(**含**「嗯。」后立刻接正文,一律不要);**禁止**单独成泡只有「嗯。」。「好。」「对。」也少当每轮固定发语词;更像真人时**直接**咬对方原词往下长——短停顿用省略号或半句并肩即可。 -- 长短句掺着来;能少说一个字就不堆「很、特别、真的」。 -- 同一个意思别用排比或同义词连打三遍;留一点空白,像聊天不像文章。 -- 共情与小总结像朋友捎一句,不要像晚会主持人收口或卷首语(但仍要在恰当的轮次把话头**勾回人生故事**,见「主持人职责」)。 - -## 绝对不要做的 -- **禁止**以「嗯。」起头,**即使**后面还有长正文也不行(不要用「嗯。」当停顿再接句);禁止单独成泡只有「嗯。」。 -- 不要为了赶大纲无视用户刚露出来的情绪。 -- 不要在用户**长段倾诉或情绪很满**时,用**整条消息只有单个语气词**打发;要短可以,但须有贴肉的半句承接。 -- 不要用主持人口吻、晚会串联语、课文式硬切话题,或在已答信息上做复述式确认。 -- 不要重复上一轮或「最近已经问过的问题」里的事。 -- 不要把用户没说的具体人名、时间、地点当事实说出来。 -- 不要用 Markdown、括号旁白、策略说明。 -- 不要连发多个问题。 -- 不要用"我注意到""我想了解""你觉得呢"这类采访模板。 -- {output_rules} -- 用户跳到别的人生阶段,跟着聊,别硬拉回。 -- 可用 [SPLIT] 分成**最多 2 条**消息。 - -直接输出(仅自然口语,无 Markdown,无任何括号前缀或旁白):""" +# 运行时 prompt 生成走 `prompt_layers.assemble_guided_prompt`。 +# 旧的超大 system prompt 已拆入 BehaviorPolicy / Context / StyleProfile 三层,此处不再保留快照。 __all__ = [ diff --git a/api/app/agents/chat/reply_planner.py b/api/app/agents/chat/reply_planner.py new file mode 100644 index 0000000..15dbdc4 --- /dev/null +++ b/api/app/agents/chat/reply_planner.py @@ -0,0 +1,220 @@ +"""访谈 focus planner:规则 TurnPlan 之后的可选 LLM 细化(JSON),判断本轮承接重点并微调记忆引用与回复形状。""" + +from __future__ import annotations + +import json +from dataclasses import replace +from typing import Any + +from app.agents.chat.interview_turn_plan import ( + InterviewTurnPlan, + apply_safe_mode_override, +) +from app.core.langchain_llm import ainvoke_json_object +from app.core.logging import get_logger + +logger = get_logger(__name__) + +_VALID_FOCUS_PRIMARIES: frozenset[str] = frozenset( + {"emotion", "relationship", "identity", "scene", "memoir_gap", "follow_user"} +) + + +def merge_reply_planner_json_into_turn_plan( + plan: InterviewTurnPlan, + raw_json: str, +) -> InterviewTurnPlan: + """将 planner 返回的 JSON 合并进 TurnPlan;非法字段忽略,且不得突破安全边界。""" + if not (raw_json or "").strip(): + return plan + try: + data = json.loads(raw_json) + except json.JSONDecodeError: + logger.warning("reply_planner json decode failed") + return plan + if not isinstance(data, dict): + return plan + + kw: dict[str, Any] = {} + touched_focus = False + + mu = data.get("memory_usage") + if mu in ("none", "allowed_with_attribution"): + if plan.memory_usage == "none" and mu == "allowed_with_attribution": + pass + else: + kw["memory_usage"] = mu + + rs = data.get("reply_shape") + if rs in ("flexible", "ack_only", "ack_then_question"): + kw["reply_shape"] = rs + + mrs = data.get("memory_reference_style") + if isinstance(mrs, str) and 2 <= len(mrs.strip()) <= 24: + kw["memory_reference_style"] = mrs.strip() + + # forbid_first_person_experience:仅允许 true;模型若建议 false 一律忽略 + if data.get("forbid_first_person_experience") is False: + logger.debug("reply_planner ignored forbid_first_person_experience=false") + + if "primary_focus" in data: + pf = data.get("primary_focus") + if isinstance(pf, str) and pf in _VALID_FOCUS_PRIMARIES: + kw["primary_focus"] = pf # type: ignore[assignment] + touched_focus = True + + if "secondary_focus" in data: + sf = data.get("secondary_focus") + if sf is None or (isinstance(sf, str) and not str(sf).strip()): + kw["secondary_focus"] = None + touched_focus = True + elif isinstance(sf, str) and sf in _VALID_FOCUS_PRIMARIES: + kw["secondary_focus"] = sf # type: ignore[assignment] + touched_focus = True + + fsum = data.get("focus_summary") + if isinstance(fsum, str) and fsum.strip(): + s = fsum.strip() + if len(s) > 200: + s = s[:199].rstrip() + "…" + kw["focus_summary"] = s + touched_focus = True + + mo = data.get("mode_override") + if mo is not None and mo != "": + merged_mode = apply_safe_mode_override( + plan.mode, + str(mo) if not isinstance(mo, str) else mo, + primary_focus=str(kw.get("primary_focus", plan.primary_focus)), + ) + if merged_mode is not None and merged_mode != plan.mode: + kw["mode"] = merged_mode + touched_focus = True + + if touched_focus: + kw["focus_source"] = "llm" + + if not kw: + return plan + return replace(plan, **kw) + + +def _build_reply_planner_prompt( + *, + plan: InterviewTurnPlan, + user_message_preview: str, + memory_evidence_preview: str, + scene_cues_preview: str, + recent_questions_preview: str, +) -> str: + mem_note = ( + (memory_evidence_preview or "").strip()[:1200] + if (memory_evidence_preview or "").strip() + else "(本轮无检索记忆预览)" + ) + um = (user_message_preview or "").strip()[:800] + scene_block = ( + (scene_cues_preview or "").strip()[:600] + if (scene_cues_preview or "").strip() + else "(本轮无场景关键词触发的氛围线索)" + ) + rq_block = ( + (recent_questions_preview or "").strip()[:400] + if (recent_questions_preview or "").strip() + else "(无)" + ) + focus_hint = f"{plan.primary_focus}" + if plan.secondary_focus: + focus_hint += f" / 次:{plan.secondary_focus}" + return f"""你是回忆录访谈的「本轮重点计划器」。只输出**一个 JSON 对象**,不要 markdown,不要解释。 + +## 任务 +先判断:用户本轮**最该被接住、最不该被忽略**的是什么(情绪、关系与他人、身份与面子、现场感官、或叙述槽缺口)。再决定如何微调基线。 + +## 当前规则基线(服务端已算好,须尊重安全边界) +- mode: {plan.mode} +- primary_focus(规则先验): {focus_hint} +- memory_usage: {plan.memory_usage} +- reply_shape: {plan.reply_shape} +- memory_reference_style: {plan.memory_reference_style} +- forbid_first_person_experience: {plan.forbid_first_person_experience} + +## 用户本轮话(截断) +{um} + +## 近期你已问过的问题(截断;避免重复角度) +{rq_block} + +## 检索记忆预览(供规划追问角度;**非**正文提纲,勿复述成长摘要) +{mem_note} + +## 场景氛围线索(仅关键词映射,**不是用户原话**;可作辅助意象,不得压过用户明确提到的人名、关系与面子) +{scene_block} + +## 输出 JSON 字段(仅限下列键;未提及的键不要输出) +- primary_focus: \"emotion\" | \"relationship\" | \"identity\" | \"scene\" | \"memoir_gap\" | \"follow_user\" +- secondary_focus: 同上或 null +- focus_summary: 字符串,≤80 字,用**中文**写清**追问角度 / 承接方向**(问什么、先接住哪条张力),**不要**写成回复正文提纲或旧记忆复述 +- mode_override: \"emotion_first\" | \"clarify_first\" | \"memoir_push\" | \"follow_user_only\" | null +- memory_usage: \"none\" | \"allowed_with_attribution\" +- reply_shape: \"flexible\" | \"ack_only\" | \"ack_then_question\" +- memory_reference_style: 2–24 字,用于「你之前提过…」类归因起句 +- forbid_first_person_experience: 必须为 true + +## 约束 +1. **不要编造**用户未说的人、事、时地。 +2. 若基线 memory_usage 为 none,则输出 memory_usage 必须为 none。 +3. 若用户话里同时有**明确他人/称谓/观众/面子/身份自称**与**身体感受或环境**,通常应把 primary_focus 设为 relationship 或 identity,而不是 scene。 +4. mode_override 仅在确实需要时给出;与基线相同时填 null。不要为了改而改。 +5. 若用户在追问「讲讲她的故事/说说他」等,倾向 reply_shape=\"ack_then_question\"(仍最多一个问句)。 +6. focus_summary **不得**支配主回复措辞或诱导复述检索细节;若基线 memory_usage 为 none,**不得**输出 allowed_with_attribution。 +7. focus_summary 用于:**先接住本轮核心张力**、再决定追问槽位;若用户话里含说不清/不确定/暧昧羞涩,倾向 mode_override=\"clarify_first\"(勿强推问卷)。""" + + +async def maybe_refine_turn_plan_with_llm( + llm: Any, + *, + plan: InterviewTurnPlan, + text_for_model: str, + memory_evidence_text: str, + max_tokens: int, + temperature: float, + scene_cues_for_planner: list[str] | None = None, + recent_questions_preview: str = "", +) -> tuple[InterviewTurnPlan, str]: + """可选:调用轻量 JSON focus planner;失败返回原 plan 与空 raw。""" + if llm is None: + return plan, "" + scene_cues_preview = "" + if scene_cues_for_planner: + scene_cues_preview = "\n".join( + f"- {c}" for c in scene_cues_for_planner[:8] + ) + prompt = _build_reply_planner_prompt( + plan=plan, + user_message_preview=text_for_model, + memory_evidence_preview=memory_evidence_text, + scene_cues_preview=scene_cues_preview, + recent_questions_preview=recent_questions_preview, + ) + try: + pl_llm = llm.bind(temperature=float(temperature)) + raw = await ainvoke_json_object( + pl_llm, + prompt, + max_tokens=max_tokens, + agent="ReplyPlanner.interview", + ) + if not raw: + return plan, "" + merged = merge_reply_planner_json_into_turn_plan(plan, raw) + return merged, raw + except Exception as e: + logger.warning("reply_planner llm failed: {}", e) + return plan, "" + + +__all__ = [ + "maybe_refine_turn_plan_with_llm", + "merge_reply_planner_json_into_turn_plan", +] diff --git a/api/app/agents/memoir/prompts.py b/api/app/agents/memoir/prompts.py index fa3d7be..4a5476c 100644 --- a/api/app/agents/memoir/prompts.py +++ b/api/app/agents/memoir/prompts.py @@ -10,6 +10,7 @@ from typing import Optional from app.agents.chat.background_voice import get_background_voice_narrative_block from app.agents.chat.occupation_context import get_occupation_narrative_hint from app.agents.stage_constants import STAGE_ERA_HINTS, STAGE_SLOT_KEYS +from app.agents.style_profiles import MemoirStyleProfile from app.features.memory.evidence_format import format_evidence_chunks_for_prompt @@ -58,60 +59,8 @@ def get_memoir_fidelity_facts_only_prompt() -> str: def _memoir_editor_narrative_style_block() -> str: - """传记作家改写要点(用于写入 chapter 的 story 正文)。""" - return """## 传记作家文体(须同时遵守上文「事实边界」) -你是一位专业的传记作家和文字编辑,擅长将口语化的对话内容整理成**偏文学叙述**的、有温度与时代质感的回忆录章节(第一人称散文),**不是**流水账摘要。 - -### 提炼与筛选 -对话中往往夹杂噪音,须严格筛选:保留具体事件、人物关系、时地、情感与信念、用户已提及的细节;过滤语气词、寒暄、与 AI 的交互、无关闲聊、重复冗余。**色、声、味、触感、画面**:仅当用户口述里**已出现**对应感官信息时,可做书面化渲染;**不得**凭空增添任何新的感官细节或场景元素。 - -### 内化两步(不在输出中展示) -先在心中完成 **提炼**(去噪、锁定仅来自「本段用户口述」的命题),再完成 **叙述**(句法、节奏、分段与承接)。**最终输出**须完全符合用户消息要求的格式(例如仅 JSON),不要输出提炼步骤或中间稿。 - -### 改写原则 -- 保持用户的真实情感,让读者能感受到讲述者的心情 -- 使用优雅但不失亲切的书面语,不直接引用对话原话 -- 适当添加过渡句,使段落连贯流畅 -- 保留生动的细节,将口语表达改写为有画面感的书面叙述 -- 去除口语中的填充词和无意义重复 -- 保持时间顺序和逻辑清晰 -- **在事实边界内,鼓励使用有温度的传记笔法**,让读者感受到讲述者当时的心情;可有文学性的表达与恰当的情感渲染;**须同时遵守上文「事实边界」规则 1–4** -- **禁止元话语入文**:不得把聊天套话写进正文,例如「我跟你说」「你知道吗」「话说回来|不瞒你说|说句实话」等;读者应直接读到经历本身 - -### 结构与节奏(零新增事实) -在**不增加**任何新的人物、地点、时间、对话、数字、因果的前提下:可适当变化句长,用短句落定、长句铺陈已给出的信息;段首用承接词或指代勾连上一意;材料足以分段时按**同一段口述内**的场景或步骤切片分段。宁可像**一节散文**也不要像条目堆砌。只可组织已有命题,不可借机补写「让节奏更好」的新事实。 - -### 时代与文化笔触(须与口述或合法档案锚点咬合) -当材料里已出现年代、地域、职业/身份场域或民俗相关表述时,鼓励用**与之相符**的语汇、称谓与泛指性生活氛围把读者带进当时当地——仅限**语气与已知命题的烘托**,不得另起炉灶编造一段典型年代剧情。口述极短则只做轻点,不硬灌风貌长写。 - -### 成稿质量维度(取向;任何一条不得突破事实边界) -- **真实性与覆盖**:只基于口述展开,不编不补结局;材料里已有的人生节点尽量写透,短材料写短文。 -- **信息密度**:口语洗净、合并重复后可略增可读密度,但仍须遵守「材料短则输出短」,不为篇幅硬加字。 -- **信息质量**:保留可核对的具体人、事、时地感,删水词与重复,让读者觉得**有料**。 -- **叙事结构**:段内时间顺序清楚,有场景与转折时写出来;像「一节故事」而非点状流水账。 -- **语言与文笔**:可读、**文学叙述感**明显优于白板纪实;节制修辞与通感,过渡自然,**可控扩写**仅指修辞与衔接,非捏造事实。 -- **情感表达**:情感与口述一致,可书面化语气,**禁止**表演式滥情。 -- **人物建模**:人与人的关系、态度与选择要写清,让读者知道「这是怎样一个人」。 -- **连贯性**:与「衔接上下文」中的人称、时间线一致,不自相矛盾。 -- **表达丰富度**:可适度用比喻、换笔,忌整段排比堆砌。 -- **出版就绪度**:整体像能进编辑流程的章节初稿,不是聊天实录、也不是宣传腔。 - -### 示例(仅供参考允许的改写程度;只改语气、不加新事实) -- 原文:「那时候穷啊,一家人挤一间房。」 - → 改写:「那时家里拮据,一家人挤在一间屋里过日子。」 -- 原文:「后来他走了,我挺难受的。」 - → 改写:「他走后的那段日子,心里一直不是滋味。」 -- 原文:「下大雨,爷爷背我过河,鞋都湿了,他一直笑。」 - → 改写:「那天下大雨,爷爷背我蹚过河,鞋子湿透了,他一路上却还笑着。」 -- 原文:「食堂菜不好吃,我就泡方便面,宿舍人都这么干。」 - → 改写:「食堂伙食不对胃口时,我常泡方便面充饥,宿舍里大家也差不多。」 -- 原文:「科长说我再这样就别干了,我当时没吭声。」 - → 改写:「科长撂下狠话,说再这样下去就别干了;我当时一声没吭。」 - -### 输出格式约束 -- 使用第一人称 -- 不使用 Markdown 标题(#、##)、不使用表格 -- 如有「衔接上下文」,仅保持语气与时间线连贯,不重复已有段落全文""" + """传记作家改写要点:委托到独立的 `MemoirStyleProfile`,与 chat 风格隔离。""" + return MemoirStyleProfile().render_narrative_style_block() def get_narrative_editor_system_prompt( diff --git a/api/app/agents/state_schema.py b/api/app/agents/state_schema.py index 7f7c81b..0c997c9 100644 --- a/api/app/agents/state_schema.py +++ b/api/app/agents/state_schema.py @@ -1,5 +1,13 @@ """ -共享状态 Schema(对话 Agent 与后台 Agent 共用) +共享状态 Schema(对话 Agent 与后台 Agent 共用)。 + +Option B 的 schema 分层: + +- `NarrativeCoverageState`:叙述覆盖视图 —— 阶段推进、叙述槽、每阶段的完成情况;**不掺控制元信息**。 +- `InterviewControlState`:访谈控制视图 —— 已确认事实、人物主线、最近已问;用于访谈控场,**不参与成稿槽位**。 +- `MemoirStateSchema`:以 facade 形式聚合两个视图,保留旧字段以兼容已持久化数据。 + +消费方**应**通过 `state.narrative()` / `state.control()` 视图取值;顶层字段会逐步按阶段迁出。 """ from __future__ import annotations @@ -48,51 +56,39 @@ class PersonaThread(BaseModel): return self.trait -class MemoirStateSchema(BaseModel): - """回忆录状态""" +# ============================================================================= +# Narrative 视图:叙述覆盖、阶段推进、叙述槽 +# ============================================================================= + + +class NarrativeCoverageState(BaseModel): + """叙述覆盖视图。 + + 只承载「人生叙事覆盖」相关信息:阶段顺序、当前阶段、覆盖过的阶段、每阶段的叙述槽。 + **禁止**在此视图承载访谈控场数据(已确认事实、人物主线、最近已问),那些数据属于 + `InterviewControlState`。 + """ stage_order: List[str] current_stage: str covered_stages: List[str] slots: Dict[str, Dict[str, SlotData]] - known_facts: List[KnownFact] = Field(default_factory=list) - persona_threads: List[PersonaThread] = Field(default_factory=list) - recent_questions: List[str] = Field(default_factory=list) def empty_slots_for_current_stage(self) -> List[str]: stage_slots = self.slots.get(self.current_stage, {}) - empty_keys: List[str] = [] - for key, value in stage_slots.items(): - if not value.snippet: - empty_keys.append(key) - return empty_keys - - def prompt_empty_slots_for_stage(self, stage: str) -> List[str]: - """生成 prompt 时可追问的槽位,排除已被 known_facts 覆盖的方向。""" - blocked = { - fact.slot_name - for fact in self.known_facts - if fact.slot_name and (not fact.stage or fact.stage == stage) - } - return [key for key in self.empty_slots_for_stage(stage) if key not in blocked] - - def prompt_empty_slots_for_current_stage(self) -> List[str]: - return self.prompt_empty_slots_for_stage(self.current_stage) + return [key for key, value in stage_slots.items() if not value.snippet] def empty_slots_for_stage(self, stage: str) -> List[str]: - """获取指定阶段的空槽位""" stage_slots = self.slots.get(stage, {}) return [key for key, value in stage_slots.items() if not value.snippet] def filled_slots_for_stage(self, stage: str) -> Dict[str, str]: - """获取指定阶段已填充的槽位及其内容""" stage_slots = self.slots.get(stage, {}) return { key: value.snippet for key, value in stage_slots.items() if value.snippet } def all_stages_coverage(self) -> Dict[str, Dict]: - """获取所有阶段的覆盖情况摘要""" coverage: Dict[str, Dict] = {} for stage in self.stage_order: stage_slots = self.slots.get(stage, {}) @@ -106,6 +102,23 @@ class MemoirStateSchema(BaseModel): } return coverage + +# ============================================================================= +# Interview Control 视图:访谈控场(已知事实 / 人物主线 / 最近已问) +# ============================================================================= + + +class InterviewControlState(BaseModel): + """访谈控制视图。 + + 承载仅与「控场 / 去重问 / 人物呼应」相关的信息。这些字段**不应**出现在叙述覆盖计算里,也 + **不应**写入 `slots`。 + """ + + known_facts: List[KnownFact] = Field(default_factory=list) + persona_threads: List[PersonaThread] = Field(default_factory=list) + recent_questions: List[str] = Field(default_factory=list) + def prompt_known_fact_lines(self, *, limit: int = 10) -> List[str]: xs: List[str] = [] for fact in self.known_facts[-limit:]: @@ -133,8 +146,89 @@ class MemoirStateSchema(BaseModel): out.append(s) return out + def blocked_slot_names_for_stage(self, stage: str) -> set[str]: + """已被 known_facts 覆盖的槽位名:追问时应避开。""" + return { + fact.slot_name + for fact in self.known_facts + if fact.slot_name and (not fact.stage or fact.stage == stage) + } + + +# ============================================================================= +# Facade:MemoirStateSchema 兼容旧字段形态,方法委托到两个视图 +# ============================================================================= + + +class MemoirStateSchema(BaseModel): + """回忆录状态(Facade)。 + + 为兼容既有持久化与旧调用方,顶层字段保持不变;内部将其投影成 + `NarrativeCoverageState` 与 `InterviewControlState` 两个视图,后续新代码应直接使用 + `state.narrative()` / `state.control()` 表达意图。 + """ + + stage_order: List[str] + current_stage: str + covered_stages: List[str] + slots: Dict[str, Dict[str, SlotData]] + known_facts: List[KnownFact] = Field(default_factory=list) + persona_threads: List[PersonaThread] = Field(default_factory=list) + recent_questions: List[str] = Field(default_factory=list) + + # ---- 视图投影 ---- + + def narrative(self) -> NarrativeCoverageState: + return NarrativeCoverageState( + stage_order=self.stage_order, + current_stage=self.current_stage, + covered_stages=self.covered_stages, + slots=self.slots, + ) + + def control(self) -> InterviewControlState: + return InterviewControlState( + known_facts=self.known_facts, + persona_threads=self.persona_threads, + recent_questions=self.recent_questions, + ) + + # ---- 兼容层:委托到 narrative / control 视图 ---- + + def empty_slots_for_current_stage(self) -> List[str]: + return self.narrative().empty_slots_for_current_stage() + + def empty_slots_for_stage(self, stage: str) -> List[str]: + return self.narrative().empty_slots_for_stage(stage) + + def filled_slots_for_stage(self, stage: str) -> Dict[str, str]: + return self.narrative().filled_slots_for_stage(stage) + + def all_stages_coverage(self) -> Dict[str, Dict]: + return self.narrative().all_stages_coverage() + + def prompt_empty_slots_for_stage(self, stage: str) -> List[str]: + """生成 prompt 时可追问的槽位,排除已被 known_facts 覆盖的方向。""" + blocked = self.control().blocked_slot_names_for_stage(stage) + return [ + key + for key in self.narrative().empty_slots_for_stage(stage) + if key not in blocked + ] + + def prompt_empty_slots_for_current_stage(self) -> List[str]: + return self.prompt_empty_slots_for_stage(self.current_stage) + + def prompt_known_fact_lines(self, *, limit: int = 10) -> List[str]: + return self.control().prompt_known_fact_lines(limit=limit) + + def prompt_persona_thread_lines(self, *, limit: int = 6) -> List[str]: + return self.control().prompt_persona_thread_lines(limit=limit) + + def prompt_recent_question_lines(self, *, limit: int = 4) -> List[str]: + return self.control().prompt_recent_question_lines(limit=limit) + -# 与 stage_constants.CHAT_STAGES 同一顺序;list() 避免与元组共享可变别名 DEFAULT_STAGE_ORDER: list[str] = list(CHAT_STAGES) @@ -184,3 +278,16 @@ def default_state() -> MemoirStateSchema: covered_stages=[], slots=default_slots(), ) + + +__all__ = [ + "DEFAULT_STAGE_ORDER", + "InterviewControlState", + "KnownFact", + "MemoirStateSchema", + "NarrativeCoverageState", + "PersonaThread", + "SlotData", + "default_slots", + "default_state", +] diff --git a/api/app/agents/style_profiles.py b/api/app/agents/style_profiles.py new file mode 100644 index 0000000..018a511 --- /dev/null +++ b/api/app/agents/style_profiles.py @@ -0,0 +1,243 @@ +""" +风格配置(Option B):Chat 与 Memoir 拥有**各自独立**的 StyleProfile。 + +历史上 chat prompt 的「成稿质量导向」段落与 memoir prompt 的「传记作家文体」段落共享同一 +批形容词与维度,导致一处微调就会牵出另一处的隐性副作用——典型的**风格闭环过拟合**。 + +本模块的目标: + +- `MemoirQualityHints`:memoir 评测维度(真实性/信息质量/叙事结构/语言与文笔等)数据; + **单一事实源**,chat 与 memoir 都只从此读取权重,不各自散写。 +- `ChatStyleProfile`:chat 侧的语气、沉浸度、风格参考;引用 `MemoirQualityHints` 作为 + 「成稿目标的镜像」,而不是自己再写一遍;调整 chat 语气不会意外动到成稿。 +- `MemoirStyleProfile`:memoir 侧的书面语文体与结构节奏;独立于 chat,chat 的语气改动 + 不会污染成稿。 + +迁移策略:当前版本把既有文本平移到两个 profile 中,保证对外生成的最终 prompt 字符串与 +过渡前一致(有测试覆盖);之后任何一侧的风格演化都只改对应 profile。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import List, Tuple + + +# ============================================================================= +# 共享:Memoir 评测维度单一事实源 +# ============================================================================= + + +@dataclass(frozen=True) +class MemoirQualityDimension: + key: str + label: str + weight: int + description: str + + +_DEFAULT_MEMOIR_QUALITY_DIMENSIONS: Tuple[MemoirQualityDimension, ...] = ( + MemoirQualityDimension( + key="truth_and_coverage", + label="真实性与覆盖", + weight=23, + description="不诱导编造;五阶段叙述槽尽量收齐关键切片,缺角时用大纲**轻推**;已述事实当铁底。", + ), + MemoirQualityDimension( + key="information_quality", + label="信息质量", + weight=14, + description="要可核对、有锚点的细节,忌水问、忌空泛「还有吗」;促使用户**落具体人事物**。", + ), + MemoirQualityDimension( + key="narrative_structure", + label="叙事结构", + weight=14, + description="帮**场景—过程—感受**成链;必要时轻轻带时间、转折,让一段话像**一小节故事**而非点状清单。", + ), + MemoirQualityDimension( + key="language_and_prose", + label="语言与文笔", + weight=18, + description="口语里也要有画面与具体词,为成稿**预埋好记的意象**;不在聊天里写书,但忌机关腔。", + ), + MemoirQualityDimension( + key="emotional_expression", + label="情感表达", + weight=9, + description="情绪接**真**不接戏;留白与并肩胜过廉价金句;让用户感到**被接住、敢说下去**,勿因追求简洁显得疏离。", + ), + MemoirQualityDimension( + key="character_modeling", + label="人物建模", + weight=9, + description="关系里谁在乎谁、怕谁、像谁、和谁拧着——从选择与反应里**多留一道口子**给成稿。", + ), + MemoirQualityDimension( + key="coherence", + label="连贯性", + weight=4, + description="若年岁、称谓、地点与前文打架,**温和**顺一下,不要审讯式揪错。", + ), + MemoirQualityDimension( + key="expression_richness", + label="表达丰富度", + weight=5, + description="比喻、通感**偶尔**即可,防单调也防堆砌;**避免固定意象反复复用**(如同一意象或同一比喻跨多轮反复出现,宁可换说法)。", + ), + MemoirQualityDimension( + key="publication_readiness", + label="出版就绪度", + weight=4, + description="忌官样排比、忌导语腔;密度像能交给编辑接着润的口述。", + ), +) + + +@dataclass(frozen=True) +class MemoirQualityHints: + """成稿质量导向(chat 访谈与 memoir 编辑都以此为单一事实源)。""" + + dimensions: Tuple[MemoirQualityDimension, ...] = _DEFAULT_MEMOIR_QUALITY_DIMENSIONS + + def bullets(self) -> List[str]: + return [ + f"- **{d.label}({d.weight})**:{d.description}" for d in self.dimensions + ] + + +# ============================================================================= +# Chat 侧风格 +# ============================================================================= + + +CHAT_REPLY_STYLE_EXAMPLES_ZH = ( + "风格要点(**不要照抄字面、不要复用示例里的意象**;每一轮都要从对方本轮新说的词里长新句):\n" + "- 承接要**贴对方原词**:复述前把对方话里的名词或动词拎一个出来当钩子,再往前推半步;\n" + "- 可用**半句并肩**或**很短**一句通感;**避免**把同一比喻或意象跨多轮反复使用;\n" + "- **禁止**用「A 很…B 很…你选哪个」且每个选项里塞长篇描写或隐喻——开放问优先;\n" + "- 拿掉「听起来…」「我理解…」「这让我想起…」这些模板句;\n" + "- **反例**:每轮用固定的「火、田野、红薯、蚂蚁、巷子、烤…」作情怀底图——换对象换词。" +) + + +@dataclass(frozen=True) +class ChatStyleProfile: + """Chat(访谈)侧风格配置。 + + 通过 `memoir_quality_hints` 显式引用 memoir 成稿目标;chat 文本不再自己重写一遍权重 + 说明。`persona_tone` / `background_voice_tone` 为调用方在运行时拼入的音调 hint。 + """ + + persona_tone: str = "" + background_voice_tone: str = "" + reply_style_examples: str = CHAT_REPLY_STYLE_EXAMPLES_ZH + memoir_quality_hints: MemoirQualityHints = field(default_factory=MemoirQualityHints) + + def tone_tail(self) -> str: + bits = [t for t in (self.persona_tone, self.background_voice_tone) if t] + if not bits: + return "" + return "\n- " + " ".join(bits) + + def render(self) -> str: + rubric_lines = "\n".join(self.memoir_quality_hints.bullets()) + return ( + "## 回应温度与叙事性\n" + "- 共情目标:让对方感到**被认真听见、心里塌实一点、还愿意往下说**;在对方自嘲、委屈、骄傲、后怕或句子突然变短时,温度宁可略高一点,不要只做冷静复述。\n" + "- **访谈优先口语与克制**:比喻、通感**偶尔一句**即可;**禁止**为追求文采写长段排比、对仗或小说腔;聊天侧**不等于**写回忆录正文。\n" + "- **优先具体**:从用户**本轮原词**里抽钉子来造句,少用可套任何人的词(如空泛的「暖心」「触动」「难忘」独句飘在那里);**勿**把每轮都写成「独特意象」命题作文。\n" + "- 忌**干瘪问答体**:不要只剩干巴巴确认句 + 程式提问;但也不要用长篇文采承接代替**清晰的一问**。\n" + "- **少用总结句当「桥」**:不要用一段抽象小结再接「那我们聊聊…」式的采访过渡;换方向时**半句并肩**顺过去即可(仍忌空泛「听起来…」判语)。" + f"{self.tone_tail()}\n" + "\n## 风格参考(密度与口吻,勿照抄字面)\n" + f"{self.reply_style_examples}\n" + "\n## 成稿质量导向(内心调度,勿对用户念指标)\n" + "以下为后续回忆录成稿的评价侧重(数字为权重视角,非对用户说出);访谈里**自然落地**,像聊天而非填表;**真实性优先于文采**。\n\n" + f"{rubric_lines}\n" + "\n## 语言与文笔(隐性执行,勿念给用户听)\n" + "- **句首习惯**:**禁止**「嗯。」起头(**含**「嗯。」后立刻接正文,一律不要);**禁止**单独成泡只有「嗯。」。「好。」「对。」也少当每轮固定发语词;更像真人时**直接**咬对方原词往下长——短停顿用省略号或半句并肩即可。\n" + "- 长短句掺着来;能少说一个字就不堆「很、特别、真的」。\n" + "- 同一个意思别用排比或同义词连打三遍;留一点空白,像聊天不像文章。\n" + "- 共情与小总结像朋友捎一句,不要像晚会主持人收口或卷首语(但仍要在恰当的轮次把话头**勾回人生故事**,见「主持人职责」)。\n" + ) + + +# ============================================================================= +# Memoir 侧风格 +# ============================================================================= + + +@dataclass(frozen=True) +class MemoirStyleProfile: + """Memoir(成稿)侧风格配置。 + + 独立于 ChatStyleProfile;若调整成稿腔调,不会意外影响 chat 访谈语气。`quality_hints` + 目前尚未用于 memoir 文本(memoir prompt 已用描述性文体规则),保留接口供未来统一。 + """ + + quality_hints: MemoirQualityHints = field(default_factory=MemoirQualityHints) + + def render_narrative_style_block(self) -> str: + return """## 传记作家文体(须同时遵守上文「事实边界」) +你是一位专业的传记作家和文字编辑,擅长将口语化的对话内容整理成**偏文学叙述**的、有温度与时代质感的回忆录章节(第一人称散文),**不是**流水账摘要。 + +### 提炼与筛选 +对话中往往夹杂噪音,须严格筛选:保留具体事件、人物关系、时地、情感与信念、用户已提及的细节;过滤语气词、寒暄、与 AI 的交互、无关闲聊、重复冗余。**色、声、味、触感、画面**:仅当用户口述里**已出现**对应感官信息时,可做书面化渲染;**不得**凭空增添任何新的感官细节或场景元素。 + +### 内化两步(不在输出中展示) +先在心中完成 **提炼**(去噪、锁定仅来自「本段用户口述」的命题),再完成 **叙述**(句法、节奏、分段与承接)。**最终输出**须完全符合用户消息要求的格式(例如仅 JSON),不要输出提炼步骤或中间稿。 + +### 改写原则 +- 保持用户的真实情感,让读者能感受到讲述者的心情 +- 使用优雅但不失亲切的书面语,不直接引用对话原话 +- 适当添加过渡句,使段落连贯流畅 +- 保留生动的细节,将口语表达改写为有画面感的书面叙述 +- 去除口语中的填充词和无意义重复 +- 保持时间顺序和逻辑清晰 +- **在事实边界内,鼓励使用有温度的传记笔法**,让读者感受到讲述者当时的心情;可有文学性的表达与恰当的情感渲染;**须同时遵守上文「事实边界」规则 1–4** +- **禁止元话语入文**:不得把聊天套话写进正文,例如「我跟你说」「你知道吗」「话说回来|不瞒你说|说句实话」等;读者应直接读到经历本身 + +### 结构与节奏(零新增事实) +在**不增加**任何新的人物、地点、时间、对话、数字、因果的前提下:可适当变化句长,用短句落定、长句铺陈已给出的信息;段首用承接词或指代勾连上一意;材料足以分段时按**同一段口述内**的场景或步骤切片分段。宁可像**一节散文**也不要像条目堆砌。只可组织已有命题,不可借机补写「让节奏更好」的新事实。 + +### 时代与文化笔触(须与口述或合法档案锚点咬合) +当材料里已出现年代、地域、职业/身份场域或民俗相关表述时,鼓励用**与之相符**的语汇、称谓与泛指性生活氛围把读者带进当时当地——仅限**语气与已知命题的烘托**,不得另起炉灶编造一段典型年代剧情。口述极短则只做轻点,不硬灌风貌长写。 + +### 成稿质量维度(取向;任何一条不得突破事实边界) +- **真实性与覆盖**:只基于口述展开,不编不补结局;材料里已有的人生节点尽量写透,短材料写短文。 +- **信息密度**:口语洗净、合并重复后可略增可读密度,但仍须遵守「材料短则输出短」,不为篇幅硬加字。 +- **信息质量**:保留可核对的具体人、事、时地感,删水词与重复,让读者觉得**有料**。 +- **叙事结构**:段内时间顺序清楚,有场景与转折时写出来;像「一节故事」而非点状流水账。 +- **语言与文笔**:可读、**文学叙述感**明显优于白板纪实;节制修辞与通感,过渡自然,**可控扩写**仅指修辞与衔接,非捏造事实。 +- **情感表达**:情感与口述一致,可书面化语气,**禁止**表演式滥情。 +- **人物建模**:人与人的关系、态度与选择要写清,让读者知道「这是怎样一个人」。 +- **连贯性**:与「衔接上下文」中的人称、时间线一致,不自相矛盾。 +- **表达丰富度**:可适度用比喻、换笔,忌整段排比堆砌。 +- **出版就绪度**:整体像能进编辑流程的章节初稿,不是聊天实录、也不是宣传腔。 + +### 示例(仅供参考允许的改写程度;只改语气、不加新事实) +- 原文:「那时候穷啊,一家人挤一间房。」 + → 改写:「那时家里拮据,一家人挤在一间屋里过日子。」 +- 原文:「后来他走了,我挺难受的。」 + → 改写:「他走后的那段日子,心里一直不是滋味。」 +- 原文:「下大雨,爷爷背我过河,鞋都湿了,他一直笑。」 + → 改写:「那天下大雨,爷爷背我蹚过河,鞋子湿透了,他一路上却还笑着。」 +- 原文:「食堂菜不好吃,我就泡方便面,宿舍人都这么干。」 + → 改写:「食堂伙食不对胃口时,我常泡方便面充饥,宿舍里大家也差不多。」 +- 原文:「科长说我再这样就别干了,我当时没吭声。」 + → 改写:「科长撂下狠话,说再这样下去就别干了;我当时一声没吭。」 + +### 输出格式约束 +- 使用第一人称 +- 不使用 Markdown 标题(#、##)、不使用表格 +- 如有「衔接上下文」,仅保持语气与时间线连贯,不重复已有段落全文""" + + +__all__ = [ + "CHAT_REPLY_STYLE_EXAMPLES_ZH", + "ChatStyleProfile", + "MemoirQualityDimension", + "MemoirQualityHints", + "MemoirStyleProfile", +] diff --git a/api/app/core/config.py b/api/app/core/config.py index 3e1b645..ce3c7af 100644 --- a/api/app/core/config.py +++ b/api/app/core/config.py @@ -90,6 +90,12 @@ class Settings(BaseSettings): chat_memory_retrieval_enabled: bool = True chat_memory_top_k: int = Field(default=8, ge=1, le=30) chat_memory_evidence_max_chars: int = Field(default=4096, ge=256, le=50_000) + # True:访谈记忆注入使用聊天专用安全格式化(编号引用 + 主语弱化说明);False 回退 legacy 压平文本 + chat_memory_safe_evidence_format_enabled: bool = True + # True:在规则 TurnPlan 之后追加一轮轻量 JSON focus planner(本轮承接重点 + memory 引用 + 回复形状;失败则回退基线) + chat_reply_planner_llm_enabled: bool = False + chat_reply_planner_max_tokens: int = Field(default=256, ge=64, le=1024) + chat_reply_planner_temperature: float = Field(default=0.2, ge=0.0, le=1.0) # ── Memoir 叙事忠实度检查(FidelityCheckAgent)──────────────── memoir_fidelity_check_enabled: bool = True diff --git a/api/app/features/evaluation/rubrics/conversation_v1.py b/api/app/features/evaluation/rubrics/conversation_v1.py index 59b1474..f1048cb 100644 --- a/api/app/features/evaluation/rubrics/conversation_v1.py +++ b/api/app/features/evaluation/rubrics/conversation_v1.py @@ -7,6 +7,7 @@ _JUDGING_CHARTER = """ - **各细项独立判断**:整体印象好不得普遍抬分;情绪承接好不得抵消信息浅或强引导。 - **优美措辞、语气礼貌不得单独抬分**,除非该维度本身考察措辞/节奏且与证据一致。 - 对严重问题(如明显冷处理情绪、强引导暗示答案、重复盘问同义信息)须在 `major_issues` 写明并可列入 `evidence_refs`。 +- **反风格过拟合**:若 AI 明显复读**固定意象 / 固定句式 / few-shot 式套句**(如同一画面反复出现「烤红薯 / 田埂 / 火堆 / 蚂蚁 / 巷子」等怀旧物象;或同一轮次内外频繁使用「那条线」「一股劲儿」「就像…一样」「这让我想起…」等模板修辞),不要把它当「文学感好」奖励;应**在 `rhythm_control` / `empathy_depth` / `question_quality` 中压分**,并在 `major_issues` 写明「风格过拟合:固定修辞复读」。表达质量看的是**对本轮用户原词的贴合度、新鲜度、准确度**,不是词藻密度或固定比喻。 """ @@ -47,13 +48,13 @@ _CONV_LEAF_SPEC = """ ## 四、结构化引导(小计最高 15) - interview_structure(访谈结构,最高 6):阶段/主题推进是否清晰(整段更明显;单轮不足则保守)。 -- context_memory(上下文记忆,最高 5):是否关联前文;**重复盘问同一已答信息、忽略上文**在此项扣分(不在 rhythm 重复扣)。 -- rhythm_control(节奏控制,最高 4):自然度;采访腔、总结腔、流程感。 +- context_memory(上下文记忆,最高 5):是否关联前文;**重复盘问同一已答信息、忽略上文**在此项扣分(不在 rhythm 重复扣)。**额外**:若 AI 把**检索到的旧记忆**当本轮用户新说的内容来复述、或反复「你之前提过…」式**同义复述旧段落**(信息未推进、像在念素材),应**明显扣分**。 +- rhythm_control(节奏控制,最高 4):自然度;采访腔、总结腔、流程感;**此项也负责抓「风格过拟合」**——若 AI 在本节选里反复套用同一意象池(「烤红薯/田埂/蚂蚁/巷子/火堆/那条线/一股劲儿」等)或同一句式模板(「这让我想起…」「听起来…」),即使单条句子读着文雅也应扣分。 ## 五、提问质量(小计最高 15) -- question_quality(问题质量,最高 7):开放、具体、可答。 -- follow_up_depth(追问能力,最高 5):顺势深问。 -- non_leading(非引导性,最高 3):是否暗示「标准答案」或评判式预设。 +- question_quality(问题质量,最高 7):开放、具体、可答;问句必须**从本轮用户原词或本轮具体画面里长出来**,若用户已点名他人、关系、观众、面子或身份自称,而 AI 只追问环境/体感却完全忽略这些线索,应**明显扣分**;不得用跨轮复读的固定锚点(如「你们是在田埂边生火吗」「带头的是谁」等不视语境一再出现的模板追问)——发现该类套问法应扣分。**记忆相关**:若追问只是在**复述/同义转述检索细节**而未帮用户说**新的一层**(感受、关系、选择、后果),应扣分;若记忆帮助把问题问得更贴肉、更个人,可略加分(仍不得牺牲本轮话头)。 +- follow_up_depth(追问能力,最高 5):顺势深问。**记忆相关**:追问应服务于**更深一层**,而不是堆叠旧事实或复述时间线。 +- non_leading(非引导性,最高 3):是否暗示「标准答案」或评判式预设。**额外**:若用检索细节**诱导用户承认本轮未确认的内容**(把旧记忆当既定事实套在用户头上),应**重扣**。 输出 JSON 字段(仅这些键;细分项为浮点数;列表字符串尽量每条 ≤120 字;`rationale` 为简短中文总述): emotion_carry, empathy_depth, emotion_safety, emotion_guidance, diff --git a/api/app/features/evaluation/rubrics/memoir_v1.py b/api/app/features/evaluation/rubrics/memoir_v1.py index 3945e87..a1e658a 100644 --- a/api/app/features/evaluation/rubrics/memoir_v1.py +++ b/api/app/features/evaluation/rubrics/memoir_v1.py @@ -10,6 +10,11 @@ _MEMOIR_CHARTER = """ - 只依据输入中可核对的文字评分;不得臆测用户人生经历。 - **缺少原文证据不等于「写得好」**:无证据或证据极短时,`mem_fidelity`、`mem_factual_coverage`、`mem_traceability` 等须保守,并在 `insufficient_evidence` 说明。 - **文笔与结构不得抵消事实问题**:`lang_*`、`narr_*` 高分不得与明显编造、张冠李戴并存。 +- **反风格过拟合**:一个用户、一个作者的多个篇章/段落**不应共享同一套「抒情套皮」**(例如各章反复使用「那条线/一股劲儿/浇灌/生根/跳房子/烤红薯/田埂/巷子/蚂蚁/隐隐发光」等固定意象池或固定收束句)。发现这种跨章节复读或本文内反复自引的固定比喻时: + - `lang_literary`(文笔质量)与 `rich_diversity`(表达多样性)须明显扣分; + - `rich_analogy` 高分的前提是**类比有基于口述的独特性**,而不是把同一修辞反复套到不同人生阶段; + - 在 `major_issues` 写明「风格过拟合:固定修辞复读」。 +- **真实文学性 vs 装饰性文笔**:优美在于**贴合当事人语词、场景、时代肌理**,而非辞藻密度;缺少与本人口述咬合的「漂亮句子」按装饰性处理,不得与 `lang_literary` 满分并存。 - 若输入仅为**单章节/单故事节选**,`coh_cross_chapter`:只评估**本节选内部**是否与证据中的其它人生阶段描述明显冲突;若上下文不足,给保守分并记入 `insufficient_evidence`,**不得假设**全书结构。 """ @@ -55,8 +60,8 @@ _MEMOIR_RUBRIC_BODY = """ 23. coh_cross_chapter(跨章节/跨片段关联,最高 2):单节选评审时按上文「总原则」处理。 ## 八、表达丰富度(小计最高 5) -24. rich_analogy(类比与引用,最高 3) -25. rich_diversity(表达多样性,最高 2) +24. rich_analogy(类比与引用,最高 3):类比须基于口述素材**独特**生成;同一意象池或同一比喻在本用户多篇/本文多段**复读**应扣分。 +25. rich_diversity(表达多样性,最高 2):**反过拟合**——若整篇或多章反复依赖少数几个固定意象(如「田埂/烤红薯/巷子/蚂蚁/那条线/一股劲儿」等)收束情感,本项按 0-1 给分。 ## 九、出版就绪度(小计最高 4) 26. pub_editorial_cost(编辑成本,最高 2) diff --git a/api/app/features/memoir/_interview_meta_store.py b/api/app/features/memoir/_interview_meta_store.py new file mode 100644 index 0000000..b3beed7 --- /dev/null +++ b/api/app/features/memoir/_interview_meta_store.py @@ -0,0 +1,84 @@ +""" +访谈控制元数据的持久化适配层(Option B 去耦)。 + +**背景**:历史上为了零迁移成本,已把 `InterviewControlState`(known_facts / +persona_threads / recent_questions)塞在 `MemoirStateModel.slots` JSON 列的保留键 +`__interview_state__` 下。这造成叙述槽(NarrativeCoverageState)与控场元数据的语义耦合。 + +**本模块的职责**:把「JSON 列里塞控制元数据」这条臭味隔离在一个独立文件里。对外提供三件 +事: + +- `read(raw_slots)`:从 slots 中抽出 `InterviewControlState`(未含 → 返回空结构) +- `write(raw_slots, control)`:把控制元数据写回 slots(返回**新的** slots,保证 ORM dirty + tracking) +- `strip(raw_slots)`:返回去掉元数据键后的「纯 narrative slots」视图 + +未来迁移到独立列(如 `interview_meta JSONB`)时,只需改本模块 + 两行 state_service +调用,不会再波及 schema / prompt / agent。 +""" + +from __future__ import annotations + +from typing import Dict, cast + +from app.agents.state_schema import InterviewControlState, KnownFact, PersonaThread + + +_META_KEY = "__interview_state__" + + +def read(raw_slots: Dict[str, Dict] | None) -> InterviewControlState: + """从原始 slots JSON 中读取 InterviewControlState;无合法元数据时返回空结构。""" + if not raw_slots or not isinstance(raw_slots, dict): + return InterviewControlState() + meta = raw_slots.get(_META_KEY) + if not isinstance(meta, dict): + return InterviewControlState() + + known_raw = meta.get("known_facts") if isinstance(meta.get("known_facts"), list) else [] + persona_raw = ( + meta.get("persona_threads") + if isinstance(meta.get("persona_threads"), list) + else [] + ) + recent_raw = ( + meta.get("recent_questions") + if isinstance(meta.get("recent_questions"), list) + else [] + ) + + return InterviewControlState( + known_facts=[KnownFact.model_validate(x) for x in known_raw if isinstance(x, dict)], + persona_threads=[ + PersonaThread.model_validate(x) for x in persona_raw if isinstance(x, dict) + ], + recent_questions=[str(x).strip() for x in recent_raw if str(x).strip()], + ) + + +def write( + raw_slots: Dict[str, Dict], *, control: InterviewControlState +) -> Dict[str, Dict]: + """把 InterviewControlState 写入 slots 的保留键,并返回一个**新 dict**。""" + out: Dict[str, Dict] = dict(raw_slots or {}) + out[_META_KEY] = cast( + Dict, + { + "known_facts": [x.model_dump() for x in control.known_facts], + "persona_threads": [x.model_dump() for x in control.persona_threads], + "recent_questions": list(control.recent_questions), + }, + ) + return out + + +def strip(raw_slots: Dict[str, Dict] | None) -> Dict[str, Dict]: + """返回去掉 meta 键的纯 narrative slots 视图。""" + if not raw_slots or not isinstance(raw_slots, dict): + return {} + clean = dict(raw_slots) + clean.pop(_META_KEY, None) + return clean + + +__all__ = ["read", "write", "strip"] diff --git a/api/app/features/memoir/state_service.py b/api/app/features/memoir/state_service.py index 6f06696..fcf7b3a 100644 --- a/api/app/features/memoir/state_service.py +++ b/api/app/features/memoir/state_service.py @@ -4,7 +4,7 @@ """ import uuid -from typing import Dict, List, cast +from typing import Dict, List from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -15,6 +15,7 @@ from app.agents.stage_constants import ( normalize_chat_stage, ) from app.agents.state_schema import ( + InterviewControlState, KnownFact, MemoirStateSchema, PersonaThread, @@ -22,10 +23,9 @@ from app.agents.state_schema import ( default_state, ) from app.core.config import settings +from app.features.memoir import _interview_meta_store as interview_meta from app.features.memoir.models import MemoirState as MemoirStateModel -_INTERVIEW_STATE_META_KEY = "__interview_state__" - def _slots_snapshot_for_merge(raw: Dict[str, Dict] | None) -> Dict[str, Dict]: """浅拷贝 slots,避免就地改 JSON 列同一 dict 引用导致 ORM 不标记 dirty。""" @@ -34,67 +34,20 @@ def _slots_snapshot_for_merge(raw: Dict[str, Dict] | None) -> Dict[str, Dict]: return {k: dict(v or {}) for k, v in raw.items()} -def _extract_interview_state_meta( - raw_slots: Dict[str, Dict] | None, -) -> tuple[list[KnownFact], list[PersonaThread], list[str]]: - if not raw_slots or not isinstance(raw_slots, dict): - return [], [], [] - meta = raw_slots.get(_INTERVIEW_STATE_META_KEY) - if not isinstance(meta, dict): - return [], [], [] - known = meta.get("known_facts") if isinstance(meta.get("known_facts"), list) else [] - persona = ( - meta.get("persona_threads") - if isinstance(meta.get("persona_threads"), list) - else [] - ) - recent = ( - meta.get("recent_questions") - if isinstance(meta.get("recent_questions"), list) - else [] - ) - return ( - [KnownFact.model_validate(x) for x in known if isinstance(x, dict)], - [PersonaThread.model_validate(x) for x in persona if isinstance(x, dict)], - [str(x).strip() for x in recent if str(x).strip()], - ) - - -def _inject_interview_state_meta( - *, - slots: Dict[str, Dict], - known_facts: list[KnownFact], - persona_threads: list[PersonaThread], - recent_questions: list[str], -) -> Dict[str, Dict]: - out = dict(slots) - out[_INTERVIEW_STATE_META_KEY] = cast( - Dict, - { - "known_facts": [x.model_dump() for x in known_facts], - "persona_threads": [x.model_dump() for x in persona_threads], - "recent_questions": list(recent_questions), - }, - ) - return out - - def coerce_memoir_state(model: MemoirStateModel) -> MemoirStateSchema: + """把 ORM 行投影成 MemoirStateSchema;控制元数据的读法已隔离在 interview_meta 适配层。""" raw_slots = model.slots if isinstance(model.slots, dict) else None - known_facts, persona_threads, recent_questions = _extract_interview_state_meta( - raw_slots - ) - clean_slots = dict(raw_slots) if raw_slots else dict(default_state().slots) - clean_slots.pop(_INTERVIEW_STATE_META_KEY, None) + control = interview_meta.read(raw_slots) + clean_slots = interview_meta.strip(raw_slots) or dict(default_state().slots) return MemoirStateSchema.model_validate( { "stage_order": model.stage_order or default_state().stage_order, "current_stage": model.current_stage, "covered_stages": model.covered_stages or [], "slots": clean_slots, - "known_facts": known_facts, - "persona_threads": persona_threads, - "recent_questions": recent_questions, + "known_facts": control.known_facts, + "persona_threads": control.persona_threads, + "recent_questions": control.recent_questions, } ) @@ -270,11 +223,13 @@ async def save_interview_state_meta( slots = _slots_snapshot_for_merge( state.slots if isinstance(state.slots, dict) else None ) - state.slots = _inject_interview_state_meta( - slots=slots, - known_facts=known_facts, - persona_threads=persona_threads, - recent_questions=recent_questions, + state.slots = interview_meta.write( + slots, + control=InterviewControlState( + known_facts=known_facts, + persona_threads=persona_threads, + recent_questions=recent_questions, + ), ) await db.commit() await db.refresh(state) diff --git a/api/app/features/memory/chat_memory_injection.py b/api/app/features/memory/chat_memory_injection.py new file mode 100644 index 0000000..5bacaac --- /dev/null +++ b/api/app/features/memory/chat_memory_injection.py @@ -0,0 +1,213 @@ +""" +访谈聊天:先检索、后筛选、再限量注入。 + +检索结果不等于注入内容:主 prompt 只收极短「线索」,TurnPlan 挂钩优先用户原话; +完整 bundle 仅给 reply_planner 做焦点规划(可选)。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from app.core.config import settings +from app.features.memory.evidence_format import ( + dedupe_evidence_chunk_rows, + format_user_memory_for_chat_display, + format_evidence_chunks_for_chat_prompt, + format_evidence_chunks_for_prompt, +) + + +@dataclass(frozen=True) +class InterviewMemorySlices: + """访谈轮次三类 memory 产物。""" + + # 进主 system Context 的极短片段(可能为空) + prompt_excerpt: str + # 供 TurnPlan / extract_anchor_snippet:仅一条短线索或空(不替代用户原话) + anchor_source: str + # 供 reply_planner JSON:可较长,仍受 max_chars 截断 + planner_preview: str + # 检索是否非空(gating 前) + had_retrieval: bool + + +_DISMISSIVE_MARKERS: tuple[str, ...] = ( + "哈哈", + "呵呵", + "笑死", + "早就不会", + "不会了", + "别提", + "不想说", + "算了", + "没感觉", + "忘了", + "不记得", + "不用了", + "别问了", + "别说了", + "你记错", + "别扯", + "打住", + "翻篇", + "没那回事", + "不是那回事", + "不是那个", +) +_REJECTION_MARKERS: tuple[str, ...] = ( + "不是", + "没有", + "不对", + "错了", + "辟谣", + "过了", +) +_CONTINUATION_MARKERS: tuple[str, ...] = ( + "继续", + "后来", + "那次", + "上次", + "再说", + "还有", + "接着", + "回到", + "上回", + "你刚", + "刚才", +) + + +def _should_suppress_memory_injection(user_message: str) -> bool: + """默认不注入:短句敷衍、否定翻篇、或本轮已足够长且未明显接续旧线。""" + um = (user_message or "").strip() + if not um: + return True + if any(m in um for m in _DISMISSIVE_MARKERS): + return True + if any(m in um for m in _REJECTION_MARKERS): + # 短句里的否定更像翻篇 + if len(um) <= 24: + return True + if len(um) <= 18: + return True + if len(um) >= 72 and not any(m in um for m in _CONTINUATION_MARKERS): + # 长段新叙事:优先当前话头,不塞旧记忆块 + return True + return False + + +def _first_chunk_line_for_anchor(evidence: dict, *, max_chars: int = 120) -> str: + chunks = evidence.get("relevant_chunks") or [] + chunks = dedupe_evidence_chunk_rows(chunks[:10]) + for c in chunks: + content = ( + c.get("content", "") if isinstance(c, dict) else getattr(c, "content", "") + ) + raw = (content or "").strip() + if len(raw) < 8: + continue + line = raw.splitlines()[0].strip() if raw else "" + if not line: + continue + safe_line = format_user_memory_for_chat_display(line, verbatim=True) + if len(safe_line) > max_chars: + return safe_line[: max_chars - 1].rstrip() + "…" + return safe_line + summaries = evidence.get("relevant_summaries") or [] + for s in summaries[:1]: + if isinstance(s, dict): + st = (s.get("content") or "").strip() + if st: + safe_st = format_user_memory_for_chat_display(st) + return safe_st[:max_chars] if len(safe_st) > max_chars else safe_st + return "" + + +def format_minimal_prompt_memory_hint(evidence: dict, *, max_chars: int = 100) -> str: + """单条极短线索,供 Context;禁止长段复述。""" + line = _first_chunk_line_for_anchor(evidence, max_chars=max_chars) + if not line: + return "" + return ( + "## 记忆线索(仅用于追问角度,禁止复述成段正文)\n" + "以下为检索到的**一条**用户过往口述/系统摘要,**不是**助手自己的经历," + "也**不是**用户本轮原话;优先用用户本轮原话承接与追问。\n" + "若需要轻量勾连,只能用「你之前提过」「你说过」「你刚讲到」这类**归因式**半句," + "禁止整段展开,禁止写成助手亲历。\n" + f"- {line}\n" + ) + + +def build_planner_preview( + evidence: dict, + *, + use_safe_chat_format: bool, +) -> str: + """reply_planner 专用:保留较完整上下文,仍截断。""" + if use_safe_chat_format: + text = format_evidence_chunks_for_chat_prompt(evidence) + else: + text = format_evidence_chunks_for_prompt(evidence) + t = (text or "").strip() + if not t: + return "" + max_c = min(int(settings.chat_memory_evidence_max_chars), 2000) + if len(t) > max_c: + return t[: max_c - 3] + "..." + return t + + +def slice_interview_memory( + evidence: dict[str, Any] | None, + user_message: str, +) -> InterviewMemorySlices: + """ + 检索 bundle → 三类切片。gating 关闭时仍可为 planner 提供 preview。 + """ + if not evidence: + return InterviewMemorySlices( + prompt_excerpt="", + anchor_source="", + planner_preview="", + had_retrieval=False, + ) + + use_safe = settings.chat_memory_safe_evidence_format_enabled + planner_preview = build_planner_preview(evidence, use_safe_chat_format=use_safe) + had = bool(planner_preview.strip()) + + if _should_suppress_memory_injection(user_message): + return InterviewMemorySlices( + prompt_excerpt="", + anchor_source="", + planner_preview=planner_preview, + had_retrieval=had, + ) + + if not had: + return InterviewMemorySlices( + prompt_excerpt="", + anchor_source="", + planner_preview="", + had_retrieval=False, + ) + + prompt_excerpt = format_minimal_prompt_memory_hint(evidence) + anchor = _first_chunk_line_for_anchor(evidence, max_chars=160) + # anchor 给 TurnPlan:带一条短句即可,不拼整段 M 块 + return InterviewMemorySlices( + prompt_excerpt=prompt_excerpt, + anchor_source=anchor, + planner_preview=planner_preview, + had_retrieval=True, + ) + + +__all__ = [ + "InterviewMemorySlices", + "slice_interview_memory", + "format_minimal_prompt_memory_hint", + "build_planner_preview", +] diff --git a/api/app/features/memory/evidence_format.py b/api/app/features/memory/evidence_format.py index 985d352..abb6f2c 100644 --- a/api/app/features/memory/evidence_format.py +++ b/api/app/features/memory/evidence_format.py @@ -66,6 +66,114 @@ def _flatten_object_json(obj_raw: object) -> str: return str(obj_raw) if obj_raw else "" +def format_user_memory_for_chat_display( + text: str, + *, + verbatim: bool = False, +) -> str: + """给聊天态的记忆文本加清晰归属,不改写原内容本身。""" + t = (text or "").strip() + if not t: + return "" + if verbatim: + return f"用户曾说:「{t}」" + return f"关于用户:{t}" + + +def format_evidence_chunks_for_chat_prompt(evidence: dict) -> str: + """聊天访谈专用:将检索 bundle 格式化为带编号引用与安全说明的短文本。 + + 与 `format_evidence_chunks_for_prompt` 并行存在;memoir/叙事流水线仍用后者,避免牵连成稿。 + """ + chunks = evidence.get("relevant_chunks") or [] + chunks = dedupe_evidence_chunk_rows(chunks[:10]) + summaries = evidence.get("relevant_summaries") or [] + facts = evidence.get("relevant_facts") or [] + timeline = evidence.get("timeline_hints") or [] + stories = evidence.get("relevant_stories") or [] + + header = ( + "【相关记忆摘录·聊天专用】\n" + "以下编号条目均来自**用户过往口述或系统摘要**,**不是**助手本人经历。\n" + "承接时**必须**用「你之前提过…」「你说过…」「你刚讲到…」等**归因式**引用;\n" + "**禁止**改写成「我当时…」「我小时候…」「我演过…」等助手第一人称亲历口吻;" + "**禁止**把条目当作你与用户的共同回忆或无归因复述。\n" + ) + + lines: list[str] = [] + n = 0 + for c in chunks: + content = ( + c.get("content", "") if isinstance(c, dict) else getattr(c, "content", "") + ) + raw = (content or "").strip() + if not raw: + continue + n += 1 + cid = "" + if isinstance(c, dict) and c.get("id"): + cid = str(c.get("id", ""))[:12] + label = f"[M{n}]" + (f"(id…{cid})" if cid else "") + safe = format_user_memory_for_chat_display(raw, verbatim=True) + lines.append(f"{label} {safe}") + + for s in summaries[:3]: + if isinstance(s, dict): + st = (s.get("content") or "").strip() + stype = (s.get("summary_type") or "").strip() + if not st: + continue + n += 1 + prefix = f"[摘要:{stype}]" if stype else "[摘要]" + safe = format_user_memory_for_chat_display(f"{prefix} {st}") + lines.append(f"[M{n}] {safe}") + + for f in facts[:5]: + if isinstance(f, dict): + subj = f.get("subject", "") + pred = f.get("predicate", "") + obj_raw = f.get("object_json", "") + obj = _flatten_object_json(obj_raw) + if not (subj or pred): + continue + n += 1 + fact_line = ( + f"{subj}:{pred}({obj})" if obj else f"{subj}:{pred}" + ) + safe = format_user_memory_for_chat_display(fact_line) + lines.append(f"[M{n}] {safe}") + + for t in timeline[:5]: + if isinstance(t, dict): + title = (t.get("title") or "").strip() + year = t.get("event_year") + desc = (t.get("description") or "").strip() + line = " ".join( + x for x in (str(year) if year is not None else "", title, desc) if x + ) + if not line: + continue + n += 1 + safe = format_user_memory_for_chat_display(line) + lines.append(f"[M{n}] {safe}") + + for st in stories[:3]: + if isinstance(st, dict): + title = (st.get("title") or "").strip() + summ = (st.get("summary") or "").strip() + if not (title or summ): + continue + n += 1 + safe = format_user_memory_for_chat_display( + " ".join(x for x in (title, summ) if x) + ) + lines.append(f"[M{n}] {safe}") + + if not lines: + return "" + return header + "\n".join(lines) + + def format_evidence_chunks_for_prompt(evidence: dict) -> str: """将 retrieve_evidence / retrieve_evidence_sync 结果格式化为简短文本,供叙事与访谈 prompt 使用。 diff --git a/api/tests/test_experience_regressions.py b/api/tests/test_experience_regressions.py index d1eb341..e09349f 100644 --- a/api/tests/test_experience_regressions.py +++ b/api/tests/test_experience_regressions.py @@ -4,6 +4,8 @@ 访谈侧仅验证 prompt 仍包含关键行为指引。 """ +from app.agents.chat.interview_turn_plan import plan_interview_turn +from app.agents.chat.output_rules import chat_output_rules from app.agents.chat.prompts_conversation import ( get_guided_conversation_prompt, get_opening_prompt, @@ -54,6 +56,23 @@ class TestChatExperienceRegressions: ) assert "接住" in p + def test_guided_prompt_bans_assistant_autobiographical_claims(self) -> None: + """避免助手把用户经历说成自己的童年/暗恋等(身份越界)。""" + p = get_guided_conversation_prompt( + current_stage="education", + empty_slots=["school"], + filled_slots={}, + detected_user_stage="education", + user_profile_context="", + persona="default", + ) + assert "身份边界" in p + assert "暗恋" in p + assert "共同回忆" in p + rules = chat_output_rules() + assert "助手本人" in rules + assert "共同回忆" in rules + def test_opening_prompt_stays_short_task_shape(self) -> None: p = get_opening_prompt( current_stage="childhood", @@ -64,6 +83,19 @@ class TestChatExperienceRegressions: assert "问候" in p assert "任务" in p or "具体问题" in p + def test_followup_her_story_turn_plan_stays_user_perspective_shape(self) -> None: + """追问第三方故事时:编排仍锁用户视角,并倾向「承接+一问」形状(防助手自传漂移)。""" + p = plan_interview_turn( + current_stage="education", + empty_slots=["school", "city"], + normalized_user_message="讲讲她的故事吧,后来怎么样了?", + memory_evidence_text="", + stage_switched_this_turn=False, + ) + assert p.subject_owner == "user_only" + assert p.forbid_first_person_experience is True + assert p.reply_shape == "ack_then_question" + class TestMemoirStyleRegressions: """保护「回忆录有文笔」体验。""" diff --git a/api/tests/test_interview_prompts.py b/api/tests/test_interview_prompts.py index d546f48..221f163 100644 --- a/api/tests/test_interview_prompts.py +++ b/api/tests/test_interview_prompts.py @@ -3,7 +3,9 @@ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage from app.agents.chat.interview_state_hints import ( + AUTOBIOGRAPHICAL_BOUNDARY_FALLBACK_ZH, DUPLICATE_QUESTION_GUARD_FALLBACK_ZH, + apply_autobiographical_boundary_guard, apply_duplicate_question_guard, extract_scene_cues, segments_are_only_duplicate_guard_fallback, @@ -16,6 +18,7 @@ from app.agents.state_schema import ( ) from app.agents.chat.helpers import format_history_string from app.agents.chat.personas import normalize_interview_persona +from app.agents.chat.output_rules import chat_output_rules from app.agents.chat.prompts_conversation import ( get_guided_conversation_prompt, get_opening_prompt, @@ -217,8 +220,7 @@ def test_guided_prompt_contains_memory_section_when_evidence(): persona="default", memory_evidence_text="[摘要:rolling] 1990年生于上海。", ) - assert "相关记忆摘录" in p - assert "过往口述" in p + assert "记忆线索" in p or "追问角度" in p assert "1990年生于上海" in p @@ -368,3 +370,92 @@ def test_format_history_string_omit_system_body() -> None: assert "System: None: + p = get_guided_conversation_prompt( + current_stage="childhood", + empty_slots=["place"], + filled_slots={}, + detected_user_stage="childhood", + user_profile_context="", + persona="default", + ) + assert "身份边界" in p + assert "真实人生传记" in p + assert "共同回忆" in p + assert "泛指" in p + + +def test_guided_prompt_blocks_using_user_context_as_assistant_identity() -> None: + p = get_guided_conversation_prompt( + current_stage="childhood", + empty_slots=["place"], + filled_slots={}, + detected_user_stage="childhood", + user_profile_context="成长地:上海", + persona="default", + ) + assert "我是上海人" in p + assert "不能把用户的成长地答成" in p + assert "你刚提到上海" in p or "你之前说过那段童年" in p + + +def test_chat_output_rules_bans_assistant_autobiography() -> None: + rules = chat_output_rules() + assert "禁止" in rules + assert "声称助手本人" in rules or "助手本人" in rules + assert "共同回忆" in rules + assert "你是哪里人" in rules + assert "你刚提到" in rules + + +def test_autobiographical_boundary_guard_replaces_crush_claim() -> None: + out, touched = apply_autobiographical_boundary_guard( + ["是啊,朱丽叶就是我当时暗恋的女生。"] + ) + assert touched is True + assert out == [AUTOBIOGRAPHICAL_BOUNDARY_FALLBACK_ZH] + + +def test_autobiographical_boundary_guard_replaces_childhood_claim() -> None: + out, touched = apply_autobiographical_boundary_guard( + ["我小时候也演过这个,还挺紧张的。"] + ) + assert touched is True + assert out == [AUTOBIOGRAPHICAL_BOUNDARY_FALLBACK_ZH] + + +def test_autobiographical_boundary_guard_allows_generic_empathy() -> None: + safe = [ + "我能想象那会儿站在台上,手心里全是汗。", + "换作很多人可能也会记很久。", + ] + out, touched = apply_autobiographical_boundary_guard(safe) + assert touched is False + assert out == safe + + +def test_autobiographical_boundary_guard_mixed_segments() -> None: + out, touched = apply_autobiographical_boundary_guard( + ["嗯,你刚才那段我接住了。", "我小时候也演过。"] + ) + assert touched is True + assert out[0] == "嗯,你刚才那段我接住了。" + assert out[1] == AUTOBIOGRAPHICAL_BOUNDARY_FALLBACK_ZH + + +def test_autobiographical_boundary_guard_catches_iyan_role_without_demo() -> None: + out, touched = apply_autobiographical_boundary_guard( + ["那次话剧里我演罗密欧,对手戏挺难的。"] + ) + assert touched is True + assert out == [AUTOBIOGRAPHICAL_BOUNDARY_FALLBACK_ZH] + + +def test_autobiographical_boundary_guard_allows_wo_yanshi_demo() -> None: + out, touched = apply_autobiographical_boundary_guard( + ["我演示一下这个按钮怎么点。"] + ) + assert touched is False + assert out == ["我演示一下这个按钮怎么点。"] diff --git a/api/tests/test_interview_turn_plan.py b/api/tests/test_interview_turn_plan.py index 953dd3f..ab90e7d 100644 --- a/api/tests/test_interview_turn_plan.py +++ b/api/tests/test_interview_turn_plan.py @@ -1,7 +1,10 @@ """interview_turn_plan:轮次模式与主槽选择(服务端硬编排)。""" from app.agents.chat.interview_turn_plan import ( + InterviewTurnPlan, + apply_safe_mode_override, extract_anchor_snippet, + format_interview_turn_directive_block, plan_interview_turn, primary_empty_slot, ) @@ -12,11 +15,177 @@ def test_primary_empty_slot_order(): assert primary_empty_slot("childhood", ["emotion"]) == "emotion" -def test_extract_anchor_snippet_prefers_memory(): +def test_extract_anchor_snippet_prefers_user_when_long_enough(): mem = "摘录的一段记忆\n\n[场景氛围提示" - assert "摘录的一段记忆" in extract_anchor_snippet( - memory_evidence_text=mem, user_message="用户说很长一句" * 3 + um = "用户说很长一句" * 5 + sn = extract_anchor_snippet(memory_evidence_text=mem, user_message=um) + assert sn.startswith("用户说") + assert "摘录" not in sn + + +def test_extract_anchor_snippet_prefers_first_m_line(): + mem = ( + "【相关记忆摘录·聊天专用】\n" + "说明行……\n" + "[M1] 你在校园演出里饰演罗密欧。\n" ) + sn = extract_anchor_snippet(memory_evidence_text=mem, user_message="短") + assert "校园演出" in sn + assert "【相关" not in sn + + +def test_plan_sets_memory_usage_when_evidence_present(): + p = plan_interview_turn( + current_stage="childhood", + empty_slots=["place"], + normalized_user_message="嗯。", + memory_evidence_text="【头】\n[M1] 你提过河边。", + stage_switched_this_turn=False, + ) + assert p.memory_usage == "allowed_with_attribution" + + +def test_plan_marks_assistant_identity_question() -> None: + p = plan_interview_turn( + current_stage="childhood", + empty_slots=["place"], + normalized_user_message="你是哪里人,你的童年是什么样的?", + memory_evidence_text="用户曾说:「我小时候在上海长大。」", + stage_switched_this_turn=False, + ) + assert p.assistant_identity_question is True + + +def test_plan_reply_shape_ack_then_question_on_her_story_followup(): + p = plan_interview_turn( + current_stage="education", + empty_slots=["school"], + normalized_user_message="那你讲讲她的故事吧。", + memory_evidence_text="", + stage_switched_this_turn=False, + ) + assert p.reply_shape == "ack_then_question" + + +def test_directive_includes_attribution_when_memory_allowed(): + plan = InterviewTurnPlan( + mode="memoir_push", + anchor_slot_key="place", + anchor_slot_readable="成长的地方", + anchor_snippet="挂钩", + memory_usage="allowed_with_attribution", + memory_reference_style="你之前提过", + reply_shape="ack_then_question", + ) + block = format_interview_turn_directive_block(plan) + assert "线索" in block + assert "你之前提过" in block + assert "真实人生传记" in block + assert "ack" not in block.lower() + assert "承接" in block + assert ( + "本轮追问" in block + or "承接角度" in block + or "本轮承接重点" in block + ) + + +def test_directive_includes_focus_summary_when_set(): + plan = InterviewTurnPlan( + mode="memoir_push", + anchor_slot_key="place", + anchor_slot_readable="成长的地方", + anchor_snippet="挂钩", + focus_summary="先接住「芳芳」与怕丢脸这条线", + focus_source="llm", + primary_focus="relationship", + ) + block = format_interview_turn_directive_block(plan) + assert "芳芳" in block + assert "本轮追问" in block or "承接角度" in block + + +def test_directive_marks_user_message_anchor_source_correctly(): + plan = InterviewTurnPlan( + mode="memoir_push", + anchor_slot_key="place", + anchor_slot_readable="成长的地方", + anchor_snippet="那年夏天我总往河边跑", + anchor_source_kind="user_message", + ) + block = format_interview_turn_directive_block(plan) + assert "来自用户本轮原话摘录" in block + assert "不是用户本轮新说的内容" not in block + + +def test_directive_marks_memory_anchor_source_correctly(): + plan = InterviewTurnPlan( + mode="memoir_push", + anchor_slot_key="place", + anchor_slot_readable="成长的地方", + anchor_snippet="你小时候常在河边玩", + anchor_source_kind="memory", + ) + block = format_interview_turn_directive_block(plan) + assert "来自检索到的用户过往口述/摘要" in block + assert "不是用户本轮新说的内容" in block + + +def test_directive_adds_boundary_for_assistant_identity_question(): + plan = InterviewTurnPlan( + mode="memoir_push", + anchor_slot_key="place", + anchor_slot_readable="成长的地方", + anchor_snippet="用户曾说:「我小时候在上海长大。」", + assistant_identity_question=True, + ) + block = format_interview_turn_directive_block(plan) + assert "本轮用户在问助手本人" in block + assert "你刚提到上海" in block + + +def test_apply_safe_mode_override_blocks_emotion_downgrade(): + assert apply_safe_mode_override("emotion_first", "memoir_push", primary_focus="emotion") is None + + +def test_apply_safe_mode_override_allows_memoir_to_emotion(): + assert ( + apply_safe_mode_override("memoir_push", "emotion_first", primary_focus="relationship") + == "emotion_first" + ) + + +def test_apply_safe_mode_override_clarify_blocks_memoir(): + assert apply_safe_mode_override("clarify_first", "memoir_push", primary_focus="emotion") is None + + +def test_apply_safe_mode_override_clarify_to_emotion(): + assert ( + apply_safe_mode_override("clarify_first", "emotion_first", primary_focus="emotion") + == "emotion_first" + ) + + +def test_plan_clarify_first_when_ambiguous(): + p = plan_interview_turn( + current_stage="childhood", + empty_slots=["place", "people"], + normalized_user_message="那种喜欢我也说不清,好像有又好像没有。", + memory_evidence_text="", + stage_switched_this_turn=False, + ) + assert p.mode == "clarify_first" + + +def test_plan_clarify_first_when_very_short(): + p = plan_interview_turn( + current_stage="childhood", + empty_slots=["place"], + normalized_user_message="还好吧", + memory_evidence_text="", + stage_switched_this_turn=False, + ) + assert p.mode == "clarify_first" def test_plan_memoir_push(): diff --git a/api/tests/test_memory_evidence.py b/api/tests/test_memory_evidence.py index a16eed2..d86947e 100644 --- a/api/tests/test_memory_evidence.py +++ b/api/tests/test_memory_evidence.py @@ -3,6 +3,7 @@ import pytest from app.features.memory import evidence as evidence_mod +from app.features.memory.evidence_format import format_evidence_chunks_for_chat_prompt from app.features.memory.evidence import ( EMPTY_EVIDENCE_BUNDLE, _facts_to_dicts, @@ -85,3 +86,107 @@ def test_format_helpers_empty() -> None: assert _facts_to_dicts([]) == [] assert _timeline_to_dicts([]) == [] assert _stories_to_dicts([]) == [] + + +def test_format_evidence_chunks_for_chat_prompt_reframes_and_labels() -> None: + evidence = { + "relevant_chunks": [ + {"id": "chunk-1", "content": "我小时候在河边长大,夏天常去玩水。"}, + ], + "relevant_summaries": [], + "relevant_facts": [], + "timeline_hints": [], + "relevant_stories": [], + } + text = format_evidence_chunks_for_chat_prompt(evidence) + assert "聊天专用" in text + assert "归因" in text + assert "[M1]" in text + assert "用户曾说" in text + assert "我小时候在河边长大" in text + + +def test_slice_interview_memory_empty_bundle(): + from app.features.memory.chat_memory_injection import slice_interview_memory + + s = slice_interview_memory(None, "你好") + assert s.prompt_excerpt == "" + assert s.anchor_source == "" + assert s.planner_preview == "" + assert s.had_retrieval is False + + +def test_slice_interview_memory_retrieval_not_equal_inject_dismissive(): + """有检索预览但 gating 后不进主 prompt / anchor。""" + from app.features.memory.chat_memory_injection import slice_interview_memory + + evidence = { + "relevant_chunks": [ + {"id": "c1", "content": "很久以前在校园礼堂排练到很晚。"}, + ], + "relevant_summaries": [], + "relevant_facts": [], + "timeline_hints": [], + "relevant_stories": [], + } + s = slice_interview_memory(evidence, "哈哈,早就不会了") + assert s.prompt_excerpt == "" + assert s.anchor_source == "" + assert s.planner_preview.strip() != "" + assert s.had_retrieval is True + + +def test_slice_interview_memory_minimal_inject_when_aligned(): + from app.features.memory.chat_memory_injection import slice_interview_memory + + evidence = { + "relevant_chunks": [ + {"id": "c1", "content": "你在校园演出里饰演罗密欧。"}, + ], + "relevant_summaries": [], + "relevant_facts": [], + "timeline_hints": [], + "relevant_stories": [], + } + s = slice_interview_memory(evidence, "那次排练其实挺紧张的,灯光一打我就忘词。") + assert "记忆线索" in s.prompt_excerpt + assert "校园演出" in s.prompt_excerpt or "罗密欧" in s.prompt_excerpt + assert s.anchor_source + assert s.had_retrieval is True + + +def test_slice_interview_memory_keeps_first_person_but_marks_ownership(): + from app.features.memory.chat_memory_injection import slice_interview_memory + + evidence = { + "relevant_chunks": [ + {"id": "c1", "content": "我小时候在河边长大,夏天常去玩水。"}, + ], + "relevant_summaries": [], + "relevant_facts": [], + "timeline_hints": [], + "relevant_stories": [], + } + s = slice_interview_memory(evidence, "那条河一到夏天就特别热闹,我现在都记得。") + assert "用户曾说" in s.prompt_excerpt + assert "我小时候在河边长大" in s.prompt_excerpt + assert s.anchor_source.startswith("用户曾说") + + +def test_slice_interview_memory_suppresses_long_new_topic(): + from app.features.memory.chat_memory_injection import slice_interview_memory + + evidence = { + "relevant_chunks": [ + {"id": "c1", "content": "旧记忆关于河边。"}, + ], + "relevant_summaries": [], + "relevant_facts": [], + "timeline_hints": [], + "relevant_stories": [], + } + long_msg = "我今天想随便聊聊工作里的事,项目压力很大。" * 6 + assert len(long_msg) > 72 + s = slice_interview_memory(evidence, long_msg) + assert s.prompt_excerpt == "" + assert s.anchor_source == "" diff --git a/api/tests/test_reply_planner.py b/api/tests/test_reply_planner.py new file mode 100644 index 0000000..c09d25d --- /dev/null +++ b/api/tests/test_reply_planner.py @@ -0,0 +1,112 @@ +"""reply_planner:JSON 合并与安全边界。""" + +import json + +from app.agents.chat.interview_turn_plan import InterviewTurnPlan +from app.agents.chat.reply_planner import merge_reply_planner_json_into_turn_plan + + +def _base_plan(**kwargs) -> InterviewTurnPlan: + defaults = dict( + mode="memoir_push", + anchor_slot_key="place", + anchor_slot_readable="成长的地方", + anchor_snippet="河边", + memory_usage="allowed_with_attribution", + reply_shape="flexible", + memory_reference_style="你之前提过", + ) + defaults.update(kwargs) + return InterviewTurnPlan(**defaults) + + +def test_merge_does_not_upgrade_memory_from_none_to_allowed(): + plan = _base_plan(memory_usage="none") + raw = json.dumps( + { + "memory_usage": "allowed_with_attribution", + "reply_shape": "ack_then_question", + "memory_reference_style": "你说过", + "forbid_first_person_experience": True, + } + ) + merged = merge_reply_planner_json_into_turn_plan(plan, raw) + assert merged.memory_usage == "none" + assert merged.reply_shape == "ack_then_question" + assert merged.memory_reference_style == "你说过" + + +def test_merge_allows_downgrade_memory_usage_to_none(): + plan = _base_plan(memory_usage="allowed_with_attribution") + raw = json.dumps({"memory_usage": "none"}) + merged = merge_reply_planner_json_into_turn_plan(plan, raw) + assert merged.memory_usage == "none" + + +def test_merge_invalid_json_returns_original(): + plan = _base_plan() + merged = merge_reply_planner_json_into_turn_plan(plan, "not json") + assert merged == plan + + +def test_merge_ignores_non_dict_json(): + plan = _base_plan() + merged = merge_reply_planner_json_into_turn_plan(plan, "[1,2]") + assert merged == plan + + +def test_merge_trims_memory_reference_style(): + plan = _base_plan() + raw = json.dumps({"memory_reference_style": " 你刚讲到 "}) + merged = merge_reply_planner_json_into_turn_plan(plan, raw) + assert merged.memory_reference_style == "你刚讲到" + + +def test_merge_sets_focus_and_summary(): + plan = _base_plan() + raw = json.dumps( + { + "primary_focus": "relationship", + "focus_summary": "先接住用户提到的在场关系与面子压力", + "forbid_first_person_experience": True, + } + ) + merged = merge_reply_planner_json_into_turn_plan(plan, raw) + assert merged.primary_focus == "relationship" + assert "关系" in merged.focus_summary + assert merged.focus_source == "llm" + + +def test_merge_mode_override_memoir_to_emotion_when_focus_supports(): + plan = _base_plan(mode="memoir_push") + raw = json.dumps( + { + "mode_override": "emotion_first", + "primary_focus": "identity", + "forbid_first_person_experience": True, + } + ) + merged = merge_reply_planner_json_into_turn_plan(plan, raw) + assert merged.mode == "emotion_first" + assert merged.focus_source == "llm" + + +def test_merge_rejects_unsafe_mode_override_emotion_to_memoir(): + plan = _base_plan(mode="emotion_first") + raw = json.dumps( + { + "mode_override": "memoir_push", + "primary_focus": "memoir_gap", + "forbid_first_person_experience": True, + } + ) + merged = merge_reply_planner_json_into_turn_plan(plan, raw) + assert merged.mode == "emotion_first" + + +def test_merge_omitted_secondary_focus_unchanged(): + plan = _base_plan() + raw = json.dumps({"reply_shape": "ack_only", "forbid_first_person_experience": True}) + merged = merge_reply_planner_json_into_turn_plan(plan, raw) + assert merged.reply_shape == "ack_only" + assert merged.focus_source == "rule" diff --git a/docs/TODO.md b/docs/TODO.md new file mode 100644 index 0000000..011d1b1 --- /dev/null +++ b/docs/TODO.md @@ -0,0 +1,211 @@ +结论先说 +如果你要走你选的这条路: + +先让模型做“本轮重点判断”,再生成回复 + +那这不是只修一个点,而是在修仓库里一个更普遍的反模式: + +系统过早承诺 micro-decisions,模型只能在被预分配好的轨道里写答案。 + +这个反模式在你们的 chat 链路里确实存在多处。 + +一类:和这次问题本质相同 +1. interview_turn_plan.py +这是最典型的同类问题。 + +它先把复杂语义压成 3 个 mode: + +emotion_first +memoir_push +follow_user_only +然后再进一步规定: + +要不要问 +最多几个问句 +是否必须问 +问句必须挂哪类锚点 +这里不是在“给模型方向”,而是在替模型做回合级编排决定。 + + +interview_turn_plan.py +Lines 157-170 +def plan_interview_turn( + *, + current_stage: str, + empty_slots: list[str], + normalized_user_message: str, + memory_evidence_text: str, + stage_switched_this_turn: bool, +) -> InterviewTurnPlan: + """ + 粗规则(可迭代): + - 情绪浓:先共情,不强推叙述槽搜集问。 + - 刚切换人生阶段:跟着用户节奏,不做「新阶段问卷首开」。 + - 当前阶段无空槽:深度跟进,不重启盘点。 + - 默认:memoir_push,锁一个主槽 + 挂钩摘录。 + """ + +interview_turn_plan.py +Lines 288-290 +- **回忆推进(memoir_push)**:对用户可见回复中须有**恰好一个**开放式回忆追问, + 且意图明显在补足下面「主追问方向」;问句必须挂住**挂钩摘录**或**用户本轮原词**(二者至少其一)。 +这和你指出的问题是同一种: +模型还没理解“这轮到底最重要的是什么”,系统已经先替它决定“这轮应该长成什么样”。 + +2. prompt_layers.py +这里是第二个高危点,而且和上面叠加。 + +它不只是给高层原则,而是在定义很多具体动作脚本,比如: + +连续两轮没问,这轮必须问 +问句最多一个 +情绪重也不能连续三轮不问 +更偏好从体感和画面往下长 + +prompt_layers.py +Lines 242-243 +- **追问义务回正(防多轮零问)**:通读**近期你方(助手)的连续回复**:若已**连续两轮**都没有任何问句...则本轮**必须在**短承接之后给出**恰好一条**带锚的开放式问... +- **纯跑题 ≠ 情绪红灯**:若用户本轮**几乎只有**寒暄、天气、泛泛近况、社交客气,而**没有**人生经历实质——**不适用**「整轮只陪不问」... +这是同类问题,因为它把“什么时候该追、什么时候该停、什么时候该纯承接”写成了比较硬的流程规则。 +这种规则不是安全边界,而是对中间思考过程的强干预。 + +3. orchestrator.py 的 scene_cues +这也是同类问题,只是形式不同。 + +系统在正式生成前,先偷偷把“场景氛围提示”塞进 context: + + +orchestrator.py +Lines 296-300 +scene_cues = extract_scene_cues(normalized_user_message) +if scene_cues: + cue_block = "\n".join(f"- {c}" for c in scene_cues) + scene_hint = f"\n\n[场景氛围提示——可借用这些感官细节自然接话,不要原样抄]\n{cue_block}" + memory_evidence_text = (memory_evidence_text or "") + scene_hint +这本质上也是“替模型预判重点”,而且还是单边偏置。 +系统没有同等强度的 relationship_cues / identity_cues,却有 scene_cues 特权通道。 + +所以如果你上“先让模型判断重点”的方案,这里几乎一定也要一起收口。 + +4. reply_planner.py +这个文件很有意思。 +它其实已经是一个“两阶段生成”的雏形了,但目前它做得太窄,没有判断本轮重点,只是在微调几个受限字段: + +memory_usage +reply_shape +memory_reference_style + +reply_planner.py +Lines 57-94 +def _build_reply_planner_prompt( + *, + plan: InterviewTurnPlan, + user_message_preview: str, + memory_evidence_preview: str, +) -> str: + ... + ## 当前规则基线(已由服务端算出,通常应尊重) + - mode: {plan.mode} + - memory_usage: {plan.memory_usage} + - reply_shape: {plan.reply_shape} + ... +这说明你们其实已经有一个很自然的插槽去做你想要的方案 A。 +只是现在这个 planner 不是“判断重点”,而是“在既定重点下微调姿势”。 +所以它解决不了你说的问题。 + +二类:有点像,但不是核心病灶 +5. interview_agent.py 的重复问句守卫 +这里有两层后处理: + +apply_duplicate_question_guard +dedupe 失败后再做一次 LLM retry + +interview_agent.py +Lines 82-86 +out, deduped = apply_duplicate_question_guard( + out, + state=memoir_state, + recent_questions=recent_questions, +) + +interview_agent.py +Lines 51-57 +上一版模型输出因包含与「最近已问过的问题」或「已确认事实」重复的问句,已被系统弃用。请**重新写一整条回复**: +- 仍须遵守上文全部主规则; +- 先贴着用户本轮原话承接半句到一两句(可有画面感); +- **禁止**再用与刚才同义、仅换说法的确认型问句; +... +这类逻辑有副作用,但我不会把它视作和你这次 case 完全同级的问题。 +原因是它更像“去重护栏”,不是“决定本轮重点”。它可能让模型更难问出正确问题,但它不是主要在替模型定义重点。 + +我的判断是:它是次级 handcuff,不是主 handcuff。 + +6. prompts_profile.py 的资料收集 choreography +这个模块里也有比较明确的流程约束: + + +prompts_profile.py +Lines 132-137 +1. **先接住**... +2. **话题优先**... +3. **资料穿插**... +4. **轮换**... +5. 每次最多 **1~2 个**资料相关问点... +这个也有点像“替模型编舞”,但这里我会更宽容。 +因为这个任务本身就更窄: + +目标明确 +信息字段有限 +风险主要是漏字段和重复问 +所以这里的 choreography 不优雅,但不一定是严重问题。 +它不像正式访谈主链那样,直接压扁复杂的人类语义。 + +三类:不是这类问题,应该保留 +7. 自传污染边界 / 禁编造 / memoir fidelity +这些不是 handcuff,而是必要边界。 + +比如: + +不准把用户经历写成助手自己的经历 +不准编造未提及的人名、地点、对话 +memoir 生成不能越过事实边界 +这些属于 safety / fidelity contract,不是“替模型做重点判断”。 + +所以如果你要上方案 A,我会明确区分: + +该放开的,是重点判断与表达路径。 +不该放开的,是事实边界与身份边界。 + +所以,是否“有类似问题”? +我的判断是: + +有,而且主要集中在 chat 主回复链路 +最明显的同类问题是这 4 个: + +api/app/agents/chat/interview_turn_plan.py +api/app/agents/chat/prompt_layers.py +api/app/agents/chat/orchestrator.py +api/app/agents/chat/reply_planner.py(不是坏,但能力位置放错了) +它们共同的问题不是“规则太多”这么简单,而是: + +系统在多个地方分别替模型决定“这轮重要什么、怎么组织、该不该问、问什么形状”,导致模型没有真正做一次独立的 salience judgment。 + +这和你想引入的方案 A,正好是正面冲突的。 + +如果你选方案 A,哪里最适合承接它 +最现实的一点是: +你们已经有一个两阶段接口,就是 reply_planner.py。 + +但如果要真做“先判断本轮重点,再生成”,我不会把它继续定位成现在这种“reply_shape 微调器”,而会把它升级成: + +focus planner / salience planner +它先输出类似这样的隐藏判定: + +primary_focus +secondary_focus +why_now +should_ask +candidate_anchor_span +然后正式生成模型再基于这个来写回复。 + +也就是说,你们不是没地方放这个方案,而是现在已经有一个很接近的位置,只是它做的事太弱、太晚。 \ No newline at end of file