diff --git a/api/.env.example b/api/.env.example index e51b1b0..a529a21 100644 --- a/api/.env.example +++ b/api/.env.example @@ -46,6 +46,31 @@ 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 +# 访谈回复长度档位(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:叙事前口述归一(segment 原文仍落库;仅 story 流水线派生输入) +# MEMOIR_ORAL_NORMALIZE_ENABLED=true +# off | rules | llm(llm 为先规则再 LLM 纠错,失败回退规则结果) +# MEMOIR_ORAL_NORMALIZE_MODE=llm +# MEMOIR_ORAL_NORMALIZE_LLM_MAX_TOKENS=512 +# MEMOIR_ORAL_NORMALIZE_LLM_MAX_INPUT_CHARS=8000 + +# Chat:模型消费净稿(segment 原文仍落库;访谈编排层归一后注入 Agent / 记忆检索) +# CHAT_INPUT_NORMALIZE_ENABLED=true +# off | rules | llm(llm 为先规则再 LLM;失败回退规则;编排层已带 LLM 时不重复在 Agent 调) +# CHAT_INPUT_NORMALIZE_MODE=rules +# CHAT_INPUT_NORMALIZE_LLM_MAX_TOKENS=512 +# CHAT_INPUT_NORMALIZE_LLM_MAX_INPUT_CHARS=8000 # ============================================================================= # Database @@ -199,6 +224,10 @@ STORY_IMAGE_MIN_BODY_CHARS=400 # 叙事模型输出相对口述过短则回退为口述原文 MEMOIR_NARRATIVE_FALLBACK_BODY_RATIO=0.5 MEMOIR_NARRATIVE_FALLBACK_MIN_CHARS=20 +# 回忆录 segment 入队:累计 strip 后字数未达此值则暂缓提交 Celery(0=关闭字数门闸,仅静默防抖后提交) +# MEMOIR_SEGMENT_BATCH_MIN_CHARS=50 +# 本批首条入队起最长等待(秒),超时仍提交;测试可调低,生产可调高 +# MEMOIR_SEGMENT_BATCH_MAX_WAIT_SECONDS=60 # 可选,Liblib 返回图片域名不在默认白名单时(逗号分隔) # MEMOIR_IMAGE_DOWNLOAD_HOSTS=liblib.cloud,liblibai.cloud diff --git a/api/.env.production b/api/.env.production index df8f3b0..ae19db9 100644 --- a/api/.env.production +++ b/api/.env.production @@ -27,6 +27,28 @@ ZHIPU_API_KEY=524eda18eb3848e881eefe4c7ef17ec2.xBmGUabYDEa44m3M # EMBEDDING_BASE_URL=https://open.bigmodel.cn/api/paas/v4 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 +# 访谈回复长度档位(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:叙事前口述归一(segment 原文仍落库;仅 story 流水线派生输入) +MEMOIR_ORAL_NORMALIZE_ENABLED=true +# off | rules | llm(llm 为先规则再 LLM 纠错,失败回退规则结果) +MEMOIR_ORAL_NORMALIZE_MODE=llm +MEMOIR_ORAL_NORMALIZE_LLM_MAX_TOKENS=512 +MEMOIR_ORAL_NORMALIZE_LLM_MAX_INPUT_CHARS=8000 + # ============================================================================= # Database # ============================================================================= diff --git a/api/Dockerfile b/api/Dockerfile index ee10208..f1d3f21 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -13,6 +13,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ zlib1g-dev \ libpng-dev \ libfreetype6-dev \ + ffmpeg \ && rm -rf /var/lib/apt/lists/* COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv diff --git a/api/alembic/versions/0005_cleanup_cross_chapter_story_links.py b/api/alembic/versions/0005_cleanup_cross_chapter_story_links.py new file mode 100644 index 0000000..15ef707 --- /dev/null +++ b/api/alembic/versions/0005_cleanup_cross_chapter_story_links.py @@ -0,0 +1,56 @@ +"""删除 story.stage 与 chapter.category 不匹配的 chapter_story_links,并标脏物化。 + +修复历史 bug:路由曾允许跨章节 append,导致同一 Story 挂到多章、内容重复。 + +Revision ID: 0005_cleanup_cross_chapter_story_links +Revises: 0004_memory_embedding_1024 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +revision: str = "0005_cleanup_cross_chapter_story_links" +down_revision: Union[str, None] = "0004_memory_embedding_1024" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 先标脏,再删链接(子查询在 DELETE 后不可用) + op.execute( + sa.text( + """ + UPDATE chapters + SET markdown_compose_dirty = true + WHERE id IN ( + SELECT DISTINCT csl.chapter_id + FROM chapter_story_links csl + JOIN stories s ON s.id = csl.story_id + JOIN chapters c ON c.id = csl.chapter_id AND c.is_active = true + WHERE s.stage IS NOT NULL AND s.stage != c.category + ) + """ + ) + ) + op.execute( + sa.text( + """ + DELETE FROM chapter_story_links + WHERE id IN ( + SELECT csl.id + FROM chapter_story_links csl + JOIN stories s ON s.id = csl.story_id + JOIN chapters c ON c.id = csl.chapter_id AND c.is_active = true + WHERE s.stage IS NOT NULL AND s.stage != c.category + ) + """ + ) + ) + + +def downgrade() -> None: + # 数据清理不可逆 + pass diff --git a/api/app/agents/chat/background_voice.py b/api/app/agents/chat/background_voice.py new file mode 100644 index 0000000..ffba063 --- /dev/null +++ b/api/app/agents/chat/background_voice.py @@ -0,0 +1,123 @@ +""" +从用户档案「职业」等文本推断访谈/叙事语气维度(干部形、军队形)。 +与 chat_interview_persona(温柔倾听等)正交,可叠加。 +""" + +from __future__ import annotations + +from typing import Final, Literal + +BackgroundVoice = Literal["default", "cadre", "military"] + +# 军队系优先:含「军、部队」等则走军队形,避免与泛「干部」冲突。 +_MILITARY_NEEDLES: Final[tuple[str, ...]] = ( + "军人", + "军官", + "士兵", + "部队", + "入伍", + "服役", + "退伍", + "转业", + "武警", + "解放军", + "陆军", + "海军", + "空军", + "火箭军", + "军区", + "军营", + "军校", + "文职干部", + "军队文职", + "现役", + "预备役", +) + +# 干部/机关系(避免过短词误判:如「机关」→机关枪、「主任」→班主任) +_CADRE_NEEDLES: Final[tuple[str, ...]] = ( + "公务员", + "党政机关", + "党政", + "组织部", + "党委书记", + "党组书记", + "书记", + "处长", + "科长", + "局长", + "厅长", + "部长", + "国企", + "事业单位", + "干部", + "科级", + "处级", + "厅级", +) + + +def infer_background_voice(occupation: str | None) -> BackgroundVoice: + """ + 据职业自由文本推断背景语气。军队关键词优先于干部关键词。 + 无匹配或未填 → default。 + """ + if not occupation or not str(occupation).strip(): + return "default" + t = str(occupation).strip().casefold() + for n in _MILITARY_NEEDLES: + if n.casefold() in t: + return "military" + for n in _CADRE_NEEDLES: + if n.casefold() in t: + return "cadre" + return "default" + + +def normalize_background_voice(voice: str | None) -> BackgroundVoice: + """调用方传入已归一化枚举或原始职业文本均可。""" + if not voice: + return "default" + s = voice.strip() + if s in ("default", "cadre", "military"): + return s # type: ignore[return-value] + return infer_background_voice(s) + + +def get_background_voice_chat_block(voice: str | None) -> str: + """注入访谈 guided/opening 的「背景语气」段落;default 返回空串。""" + v = normalize_background_voice(voice) + if v == "default": + return "" + if v == "military": + return ( + "## 背景语气:军队语境(仅语气,不编造事实)\n" + "称呼得体、句子简洁利落、条理清楚;避免网络梗与油滑套话。\n" + "先简短接住对方,再**最多一个**具体问题;不写命令式、不做思想政治表态。\n" + "涉及纪律、集体、任务等措辞,**仅当用户口述已出现相关事实时**自然呼应,禁止堆砌军事化辞藻或虚构经历。" + ) + # cadre + return ( + "## 背景语气:干部/机关语境(仅语气,不编造事实)\n" + "稳重、有分寸,敬语适度;句子可略完整,但仍控制总字数,避免官样文章与排比空话。\n" + "先回应对方内容,再**最多一个**具体问题;不写公文套话、不做政治评价。\n" + "涉及职务与组织时,**不得编造**用户未提及的职级、单位与荣誉。" + ) + + +def get_background_voice_narrative_block(voice: str | None) -> str: + """附在叙事系统提示后的文体补充;default 返回空串。""" + v = normalize_background_voice(voice) + if v == "default": + return "" + if v == "military": + return ( + "## 背景文体(军队,须遵守上文事实边界)\n" + "叙事紧凑、层次清楚;若口述已出现纪律、集体、任务等语境,可适度用书面语呼应,**禁止**堆砌口号式军事辞藻或虚构军旅细节。\n" + "不新增军衔、单位番号、表彰等口述未出现的信息。" + ) + return ( + "## 背景文体(干部/机关,须遵守上文事实边界)\n" + "段落层次清晰,用语庄重自然,避免口语碎词与段子感;**不得编造**职务、荣誉、单位名称与组织细节。\n" + "文采服务于真实内容,不写成公文或汇报腔。" + ) diff --git a/api/app/agents/chat/interview_agent.py b/api/app/agents/chat/interview_agent.py index 92311b6..eab281e 100644 --- a/api/app/agents/chat/interview_agent.py +++ b/api/app/agents/chat/interview_agent.py @@ -11,6 +11,8 @@ from app.core.dependencies import get_llm_provider from app.core.logging import get_logger from app.agents.chat.helpers import format_history_string, get_history_messages +from app.agents.chat.personas import normalize_interview_persona +from app.agents.chat.interview_reply_length import compute_reply_plan from app.agents.chat.prompts_conversation import ( SLOT_NAME_MAP, get_guided_conversation_prompt, @@ -28,6 +30,7 @@ from app.core.agent_logging import ( log_agent_summary, ) from app.core.config import settings +from app.features.conversation.input_normalize import normalize_chat_input_for_agent logger = get_logger(__name__) @@ -56,27 +59,34 @@ class InterviewAgent: def _estimate_same_topic_turns( self, history_messages: List[Any], current_filled_slots: dict ) -> int: - """估算同一话题的连续轮数""" - if len(history_messages) < 4: - return len(history_messages) // 2 - recent_messages = history_messages[-6:] - keywords_per_turn = [] - for i in range(0, len(recent_messages), 2): - if i + 1 < len(recent_messages): - human_msg = ( - recent_messages[i].content - if hasattr(recent_messages[i], "content") - else str(recent_messages[i]) - ) - ai_msg = ( - recent_messages[i + 1].content - if hasattr(recent_messages[i + 1], "content") - else str(recent_messages[i + 1]) - ) - keywords_per_turn.append((human_msg + ai_msg)[:100]) - if len(keywords_per_turn) >= 3: - return 3 - return len(keywords_per_turn) + """估算同一话题的连续轮数(保守:宁可多陪聊几轮再换)。""" + n_pairs = len(history_messages) // 2 + if n_pairs <= 1: + return n_pairs + recent_window = min(n_pairs, 5) + recent = history_messages[-(recent_window * 2) :] + nonempty_user_turns = 0 + for i in range(0, len(recent), 2): + msg = recent[i] + text = msg.content if hasattr(msg, "content") else str(msg) + if len(text.strip()) > 5: + nonempty_user_turns += 1 + return nonempty_user_turns + + def _resolve_text_for_model( + self, + user_message: str, + normalized_user_message: Optional[str], + ) -> str: + """模型侧净稿:编排层已归一则直接用;否则在本层补一次(含可选 LLM)。""" + if normalized_user_message is not None: + return (normalized_user_message or "").strip() + llm_n = None + if settings.chat_input_normalize_enabled and ( + (settings.chat_input_normalize_mode or "").strip().lower() == "llm" + ): + llm_n = self.llm + return normalize_chat_input_for_agent(user_message or "", llm=llm_n) async def generate_response_with_state( self, @@ -85,12 +95,18 @@ class InterviewAgent: memoir_state: MemoirStateSchema, user_profile_context: str = "", detected_user_stage: Optional[str] = None, + memory_evidence_text: str = "", + background_voice: str = "default", + normalized_user_message: Optional[str] = None, ) -> AgentChatTurn: """生成状态感知的访谈回复,不持久化(由 Orchestrator 负责)""" if not self.llm: logger.warning("InterviewAgent: LLM 未配置,返回兜底文案") return AgentChatTurn(messages=[_FALLBACK_REPLY], skip_tts=True) try: + text_for_model = self._resolve_text_for_model( + user_message, normalized_user_message + ) empty_slots = memoir_state.empty_slots_for_current_stage() filled_slots = { key: value.snippet @@ -102,30 +118,40 @@ class InterviewAgent: if detected_user_stage is not None: du = detected_user_stage else: - du = self._detect_user_stage(user_message) + du = self._detect_user_stage(text_for_model) history_messages = await get_history_messages(conversation_id) conversation_turn = len(history_messages) // 2 same_topic_turns = self._estimate_same_topic_turns( history_messages, filled_slots ) all_stages_coverage = memoir_state.all_stages_coverage() + persona = normalize_interview_persona(settings.chat_interview_persona) + reply_plan = compute_reply_plan( + text_for_model, + background_voice=background_voice, + settings=settings, + ) system_prompt = get_guided_conversation_prompt( current_stage=memoir_state.current_stage, empty_slots=empty_slots, filled_slots=filled_slots, - user_message=user_message, + user_message=text_for_model, conversation_turn=conversation_turn, same_topic_turns=same_topic_turns, all_stages_coverage=all_stages_coverage, detected_user_stage=du, user_profile_context=user_profile_context, + persona=persona, + memory_evidence_text=memory_evidence_text, + reply_length_mode=reply_plan.mode.value, + background_voice=background_voice, ) history_string = format_history_string(history_messages) - full_prompt = f"{system_prompt}\n\n{history_string}\n\nHuman: {user_message}\n\nAssistant:" + full_prompt = f"{system_prompt}\n\n{history_string}\n\nHuman: {text_for_model}\n\nAssistant:" log_agent_payload( logger, "InterviewAgent.generate_response.prompt", full_prompt ) - chat_llm = self.llm.bind(max_tokens=settings.chat_interview_max_tokens) + chat_llm = self.llm.bind(max_tokens=reply_plan.max_tokens) with agent_span( logger, "InterviewAgent.generate_response.llm", @@ -141,27 +167,26 @@ class InterviewAgent: ) raw_list = segments_from_llm_response( response_text, - max_segments=settings.chat_interview_max_segments, + max_segments=reply_plan.max_segments, ) if not raw_list: raw_list = [response_text.strip()] out = truncate_chat_segments( raw_list, - max_segments=settings.chat_interview_max_segments, - max_chars_per_segment=settings.chat_interview_max_chars_per_segment, + max_segments=reply_plan.max_segments, + max_chars_per_segment=reply_plan.max_chars_per_segment, ) if not out: - out = [ - response_text.strip()[ - : settings.chat_interview_max_chars_per_segment - ] - ] + out = [response_text.strip()[: reply_plan.max_chars_per_segment]] out = nonempty_segments_or_fallback(out, fallback=_FALLBACK_REPLY) log_agent_summary( logger, - "InterviewAgent.generate_response segments={} conversation_id={}", + "InterviewAgent.generate_response segments={} conversation_id={} " + "reply_length_mode={} max_tokens={}", len(out), conversation_id, + reply_plan.mode.value, + reply_plan.max_tokens, ) return AgentChatTurn(messages=out, skip_tts=False) except Exception as e: @@ -173,6 +198,7 @@ class InterviewAgent: conversation_id: str, memoir_state: MemoirStateSchema, user_profile_context: str = "", + background_voice: str = "default", ) -> List[str]: """生成空对话开场白,不持久化(由 Orchestrator 负责)""" if not self.llm: @@ -180,10 +206,13 @@ class InterviewAgent: try: empty_slots = memoir_state.empty_slots_for_current_stage() empty_slots_readable = [SLOT_NAME_MAP.get(s, s) for s in empty_slots] + persona = normalize_interview_persona(settings.chat_interview_persona) prompt = get_opening_prompt( current_stage=memoir_state.current_stage, empty_slots_readable=empty_slots_readable, user_profile_context=user_profile_context, + persona=persona, + background_voice=background_voice, ) full_prompt = f"{prompt}\n\nAssistant:" log_agent_payload(logger, "InterviewAgent.opening.prompt", full_prompt) @@ -203,10 +232,15 @@ class InterviewAgent: raw_list = segments_from_llm_response(response_text, max_segments=2) if not raw_list: raw_list = [response_text.strip()] + open_plan = compute_reply_plan( + "x" * 50, + background_voice=background_voice, + settings=settings, + ) out = truncate_chat_segments( raw_list, max_segments=2, - max_chars_per_segment=settings.chat_interview_max_chars_per_segment, + max_chars_per_segment=open_plan.max_chars_per_segment, ) log_agent_summary( logger, @@ -217,11 +251,7 @@ class InterviewAgent: segments = ( out if out - else [ - response_text.strip()[ - : settings.chat_interview_max_chars_per_segment - ] - ] + else [response_text.strip()[: open_plan.max_chars_per_segment]] ) return nonempty_segments_or_fallback( segments, diff --git a/api/app/agents/chat/interview_reply_length.py b/api/app/agents/chat/interview_reply_length.py new file mode 100644 index 0000000..27ba283 --- /dev/null +++ b/api/app/agents/chat/interview_reply_length.py @@ -0,0 +1,357 @@ +""" +访谈回复长度:由用户本轮文本 + 启发式(新细节 / 闲聊 / 信息密度)决定档位, +与 max_tokens、max_chars_per_segment 联动;单一 ReplyPlan 供 prompt 与截断共用。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import TYPE_CHECKING + +from app.agents.chat.background_voice import normalize_background_voice + +if TYPE_CHECKING: + from app.core.config import Settings + + +class ReplyLengthMode(str, Enum): + """brief:极短;standard:默认;expanded:值得展开承接时稍长。""" + + brief = "brief" + standard = "standard" + expanded = "expanded" + + +# 用户本轮字符数分桶(strip 后按 len,中文友好) +_LEN_BRIEF_MAX = 20 +_LEN_MID_EXPAND_MIN = 40 +_LEN_LONG_MIN = 80 + + +def heuristic_likely_new_detail(user_message: str) -> bool: + """ + 轻量启发:本轮是否很可能补充了新人名、新关系或新情节(追问触发与长度共用)。 + """ + m = (user_message or "").strip() + if len(m) < 2: + return False + needles = ( + "叫", + "名字", + "名叫", + "同桌", + "初恋", + "现实里", + "戏里", + "饰演", + "演我", + "第一次", + "认识", + "没想到", + "猜猜", + ) + return any(n in m for n in needles) + + +def heuristic_information_rich(user_message: str) -> bool: + """ + 轻量启发:短句也可能信息密度高(新转折、重大事件、时间锚点),用于避免误压成 brief。 + """ + m = (user_message or "").strip() + if len(m) < 2: + return False + needles = ( + "突然", + "那年", + "后来", + "记得", + "第一次", + "没想到", + "离开", + "去世", + "走了", + "结婚", + "离婚", + "生病", + "辍学", + "退学", + "下岗", + "破产", + "我爸", + "我妈", + "爷爷", + "奶奶", + ) + return any(n in m for n in needles) + + +def heuristic_likely_emotional(user_message: str) -> bool: + """ + 轻量启发:用户本轮是否在表达较强情绪(需要更多承接空间、不应被压成 brief)。 + """ + m = (user_message or "").strip() + if len(m) < 4: + return False + needles = ( + "想哭", + "哭了", + "难过", + "伤心", + "心酸", + "感动", + "激动", + "害怕", + "委屈", + "后悔", + "对不起", + "愧疚", + "感激", + "谢谢你", + "想念", + "想他", + "想她", + "舍不得", + "不容易", + "太难了", + "崩溃", + "绝望", + "幸福", + "骄傲", + "自豪", + ) + return any(n in m for n in needles) + + +def heuristic_likely_chit_chat(user_message: str) -> bool: + """ + 轻量启发:本轮是否偏闲聊(放宽长句里纯寒暄/天气类)。 + """ + m = (user_message or "").strip() + if len(m) > 200: + return False + + needles_short = ( + "天气", + "谢谢", + "哈哈", + "呵呵", + "在吗", + "吃了吗", + "早上好", + "晚安", + "闲聊", + "逗你", + ) + if len(m) > 48: + head = m[:100] + if any(n in head for n in needles_short): + if not heuristic_information_rich(m) and not heuristic_likely_new_detail(m): + return True + return False + + if any(n in m for n in needles_short): + return True + if len(m) <= 8 and m in ("嗯", "好", "行的", "谢谢", "哈哈", "可以", "没事"): + return True + return False + + +@dataclass(frozen=True) +class ReplyPlan: + """单一计划:prompt 展示档位与数值上限一致(含背景语气微调)。""" + + mode: ReplyLengthMode + max_tokens: int + max_chars_per_segment: int + max_segments: int + likely_new_detail: bool + likely_chit_chat: bool + information_rich: bool + + +def compute_reply_plan( + user_message: str, + *, + background_voice: str | None, + settings: "Settings", +) -> ReplyPlan: + """ + 信息量与情绪优先,字数次之: + - 短输入且无新信息、无情绪 → brief + - 短输入但有新细节/高密度/强情绪 → standard + - 中段(40-79)有实质/情绪 → expanded(给足承接空间) + - 中段无实质 → standard + - 长输入:闲聊为主 → standard;有展开价值 → expanded + """ + norm = (user_message or "").strip() + n = max(0, len(norm)) + max_segments = int(settings.chat_interview_max_segments) + + likely_new = heuristic_likely_new_detail(norm) + likely_chit = heuristic_likely_chit_chat(norm) + info_rich = heuristic_information_rich(norm) + emotional = heuristic_likely_emotional(norm) + substantive = likely_new or info_rich or emotional + + def _mk(m: ReplyLengthMode) -> ReplyPlan: + return _plan_from_mode( + m, + max_segments=max_segments, + settings=settings, + background_voice=background_voice, + likely_new=likely_new, + likely_chit=likely_chit, + info_rich=info_rich, + ) + + if likely_chit and not substantive: + return _mk( + ReplyLengthMode.brief if n <= _LEN_BRIEF_MAX else ReplyLengthMode.standard + ) + + if n <= _LEN_BRIEF_MAX: + return _mk(ReplyLengthMode.standard if substantive else ReplyLengthMode.brief) + + if n < _LEN_MID_EXPAND_MIN: + return _mk(ReplyLengthMode.standard) + + if n < _LEN_LONG_MIN: + return _mk( + ReplyLengthMode.expanded if substantive else ReplyLengthMode.standard + ) + + return _mk(ReplyLengthMode.expanded if substantive else ReplyLengthMode.standard) + + +def _plan_from_mode( + mode: ReplyLengthMode, + *, + max_segments: int, + settings: "Settings", + background_voice: str | None, + likely_new: bool, + likely_chit: bool, + info_rich: bool, +) -> ReplyPlan: + if mode == ReplyLengthMode.brief: + base = ReplyPlan( + mode=mode, + max_tokens=int(settings.chat_interview_brief_max_tokens), + max_chars_per_segment=int( + settings.chat_interview_brief_max_chars_per_segment + ), + max_segments=max_segments, + likely_new_detail=likely_new, + likely_chit_chat=likely_chit, + information_rich=info_rich, + ) + elif mode == ReplyLengthMode.expanded: + base = ReplyPlan( + mode=mode, + max_tokens=int(settings.chat_interview_expanded_max_tokens), + max_chars_per_segment=int( + settings.chat_interview_expanded_max_chars_per_segment + ), + max_segments=max_segments, + likely_new_detail=likely_new, + likely_chit_chat=likely_chit, + information_rich=info_rich, + ) + else: + base = ReplyPlan( + mode=ReplyLengthMode.standard, + max_tokens=int(settings.chat_interview_max_tokens), + max_chars_per_segment=int(settings.chat_interview_max_chars_per_segment), + max_segments=max_segments, + likely_new_detail=likely_new, + likely_chit_chat=likely_chit, + information_rich=info_rich, + ) + return bump_reply_plan_for_background_voice( + base, background_voice=background_voice, settings=settings + ) + + +def bump_reply_plan_for_background_voice( + plan: ReplyPlan, + *, + background_voice: str | None, + settings: "Settings", +) -> ReplyPlan: + """ + 干部/军队背景时,仅对 standard 档小幅提高 token 与单段字数;**展示档位不变**(仍为 standard)。 + """ + if normalize_background_voice(background_voice) == "default": + return plan + if plan.mode != ReplyLengthMode.standard: + return plan + extra_t = int( + getattr( + settings, + "chat_interview_cadre_military_standard_extra_tokens", + 0, + ) + ) + extra_c = int( + getattr( + settings, + "chat_interview_cadre_military_standard_extra_chars", + 0, + ) + ) + return ReplyPlan( + mode=plan.mode, + max_tokens=plan.max_tokens + extra_t, + max_chars_per_segment=plan.max_chars_per_segment + extra_c, + max_segments=plan.max_segments, + likely_new_detail=plan.likely_new_detail, + likely_chit_chat=plan.likely_chit_chat, + information_rich=plan.information_rich, + ) + + +# 向后兼容:旧名与旧签名(仅测试或外部引用) +def compute_reply_length_strategy( + user_message_len: int, + *, + likely_new_detail: bool, + likely_chit_chat: bool, + settings: "Settings", +) -> ReplyPlan: + """已弃用:请用 compute_reply_plan(user_message, ...)。保留供过渡期。""" + # 无法还原 information_rich,按旧逻辑近似 + n = max(0, int(user_message_len)) + max_segments = int(settings.chat_interview_max_segments) + if n <= _LEN_BRIEF_MAX: + mode = ReplyLengthMode.brief + elif n < _LEN_LONG_MIN: + mode = ReplyLengthMode.standard + else: + if likely_chit_chat: + mode = ReplyLengthMode.standard + elif likely_new_detail: + mode = ReplyLengthMode.expanded + else: + mode = ReplyLengthMode.standard + return _plan_from_mode( + mode, + max_segments=max_segments, + settings=settings, + background_voice=None, + likely_new=likely_new_detail, + likely_chit=likely_chit_chat, + info_rich=False, + ) + + +def bump_reply_length_strategy_for_background_voice( + plan: ReplyPlan, + *, + background_voice: str | None, + settings: "Settings", +) -> ReplyPlan: + """旧名兼容。""" + return bump_reply_plan_for_background_voice( + plan, background_voice=background_voice, settings=settings + ) diff --git a/api/app/agents/chat/orchestrator.py b/api/app/agents/chat/orchestrator.py index 921e77d..4b452ba 100644 --- a/api/app/agents/chat/orchestrator.py +++ b/api/app/agents/chat/orchestrator.py @@ -19,8 +19,20 @@ from app.agents.chat.stage_detection import ( detect_primary_life_stage, life_stage_display_name, ) +from app.core.config import settings +from app.core.dependencies import get_llm_provider +from app.features.conversation.input_normalize import normalize_chat_input_for_agent from app.features.memoir.state_service import get_or_create_state, switch_stage + +def _llm_for_chat_input_normalize(): + try: + p = get_llm_provider() + return getattr(p, "langchain_llm", None) + except Exception: + return None + + if TYPE_CHECKING: from app.features.user.models import User @@ -31,6 +43,41 @@ _UNAUTH_TURN = AgentChatTurn( ) +async def _fetch_interview_memory_evidence( + db: AsyncSession, + user_id: str, + user_message: str, +) -> str: + """按本轮用户话检索记忆,格式化为短文本;失败或未启用时返回空串。""" + from app.core.dependencies import get_embedding_provider + from app.features.memory.evidence_format import format_evidence_chunks_for_prompt + from app.features.memory.service import MemoryService + + if not settings.chat_memory_retrieval_enabled: + return "" + msg = (user_message or "").strip() + if not msg: + return "" + try: + ms = MemoryService(db, embedding_provider=get_embedding_provider()) + bundle = await ms.retrieve(user_id, msg, top_k=settings.chat_memory_top_k) + text = format_evidence_chunks_for_prompt(bundle.model_dump()) + t = (text or "").strip() + if not t: + return "" + max_c = settings.chat_memory_evidence_max_chars + if len(t) > max_c: + return t[: max_c - 3] + "..." + return t + except Exception as e: + try: + await db.rollback() + except Exception as rollback_error: + logger.warning("访谈记忆检索失败后回滚也失败: {}", rollback_error) + logger.warning("访谈记忆检索失败: {}", e) + return "" + + class ChatOrchestrator: """ 聊天编排器:根据用户资料完成度路由到 ProfileAgent 或 InterviewAgent。 @@ -106,6 +153,10 @@ class ChatOrchestrator: return AgentChatTurn(messages=responses, skip_tts=False) except Exception as e: logger.error(f"资料收集处理失败: {e}", exc_info=True) + return AgentChatTurn( + messages=["不好意思刚才没接住,你再说一遍好吗?"], + skip_tts=False, + ) # --- 正式访谈模式 --- user_id = user.id if user else None @@ -125,9 +176,18 @@ class ChatOrchestrator: conversation_id, len(user_message or ""), ) + llm_n = None + if settings.chat_input_normalize_enabled and ( + (settings.chat_input_normalize_mode or "").strip().lower() == "llm" + ): + llm_n = _llm_for_chat_input_normalize() + normalized_user_message = normalize_chat_input_for_agent( + user_message or "", + llm=llm_n, + ) state = await get_or_create_state(user_id, db) detected = await detect_primary_life_stage( - user_message, + normalized_user_message, state.current_stage, self.interview_agent.llm, ) @@ -138,9 +198,11 @@ class ChatOrchestrator: conversation.conversation_stage = state.current_stage await db.commit() + from app.agents.chat.background_voice import infer_background_voice from app.agents.chat.prompts_profile import format_user_profile_context user_profile_context = "" + background_voice = "default" if user: user_profile_context = format_user_profile_context( birth_year=user.birth_year, @@ -148,6 +210,11 @@ class ChatOrchestrator: grew_up_place=user.grew_up_place, occupation=user.occupation, ) + background_voice = infer_background_voice(user.occupation) + + memory_evidence_text = await _fetch_interview_memory_evidence( + db, user_id, normalized_user_message + ) turn = await self.interview_agent.generate_response_with_state( conversation_id=conversation_id, @@ -155,6 +222,9 @@ class ChatOrchestrator: memoir_state=state, user_profile_context=user_profile_context, detected_user_stage=detected, + memory_evidence_text=memory_evidence_text, + background_voice=background_voice, + normalized_user_message=normalized_user_message, ) if agent_summary_enabled(): logger.info( @@ -224,6 +294,9 @@ class ChatOrchestrator: user_message_timestamp: datetime | None = None, audio_duration_seconds: int | None = None, detected_user_stage: str | None = None, + memory_evidence_text: str = "", + background_voice: str = "default", + normalized_user_message: str | None = None, ) -> AgentChatTurn: """委托 InterviewAgent 生成访谈回复(持久化由调用方负责)。""" return await self.interview_agent.generate_response_with_state( @@ -232,6 +305,9 @@ class ChatOrchestrator: memoir_state=memoir_state, user_profile_context=user_profile_context, detected_user_stage=detected_user_stage, + memory_evidence_text=memory_evidence_text, + background_voice=background_voice, + normalized_user_message=normalized_user_message, ) def detect_user_stage(self, user_message: str) -> str: @@ -243,6 +319,7 @@ class ChatOrchestrator: conversation_id: str, memoir_state: MemoirStateSchema, user_profile_context: str = "", + background_voice: str = "default", ) -> List[str]: """ 委托 InterviewAgent 生成访谈开场白(持久化由调用方 ConversationHistoryStore 负责)。 @@ -251,4 +328,5 @@ class ChatOrchestrator: conversation_id=conversation_id, memoir_state=memoir_state, user_profile_context=user_profile_context, + background_voice=background_voice, ) diff --git a/api/app/agents/chat/personas.py b/api/app/agents/chat/personas.py new file mode 100644 index 0000000..0e34e97 --- /dev/null +++ b/api/app/agents/chat/personas.py @@ -0,0 +1,60 @@ +""" +访谈 Agent 可配置性格(Persona):仅影响语气与追问倾向,不替代事实边界与槽位约束。 +""" + +from __future__ import annotations + +from typing import Final + +# 与 settings.chat_interview_persona 及文档保持一致 +VALID_INTERVIEW_PERSONAS: Final[frozenset[str]] = frozenset( + {"default", "warm_listener", "curious_guide"} +) + + +def normalize_interview_persona(raw: str | None) -> str: + """未知或空值回退 default,避免部署拼写错误导致空提示。""" + key = (raw or "default").strip().lower() + if key in VALID_INTERVIEW_PERSONAS: + return key + return "default" + + +def get_interview_persona_block(persona: str) -> str: + """ + 返回注入到访谈 prompt 的「访谈性格」段落(不含 default,由调用方跳过)。 + """ + key = normalize_interview_persona(persona) + if key == "default": + return "" + + blocks = { + "warm_listener": ( + "## 访谈性格:温柔倾听\n" + "在遵守「回忆录导向与闲聊」的前提下,优先把对话引向可写进回忆录的素材;明显闲聊时先陪聊。\n" + "你更偏倾听与承接,语气柔和、少打断;" + "但一旦用户说出**新的人名、新的关系、或新的情节线**(上文未展开)," + "仍必须按本提示中的「追问触发」规则,在承接后带**一个**具体问题,不能用纯感慨代替。\n" + "禁止审问感、禁止一次抛多个问题。" + ), + "curious_guide": ( + "## 访谈性格:好奇引导\n" + "在遵守「回忆录导向与闲聊」的前提下,追问尽量落在人生故事与未覆盖方向上;明显闲聊时先陪聊。\n" + "你更愿意把人往**一个具体细节**里带:时间、场景、对方反应、你心里一闪而过的念头;" + "每轮**最多一个**具体问题,短句、像微信。\n" + "若本轮触发「追问触发」,优先追问用户刚抛出的新信息,不要为了凑问题去重复上文已清楚的事。" + ), + } + return blocks.get(key, "") + + +def get_opening_persona_line(persona: str) -> str: + """开场白用的一行性格提示(短,避免喧宾夺主)。""" + key = normalize_interview_persona(persona) + if key == "default": + return "" + lines = { + "warm_listener": "语气偏倾听、少打断;但仍须完成「问候 + 一个具体问题」。", + "curious_guide": "语气偏好奇、爱往细节里带一个具体问题;不要一次问很多。", + } + return lines.get(key, "") diff --git a/api/app/agents/chat/prompts_conversation.py b/api/app/agents/chat/prompts_conversation.py index 793e308..48c36ee 100644 --- a/api/app/agents/chat/prompts_conversation.py +++ b/api/app/agents/chat/prompts_conversation.py @@ -3,8 +3,22 @@ """ from enum import Enum -from typing import Dict, List +from typing import Dict, List, Optional +from app.agents.chat.background_voice import ( + get_background_voice_chat_block, + normalize_background_voice, +) +from app.agents.chat.interview_reply_length import ( + heuristic_likely_chit_chat, + heuristic_likely_emotional, + heuristic_likely_new_detail, +) +from app.agents.chat.personas import ( + get_interview_persona_block, + get_opening_persona_line, + normalize_interview_persona, +) from app.core.config import settings @@ -48,6 +62,19 @@ INTERVIEW_QUESTIONS: Dict[ConversationStage, List[str]] = { } +def _guided_voice_intro_line(background_voice: str) -> str: + """顶部角色描述:温暖陪聊,但仍控制篇幅。""" + if normalize_background_voice(background_voice) == "default": + return ( + "你是「岁月知己」,像老朋友陪用户聊人生。" + "**先真诚接住对方的话**,再决定要不要追问;短句为主,但**接住情绪比控制字数更重要**。" + ) + return ( + "你是「岁月知己」,像老朋友陪用户聊人生。" + "**先真诚承接对方一句,再自然推进**;短句为主,遵守下方长度档位。" + ) + + def get_system_prompt( current_stage: ConversationStage, covered_topics: List[str], @@ -146,6 +173,8 @@ def get_opening_prompt( current_stage: str, empty_slots_readable: List[str], user_profile_context: str = "", + persona: str = "default", + background_voice: str = "default", ) -> str: """空对话时 AI 先开口的提示词""" stage_name_map = { @@ -196,6 +225,19 @@ def get_opening_prompt( current_stage, _opening_examples["childhood"], ) + bv = normalize_background_voice(background_voice) + if bv == "cadre": + style_examples += ( + "\n(干部/机关语境:问候稳重、不用「嗨~」;示例可参考)\n" + '"您好,想听听您的人生故事。您小时候是在哪儿长大的?哪一段印象最深?"\n或\n' + '"您好。今天想从您印象最深的一件事聊起,可以吗?"' + ) + elif bv == "military": + style_examples += ( + "\n(军队语境:简洁、得体;不用「嗨~」;示例可参考)\n" + '"您好。想听听您的经历。您童年印象最深的一件事是什么?"\n或\n' + '"您好,有空的话想聊聊您的人生故事。您小时候在哪儿长大?"' + ) else: topics_heading = ( f"## 当前阶段({stage_name})\n" @@ -215,10 +257,26 @@ def get_opening_prompt( profile_section = ( f"\n## 用户基本信息\n{user_profile_context}\n" if user_profile_context else "" ) - return f"""你是「岁月知己」。用户刚进对话,**还没说话**,请你先开口。**短、像微信**,一两句问候 + 一句具体问题即可,不要排比、不要文学描写。 + persona_key = normalize_interview_persona(persona) + opening_persona = get_opening_persona_line(persona_key) + persona_extra = f"\n## 访谈性格\n{opening_persona}\n" if opening_persona else "" + voice_block = get_background_voice_chat_block(background_voice) + voice_section = f"\n{voice_block}\n" if voice_block else "" + bv = normalize_background_voice(background_voice) + if bv == "default": + opening_head = ( + "你是「岁月知己」。用户刚进对话,**还没说话**,请你先开口。" + "**短、像微信**,一两句问候 + 一句具体问题即可,不要排比、不要文学描写。" + ) + else: + opening_head = ( + "你是「岁月知己」。用户刚进对话,**还没说话**,请你先开口。" + "**短;两三句内完成问候 + 一个具体问题**;不要排比、不要文学描写。" + ) + return f"""{opening_head} {profile_section} {topics_heading} - +{persona_extra}{voice_section} ## 任务 1. 简短问候。 {task_question} @@ -293,6 +351,21 @@ def _build_era_context(current_stage: str, user_profile_context: str) -> str: ) +def _format_reply_length_section(current_mode: str) -> str: + """软提示:本轮档位 + 三档说明(模型始终可见完整对照)。""" + safe = ( + current_mode + if current_mode in ("brief", "standard", "expanded") + else "standard" + ) + return f"""## 本轮回复长度 +**当前档位:{safe}** +- **brief**:一两句话,简短温暖地接住对方,可以带一个小问题也可以不带。 +- **standard**:承接 + 最多一个具体问题;像朋友聊天,不写长段。 +- **expanded**:用户本轮分享了较多内容或情绪较浓——可以多说一两句承接对方话里的核心点,表达你听到了、你在意,再自然追问;**仍控制在两段以内**。 +""" + + def get_guided_conversation_prompt( current_stage: str, empty_slots: List[str], @@ -300,11 +373,20 @@ def get_guided_conversation_prompt( user_message: str, conversation_turn: int = 0, same_topic_turns: int = 0, - all_stages_coverage: Dict[str, Dict] = None, + all_stages_coverage: Optional[Dict[str, Dict]] = None, detected_user_stage: str = "", user_profile_context: str = "", + persona: str = "default", + memory_evidence_text: str = "", + reply_length_mode: str = "standard", + background_voice: str = "default", ) -> str: - """生成状态感知的对话提示词""" + """生成状态感知的对话提示词(档位由 Agent 计算的 ReplyPlan 传入,不在此重复推导)。""" + persona_key = normalize_interview_persona(persona) + persona_block = get_interview_persona_block(persona_key) + likely_new = heuristic_likely_new_detail(user_message) + likely_chit = heuristic_likely_chit_chat(user_message) + reply_length_section = _format_reply_length_section(reply_length_mode) stage_name_map = { "childhood": "童年时光", "education": "求学经历", @@ -352,23 +434,65 @@ def get_guided_conversation_prompt( progress_str = "\n".join(progress_lines) if progress_lines else "" filled_count = len(filled_slots) - should_switch_topic = same_topic_turns >= 3 or ( - filled_count >= 2 and same_topic_turns >= 2 + should_switch_topic = same_topic_turns >= 5 or ( + filled_count >= 3 and same_topic_turns >= 4 ) - should_lighten_mood = conversation_turn > 0 and conversation_turn % 5 == 0 - should_try_new_stage = filled_count >= 3 and len(empty_slots) <= 2 + should_lighten_mood = conversation_turn > 0 and conversation_turn % 7 == 0 + should_try_new_stage = filled_count >= 4 and len(empty_slots) <= 1 related_stages = STAGE_RELATED_TOPICS.get(current_stage, []) related_stages_str = "、".join([stage_name_map.get(s, s) for s in related_stages]) - style = RESPONSE_STYLES[conversation_turn % len(RESPONSE_STYLES)] - style_guidance = { - "empathy": "共情一两句即可", - "curious": "若还有未展开的细节可好奇问一个点;若上文已说清或可自然推断,只承接或换角度,**勿为凑问题而追问**", - "reflection": "可一句简短感慨,勿讲大道理", - "lighthearted": "轻松一点,别讲段子太长", - "connection": "可提「我也有过类似感受」一句,勿编造具体经历细节", - }.get(style, "") + emotional = heuristic_likely_emotional(user_message) + + if persona_block: + tone_section = f"{persona_block}\n" + else: + tone_section = "" + + followup_trigger_block = """## 什么时候追问、什么时候只承接 +**该追问**(承接后带 1 个具体问题): +- 出现**新的人名、新关系、新情节**,上文还没展开过; +- 用户邀你接话(如「你猜猜」); +- 本阶段仍有未聊方向,且对方话里露出可深挖的线头。 + +**可以只承接、不追问**: +- 本轮几乎无新信息(「嗯」「对」「行」); +- 用户明确要结束或换话题; +- 再问会重复上文已说清的事。 + +**用户在表达情绪时**:先好好接住情绪,让对方感觉被听到、被理解;不急着追问,等情绪有着落后再自然引回。 +""" + if likely_new: + followup_trigger_block += ( + "\n**【本轮判定】用户补充了新细节 → 承接后须追问 1 句。**\n" + ) + if emotional and not likely_new: + followup_trigger_block += ( + "\n**【本轮判定】用户情绪较浓 → 先好好共情承接,不必急着追问。**\n" + ) + + memoir_orientation_lines = [ + "## 对话方向", + "追问与承接**优先服务于人生故事与回忆录素材**,但不要让对方觉得你在走流程。", + "若用户**明显在闲聊**,以陪聊为主,**不要**用回忆录式问题打断。", + "若用户一边回忆一边开玩笑,先接情绪,再轻轻带回一个与经历相关的小问题。", + ] + if likely_chit: + memoir_orientation_lines.append( + "**【本轮偏闲聊】** → 以承接与陪聊为主;若用户自然带回经历,再追问。" + ) + memoir_orientation_block = "\n".join(memoir_orientation_lines) + "\n" + + memory_section = "" + mem_trim = (memory_evidence_text or "").strip() + if mem_trim: + memory_section = ( + "## 相关记忆摘录(仅供衔接,禁止编造)\n" + "以下为系统从用户**过往口述**中检索到的摘录,**不是**用户本轮亲口新说的内容。\n" + "承接时可自然用「你之前提过……」「上次你说到……」等口语,不要把摘录里的细节写成本轮用户新告诉你的事实;禁止编造摘录未出现的内容。\n\n" + f"{mem_trim}\n\n" + ) dynamic_guidance = "" if user_jumped: @@ -380,9 +504,12 @@ def get_guided_conversation_prompt( if should_lighten_mood: dynamic_guidance += "\n- 聊了一会儿了,可以适当轻松一下,聊点有趣的" if should_switch_topic and empty_slots_readable: - dynamic_guidance += ( - f"\n- 这个话题聊得差不多了,可以自然转到:{empty_slots_str}" - ) + if likely_new: + dynamic_guidance += f"\n- 若用户本轮**刚补充**新细节,请先就这一点追问一句,再自然转到未聊方向:{empty_slots_str}" + else: + dynamic_guidance += ( + f"\n- 这个话题聊得差不多了,可以自然转到:{empty_slots_str}" + ) if should_try_new_stage and related_stages: dynamic_guidance += ( f"\n- 如果自然的话,可以尝试聊聊相关的话题,比如{related_stages_str}" @@ -410,9 +537,15 @@ def get_guided_conversation_prompt( else "" ) - prompt = f"""你是「岁月知己」,陪用户聊人生。**像微信:短句、少修辞、别写小作文。** + voice_block = get_background_voice_chat_block(background_voice) + voice_section = f"\n{voice_block}\n" if voice_block else "" + intro_line = _guided_voice_intro_line(background_voice) + + prompt = f"""{intro_line} {topic_desc} +{reply_length_section} {profile_section} +{voice_section} ## 本阶段已聊 {filled_slots_str} @@ -425,21 +558,21 @@ def get_guided_conversation_prompt( ## 用户刚才说 "{user_message}" -## 本轮语气 -{style_guidance} +{memoir_orientation_block}{memory_section}{followup_trigger_block} +{tone_section} -## 任务(短) -1. 先简短回应一句,不要总结成长文。 -2. 用户若跳到别的人生阶段,跟着他聊,别硬拉回。 -3. 需要追问时**只问一个**具体小问题;**不必每轮都问**;若用户已说明或语境已能推出(如谁买的、和谁),**别再为同一件事做 yes/no 确认**。 -4. 用户只回简短肯定/否定(如「是的」「对」)时,**结合上文**理解,承接即可或问**新**角度,勿重复上一句已问过的事。 -5. 可用 [SPLIT] 分成**最多 2 条**消息,每条都很短。 +## 你要做的 +1. **先接住对方**——一句真诚回应,不要写成总结或讲评。 +2. 用户跳到别的人生阶段,跟着聊,别硬拉回。 +3. **最多追问一个**具体、好答的问题(参照上方「什么时候追问」);无需追问时,只承接就好。 +4. 用户回「嗯」「对」之类,结合上文理解,承接或换个新角度,不要重复上一轮问过的事。 +5. 可用 [SPLIT] 分成**最多 2 条**消息。 {dynamic_guidance}{uncovered_hint} -## 禁止 -括号/思考过程;采访腔;**重复确认**用户档案、**上文已说**或**强暗示下已可知**的事实(包括无信息量的「是不是他/她…」式追问);别编用户没说的细节。 +## 不要做的 +括号/思考过程;采访腔(「我注意到」「我想了解」);重复确认对方已经说过或能推断出的信息;编造对方没说的细节。 -直接输出([SPLIT] 可选,最多 2 段):""" +直接输出:""" return prompt diff --git a/api/app/agents/memoir/__init__.py b/api/app/agents/memoir/__init__.py index 54d3c07..68465b9 100644 --- a/api/app/agents/memoir/__init__.py +++ b/api/app/agents/memoir/__init__.py @@ -1,6 +1,9 @@ """回忆录模块:MemoirOrchestrator、各 Specialist Agent。""" -from app.agents.memoir.classification_agent import ClassificationAgent +from app.agents.memoir.classification_agent import ( + ChapterClassifyResult, + ClassificationAgent, +) from app.agents.memoir.extraction_agent import ExtractionAgent, ExtractionResult from app.agents.memoir.fidelity_check_agent import FidelityCheckAgent from app.agents.memoir.narrative_agent import NarrativeAgent @@ -14,6 +17,7 @@ from app.agents.memoir.story_route_agent import ( ) __all__ = [ + "ChapterClassifyResult", "MemoirOrchestrator", "PreparedMemoirBatches", "StoryRouteAgent", diff --git a/api/app/agents/memoir/classification_agent.py b/api/app/agents/memoir/classification_agent.py index b17e9dd..6152ac9 100644 --- a/api/app/agents/memoir/classification_agent.py +++ b/api/app/agents/memoir/classification_agent.py @@ -10,6 +10,7 @@ from __future__ import annotations import json import re +from dataclasses import dataclass from typing import Any from app.agents.memoir.prompts import ( @@ -95,6 +96,14 @@ def _normalize_llm_category(raw: str) -> str: return s +@dataclass(frozen=True) +class ChapterClassifyResult: + """章节分类结果;``llm_said_none`` 仅当走 LLM 且解析为 none 时为 True(fragment 启发式不为 True)。""" + + category: str + llm_said_none: bool = False + + def _parse_category_from_llm_response(raw: str) -> str: """优先解析 JSON ``{"category": "..."}``,失败则按纯文本 key 处理。""" s = (raw or "").strip() @@ -119,10 +128,11 @@ class ClassificationAgent: llm: Any, *, segment_id: str | None = None, - ) -> str: + ) -> ChapterClassifyResult: """ 分类到 8 个章节类别之一。 - LLM 返回 none 或启发式为零散档案时,返回 ``summary``(仍走回忆录流水线)。 + LLM 返回 none 或启发式为零散档案时,``category`` 为 ``summary``(仍可走回忆录流水线; + ``llm_said_none`` 仅在 LLM 明确返回 none 时为 True,供空转抑制判断)。 llm 需支持 .invoke(prompt) 同步调用。 """ if _looks_like_fragment_only(text): @@ -133,7 +143,10 @@ class ClassificationAgent: len(text or ""), _SUMMARY_FALLBACK_CATEGORY, ) - return _SUMMARY_FALLBACK_CATEGORY + return ChapterClassifyResult( + category=_SUMMARY_FALLBACK_CATEGORY, + llm_said_none=False, + ) if llm: try: @@ -153,14 +166,18 @@ class ClassificationAgent: len(text or ""), _SUMMARY_FALLBACK_CATEGORY, ) - return _SUMMARY_FALLBACK_CATEGORY + return ChapterClassifyResult( + category=_SUMMARY_FALLBACK_CATEGORY, + llm_said_none=True, + ) if category in CHAPTER_CATEGORIES: - return category + return ChapterClassifyResult(category=category, llm_said_none=False) except Exception as e: logger.warning("ClassificationAgent LLM 章节分类失败: {}", e) stage = _detect_stage(text, fallback_stage) - return _STAGE_TO_DEFAULT_CATEGORY.get( + cat = _STAGE_TO_DEFAULT_CATEGORY.get( stage, _STAGE_TO_DEFAULT_CATEGORY.get(fallback_stage, "childhood"), ) + return ChapterClassifyResult(category=cat, llm_said_none=False) diff --git a/api/app/agents/memoir/fidelity_check_agent.py b/api/app/agents/memoir/fidelity_check_agent.py index 886c5b7..00a6a78 100644 --- a/api/app/agents/memoir/fidelity_check_agent.py +++ b/api/app/agents/memoir/fidelity_check_agent.py @@ -54,21 +54,35 @@ class FidelityCheckAgent: return True existing = (existing_canonical_markdown or "").strip() _log_suspicious_years_not_in_oral(oral, gen) - if existing: - prompt = f"""你是事实核对员。当前为**续写合并**:模型需要把「已有故事正文」与「本轮口述」合成一篇,生成稿**允许且应当**保留已有正文中的事实(可改写语序、合并段落),并融入本轮口述中的新事实。 + pass_rules = """## 以下行为是 pass(不算编造) +- 口语转书面语(删语气词、调语序、用成语替换口语) +- 过渡句与衔接句(「那段日子」「回想起来」等,不引入新实体) +- 基于口述已有情感的渲染与书面化(如口述说「难受」,改写为「心里像堵了一团棉花」,但不能新增具体场景细节) +- 合并同义重复表述 +- 纠正明显的语音识别或同音错别字 -【用户本轮口述】(本段亲口补充) +## 以下行为是 fail(算编造) +- 新增口述中**没有**的具体人名、地名、时间、数字、对话原文 +- 补全口述未说明的结果或结局(如「最终没考上」) +- 把系统摘录/档案里才有的信息写成用户亲口经历 +- 虚构具体场景细节来「让文章更好看」""" + + if existing: + prompt = f"""你是事实核对员。当前为**续写合并**:生成稿应保留「已有故事正文」中的事实并融入「本轮口述」中的新事实。 + +【用户本轮口述】 {oral[:8000]} -【已有故事正文】(已落库、允许在生成稿中出现或改写;出现于此处的内容**不算**本轮编造) +【已有故事正文】(已落库,出现于此处的内容**不算**编造) {existing[:12000]} -【模型生成的 JSON 叙事】 +【模型生成的叙事】 {gen[:16000]} -判断:生成稿是否出现**既明显不在本轮口述、也明显不在已有故事正文**的具体人名、地名、时间、数字、事件经过、对话,或把摘录/档案里才有的信息写成了用户亲口经历? -若内容可归因于「已有故事」或「本轮口述」的合理整理,pass=true。 -若存在无法归因的明显编造或越界,pass=false。 +{pass_rules} + +判断:生成稿是否出现**既不在本轮口述、也不在已有正文**的具体新实体或虚构细节? +若内容可归因于上述两个来源的合理书面化整理,pass=true。 **JSON 输出**:只输出一个合法 JSON 对象。 {{"pass": true, "reason": null}} @@ -77,16 +91,18 @@ class FidelityCheckAgent: 只输出 JSON,不要其它文字。""" else: - prompt = f"""你是事实核对员。比较下面两段文字。 + prompt = f"""你是事实核对员。比较用户口述与模型生成的叙事。 -【用户口述】(亲历内容) +【用户口述】 {oral[:8000]} -【模型生成的 JSON 叙事】(应只含口述中已有事实的整理,不得添油加醋) +【模型生成的叙事】 {gen[:16000]} -判断:生成稿是否出现**口述中明显没有**的具体人名、地名、时间、数字、事件经过、对话,或把摘录/档案里才有的信息写成了用户亲口经历? -若存在明显编造或越界,pass=false;若仅口语转书面、删赘词、合并指代,pass=true。 +{pass_rules} + +判断:生成稿是否出现口述中**明显没有**的具体新实体或虚构细节? +若仅为口述的书面化整理(含文学性改写、情感渲染、过渡衔接),pass=true。 **JSON 输出**:只输出一个合法 JSON 对象。 {{"pass": true, "reason": null}} diff --git a/api/app/agents/memoir/narrative_agent.py b/api/app/agents/memoir/narrative_agent.py index dfe852a..2cf17cf 100644 --- a/api/app/agents/memoir/narrative_agent.py +++ b/api/app/agents/memoir/narrative_agent.py @@ -67,6 +67,7 @@ class NarrativeAgent: user_profile: str = "", birth_year: Optional[int] = None, llm: Any = None, + background_voice: str = "default", ) -> str: """将新对话改写为叙述。若无 LLM 则直接拼接。 @@ -86,6 +87,7 @@ class NarrativeAgent: existing_content=existing_content, user_profile=user_profile, birth_year=birth_year, + background_voice=background_voice, ) max_tokens = 8192 agent_name = "NarrativeAgent.generate_narrative_merge" @@ -97,6 +99,7 @@ class NarrativeAgent: existing_content=existing_content, user_profile=user_profile, birth_year=birth_year, + background_voice=background_voice, ) max_tokens = 4096 agent_name = "NarrativeAgent.generate_narrative" diff --git a/api/app/agents/memoir/orchestrator.py b/api/app/agents/memoir/orchestrator.py index 51b05a3..b3bd784 100644 --- a/api/app/agents/memoir/orchestrator.py +++ b/api/app/agents/memoir/orchestrator.py @@ -31,6 +31,8 @@ class PreparedMemoirBatches: state: MemoirStateSchema category_to_segments: Dict[str, List[Segment]] + #: segment id 在「LLM 判 none 且 extraction slots 为空」时加入;batch 级短路见 memoir_tasks + segment_skip_story_ids: Set[str] class MemoirOrchestrator: @@ -58,6 +60,7 @@ class MemoirOrchestrator: """ state = get_or_create_state() category_to_segments: Dict[str, List[Segment]] = {} + segment_skip_story_ids: Set[str] = set() for segment in segments: text = segment.user_input_text or "" @@ -87,12 +90,16 @@ class MemoirOrchestrator: "MemoirOrchestrator.ClassificationAgent.classify", segment_id=segment.id, ): - chapter_category = self.classification_agent.classify( + classify_result = self.classification_agent.classify( text=text, fallback_stage=detected_stage, llm=llm, segment_id=segment.id, ) + chapter_category = classify_result.category + if (not result.slots) and classify_result.llm_said_none: + segment_skip_story_ids.add(str(segment.id)) + if agent_summary_enabled(): logger.info( "MemoirOrchestrator.segment segment_id={} text_len={} " @@ -114,6 +121,7 @@ class MemoirOrchestrator: return PreparedMemoirBatches( state=state, category_to_segments=category_to_segments, + segment_skip_story_ids=segment_skip_story_ids, ) def run( diff --git a/api/app/agents/memoir/prompts.py b/api/app/agents/memoir/prompts.py index c62a7c4..dfbaf60 100644 --- a/api/app/agents/memoir/prompts.py +++ b/api/app/agents/memoir/prompts.py @@ -6,6 +6,12 @@ import json import re from typing import Optional +from app.agents.chat.background_voice import get_background_voice_narrative_block +from app.features.memory.evidence_format import ( + dedupe_evidence_chunk_rows, + format_evidence_chunks_for_prompt, +) + CHAPTER_CATEGORIES = { "childhood": "童年与成长背景", "education": "教育经历与青年时期", @@ -134,7 +140,7 @@ def _memoir_fidelity_core_rules() -> str: """事实边界 1–4 条(与文体第 5 条拆分,供 story 叙事与标题等复用)。""" return """## 事实边界(必须遵守,优先于文采) 1. **正文只能展开「本段用户口述」区块中的内容**。若输入中有「相关记忆摘录」等参考区,其中信息**不得**写成本人本轮亲口经历的细节;最多用一两句作主题衔接,且不得引入摘录里才有的具体人名、地点、时间、对话、数字。 -2. **禁止编造**:不得新增用户未提及的具体人物姓名、对话原文、地点、时间、事件经过、因果、数字;不得推断性心理描写或「典型年代场景」填充。 +2. **禁止编造**:不得新增用户未提及的具体人物姓名、对话原文、地点、时间、事件经过、因果、数字;不得推断性心理描写或「典型年代场景」填充。**口述未明确结果、结局或对方最终决定时**,不得用常识补全为确定断言(例如未清楚表达落选、未通过、被拒绝等,则不得写「未能被选中」「最终没有录用」等);只写已明确的过程与事实,不确定处宁可略写或使用中性表述。 3. **禁止为凑字数扩写**:材料短则输出短;段落数量与长度随材料而定。 4. 允许:去除口语赘词与寒暄、调整语序、合并重复指代、把口语改为书面语;**不得**用虚构细节「让文章更好看」。""" @@ -167,18 +173,19 @@ def get_memoir_fidelity_facts_only_prompt() -> str: def _memoir_editor_narrative_style_block() -> str: """与 `get_memoir_editor_system_prompt` 对齐的传记作家改写要点(用于写入 chapter 的 story 正文)。""" return """## 传记作家文体(须同时遵守上文「事实边界」) -你是一位专业的传记作家和文字编辑,擅长将口语化的对话内容整理成优雅的书面语回忆录章节。 +你是一位专业的传记作家和文字编辑,擅长将口语化的对话内容整理成优雅、有温度的书面语回忆录章节。 ### 提炼与筛选 对话中往往夹杂噪音,须严格筛选:保留具体事件、人物关系、时地、情感与信念、用户已提及的细节;过滤语气词、寒暄、与 AI 的交互、无关闲聊、重复冗余。 ### 改写原则 -- 保持用户的真实情感 -- 使用优雅但不失亲切的书面语,不要直接引用对话原话 -- 适当添加过渡句,使段落连贯 -- 保留生动的细节,但将口语表达改写为书面叙述 +- 保持用户的真实情感,让读者能感受到讲述者的心情 +- 使用优雅但不失亲切的书面语,不直接引用对话原话 +- 适当添加过渡句,使段落连贯流畅 +- 保留生动的细节,将口语表达改写为有画面感的书面叙述 - 去除口语中的填充词和无意义重复 - 保持时间顺序和逻辑清晰 +- **文采服务于真实**:可以有文学性的表达与恰当的情感渲染,但不得虚构新的事实来增色 ### 输出格式约束 - 使用第一人称 @@ -186,11 +193,15 @@ def _memoir_editor_narrative_style_block() -> str: - 如有「衔接上下文」,仅保持语气与时间线连贯,不重复已有段落全文""" -def get_narrative_editor_system_prompt() -> str: +def get_narrative_editor_system_prompt(background_voice: str = "default") -> str: """故事/章节叙事:传记作家式书面语 + 事实边界(chapter 直接展示 story 时使用)。""" - return f"""{get_memoir_fidelity_facts_only_prompt()} + tail = get_background_voice_narrative_block(background_voice) + base = f"""{get_memoir_fidelity_facts_only_prompt()} {_memoir_editor_narrative_style_block()}""" + if not tail: + return base + return f"{base}\n\n{tail}" def _short_classification_edit_prefix() -> str: @@ -336,7 +347,7 @@ def get_creative_title_prompt( profile_section = f"\n用户基本信息:\n{user_profile}" if user_profile else "" time_section = f"\n时间参考:{age_hint}" if age_hint else "" - return f"""{get_memoir_fidelity_system_prompt()} + return f"""{get_memoir_fidelity_facts_only_prompt()} 请根据下面「阶段、情绪、可用信息」生成 **1 个**回忆录故事标题。 @@ -346,8 +357,8 @@ def get_creative_title_prompt( 要求: 1. 格式:「时间标注 · 标题正文」(时间标注可用年龄、年代或阶段,须与上列信息一致;勿编造未出现的年份)。 -2. 标题正文 **12–18 字**,必须概括 **用户口述或 slots 中已出现的主题/事实**;**禁止**文学意象与比喻(如未提巷子/蝉鸣则不得写)。 -3. **平实**概括,不得引入口述中不存在的人、事、地、物。 +2. 标题正文 **12–18 字**,须概括用户口述或 slots 中已出现的主题/事实;可以用书面化的概括与凝练表达,但**禁止虚构**口述中不存在的人、事、地、物。 +3. 语言凝练、有回忆录感,不需要平白直叙也不需要堆砌辞藻。 只输出标题这一行文字,不要加引号或书名号。 """ @@ -384,6 +395,7 @@ def get_narrative_prompt( user_profile: str = "", birth_year: Optional[int] = None, archived_summaries: str = "", + background_voice: str = "default", ) -> str: """将新对话改写为叙述(只输出新内容的改写,不重复已有内容)""" context_tail = "" @@ -406,7 +418,7 @@ def get_narrative_prompt( age_hint = _build_age_hint(stage, birth_year) time_section = f"\n时间参考:{age_hint}" if age_hint else "" - return f"""{get_narrative_editor_system_prompt()} + return f"""{get_narrative_editor_system_prompt(background_voice=background_voice)} 阶段:{stage} 可用信息(slots,仅可复述其中已出现事实):{slots}{profile_section}{time_section} @@ -436,6 +448,7 @@ def get_narrative_json_prompt( existing_content: str = "", user_profile: str = "", birth_year: Optional[int] = None, + background_voice: str = "default", ) -> str: """将新对话改写为叙述,输出 JSON 格式(paragraphs: [{content, image_description}])""" context_tail = "" @@ -452,7 +465,7 @@ def get_narrative_json_prompt( age_hint = _build_age_hint(stage, birth_year) time_section = f"\n时间参考:{age_hint}" if age_hint else "" - return f"""{get_narrative_editor_system_prompt()} + return f"""{get_narrative_editor_system_prompt(background_voice=background_voice)} 请将「本段用户口述」改写为第一人称书面叙述,并输出 **纯 JSON**,不要包含任何其他文字或 markdown 代码块。 **JSON 输出**:接口已启用 `response_format=json_object`(与 DeepSeek JSON 模式一致),只输出一个合法 JSON 对象。 @@ -469,6 +482,7 @@ def get_narrative_json_prompt( 2. 过滤语气词、寒暄、与 AI 的交互;不重复已有故事全文;本批只写同一主题/事件链。 3. 段落数量与每段长度**随材料而定**,禁止为凑字数编造。 4. 使用第一人称、**优雅书面语**(可适当过渡与铺陈,须基于口述事实);不要直接引用原话;不要用 `#`、`##`、表格。 +5. **不推断结局**:若用户未明确说结果(是否录取、是否被选中等),不要凭常识补全为确定结论;只复述已说清楚的内容。 ## 输出格式(严格 JSON) {{ @@ -512,6 +526,7 @@ def get_narrative_merge_json_prompt( existing_content: str, user_profile: str = "", birth_year: Optional[int] = None, + background_voice: str = "default", ) -> str: """ 已有故事追加:将「已有全文(或节选)」与「本段口述」合并为**一篇**第一人称叙述, @@ -527,7 +542,7 @@ def get_narrative_merge_json_prompt( age_hint = _build_age_hint(stage, birth_year) time_section = f"\n时间参考:{age_hint}" if age_hint else "" - return f"""{get_narrative_editor_system_prompt()} + return f"""{get_narrative_editor_system_prompt(background_voice=background_voice)} 你正在**扩写并重组**一则已有回忆录故事:必须把「已有故事」中的事实全部保留在输出中(可合并重复表述、调整语序),并融入「本段用户口述」中的新事实;按**事件发生的时间顺序**排列段落(早→晚);禁止丢弃未矛盾的旧内容。 @@ -545,6 +560,7 @@ def get_narrative_merge_json_prompt( 2. **禁止编造**:不得新增用户未在「已有」或「本段」中出现的人名、地点、时间、对话、数字。 3. 若本段与旧文完全重复或无新信息,可仅输出与旧文等价重组后的正文(不得无故缩短到明显少于旧文)。 4. 使用第一人称、**优雅书面语**(与系统说明中的传记作家文体一致);不要用 `#`、`##`、表格。 +5. **不推断结局**:本段口述未明确结果时,不要用常识补全落选/未通过等确定说法,除非旧文中已有同一事实。 ## 输出格式(严格 JSON) {{ @@ -580,6 +596,8 @@ def get_story_route_prompt( **new_story_title 与 reason 只能依据口述中已有信息概括,不得编造口述未出现的人、事、地、物。** +**路由边界(必须遵守)**:仅根据下方「本批口述合并文本」判断 new_story 与 append_story;不得将系统检索摘要、记忆摘录、图谱事实或其它非用户口述材料当作本批口述内容来匹配候选故事。 + 当前章节(写作容器): - category: {chapter_category} - title: {chapter_title} @@ -673,96 +691,7 @@ def format_narrative_user_content(oral_text: str, evidence_text: str = "") -> st ) -def _normalize_evidence_line(s: str) -> str: - return re.sub(r"\s+", " ", (s or "").strip().lower()) - - -def dedupe_evidence_chunk_rows(chunks: list) -> list: - """ - 对 relevant_chunks 做稳定去重:按归一化后长度降序 + 原下标,单遍包含判定; - 复杂度 O(n log n);输出按原顺序中保留条目的相对顺序稳定。 - """ - extracted: list[tuple[int, str, object]] = [] - for i, c in enumerate(chunks): - content = ( - c.get("content", "") if isinstance(c, dict) else getattr(c, "content", "") - ) - t = (content or "").strip() - if not t: - continue - extracted.append((i, t, c)) - if len(extracted) <= 1: - return [x[2] for x in extracted] - extracted.sort( - key=lambda x: (-len(_normalize_evidence_line(x[1])), x[0]), - ) - kept_norms: list[str] = [] - kept: list[tuple[int, object]] = [] - for orig_idx, text, c in extracted: - n = _normalize_evidence_line(text) - dup = False - for kn in kept_norms: - if len(n) <= len(kn) and n in kn: - dup = True - break - if not dup: - kept_norms.append(n) - kept.append((orig_idx, c)) - kept.sort(key=lambda x: x[0]) - return [x[1] for x in kept] - - -def format_evidence_chunks_for_prompt(evidence: dict) -> str: - """将 retrieve_evidence / retrieve_evidence_sync 结果格式化为简短文本,供叙事 prompt 使用。 - - 包含 chunks、摘要(若有)、confirmed facts、timeline、故事摘要(若有)。 - """ - 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 [] - parts: list[str] = [] - for c in chunks: - content = ( - c.get("content", "") if isinstance(c, dict) else getattr(c, "content", "") - ) - if content: - parts.append(content.strip()) - for s in summaries[:3]: - if isinstance(s, dict): - st = (s.get("content") or "").strip() - stype = (s.get("summary_type") or "").strip() - if st: - label = f"[摘要:{stype}]" if stype else "[摘要]" - parts.append(f"{label} {st}") - for f in facts[:5]: - if isinstance(f, dict): - subj = f.get("subject", "") - pred = f.get("predicate", "") - obj = f.get("object_json", "") - if subj or pred: - parts.append(f"{subj} {pred} {obj}") - else: - parts.append(f"{getattr(f, 'subject', '')} {getattr(f, 'predicate', '')}") - 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 line: - parts.append(line) - for st in stories[:3]: - if isinstance(st, dict): - title = (st.get("title") or "").strip() - summ = (st.get("summary") or "").strip() - if title or summ: - parts.append(" ".join(x for x in (title, summ) if x)) - return "\n\n".join(parts) if parts else "" +# dedupe_evidence_chunk_rows / format_evidence_chunks_for_prompt 见 app.features.memory.evidence_format # 向后兼容:旧代码中的 get_system_prompt 指「回忆录编辑」系统提示,勿与访谈模块的 get_system_prompt 混淆 diff --git a/api/app/core/config.py b/api/app/core/config.py index bd7c25c..d3dde9f 100644 --- a/api/app/core/config.py +++ b/api/app/core/config.py @@ -54,20 +54,55 @@ class Settings(BaseSettings): embedding_base_url: str = "https://open.bigmodel.cn/api/paas/v4" embedding_model: str = "embedding-3" - # ── Chat 访谈(短回复:token 上限 + 代码截断,见 reply_limits)── - chat_interview_max_tokens: int = 320 + # ── Chat 访谈(token 上限 + 代码截断,见 reply_limits)── + chat_interview_max_tokens: int = 380 chat_interview_max_segments: int = 2 - chat_interview_max_chars_per_segment: int = 220 + chat_interview_max_chars_per_segment: int = 260 + # 访谈:用户本轮极短输入时的更紧上限(见 interview_reply_length) + chat_interview_brief_max_tokens: int = Field(default=260, ge=64, le=2048) + chat_interview_brief_max_chars_per_segment: int = Field(default=200, ge=32, le=2000) + # 访谈:有新细节/情绪/长段时的展开上限 + chat_interview_expanded_max_tokens: int = Field(default=520, ge=64, le=4096) + chat_interview_expanded_max_chars_per_segment: int = Field( + default=380, ge=32, le=4000 + ) + # 干部/军队推断命中时,standard 档在分桶基础上小幅放宽(brief/expanded 不变) + chat_interview_cadre_military_standard_extra_tokens: int = Field( + default=40, ge=0, le=512 + ) + chat_interview_cadre_military_standard_extra_chars: int = Field( + default=40, ge=0, le=2000 + ) chat_opening_max_tokens: int = 256 chat_profile_followup_max_tokens: int = 280 chat_era_context_enabled: bool = True # 访谈:每轮用 LLM 判定用户主人生阶段并更新 MemoirState.current_stage;False 时仅用关键词 chat_stage_detection_enabled: bool = True chat_stage_detection_max_tokens: int = 128 + # 访谈性格:default | warm_listener | curious_guide(未知值按 default) + chat_interview_persona: str = "default" + # 访谈:按用户本轮话检索记忆并注入 prompt(关则不调 MemoryService.retrieve) + 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) # ── Memoir 叙事忠实度检查(FidelityCheckAgent)──────────────── memoir_fidelity_check_enabled: bool = True memoir_fidelity_check_max_tokens: int = 512 + # 口述归一(进入叙事 / 忠实度前;segment 原文不落库):off | rules | llm + memoir_oral_normalize_enabled: bool = True + memoir_oral_normalize_mode: str = "rules" + memoir_oral_normalize_llm_max_tokens: int = Field(default=512, ge=64, le=4096) + memoir_oral_normalize_llm_max_input_chars: int = Field( + default=8000, ge=64, le=50_000 + ) + # 聊天:模型消费净稿(不改变 segment 落库原文);与 memoir 规则层共用,配置独立 + chat_input_normalize_enabled: bool = True + chat_input_normalize_mode: str = "rules" # off | rules | llm + chat_input_normalize_llm_max_tokens: int = Field(default=512, ge=64, le=4096) + chat_input_normalize_llm_max_input_chars: int = Field( + default=8000, ge=64, le=50_000 + ) # ── ASR ─────────────────────────────────────────────────── asr_provider: str = "whisper" @@ -163,9 +198,15 @@ class Settings(BaseSettings): evidence_top_k_default: int = Field(default=10, ge=1, le=50) evidence_top_k_large_batch: int = Field(default=5, ge=1, le=50) evidence_large_batch_threshold: int = Field(default=3, ge=1, le=100) - # 叙事输出相对口述过短则回退为口述原文(比例与下限) - memoir_narrative_fallback_body_ratio: float = 0.5 - memoir_narrative_fallback_min_chars: int = 20 + # 叙事输出相对口述极端过短才回退(仅防极端压缩;0.3 = 模型输出不到口述 30% 才触发) + memoir_narrative_fallback_body_ratio: float = 0.3 + memoir_narrative_fallback_min_chars: int = 15 + # 回忆录 Celery:累计 strip 后口述字数未达此值则暂缓提交(0=关闭,仅防抖后提交) + memoir_segment_batch_min_chars: int = Field(default=50, ge=0, le=50_000) + # 本批首条 segment 入队起最长等待(秒),超时则提交(即使字数不足) + memoir_segment_batch_max_wait_seconds: float = Field( + default=60.0, ge=0.0, le=3600.0 + ) # ── Memory 检索与富化 ───────────────────────────────────── # True:query 为空时仍返回 rolling 摘要 + 最近事实/时间线(无 chunk FTS) diff --git a/api/app/features/conversation/input_normalize.py b/api/app/features/conversation/input_normalize.py new file mode 100644 index 0000000..907640c --- /dev/null +++ b/api/app/features/conversation/input_normalize.py @@ -0,0 +1,98 @@ +""" +聊天输入归一:供访谈 Agent / 编排层对 ASR 与键盘输入做可控预处理(规则 / 可选 LLM)。 + +不改变 segment 落库原文;仅作为模型侧派生净稿。 +与 memoir 共用同一套确定性规则,避免聊天与回忆录对同一句理解割裂。 +""" + +from __future__ import annotations + +import json +import re +from typing import Any + +from app.core.config import settings +from app.core.langchain_llm import invoke_json_object +from app.core.logging import get_logger +from app.features.memoir.memoir_images.json_payload import extract_json_payload + +logger = get_logger(__name__) + +# 口语/ASR 常见同音:「没」误为「美」且与「看上」搭配(避免误伤「美容」「选美」等) +_MEI_KANSHANG_RE = re.compile(r"美(?=看上[我你他她它])") + + +def apply_conversation_input_rules(text: str) -> str: + """确定性规则;保守替换,仅覆盖高频误听误打模式。与 memoir 共用。""" + s = text or "" + if not s: + return s + return _MEI_KANSHANG_RE.sub("没", s) + + +def _llm_normalize_chat_input(text: str, llm: Any) -> str | None: + """仅修正明显错字与同音字,不增事实;失败返回 None。""" + if not llm or not (text or "").strip(): + return None + max_in = int(settings.chat_input_normalize_llm_max_input_chars) + t = (text or "").strip() + if len(t) > max_in: + logger.debug( + "event=chat_input_normalize_llm_skip reason=input_too_long len={} max={}", + len(t), + max_in, + ) + return None + prompt = f"""你是口述转写纠错助手。只修正明显的同音错别字、别字与标点,使句子通顺可读。 +禁止增加事实、不补充细节、不摘要、不改写句式风格;不得新增人名、地名、数字、事件。 +若原文已通顺或无法确定错误,则照抄输入。 + +【用户口述】 +{t} + +**JSON 输出**:只输出一个合法 JSON 对象。 +{{"normalized_text": "纠错后的完整文本(与输入等意,仅修错字与标点)"}} + +只输出 JSON,不要其它文字。""" + try: + raw = invoke_json_object( + llm, + prompt, + max_tokens=int(settings.chat_input_normalize_llm_max_tokens), + agent="chat_input_normalize.llm", + ) + data = json.loads(extract_json_payload(raw)) + if not isinstance(data, dict): + return None + out = (data.get("normalized_text") or "").strip() + if not out: + return None + return out + except Exception as e: + logger.warning("chat_input_normalize LLM 失败,回退规则结果: {}", e) + return None + + +def normalize_chat_input_for_agent(text: str, *, llm: Any | None = None) -> str: + """ + 聊天侧单一出口:编排层与 InterviewAgent 共用。 + + - 全局关闭:原文 + - off:原文 + - rules:仅规则 + - llm:先规则,再(可选)LLM;无 llm 或失败则保留规则结果 + """ + if not settings.chat_input_normalize_enabled: + return text or "" + mode = (settings.chat_input_normalize_mode or "rules").strip().lower() + if mode == "off": + return text or "" + + base = apply_conversation_input_rules(text or "") + if mode != "llm": + return base + + refined = _llm_normalize_chat_input(base, llm) + if refined is not None: + return refined + return base diff --git a/api/app/features/conversation/service.py b/api/app/features/conversation/service.py index 2d62a65..2f6e939 100644 --- a/api/app/features/conversation/service.py +++ b/api/app/features/conversation/service.py @@ -52,8 +52,9 @@ def _message_timestamp_ms(msg: dict, fallback: datetime | None) -> int: def _latest_message_time_ms(conversation: Conversation, history: list[dict]) -> int: - if conversation.last_message_at: - return _datetime_to_timestamp_ms(conversation.last_message_at) + last_at = getattr(conversation, "last_message_at", None) + if last_at: + return _datetime_to_timestamp_ms(last_at) if history: return _message_timestamp_ms(history[-1], conversation.started_at) return _datetime_to_timestamp_ms(conversation.started_at) @@ -154,6 +155,8 @@ class ConversationService: "avatarUrl": None, "latestMessagePreview": latest_message or conv.summary, "latestMessageTime": _latest_message_time_ms(conv, history), + # 对话「初次创建」时间(ms),供客户端按日历日区分「打个招呼 / 继续对话」 + "startedAt": _datetime_to_timestamp_ms(conv.started_at), "unreadCount": 0, "isDefaultAssistant": conv.summary is None, "hasUserMessage": has_user_message, diff --git a/api/app/features/conversation/ws/pipeline.py b/api/app/features/conversation/ws/pipeline.py index dbc624b..d127cf9 100644 --- a/api/app/features/conversation/ws/pipeline.py +++ b/api/app/features/conversation/ws/pipeline.py @@ -2,6 +2,7 @@ import asyncio import base64 +import io import time import uuid from dataclasses import dataclass, field @@ -358,6 +359,58 @@ async def _delayed_listening_feedback( await _send_segment_transition_feedback(conversation_id, 0) +# ── 长音频切片转写 ──────────────────────────────────────────── + +MAX_ASR_CHUNK_MS = 55_000 + + +def _split_audio_bytes(audio_bytes: bytes, fmt: str) -> list[bytes]: + """用 pydub 将长音频按 ≤55 s 切片,每片导出为 16 kHz mono WAV(腾讯 ASR 3 MB 限制内)。""" + from pydub import AudioSegment as PydubSegment + + audio = PydubSegment.from_file(io.BytesIO(audio_bytes), format=fmt) + duration_ms = len(audio) + + if duration_ms <= MAX_ASR_CHUNK_MS: + return [audio_bytes] + + mono_16k = audio.set_frame_rate(16000).set_channels(1).set_sample_width(2) + chunks: list[bytes] = [] + for start in range(0, duration_ms, MAX_ASR_CHUNK_MS): + chunk = mono_16k[start : start + MAX_ASR_CHUNK_MS] + buf = io.BytesIO() + chunk.export(buf, format="wav") + chunks.append(buf.getvalue()) + return chunks + + +async def _transcribe_long_audio(audio_bytes: bytes, fmt: str = "m4a") -> str: + """超过 55 s 的音频自动切片后并行 ASR;短音频直接转写。""" + asr = get_asr_provider() + try: + chunks = await asyncio.to_thread(_split_audio_bytes, audio_bytes, fmt) + except Exception as exc: + logger.warning("pydub 切片失败 ({}), 回退到直接转写", exc) + return await asr.transcribe(audio_bytes, format=fmt) + + if len(chunks) <= 1: + return await asr.transcribe(audio_bytes, format=fmt) + + logger.info("长音频切片: {} 段", len(chunks)) + results = await asyncio.gather( + *[asr.transcribe(c, format="wav") for c in chunks], + return_exceptions=True, + ) + texts: list[str] = [] + for i, r in enumerate(results): + if isinstance(r, BaseException): + logger.warning("切片 {} 转写异常: {}", i, r) + continue + if r and not _is_transcribe_failure(r): + texts.append(r) + return "".join(texts) + + # ── 分段语音异步处理 ──────────────────────────────────────────── @@ -439,9 +492,7 @@ async def process_audio_segment( conversation_id, segment_index, ) - transcript_text = await get_asr_provider().transcribe( - audio_bytes, format="m4a" - ) + transcript_text = await _transcribe_long_audio(audio_bytes, fmt="m4a") await manager.send_message( conversation_id, { @@ -513,7 +564,11 @@ async def process_audio_segment( user_message_timestamp = _mark_conversation_active(conversation) await db.commit() await db.refresh(segment) - await background_runner.queue_message(conversation.user_id, segment.id) + await background_runner.queue_message( + conversation.user_id, + segment.id, + text_char_count=len((transcript_text or "").strip()), + ) ready_segments: List[Tuple[int, str, Segment]] = [] async with state.lock: diff --git a/api/app/features/conversation/ws/router.py b/api/app/features/conversation/ws/router.py index 77c4354..4a3ef18 100644 --- a/api/app/features/conversation/ws/router.py +++ b/api/app/features/conversation/ws/router.py @@ -11,6 +11,7 @@ from datetime import datetime, timezone from fastapi import WebSocket, WebSocketDisconnect, status from starlette.websockets import WebSocketState +from app.agents.chat.background_voice import infer_background_voice from app.agents.chat.prompts_profile import format_user_profile_context from app.core.db import AsyncSessionLocal from app.core.dependencies import get_asr_provider @@ -201,6 +202,9 @@ async def websocket_endpoint( conversation_id=conversation_id, memoir_state=state, user_profile_context=user_profile_context, + background_voice=infer_background_voice( + user.occupation + ), ) ) ai_msg_id = await ConversationHistoryStore( @@ -300,7 +304,9 @@ async def websocket_endpoint( await db.commit() await db.refresh(segment) await background_runner.queue_message( - conversation.user_id, segment.id + conversation.user_id, + segment.id, + text_char_count=len(text_message.strip()), ) await process_user_message( @@ -563,7 +569,9 @@ async def websocket_endpoint( await db.commit() await db.refresh(segment) await background_runner.queue_message( - conversation.user_id, segment.id + conversation.user_id, + segment.id, + text_char_count=len((asr_text or "").strip()), ) if asr_text and not asr_text.startswith("转写失败"): diff --git a/api/app/features/memoir/background_runner.py b/api/app/features/memoir/background_runner.py index 6b1e5ee..b0331fc 100644 --- a/api/app/features/memoir/background_runner.py +++ b/api/app/features/memoir/background_runner.py @@ -2,19 +2,63 @@ from __future__ import annotations +import asyncio +import time +from dataclasses import dataclass, field from typing import Dict, List +from app.core.config import settings from app.core.logging import get_logger from app.core.task_tracker import task_tracker logger = get_logger(__name__) +def _batch_ready_for_submit( + *, + min_chars: int, + max_wait_seconds: float, + total_text_chars: int, + elapsed_seconds: float, +) -> bool: + """字数门闸开启时,静默结束后是否应提交(不含 min_chars==0 的早退,由调用方处理)。""" + if min_chars <= 0: + return True + if total_text_chars >= min_chars: + return True + if max_wait_seconds <= 0: + return True + return elapsed_seconds >= max_wait_seconds + + +def _next_retry_sleep_seconds( + debounce_seconds: float, + max_wait_seconds: float, + elapsed_seconds: float, +) -> float: + """未达字数且未超时:下次再 sleep 的秒数。""" + return min(debounce_seconds, max(0.0, max_wait_seconds - elapsed_seconds)) + + +@dataclass +class _MemoirBatchState: + segment_ids: list[str] = field(default_factory=list) + total_text_chars: int = 0 + first_queued_monotonic: float | None = None + + class BackgroundTaskRunner: def __init__(self, debounce_seconds: int = 5) -> None: self.debounce_seconds = debounce_seconds - self._pending: Dict[str, List[str]] = {} - self._timers: Dict[str, object] = {} + self._batch: Dict[str, _MemoirBatchState] = {} + self._timers: Dict[str, asyncio.Task[None]] = {} + + def _pop_batch(self, user_id: str) -> list[str]: + st = self._batch.pop(user_id, None) + if not st or not st.segment_ids: + return [] + ids = st.segment_ids + return ids async def _submit_task(self, user_id: str, segment_ids: List[str]) -> str | None: try: @@ -34,19 +78,70 @@ class BackgroundTaskRunner: logger.error("提交 Celery 任务失败: {}", e) return None - async def queue_message(self, user_id: str, segment_id: str) -> None: - import asyncio + async def queue_message( + self, user_id: str, segment_id: str, *, text_char_count: int = 0 + ) -> None: + st = self._batch.setdefault(user_id, _MemoirBatchState()) + if not st.segment_ids: + st.first_queued_monotonic = time.monotonic() + st.segment_ids.append(segment_id) + st.total_text_chars += max(0, text_char_count) - self._pending.setdefault(user_id, []).append(segment_id) if user_id in self._timers: self._timers[user_id].cancel() - async def delayed_submit(): + async def delayed_submit() -> None: try: await asyncio.sleep(self.debounce_seconds) - segment_ids = self._pending.pop(user_id, []) - if segment_ids: - await self._submit_task(user_id, segment_ids) + while True: + if user_id not in self._batch: + return + batch = self._batch.get(user_id) + if not batch or not batch.segment_ids: + return + + min_c = int(settings.memoir_segment_batch_min_chars) + max_w = float(settings.memoir_segment_batch_max_wait_seconds) + + if min_c <= 0: + segment_ids = self._pop_batch(user_id) + if segment_ids: + await self._submit_task(user_id, segment_ids) + return + + first = batch.first_queued_monotonic + if first is None: + segment_ids = self._pop_batch(user_id) + if segment_ids: + await self._submit_task(user_id, segment_ids) + return + + now = time.monotonic() + elapsed = now - first + total = batch.total_text_chars + + if _batch_ready_for_submit( + min_chars=min_c, + max_wait_seconds=max_w, + total_text_chars=total, + elapsed_seconds=elapsed, + ): + segment_ids = self._pop_batch(user_id) + if segment_ids: + await self._submit_task(user_id, segment_ids) + return + + sleep_more = _next_retry_sleep_seconds( + float(self.debounce_seconds), + max_w, + elapsed, + ) + if sleep_more <= 0: + segment_ids = self._pop_batch(user_id) + if segment_ids: + await self._submit_task(user_id, segment_ids) + return + await asyncio.sleep(sleep_more) except asyncio.CancelledError: pass except Exception as e: @@ -58,7 +153,7 @@ class BackgroundTaskRunner: if user_id in self._timers: self._timers[user_id].cancel() del self._timers[user_id] - segment_ids = self._pending.pop(user_id, []) + segment_ids = self._pop_batch(user_id) if segment_ids: return await self._submit_task(user_id, segment_ids) return None diff --git a/api/app/features/memoir/oral_normalize.py b/api/app/features/memoir/oral_normalize.py new file mode 100644 index 0000000..9fdc300 --- /dev/null +++ b/api/app/features/memoir/oral_normalize.py @@ -0,0 +1,92 @@ +""" +口述归一:在进入叙事与忠实度校验前,对同一段文本做可控预处理(规则 / 可选 LLM)。 + +不改变 segment 落库原文;仅作为 memoir story 生成路径的派生输入。 + +规则层与聊天侧共用 `apply_conversation_input_rules`(见 conversation.input_normalize)。 +""" + +from __future__ import annotations + +import json +from typing import Any + +from app.core.config import settings +from app.core.langchain_llm import invoke_json_object +from app.core.logging import get_logger +from app.features.conversation.input_normalize import apply_conversation_input_rules +from app.features.memoir.memoir_images.json_payload import extract_json_payload + +logger = get_logger(__name__) + + +def apply_oral_normalization_rules(text: str) -> str: + """确定性规则;与 `apply_conversation_input_rules` 等价(memoir 历史名保留)。""" + return apply_conversation_input_rules(text) + + +def _llm_normalize_oral(text: str, llm: Any) -> str | None: + """仅修正明显错字与同音字,不增事实;失败返回 None。""" + if not llm or not (text or "").strip(): + return None + max_in = int(settings.memoir_oral_normalize_llm_max_input_chars) + t = (text or "").strip() + if len(t) > max_in: + logger.debug( + "event=oral_normalize_llm_skip reason=input_too_long len={} max={}", + len(t), + max_in, + ) + return None + prompt = f"""你是口述转写纠错助手。只修正明显的同音错别字、别字与标点,使句子通顺可读。 +禁止增加事实、不补充细节、不摘要、不改写句式风格;不得新增人名、地名、数字、事件。 +若原文已通顺或无法确定错误,则照抄输入。 + +【用户口述】 +{t} + +**JSON 输出**:只输出一个合法 JSON 对象。 +{{"normalized_text": "纠错后的完整文本(与输入等意,仅修错字与标点)"}} + +只输出 JSON,不要其它文字。""" + try: + raw = invoke_json_object( + llm, + prompt, + max_tokens=int(settings.memoir_oral_normalize_llm_max_tokens), + agent="oral_normalize.llm", + ) + data = json.loads(extract_json_payload(raw)) + if not isinstance(data, dict): + return None + out = (data.get("normalized_text") or "").strip() + if not out: + return None + return out + except Exception as e: + logger.warning("oral_normalize LLM 失败,回退规则结果: {}", e) + return None + + +def normalize_oral_for_memoir(text: str, *, llm: Any | None = None) -> str: + """ + 供 story pipeline 单一出口:叙事与忠实度使用同一返回值。 + + - off / 全局关闭:原文 + - rules:仅规则 + - rules + LLM 分支:先规则,再(可选)LLM;LLM 失败则保留规则结果 + """ + if not settings.memoir_oral_normalize_enabled: + return text or "" + mode = (settings.memoir_oral_normalize_mode or "rules").strip().lower() + if mode == "off": + return text or "" + + base = apply_oral_normalization_rules(text or "") + if mode != "llm": + return base + + refined = _llm_normalize_oral(base, llm) + if refined is not None: + return refined + return base diff --git a/api/app/features/memoir/story_pipeline_sync.py b/api/app/features/memoir/story_pipeline_sync.py index 97e1a5a..93ee30e 100644 --- a/api/app/features/memoir/story_pipeline_sync.py +++ b/api/app/features/memoir/story_pipeline_sync.py @@ -32,6 +32,10 @@ from app.features.memoir.cover_eligibility import chapter_needs_cover_enqueue from app.features.memoir.memoir_images.settings import MemoirImageSettings from app.features.memoir.models import Chapter from app.features.memoir.narrative_to_markdown import narrative_to_markdown +from app.features.memoir.oral_normalize import ( + apply_oral_normalization_rules, + normalize_oral_for_memoir, +) from app.features.memoir.repo import ( mark_chapter_dirty_sync, reorder_chapter_story_links_by_life_order_sync, @@ -49,6 +53,23 @@ from app.features.story.sync_write import ( logger = get_logger(__name__) +def _route_segment_texts(category_segments: list) -> list[tuple[str, str]]: + """批量路由 plan_batch:每段仅做规则归一,避免 N 次 LLM。""" + out: list[tuple[str, str]] = [] + for seg in category_segments: + raw = seg.user_input_text or "" + if ( + settings.memoir_oral_normalize_enabled + and (settings.memoir_oral_normalize_mode or "rules").strip().lower() + != "off" + ): + t = apply_oral_normalization_rules(raw) + else: + t = raw + out.append((str(seg.id), t)) + return out + + def _fidelity_fallback_json(oral: str, existing_canonical: str | None) -> str: """忠实度未通过时的安全回退:续写场景保留旧文 + 本段口述,避免只剩一句。""" o = (oral or "").strip()[:15000] @@ -102,7 +123,7 @@ def _gate_narrative_fidelity( def _should_fallback_to_transcript(md: str, oral: str) -> bool: - """模型输出相对口述明显过短时回退为口述原文(防「1999」类压缩)。""" + """模型输出相对口述极度过短时才回退(仅防极端压缩如「1999」)。""" o = (oral or "").strip() if not o: return False @@ -165,7 +186,7 @@ def _apply_narrative_fallbacks( if existing_for_narrative and _is_json_narrative(narrative_raw): merged_md = narrative_to_markdown(narrative_raw).strip() ex = (existing_for_narrative or "").strip() - if ex and len(ex) > 400 and len(merged_md) < len(ex) * 0.35: + if ex and len(ex) > 400 and len(merged_md) < len(ex) * 0.25: logger.warning( "event=narrative_fallback reason=merge_shrink action=append_oral " "chapter_category={}", @@ -176,7 +197,7 @@ def _apply_narrative_fallbacks( if ( existing_for_narrative and not _is_json_narrative(narrative_raw) - and len(narrative_raw) < len(existing_for_narrative) * 0.8 + and len(narrative_raw) < len(existing_for_narrative) * 0.5 ): logger.warning( "event=narrative_fallback reason=length_anomaly action=append_raw " @@ -290,6 +311,7 @@ def _run_batch_plan_writes( user_birth_year: int | None, llm: Any, narrative_agent: NarrativeAgent, + background_voice: str = "default", ) -> set[str]: dispatch_ids: set[str] = set() max_chars = int(settings.story_append_max_canonical_chars) @@ -297,7 +319,16 @@ def _run_batch_plan_writes( for unit in plan.units: t0 = time.perf_counter() unit_text = _ordered_text_for_segment_ids(category_segments, unit.segment_ids) - new_content_input = format_narrative_user_content(unit_text, evidence_text) + oral_unit = normalize_oral_for_memoir(unit_text, llm=llm) + ut_raw = (unit_text or "").strip() + ut_norm = (oral_unit or "").strip() + if ut_raw != ut_norm: + logger.info( + "event=oral_normalized context=batch_unit raw_len={} norm_len={}", + len(ut_raw), + len(ut_norm), + ) + new_content_input = format_narrative_user_content(oral_unit, evidence_text) target_story_id: str | None = None existing_for_narrative = "" @@ -330,6 +361,7 @@ def _run_batch_plan_writes( user_profile=user_profile, birth_year=user_birth_year, llm=llm, + background_voice=background_voice, ) json_invalid = False s0 = (raw_gen or "").strip() @@ -340,14 +372,14 @@ def _run_batch_plan_writes( json_invalid = True narrative_raw, fb_gate = _gate_narrative_fidelity( - unit_text, + oral_unit, raw_gen, llm, existing_canonical=existing_for_narrative or None, ) narrative_raw, fb_apply = _apply_narrative_fallbacks( narrative_raw, - unit_text, + oral_unit, existing_for_narrative, chapter_category=chapter_category, ) @@ -357,7 +389,7 @@ def _run_batch_plan_writes( md = _coalesce_story_markdown( narrative_to_markdown(narrative_raw).strip(), - unit_text.strip(), + oral_unit.strip(), existing_for_narrative or "", ) @@ -399,7 +431,7 @@ def _run_batch_plan_writes( "event=story_generated route_type=batch decision_source={} route_decision={} " "unit_segments={} used_evidence={} narrative_json_valid={} fidelity_passed={} " "fallback_type={} oral_len={} md_len={} chapter_category={} is_append={} " - "story_id={} seconds={:.3f}", + "story_id={} seconds={:.3f} oral_normalize_changed={}", decision_source, unit.decision, len(unit.segment_ids), @@ -407,12 +439,13 @@ def _run_batch_plan_writes( _is_json_narrative(raw_gen), fb_gate == "none", fallback_type, - len(unit_text.strip()), + len(ut_norm), len(md.strip()), chapter_category, is_append, sid_log, elapsed, + ut_raw != ut_norm, ) return dispatch_ids @@ -427,6 +460,7 @@ def run_story_pipeline_for_category_batch( user_profile: str, user_birth_year: int | None, llm: Any, + background_voice: str = "default", ) -> tuple[Chapter | None, bool, set[str]]: """ 返回 (chapter, needs_cover_enqueue, story_ids_to_dispatch_after_commit)。 @@ -456,7 +490,16 @@ def run_story_pipeline_for_category_batch( } evidence_text = format_evidence_chunks_for_prompt(evidence) - new_content_input = format_narrative_user_content(combined_text, evidence_text) + oral_for_memoir = normalize_oral_for_memoir(combined_text, llm=llm) + ct_raw = (combined_text or "").strip() + om_norm = (oral_for_memoir or "").strip() + if ct_raw != om_norm: + logger.info( + "event=oral_normalized context=category_batch raw_len={} norm_len={}", + len(ct_raw), + len(om_norm), + ) + new_content_input = format_narrative_user_content(oral_for_memoir, evidence_text) stmt_chapter = ( select(Chapter) @@ -493,15 +536,14 @@ def run_story_pipeline_for_category_batch( llm=llm, ) - candidates = list_active_stories_for_user_sync(session, user_id) + # 仅同 chapter_category(story.stage)的 Story 可作为 append 候选,避免跨章节链接导致多章内容相同 + all_stories = list_active_stories_for_user_sync(session, user_id) + candidates = [s for s in all_stories if s.stage == chapter_category] valid_ids = {str(s.id) for s in candidates} story_meta = _story_meta_for_route(session, candidates) - batch_for_route = ( - f"{combined_text}\n\n{evidence_text}" - if evidence_text.strip() - else combined_text - ) + # Story route 仅依据本批用户口述;evidence 只进入叙事/合并,不参与 new/append 判定。 + route_transcript = oral_for_memoir calculated_order_index = STAGE_TO_ORDER.get(chapter_category, 999) @@ -512,7 +554,7 @@ def run_story_pipeline_for_category_batch( ) plan: StoryBatchPlan | None = None if use_batch_plan: - segs = [(seg.id, seg.user_input_text or "") for seg in category_segments] + segs = _route_segment_texts(category_segments) plan = route_agent.plan_batch( chapter_category=chapter_category, chapter_title=title, @@ -546,12 +588,13 @@ def run_story_pipeline_for_category_batch( user_birth_year=user_birth_year, llm=llm, narrative_agent=narrative_agent, + background_voice=background_voice, ) else: route = route_agent.decide( chapter_category=chapter_category, chapter_title=title, - batch_transcript=batch_for_route, + batch_transcript=route_transcript, candidate_stories=candidates, llm=llm, valid_story_ids=valid_ids, @@ -592,6 +635,7 @@ def run_story_pipeline_for_category_batch( user_profile=user_profile, birth_year=user_birth_year, llm=llm, + background_voice=background_voice, ) json_invalid = False s0 = (raw_gen or "").strip() @@ -602,7 +646,7 @@ def run_story_pipeline_for_category_batch( json_invalid = True narrative_raw, fb_gate = _gate_narrative_fidelity( - combined_text, + oral_for_memoir, raw_gen, llm, existing_canonical=existing_for_narrative or None, @@ -610,7 +654,7 @@ def run_story_pipeline_for_category_batch( narrative_raw, fb_apply = _apply_narrative_fallbacks( narrative_raw, - combined_text, + oral_for_memoir, existing_for_narrative, chapter_category=chapter_category, ) @@ -620,7 +664,7 @@ def run_story_pipeline_for_category_batch( md = _coalesce_story_markdown( narrative_to_markdown(narrative_raw).strip(), - combined_text.strip(), + oral_for_memoir.strip(), existing_for_narrative or "", ) @@ -664,7 +708,7 @@ def run_story_pipeline_for_category_batch( "event=story_generated route_type=single decision_source={} route_decision={} " "unit_segments={} used_evidence={} narrative_json_valid={} fidelity_passed={} " "fallback_type={} oral_len={} md_len={} chapter_category={} is_append={} " - "story_id={} seconds={:.3f}", + "story_id={} seconds={:.3f} oral_normalize_changed={}", decision_source, route.decision, len(category_segments), @@ -672,12 +716,13 @@ def run_story_pipeline_for_category_batch( _is_json_narrative(raw_gen), fb_gate == "none", fallback_type, - len(combined_text.strip()), + len(om_norm), len(md.strip()), chapter_category, is_append, sid_log, elapsed, + ct_raw != om_norm, ) reorder_chapter_story_links_by_life_order_sync(session, str(chapter.id)) diff --git a/api/app/features/memory/evidence_format.py b/api/app/features/memory/evidence_format.py new file mode 100644 index 0000000..8a66dcf --- /dev/null +++ b/api/app/features/memory/evidence_format.py @@ -0,0 +1,99 @@ +""" +将 MemoryService.retrieve / evidence bundle 格式化为 prompt 用短文本(叙事与访谈共用)。 +""" + +from __future__ import annotations + +import re + + +def _normalize_evidence_line(s: str) -> str: + return re.sub(r"\s+", " ", (s or "").strip().lower()) + + +def dedupe_evidence_chunk_rows(chunks: list) -> list: + """ + 对 relevant_chunks 做稳定去重:按归一化后长度降序 + 原下标,单遍包含判定; + 复杂度 O(n log n);输出按原顺序中保留条目的相对顺序稳定。 + """ + extracted: list[tuple[int, str, object]] = [] + for i, c in enumerate(chunks): + content = ( + c.get("content", "") if isinstance(c, dict) else getattr(c, "content", "") + ) + t = (content or "").strip() + if not t: + continue + extracted.append((i, t, c)) + if len(extracted) <= 1: + return [x[2] for x in extracted] + extracted.sort( + key=lambda x: (-len(_normalize_evidence_line(x[1])), x[0]), + ) + kept_norms: list[str] = [] + kept: list[tuple[int, object]] = [] + for orig_idx, text, c in extracted: + n = _normalize_evidence_line(text) + dup = False + for kn in kept_norms: + if len(n) <= len(kn) and n in kn: + dup = True + break + if not dup: + kept_norms.append(n) + kept.append((orig_idx, c)) + kept.sort(key=lambda x: x[0]) + return [x[1] for x in kept] + + +def format_evidence_chunks_for_prompt(evidence: dict) -> str: + """将 retrieve_evidence / retrieve_evidence_sync 结果格式化为简短文本,供叙事与访谈 prompt 使用。 + + 包含 chunks、摘要(若有)、confirmed facts、timeline、故事摘要(若有)。 + """ + 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 [] + parts: list[str] = [] + for c in chunks: + content = ( + c.get("content", "") if isinstance(c, dict) else getattr(c, "content", "") + ) + if content: + parts.append(content.strip()) + for s in summaries[:3]: + if isinstance(s, dict): + st = (s.get("content") or "").strip() + stype = (s.get("summary_type") or "").strip() + if st: + label = f"[摘要:{stype}]" if stype else "[摘要]" + parts.append(f"{label} {st}") + for f in facts[:5]: + if isinstance(f, dict): + subj = f.get("subject", "") + pred = f.get("predicate", "") + obj = f.get("object_json", "") + if subj or pred: + parts.append(f"{subj} {pred} {obj}") + else: + parts.append(f"{getattr(f, 'subject', '')} {getattr(f, 'predicate', '')}") + 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 line: + parts.append(line) + for st in stories[:3]: + if isinstance(st, dict): + title = (st.get("title") or "").strip() + summ = (st.get("summary") or "").strip() + if title or summ: + parts.append(" ".join(x for x in (title, summ) if x)) + return "\n\n".join(parts) if parts else "" diff --git a/api/app/features/memory/repo.py b/api/app/features/memory/repo.py index 8c8aa3b..198e733 100644 --- a/api/app/features/memory/repo.py +++ b/api/app/features/memory/repo.py @@ -349,11 +349,11 @@ async def search_chunks_vector( # pgvector cosine distance: 1 - cosine_similarity, lower is better stmt = text(""" SELECT id, content, chunk_index, - (embedding <=> :emb::vector) AS distance + (embedding <=> CAST(:emb AS vector)) AS distance FROM memory_chunks WHERE user_id = :user_id AND (is_excluded IS NOT TRUE OR is_excluded = false) AND embedding IS NOT NULL - ORDER BY embedding <=> :emb2::vector + ORDER BY embedding <=> CAST(:emb2 AS vector) LIMIT :lim """) emb_str = "[" + ",".join(str(x) for x in query_embedding) + "]" @@ -838,14 +838,14 @@ def search_nearest_chunks_for_compaction_sync( stmt = text(""" SELECT mc.id, mc.content, mc.source_id, mc.event_year, mc.metadata_json, ms.source_type, mc.created_at, - (mc.embedding <=> :emb::vector) AS distance + (mc.embedding <=> CAST(:emb AS vector)) AS distance FROM memory_chunks mc JOIN memory_sources ms ON ms.id = mc.source_id WHERE mc.user_id = :user_id AND (mc.is_excluded IS NOT TRUE OR mc.is_excluded = false) AND mc.embedding IS NOT NULL AND mc.id != :chunk_id - ORDER BY mc.embedding <=> :emb2::vector + ORDER BY mc.embedding <=> CAST(:emb2 AS vector) LIMIT :lim """) emb_str = "[" + ",".join(str(x) for x in query_embedding) + "]" diff --git a/api/app/tasks/memoir_tasks.py b/api/app/tasks/memoir_tasks.py index 60cc978..06fd9b8 100644 --- a/api/app/tasks/memoir_tasks.py +++ b/api/app/tasks/memoir_tasks.py @@ -12,6 +12,7 @@ from celery import shared_task from sqlalchemy import select from sqlalchemy.orm import Session +from app.agents.chat.background_voice import infer_background_voice from app.agents.chat.prompts_profile import format_user_profile_context from app.agents.memoir import MemoirOrchestrator from app.agents.state_schema import MemoirStateSchema, SlotData, default_state @@ -312,6 +313,7 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]): user_obj = db.get(User, user_id) user_profile = "" user_birth_year = None + background_voice = "default" if user_obj: user_birth_year = user_obj.birth_year user_profile = format_user_profile_context( @@ -320,6 +322,7 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]): grew_up_place=user_obj.grew_up_place, occupation=user_obj.occupation, ) + background_voice = infer_background_voice(user_obj.occupation) story_dispatch_ids: Set[str] = set() @@ -349,6 +352,26 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]): ) raise self.retry(countdown=10) try: + batch_ids = {str(s.id) for s in category_segments} + skip_ids = prepared.segment_skip_story_ids + in_skip = batch_ids & skip_ids + if in_skip: + logger.info( + "event=memoir_skip_story_signal chapter_category={} " + "segment_ids_in_skip_set={}", + chapter_category, + sorted(in_skip), + ) + + if batch_ids and batch_ids <= skip_ids: + logger.info( + "event=story_pipeline_skipped reason=no_substantive_after_none " + "chapter_category={} segment_ids={}", + chapter_category, + sorted(batch_ids), + ) + continue + chapter, needs_cover, disp = run_story_pipeline_for_category_batch( db, user_id=user_id, @@ -358,6 +381,7 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]): user_profile=user_profile, user_birth_year=user_birth_year, llm=llm, + background_voice=background_voice, ) story_dispatch_ids |= disp db.flush() @@ -487,6 +511,7 @@ def generate_chapter_content(self, user_id: str, stage: str, new_content: str): user_obj = db.get(User, user_id) user_profile = "" user_birth_year = None + background_voice = "default" if user_obj: user_birth_year = user_obj.birth_year user_profile = format_user_profile_context( @@ -495,6 +520,7 @@ def generate_chapter_content(self, user_id: str, stage: str, new_content: str): grew_up_place=user_obj.grew_up_place, occupation=user_obj.occupation, ) + background_voice = infer_background_voice(user_obj.occupation) class _Seg: def __init__(self, text: str): @@ -511,6 +537,7 @@ def generate_chapter_content(self, user_id: str, stage: str, new_content: str): user_profile=user_profile, user_birth_year=user_birth_year, llm=llm, + background_voice=background_voice, ) db.commit() db.refresh(chapter) diff --git a/api/pyproject.toml b/api/pyproject.toml index ef09069..0784ce8 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "psycopg[binary]>=3.2.0", "pydantic>=2.12.5", "pydantic-settings>=2.13.1", + "pydub>=0.25.1", "pyjwt>=2.12.0", "python-alipay-sdk>=3.4.0", "redis>=6.4.0", diff --git a/api/tests/test_background_runner.py b/api/tests/test_background_runner.py new file mode 100644 index 0000000..24a2868 --- /dev/null +++ b/api/tests/test_background_runner.py @@ -0,0 +1,165 @@ +"""BackgroundTaskRunner:字数门闸、超时、flush(纯函数 + 异步 mock)。""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest + +from app.features.memoir import background_runner as br + + +def test_batch_ready_for_submit_min_chars_zero() -> None: + assert br._batch_ready_for_submit( + min_chars=0, + max_wait_seconds=60.0, + total_text_chars=0, + elapsed_seconds=0.0, + ) + + +def test_batch_ready_for_submit_chars_met() -> None: + assert br._batch_ready_for_submit( + min_chars=50, + max_wait_seconds=60.0, + total_text_chars=50, + elapsed_seconds=1.0, + ) + + +def test_batch_ready_for_submit_not_ready() -> None: + assert not br._batch_ready_for_submit( + min_chars=50, + max_wait_seconds=60.0, + total_text_chars=10, + elapsed_seconds=5.0, + ) + + +def test_batch_ready_for_submit_max_wait_elapsed() -> None: + assert br._batch_ready_for_submit( + min_chars=50, + max_wait_seconds=60.0, + total_text_chars=10, + elapsed_seconds=60.0, + ) + + +def test_next_retry_sleep_seconds() -> None: + assert br._next_retry_sleep_seconds(5.0, 60.0, 1.0) == 5.0 + assert br._next_retry_sleep_seconds(5.0, 60.0, 58.0) == 2.0 + assert br._next_retry_sleep_seconds(5.0, 60.0, 60.0) == 0.0 + + +@pytest.mark.asyncio +async def test_flush_pending_submits_without_gate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(br.settings, "memoir_segment_batch_min_chars", 9999) + monkeypatch.setattr(br.settings, "memoir_segment_batch_max_wait_seconds", 9999.0) + + submitted: list[tuple[str, list[str]]] = [] + + async def fake_submit(uid: str, ids: list[str]) -> str: + submitted.append((uid, ids)) + return "tid" + + runner = br.BackgroundTaskRunner(debounce_seconds=30) + uid = "u1" + runner._batch[uid] = br._MemoirBatchState( + segment_ids=["s1", "s2"], + total_text_chars=3, + first_queued_monotonic=0.0, + ) + + with patch.object(runner, "_submit_task", new=AsyncMock(side_effect=fake_submit)): + await runner.flush_pending(uid) + + assert submitted == [("u1", ["s1", "s2"])] + assert uid not in runner._batch + + +@pytest.mark.asyncio +async def test_queue_message_min_chars_zero_submits_after_debounce( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(br.settings, "memoir_segment_batch_min_chars", 0) + monkeypatch.setattr(br.settings, "memoir_segment_batch_max_wait_seconds", 60.0) + + submitted: list[tuple[str, list[str]]] = [] + + async def fake_submit(uid: str, ids: list[str]) -> str: + submitted.append((uid, ids)) + return "tid" + + runner = br.BackgroundTaskRunner(debounce_seconds=0) + with patch.object(runner, "_submit_task", new=AsyncMock(side_effect=fake_submit)): + await runner.queue_message("u1", "seg-a", text_char_count=0) + await asyncio.sleep(0.05) + + assert submitted and submitted[0][1] == ["seg-a"] + + +@pytest.mark.asyncio +async def test_queue_message_not_ready_then_max_wait_submits( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(br.settings, "memoir_segment_batch_min_chars", 100) + monkeypatch.setattr(br.settings, "memoir_segment_batch_max_wait_seconds", 0.12) + + submitted: list[tuple[str, list[str]]] = [] + + async def fake_submit(uid: str, ids: list[str]) -> str: + submitted.append((uid, ids)) + return "tid" + + # debounce 须 >0,否则 retry sleep 为 0 会误走「立即提交」分支 + runner = br.BackgroundTaskRunner(debounce_seconds=0.02) + with patch.object(runner, "_submit_task", new=AsyncMock(side_effect=fake_submit)): + await runner.queue_message("u1", "seg-a", text_char_count=5) + await asyncio.sleep(0.2) + + assert submitted and submitted[0][1] == ["seg-a"] + + +@pytest.mark.asyncio +async def test_queue_message_not_ready_before_debounce_no_submit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(br.settings, "memoir_segment_batch_min_chars", 100) + monkeypatch.setattr(br.settings, "memoir_segment_batch_max_wait_seconds", 60.0) + + submitted: list[tuple[str, list[str]]] = [] + + async def fake_submit(uid: str, ids: list[str]) -> str: + submitted.append((uid, ids)) + return "tid" + + runner = br.BackgroundTaskRunner(debounce_seconds=0.5) + with patch.object(runner, "_submit_task", new=AsyncMock(side_effect=fake_submit)): + await runner.queue_message("u1", "seg-a", text_char_count=5) + await asyncio.sleep(0.05) + + assert submitted == [] + + +@pytest.mark.asyncio +async def test_queue_message_chars_met_submits_after_debounce( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(br.settings, "memoir_segment_batch_min_chars", 10) + monkeypatch.setattr(br.settings, "memoir_segment_batch_max_wait_seconds", 60.0) + + submitted: list[tuple[str, list[str]]] = [] + + async def fake_submit(uid: str, ids: list[str]) -> str: + submitted.append((uid, ids)) + return "tid" + + runner = br.BackgroundTaskRunner(debounce_seconds=0) + with patch.object(runner, "_submit_task", new=AsyncMock(side_effect=fake_submit)): + await runner.queue_message("u1", "seg-long", text_char_count=50) + await asyncio.sleep(0.05) + + assert submitted and submitted[0][1] == ["seg-long"] diff --git a/api/tests/test_background_voice.py b/api/tests/test_background_voice.py new file mode 100644 index 0000000..5764e84 --- /dev/null +++ b/api/tests/test_background_voice.py @@ -0,0 +1,40 @@ +"""职业文本推断 background_voice(干部/军队)。""" + +from app.agents.chat.background_voice import ( + infer_background_voice, + normalize_background_voice, +) + + +def test_infer_military_before_cadre() -> None: + assert infer_background_voice("机关文职干部") == "military" + + +def test_infer_military_keywords() -> None: + assert infer_background_voice("退伍军人") == "military" + assert infer_background_voice("陆军某部") == "military" + + +def test_infer_cadre_keywords() -> None: + assert infer_background_voice("公务员") == "cadre" + assert infer_background_voice("某局科长") == "cadre" + + +def test_infer_default() -> None: + assert infer_background_voice(None) == "default" + assert infer_background_voice("") == "default" + assert infer_background_voice("中学教师") == "default" + + +def test_normalize_accepts_enum_strings() -> None: + assert normalize_background_voice("military") == "military" + assert normalize_background_voice("cadre") == "cadre" + + +def test_narrative_editor_system_prompt_appends_voice() -> None: + from app.agents.memoir.prompts import get_narrative_editor_system_prompt + + base = get_narrative_editor_system_prompt("default") + mil = get_narrative_editor_system_prompt("military") + assert len(mil) > len(base) + assert "背景文体(军队" in mil diff --git a/api/tests/test_chat_input_normalize.py b/api/tests/test_chat_input_normalize.py new file mode 100644 index 0000000..8a1ff2c --- /dev/null +++ b/api/tests/test_chat_input_normalize.py @@ -0,0 +1,39 @@ +"""聊天输入归一:与 memoir 规则共用,配置独立。""" + +from unittest.mock import patch + +from app.features.conversation.input_normalize import ( + apply_conversation_input_rules, + normalize_chat_input_for_agent, +) + + +def test_apply_conversation_rules_matches_memoir_mei_kanshang() -> None: + raw = "我去试镜了 美看上我 张伟" + assert "没看上我" in apply_conversation_input_rules(raw) + + +def test_normalize_chat_rules_mode() -> None: + raw = "美看上我" + with patch("app.features.conversation.input_normalize.settings") as m: + m.chat_input_normalize_enabled = True + m.chat_input_normalize_mode = "rules" + m.chat_input_normalize_llm_max_tokens = 512 + m.chat_input_normalize_llm_max_input_chars = 8000 + assert normalize_chat_input_for_agent(raw, llm=None) == "没看上我" + + +def test_normalize_chat_disabled_returns_raw() -> None: + raw = "美看上我" + with patch("app.features.conversation.input_normalize.settings") as m: + m.chat_input_normalize_enabled = False + m.chat_input_normalize_mode = "rules" + assert normalize_chat_input_for_agent(raw, llm=None) == raw + + +def test_normalize_chat_off_mode() -> None: + raw = "美看上我" + with patch("app.features.conversation.input_normalize.settings") as m: + m.chat_input_normalize_enabled = True + m.chat_input_normalize_mode = "off" + assert normalize_chat_input_for_agent(raw, llm=None) == raw diff --git a/api/tests/test_classification_fragment.py b/api/tests/test_classification_fragment.py index 1d7dc36..cf1f11f 100644 --- a/api/tests/test_classification_fragment.py +++ b/api/tests/test_classification_fragment.py @@ -30,9 +30,9 @@ def test_looks_like_fragment_only(text: str, expected_fragment: bool) -> None: def test_classify_maps_birth_year_fragment_to_summary_without_llm() -> None: agent = ClassificationAgent() - assert ( - agent.classify("1999年出生", fallback_stage="childhood", llm=None) == "summary" - ) + result = agent.classify("1999年出生", fallback_stage="childhood", llm=None) + assert result.category == "summary" + assert result.llm_said_none is False @pytest.mark.parametrize( @@ -55,4 +55,5 @@ def test_classify_fallback_when_no_llm_and_narrative_snippet() -> None: fallback_stage="childhood", llm=None, ) - assert out == "education" + assert out.category == "education" + assert out.llm_said_none is False diff --git a/api/tests/test_experience_regressions.py b/api/tests/test_experience_regressions.py new file mode 100644 index 0000000..23045cc --- /dev/null +++ b/api/tests/test_experience_regressions.py @@ -0,0 +1,208 @@ +"""面向体验的回归测试:保护"聊得下去"与"回忆录有文笔"两个核心目标。 + +与 test_interview_prompts / test_interview_reply_length 不同,这组测试不验证字面规则, +而是验证体验目标的必要条件是否成立。改 agent 后如果这里挂了,说明体验方向可能在退步。 +""" + +from types import SimpleNamespace + +import pytest + +from app.agents.chat.interview_reply_length import ( + ReplyLengthMode, + compute_reply_plan, + heuristic_likely_emotional, + heuristic_likely_new_detail, +) +from app.agents.chat.prompts_conversation import ( + get_guided_conversation_prompt, + get_opening_prompt, +) +from app.agents.memoir.prompts import ( + get_creative_title_json_prompt, + get_narrative_editor_system_prompt, + get_narrative_json_prompt, +) +from app.features.memoir import story_pipeline_sync as sps + + +def _fake_settings(**overrides: object) -> SimpleNamespace: + base = { + "chat_interview_max_tokens": 380, + "chat_interview_max_segments": 2, + "chat_interview_max_chars_per_segment": 260, + "chat_interview_brief_max_tokens": 260, + "chat_interview_brief_max_chars_per_segment": 200, + "chat_interview_expanded_max_tokens": 520, + "chat_interview_expanded_max_chars_per_segment": 380, + } + base.update(overrides) + return SimpleNamespace(**base) + + +# ── 聊天体验回归 ────────────────────────────────────────────────── + + +class TestChatExperienceRegressions: + """保护"聊得下去"体验。""" + + def test_emotional_short_message_not_brief(self) -> None: + """用户表达强情绪时不应压成 brief,要给模型足够空间承接情绪。""" + p = compute_reply_plan( + "我妈走了以后,我真的很难过", + background_voice=None, + settings=_fake_settings(), + ) + assert p.mode != ReplyLengthMode.brief + assert heuristic_likely_emotional("我妈走了以后,我真的很难过") is True + + def test_emotional_medium_message_gets_expanded(self) -> None: + """中等长度且有情绪的消息应该给 expanded 档位,让模型有空间好好共情。""" + msg = "那年我奶奶去世的时候,我在外地上学,没来得及见最后一面,到现在想起来还是特别难过" + assert len(msg) >= 40 + p = compute_reply_plan(msg, background_voice=None, settings=_fake_settings()) + assert p.mode == ReplyLengthMode.expanded + + def test_new_detail_triggers_followup_hint_in_prompt(self) -> None: + """用户提到新人名/新关系时,prompt 应明确要求追问(而不是只感慨)。""" + p = get_guided_conversation_prompt( + current_stage="childhood", + empty_slots=["place", "people"], + filled_slots={}, + user_message="那个女生叫小芳,是我同桌", + conversation_turn=2, + same_topic_turns=2, + all_stages_coverage=None, + detected_user_stage="childhood", + user_profile_context="", + persona="default", + ) + assert "本轮判定" in p + assert "追问" in p + + def test_emotional_prompt_prioritizes_empathy(self) -> None: + """用户情绪浓时 prompt 应出现情绪承接优先的提示。""" + p = get_guided_conversation_prompt( + current_stage="family", + empty_slots=["relationship"], + filled_slots={}, + user_message="想起我妈,心酸", + conversation_turn=3, + same_topic_turns=1, + all_stages_coverage=None, + detected_user_stage="family", + user_profile_context="", + persona="default", + ) + assert "情绪" in p + + def test_chit_chat_does_not_force_memoir_question(self) -> None: + """闲聊时 prompt 不应强行追问回忆录问题。""" + p = get_guided_conversation_prompt( + current_stage="childhood", + empty_slots=["place"], + filled_slots={}, + user_message="今天天气真好哈哈", + conversation_turn=0, + same_topic_turns=0, + all_stages_coverage=None, + detected_user_stage="childhood", + user_profile_context="", + persona="default", + ) + assert "偏闲聊" in p + assert "陪聊" in p + + def test_topic_switch_not_triggered_at_3_turns(self) -> None: + """聊了 3 轮同话题不应该就要换——用户可能还想继续。""" + p = get_guided_conversation_prompt( + current_stage="childhood", + empty_slots=["place", "people", "emotion"], + filled_slots={"daily_life": "放学后去河边玩"}, + user_message="对啊,那条河特别浅", + conversation_turn=4, + same_topic_turns=3, + all_stages_coverage=None, + detected_user_stage="childhood", + user_profile_context="", + persona="default", + ) + assert "聊得差不多了" not in p + + def test_prompt_intro_mentions_empathy_first(self) -> None: + """prompt 开头应强调"先接住对方"而不是"控制字数"。""" + p = get_guided_conversation_prompt( + current_stage="childhood", + empty_slots=["place"], + filled_slots={}, + user_message="小时候家里穷", + conversation_turn=0, + same_topic_turns=0, + all_stages_coverage=None, + detected_user_stage="childhood", + user_profile_context="", + persona="default", + ) + assert "接住" in p + + +# ── 回忆录文风回归 ────────────────────────────────────────────────── + + +class TestMemoirStyleRegressions: + """保护"回忆录有文笔"体验。""" + + def test_title_prompt_allows_literary_expression(self) -> None: + """标题 prompt 不应禁止一切文学性表达——只禁止虚构。""" + prompt = get_creative_title_json_prompt( + stage="childhood", + emotion="warm", + slots={"place": "湖南老家", "turning_event": "爷爷背我过河"}, + ) + assert "禁止虚构" in prompt + assert "平实" not in prompt.lower() + + def test_title_prompt_uses_facts_only_not_plain(self) -> None: + """标题 prompt 应该走 facts_only(允许文采),而不是 plain(要求平实)。""" + prompt = get_creative_title_json_prompt( + stage="childhood", + emotion="warm", + slots={"place": "老家"}, + ) + assert "优雅" in prompt or "书面语" in prompt or "文采" in prompt + + def test_narrative_prompt_encourages_literary_quality(self) -> None: + """叙事 prompt 应该鼓励"有温度"的书面语,不只是"清楚记事"。""" + sys_prompt = get_narrative_editor_system_prompt() + assert "温度" in sys_prompt or "优雅" in sys_prompt + assert "画面感" in sys_prompt or "生动" in sys_prompt + + def test_narrative_json_prompt_allows_emotion_rendering(self) -> None: + """叙事 JSON prompt 应允许情感渲染(不新增事实前提下)。""" + prompt = get_narrative_json_prompt( + stage="childhood", + slots={"turning_event": "爷爷背我过河"}, + new_content="【本段用户口述】\n那年下大雨,爷爷背我过河,鞋全湿了,他一直笑。", + ) + assert "文采服务于真实" in prompt or "虚构描写" in prompt + + def test_fallback_ratio_is_lenient(self) -> None: + """fallback 阈值应该宽松——只有极端压缩才触发,正常书面化改写不触发。""" + oral = "我一九九九年出生在上海,后来搬到苏州。小学时爷爷常带我去河边散步。" + half_length_md = oral[: len(oral) // 2 + 5] + assert not sps._should_fallback_to_transcript(half_length_md, oral) + + def test_merge_shrink_only_on_extreme_loss(self) -> None: + """合并场景只有在极端缩水时才触发 fallback,不因正常重组而退回。""" + existing = "这是一段已有的故事正文,讲述了童年在河边的回忆。" * 20 + assert len(existing) > 400 + half_content = existing[: len(existing) // 2] + import json + + raw = json.dumps( + {"paragraphs": [{"content": half_content}]}, ensure_ascii=False + ) + out, ft = sps._apply_narrative_fallbacks( + raw, "新的口述补充", existing, chapter_category="childhood" + ) + assert ft == "none" diff --git a/api/tests/test_interview_prompts.py b/api/tests/test_interview_prompts.py new file mode 100644 index 0000000..c02e526 --- /dev/null +++ b/api/tests/test_interview_prompts.py @@ -0,0 +1,164 @@ +"""访谈提示词:追问触发与性格(Persona)拼接回归。""" + +from app.agents.chat.personas import normalize_interview_persona +from app.agents.chat.prompts_conversation import ( + get_guided_conversation_prompt, + get_opening_prompt, +) + + +def test_guided_prompt_contains_mandatory_followup_when_heuristic_matches(): + p = get_guided_conversation_prompt( + current_stage="childhood", + empty_slots=["place", "people"], + filled_slots={}, + user_message="厉害吧 那个女生叫娟娟", + conversation_turn=1, + same_topic_turns=1, + all_stages_coverage=None, + detected_user_stage="childhood", + user_profile_context="", + persona="default", + ) + assert "什么时候追问" in p + assert "本轮判定" in p + + +def test_guided_prompt_persona_curious_guide(): + p = get_guided_conversation_prompt( + current_stage="education", + empty_slots=["school"], + filled_slots={}, + user_message="还行吧", + conversation_turn=0, + same_topic_turns=0, + all_stages_coverage=None, + detected_user_stage="education", + user_profile_context="", + persona="curious_guide", + ) + assert "好奇引导" in p + + +def test_normalize_interview_persona_unknown_falls_back(): + assert normalize_interview_persona("not_a_real_persona") == "default" + assert normalize_interview_persona("") == "default" + + +def test_guided_prompt_contains_memoir_orientation(): + p = get_guided_conversation_prompt( + current_stage="childhood", + empty_slots=["place"], + filled_slots={}, + user_message="后来我就去上班了", + conversation_turn=0, + same_topic_turns=0, + all_stages_coverage=None, + detected_user_stage="childhood", + user_profile_context="", + persona="default", + ) + assert "对话方向" in p + assert "人生故事" in p + + +def test_guided_prompt_contains_memory_section_when_evidence(): + p = get_guided_conversation_prompt( + current_stage="childhood", + empty_slots=["place"], + filled_slots={}, + user_message="后来我就去上班了", + conversation_turn=0, + same_topic_turns=0, + all_stages_coverage=None, + detected_user_stage="childhood", + user_profile_context="", + persona="default", + memory_evidence_text="[摘要:rolling] 1990年生于上海。", + ) + assert "相关记忆摘录" in p + assert "过往口述" in p + assert "1990年生于上海" in p + + +def test_guided_prompt_chit_chat_hint(): + p = get_guided_conversation_prompt( + current_stage="childhood", + empty_slots=["place"], + filled_slots={}, + user_message="今天天气真好哈哈", + conversation_turn=0, + same_topic_turns=0, + all_stages_coverage=None, + detected_user_stage="childhood", + user_profile_context="", + persona="default", + ) + assert "偏闲聊" in p + + +def test_guided_prompt_reply_length_section_explicit_expanded(): + p = get_guided_conversation_prompt( + current_stage="childhood", + empty_slots=["place"], + filled_slots={}, + user_message="还行吧", + conversation_turn=0, + same_topic_turns=0, + all_stages_coverage=None, + detected_user_stage="childhood", + user_profile_context="", + persona="default", + reply_length_mode="expanded", + ) + assert "本轮回复长度" in p + assert "当前档位:expanded" in p + assert "expanded" in p + + +def test_guided_prompt_reply_length_explicit_brief(): + """档位由 Agent 的 ReplyPlan 传入,prompt 不再自行推导。""" + p = get_guided_conversation_prompt( + current_stage="childhood", + empty_slots=["place"], + filled_slots={}, + user_message="嗯", + conversation_turn=0, + same_topic_turns=0, + all_stages_coverage=None, + detected_user_stage="childhood", + user_profile_context="", + persona="default", + reply_length_mode="brief", + ) + assert "当前档位:brief" in p + + +def test_guided_prompt_background_voice_military() -> None: + p = get_guided_conversation_prompt( + current_stage="childhood", + empty_slots=["place"], + filled_slots={}, + user_message="后来我就去上班了", + conversation_turn=0, + same_topic_turns=0, + all_stages_coverage=None, + detected_user_stage="childhood", + user_profile_context="", + persona="default", + background_voice="military", + ) + assert "背景语气:军队语境" in p + assert "真诚承接" in p + + +def test_opening_prompt_military_has_examples_note() -> None: + p = get_opening_prompt( + current_stage="childhood", + empty_slots_readable=["成长的地方"], + user_profile_context="", + persona="default", + background_voice="military", + ) + assert "军队语境" in p + assert "(军队语境:简洁" in p or "军队语境" in p diff --git a/api/tests/test_interview_reply_length.py b/api/tests/test_interview_reply_length.py new file mode 100644 index 0000000..75a1057 --- /dev/null +++ b/api/tests/test_interview_reply_length.py @@ -0,0 +1,203 @@ +"""访谈回复长度策略:分桶与 InterviewAgent 的 max_tokens / 截断联动。""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.agents.chat.interview_reply_length import ( + ReplyLengthMode, + bump_reply_length_strategy_for_background_voice, + compute_reply_length_strategy, + compute_reply_plan, +) +from app.agents.state_schema import MemoirStateSchema + + +def _fake_settings(**overrides: object) -> SimpleNamespace: + base = { + "chat_interview_max_tokens": 380, + "chat_interview_max_segments": 2, + "chat_interview_max_chars_per_segment": 260, + "chat_interview_brief_max_tokens": 260, + "chat_interview_brief_max_chars_per_segment": 200, + "chat_interview_expanded_max_tokens": 520, + "chat_interview_expanded_max_chars_per_segment": 380, + } + base.update(overrides) + return SimpleNamespace(**base) + + +def test_strategy_brief_when_very_short() -> None: + s = compute_reply_length_strategy( + 5, + likely_new_detail=False, + likely_chit_chat=False, + settings=_fake_settings(), + ) + assert s.mode == ReplyLengthMode.brief + assert s.max_tokens == 260 + assert s.max_chars_per_segment == 200 + + +def test_strategy_standard_mid_length() -> None: + s = compute_reply_length_strategy( + 50, + likely_new_detail=True, + likely_chit_chat=False, + settings=_fake_settings(), + ) + assert s.mode == ReplyLengthMode.standard + assert s.max_tokens == 380 + assert s.max_chars_per_segment == 260 + + +def test_strategy_long_chit_stays_standard() -> None: + s = compute_reply_length_strategy( + 120, + likely_new_detail=False, + likely_chit_chat=True, + settings=_fake_settings(), + ) + assert s.mode == ReplyLengthMode.standard + assert s.max_tokens == 380 + + +def test_strategy_long_with_new_detail_expanded() -> None: + s = compute_reply_length_strategy( + 120, + likely_new_detail=True, + likely_chit_chat=False, + settings=_fake_settings(), + ) + assert s.mode == ReplyLengthMode.expanded + assert s.max_tokens == 520 + assert s.max_chars_per_segment == 380 + + +def test_strategy_boundary_len_20_brief_len_21_standard() -> None: + a = compute_reply_length_strategy( + 20, likely_new_detail=False, likely_chit_chat=False, settings=_fake_settings() + ) + b = compute_reply_length_strategy( + 21, likely_new_detail=False, likely_chit_chat=False, settings=_fake_settings() + ) + assert a.mode == ReplyLengthMode.brief + assert b.mode == ReplyLengthMode.standard + + +def test_bump_standard_only_for_cadre_military() -> None: + s0 = compute_reply_length_strategy( + 50, + likely_new_detail=False, + likely_chit_chat=False, + settings=_fake_settings(), + ) + bumped = bump_reply_length_strategy_for_background_voice( + s0, + background_voice="cadre", + settings=_fake_settings( + chat_interview_cadre_military_standard_extra_tokens=40, + chat_interview_cadre_military_standard_extra_chars=40, + ), + ) + assert bumped.max_tokens == s0.max_tokens + 40 + assert bumped.max_chars_per_segment == s0.max_chars_per_segment + 40 + + brief = compute_reply_length_strategy( + 5, + likely_new_detail=False, + likely_chit_chat=False, + settings=_fake_settings( + chat_interview_cadre_military_standard_extra_tokens=40, + chat_interview_cadre_military_standard_extra_chars=40, + ), + ) + same = bump_reply_length_strategy_for_background_voice( + brief, + background_voice="military", + settings=_fake_settings( + chat_interview_cadre_military_standard_extra_tokens=40, + chat_interview_cadre_military_standard_extra_chars=40, + ), + ) + assert same.max_tokens == brief.max_tokens + + +def test_plan_short_information_rich_is_standard_not_brief() -> None: + """短句但含高密度锚点(如「那年」「我爸」)→ standard,避免误压成 brief。""" + p = compute_reply_plan( + "那年我爸突然病了", + background_voice=None, + settings=_fake_settings(), + ) + assert p.mode == ReplyLengthMode.standard + assert p.information_rich is True + + +def test_plan_long_chit_stays_standard_not_expanded() -> None: + """长段明显闲聊 → standard,不因字数进入 expanded。""" + msg = "今天天气真好哈哈" * 11 + assert len(msg) >= 80 + p = compute_reply_plan( + msg, + background_voice=None, + settings=_fake_settings(), + ) + assert p.mode == ReplyLengthMode.standard + assert p.likely_chit_chat is True + + +def test_strategy_boundary_len_79_standard_len_80_long_branch() -> None: + a = compute_reply_length_strategy( + 79, likely_new_detail=False, likely_chit_chat=False, settings=_fake_settings() + ) + b = compute_reply_length_strategy( + 80, + likely_new_detail=False, + likely_chit_chat=False, + settings=_fake_settings(), + ) + assert a.mode == ReplyLengthMode.standard + assert b.mode == ReplyLengthMode.standard + + +@pytest.mark.asyncio +async def test_interview_agent_passes_strategy_to_bind_and_truncate() -> None: + """同一套 strategy 用于 llm.bind(max_tokens=) 与 truncate_chat_segments。""" + from app.agents.chat import interview_agent as ia + + mock_llm = MagicMock() + mock_bound = MagicMock() + mock_bound.ainvoke = AsyncMock( + return_value=MagicMock(content="你好,后来呢?[SPLIT]还有吗?") + ) + mock_llm.bind = MagicMock(return_value=mock_bound) + + agent = ia.InterviewAgent() + agent.llm = mock_llm + + state = MemoirStateSchema( + stage_order=["childhood"], + current_stage="childhood", + covered_stages=[], + slots={"childhood": {}}, + ) + + with patch( + "app.agents.chat.interview_agent.get_history_messages", + new=AsyncMock(return_value=[]), + ): + turn = await agent.generate_response_with_state( + conversation_id="c1", + user_message="x" * 100 + "第一次认识他", + memoir_state=state, + ) + + mock_llm.bind.assert_called_once() + call_kw = mock_llm.bind.call_args[1] + assert call_kw["max_tokens"] == 520 + + assert len(turn.messages) >= 1 + for seg in turn.messages: + assert len(seg) <= 380 diff --git a/api/tests/test_json_and_memory_utils.py b/api/tests/test_json_and_memory_utils.py index 46a5729..0708265 100644 --- a/api/tests/test_json_and_memory_utils.py +++ b/api/tests/test_json_and_memory_utils.py @@ -6,6 +6,9 @@ from app.agents.chat.reply_limits import truncate_chat_segments from app.agents.memoir.classification_agent import _normalize_llm_category from app.agents.memoir.prompts import format_evidence_chunks_for_prompt +from app.features.memory.evidence_format import ( + format_evidence_chunks_for_prompt as format_evidence_from_memory, +) from app.agents.memoir.story_route_agent import ( StoryBatchPlan, StoryBatchPlanUnit, @@ -48,6 +51,7 @@ def test_format_evidence_chunks_includes_timeline() -> None: assert "chunk1" in out assert "1950" in out or "生于" in out assert "1977" in out or "恢复高考" in out + assert format_evidence_from_memory(ev) == out def test_validate_story_batch_plan_ok() -> None: diff --git a/api/tests/test_memoir_skip_story.py b/api/tests/test_memoir_skip_story.py new file mode 100644 index 0000000..6ce7e72 --- /dev/null +++ b/api/tests/test_memoir_skip_story.py @@ -0,0 +1,122 @@ +"""回忆录:segment_skip_story_ids 与 batch 级短路条件(orchestrator 侧)。""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from app.agents.memoir.classification_agent import ( + ChapterClassifyResult, + ClassificationAgent, +) +from app.agents.memoir.extraction_agent import ExtractionResult +from app.agents.memoir.orchestrator import MemoirOrchestrator +from app.agents.state_schema import MemoirStateSchema + + +def _empty_state() -> MemoirStateSchema: + return MemoirStateSchema( + stage_order=["childhood"], + current_stage="childhood", + covered_stages=[], + slots={}, + ) + + +def test_prepare_batches_skip_story_id_when_llm_none_and_empty_slots() -> None: + orch = MemoirOrchestrator() + orch.extraction_agent.extract = MagicMock( + return_value=ExtractionResult(detected_stage="career", slots={}) + ) + orch.classification_agent.classify = MagicMock( + return_value=ChapterClassifyResult(category="summary", llm_said_none=True) + ) + st = _empty_state() + + def get_state() -> MemoirStateSchema: + return st + + def update_slot( + stage: str, slot_name: str, snippet: str, seg_ids: list[str] + ) -> MemoirStateSchema: + return st + + seg = SimpleNamespace(id="seg-skip-1", user_input_text="聊聊别的吧") + p = orch.prepare_batches( + segments=[seg], + llm=MagicMock(), + get_or_create_state=get_state, + update_slot=update_slot, + ) + assert "seg-skip-1" in p.segment_skip_story_ids + + +def test_prepare_batches_fragment_heuristic_not_in_skip_set() -> None: + """fragment-only→summary 且 llm_said_none=False,不进入 skip 集合。""" + orch = MemoirOrchestrator() + orch.extraction_agent.extract = MagicMock( + return_value=ExtractionResult(detected_stage="career", slots={}) + ) + orch.classification_agent = ClassificationAgent() + st = _empty_state() + + def get_state() -> MemoirStateSchema: + return st + + def update_slot( + stage: str, slot_name: str, snippet: str, seg_ids: list[str] + ) -> MemoirStateSchema: + return st + + seg = SimpleNamespace(id="seg-frag-1", user_input_text="1999年出生") + p = orch.prepare_batches( + segments=[seg], + llm=None, + get_or_create_state=get_state, + update_slot=update_slot, + ) + assert "seg-frag-1" not in p.segment_skip_story_ids + + +def test_prepare_batches_mixed_batch_only_one_segment_in_skip_set() -> None: + """同 category 两段:仅一段满足 skip 条件 → skip 集合仅含该段 id。""" + orch = MemoirOrchestrator() + orch.extraction_agent.extract = MagicMock( + side_effect=[ + ExtractionResult(detected_stage="career", slots={}), + ExtractionResult(detected_stage="career", slots={"job": "戏剧演员"}), + ] + ) + orch.classification_agent.classify = MagicMock( + side_effect=[ + ChapterClassifyResult(category="summary", llm_said_none=True), + ChapterClassifyResult(category="summary", llm_said_none=False), + ] + ) + st = _empty_state() + + def get_state() -> MemoirStateSchema: + return st + + def update_slot( + stage: str, slot_name: str, snippet: str, seg_ids: list[str] + ) -> MemoirStateSchema: + return st + + s1 = SimpleNamespace(id="mix-1", user_input_text="聊聊别的吧") + s2 = SimpleNamespace(id="mix-2", user_input_text="后来当了演员") + p = orch.prepare_batches( + segments=[s1, s2], + llm=MagicMock(), + get_or_create_state=get_state, + update_slot=update_slot, + ) + assert p.segment_skip_story_ids == {"mix-1"} + assert len(p.category_to_segments.get("summary", [])) == 2 + + +def test_batch_all_skip_predicate() -> None: + """memoir_tasks 短路条件:batch_ids <= skip_ids。""" + batch_ids = {"a", "b"} + skip_ids = {"a"} + assert not (batch_ids <= skip_ids) + assert {"a"} <= {"a", "b"} + assert {"a", "b"} <= {"a", "b"} diff --git a/api/tests/test_oral_normalize.py b/api/tests/test_oral_normalize.py new file mode 100644 index 0000000..73ddcea --- /dev/null +++ b/api/tests/test_oral_normalize.py @@ -0,0 +1,50 @@ +"""口述规则归一与 memoir 入口行为。""" + +from unittest.mock import patch + +from app.features.memoir.oral_normalize import ( + apply_oral_normalization_rules, + normalize_oral_for_memoir, +) + + +def test_apply_rules_mei_kanshang_wo() -> None: + assert "没看上我" in apply_oral_normalization_rules("我去试镜了 美看上我 张伟") + + +def test_apply_rules_mei_kanshang_ni() -> None: + assert apply_oral_normalization_rules("美看上你") == "没看上你" + + +def test_apply_rules_no_false_positive_rong() -> None: + """「美容」等不应被误替换。""" + s = "我去了解美容项目" + assert apply_oral_normalization_rules(s) == s + + +def test_normalize_respects_global_off() -> None: + raw = "美看上我" + with patch("app.features.memoir.oral_normalize.settings") as m: + m.memoir_oral_normalize_enabled = False + m.memoir_oral_normalize_mode = "rules" + assert normalize_oral_for_memoir(raw, llm=None) == raw + + +def test_normalize_rules_mode_no_llm() -> None: + raw = "美看上我" + with patch("app.features.memoir.oral_normalize.settings") as m: + m.memoir_oral_normalize_enabled = True + m.memoir_oral_normalize_mode = "rules" + m.memoir_oral_normalize_llm_max_tokens = 512 + m.memoir_oral_normalize_llm_max_input_chars = 8000 + assert normalize_oral_for_memoir(raw, llm=None) == "没看上我" + + +def test_normalize_mode_off_string() -> None: + raw = "美看上我" + with patch("app.features.memoir.oral_normalize.settings") as m: + m.memoir_oral_normalize_enabled = True + m.memoir_oral_normalize_mode = "off" + m.memoir_oral_normalize_llm_max_tokens = 512 + m.memoir_oral_normalize_llm_max_input_chars = 8000 + assert normalize_oral_for_memoir(raw, llm=None) == raw diff --git a/api/tests/test_story_route_oral_invariant.py b/api/tests/test_story_route_oral_invariant.py new file mode 100644 index 0000000..a32df9a --- /dev/null +++ b/api/tests/test_story_route_oral_invariant.py @@ -0,0 +1,258 @@ +"""Story 路由:batch_transcript 仅含本批口述,不含 evidence(与 story_pipeline_sync 行为一致)。""" + +# 与 alembic/env.py 一致:注册全部 ORM,避免 relationship 解析 KeyError +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from app.agents.memoir.prompts import format_evidence_chunks_for_prompt +from app.agents.memoir.story_route_agent import StoryRouteDecision +from app.agents.state_schema import MemoirStateSchema +from app.features.asset import models as _asset_models # noqa: F401 +from app.features.auth import models as _auth_models # noqa: F401 +from app.features.conversation import models as _conv_models # noqa: F401 +from app.features.memoir import models as _memoir_models # noqa: F401 +from app.features.memoir.story_pipeline_sync import ( + run_story_pipeline_for_category_batch, +) +from app.features.memory import models as _memory_models # noqa: F401 +from app.features.payment import models as _payment_models # noqa: F401 +from app.features.story import models as _story_models # noqa: F401 +from app.features.user import models as _user_models # noqa: F401 + + +def test_single_segment_decide_receives_only_combined_text_not_evidence() -> None: + """路由输入不变量:decide 的 batch_transcript 等于 oral combined_text,且不含证据标记。""" + oral = "这是一条用于测试路由输入的用户口述内容足够长以避免叙事回退误判" + captured: dict[str, str] = {} + + def decide_capture( + *, + batch_transcript: str, + **kwargs: object, + ) -> StoryRouteDecision: + captured["batch_transcript"] = batch_transcript + return StoryRouteDecision( + decision="new_story", + new_story_title="测试新故事标题六个字以上", + reason="测试", + ) + + seg = SimpleNamespace(id="seg-route-test-1", user_input_text=oral) + evidence_payload = { + "relevant_chunks": [], + "relevant_summaries": [ + { + "content": "滚动摘要里的旧内容不应出现在路由输入", + "summary_type": "rolling", + } + ], + "relevant_facts": [{"subject": "X", "predicate": "y", "object_json": {}}], + "timeline_hints": [], + "relevant_stories": [], + } + evidence_formatted = format_evidence_chunks_for_prompt(evidence_payload) + assert "[摘要:rolling]" in evidence_formatted + + route_agent_mock = MagicMock() + + with ( + patch( + "app.features.memoir.story_pipeline_sync.retrieve_evidence_sync", + return_value=evidence_payload, + ), + patch( + "app.features.memoir.story_pipeline_sync.list_active_stories_for_user_sync", + return_value=[], + ), + patch( + "app.features.memoir.story_pipeline_sync.StoryRouteAgent", + return_value=route_agent_mock, + ), + patch( + "app.features.memoir.story_pipeline_sync.NarrativeAgent", + ) as nac, + patch( + "app.features.memoir.story_pipeline_sync.create_story_with_version_sync", + ) as csw, + patch( + "app.features.memoir.story_pipeline_sync.ensure_chapter_story_link_sync", + ), + patch( + "app.features.memoir.story_pipeline_sync.reorder_chapter_story_links_by_life_order_sync", + ), + patch( + "app.features.memoir.story_pipeline_sync.mark_chapter_dirty_sync", + ), + patch( + "app.features.memoir.story_pipeline_sync.chapter_needs_cover_enqueue", + return_value=False, + ), + patch( + "app.features.memoir.story_pipeline_sync.MemoirImageSettings", + ) as mis, + ): + route_agent_mock.plan_batch.return_value = None + route_agent_mock.decide.side_effect = decide_capture + + na = MagicMock() + nac.return_value = na + na.generate_title.return_value = "章节标题" + na.generate_narrative.return_value = '{"paragraphs": [{"content": "叙事正文段落足够长用于测试合并逻辑避免触发过短回退"}]}' + + mock_story = MagicMock() + mock_story.id = "11111111-1111-1111-1111-111111111111" + csw.return_value = mock_story + + mis.from_env.return_value = MagicMock(enabled=False) + + session = MagicMock() + exec_result = MagicMock() + exec_result.unique.return_value.scalar_one_or_none.return_value = None + session.execute.return_value = exec_result + + state = MemoirStateSchema( + stage_order=["childhood"], + current_stage="childhood", + covered_stages=[], + slots={}, + ) + run_story_pipeline_for_category_batch( + session, + user_id="user-1", + chapter_category="summary", + category_segments=[seg], + state=state, + user_profile="", + user_birth_year=None, + llm=object(), + ) + + assert captured["batch_transcript"] == oral + assert "[摘要:rolling]" not in captured["batch_transcript"] + assert "滚动摘要" not in captured["batch_transcript"] + route_agent_mock.decide.assert_called_once() + assert route_agent_mock.decide.call_args.kwargs["batch_transcript"] == oral + + +def test_decide_receives_only_same_stage_story_candidates() -> None: + """路由候选仅含 story.stage == chapter_category,禁止跨章节 append 导致多章内容相同。""" + oral = "这是一条用于测试路由候选过滤的用户口述内容足够长以避免叙事回退误判" + captured: dict[str, list] = {} + + def decide_capture( + *, candidate_stories: object, **kwargs: object + ) -> StoryRouteDecision: + captured["candidates"] = list(candidate_stories) + return StoryRouteDecision( + decision="new_story", + new_story_title="测试新故事标题六个字以上", + reason="测试", + ) + + seg = SimpleNamespace(id="seg-route-stage-filter-1", user_input_text=oral) + childhood_story = SimpleNamespace( + id="s-child", stage="childhood", canonical_markdown="" + ) + education_story = SimpleNamespace( + id="s-edu", stage="education", canonical_markdown="" + ) + + route_agent_mock = MagicMock() + + with ( + patch( + "app.features.memoir.story_pipeline_sync.retrieve_evidence_sync", + return_value={ + "relevant_chunks": [], + "relevant_summaries": [], + "relevant_facts": [], + "timeline_hints": [], + "relevant_stories": [], + }, + ), + patch( + "app.features.memoir.story_pipeline_sync.list_active_stories_for_user_sync", + return_value=[childhood_story, education_story], + ), + patch( + "app.features.memoir.story_pipeline_sync.StoryRouteAgent", + return_value=route_agent_mock, + ), + patch( + "app.features.memoir.story_pipeline_sync.NarrativeAgent", + ) as nac, + patch( + "app.features.memoir.story_pipeline_sync.create_story_with_version_sync", + ) as csw, + patch( + "app.features.memoir.story_pipeline_sync.ensure_chapter_story_link_sync", + ), + patch( + "app.features.memoir.story_pipeline_sync.reorder_chapter_story_links_by_life_order_sync", + ), + patch( + "app.features.memoir.story_pipeline_sync.mark_chapter_dirty_sync", + ), + patch( + "app.features.memoir.story_pipeline_sync.chapter_needs_cover_enqueue", + return_value=False, + ), + patch( + "app.features.memoir.story_pipeline_sync.MemoirImageSettings", + ) as mis, + ): + route_agent_mock.plan_batch.return_value = None + route_agent_mock.decide.side_effect = decide_capture + + na = MagicMock() + nac.return_value = na + na.generate_title.return_value = "章节标题" + na.generate_narrative.return_value = '{"paragraphs": [{"content": "叙事正文段落足够长用于测试合并逻辑避免触发过短回退"}]}' + + mock_story = MagicMock() + mock_story.id = "22222222-2222-2222-2222-222222222222" + csw.return_value = mock_story + + mis.from_env.return_value = MagicMock(enabled=False) + + session = MagicMock() + exec_result = MagicMock() + exec_result.unique.return_value.scalar_one_or_none.return_value = None + session.execute.return_value = exec_result + + state = MemoirStateSchema( + stage_order=["education"], + current_stage="education", + covered_stages=[], + slots={}, + ) + run_story_pipeline_for_category_batch( + session, + user_id="user-1", + chapter_category="education", + category_segments=[seg], + state=state, + user_profile="", + user_birth_year=None, + llm=object(), + ) + + assert len(captured["candidates"]) == 1 + assert captured["candidates"][0].id == "s-edu" + assert captured["candidates"][0].stage == "education" + route_agent_mock.decide.assert_called_once() + va = route_agent_mock.decide.call_args.kwargs["valid_story_ids"] + assert va == {"s-edu"} + + +def test_get_story_route_prompt_includes_route_boundary_rule() -> None: + from app.agents.memoir.prompts import get_story_route_prompt + + out = get_story_route_prompt( + chapter_category="summary", + chapter_title="标题", + batch_transcript="仅口述", + candidate_stories_json="[]", + ) + assert "路由边界" in out + assert "仅根据" in out or "本批口述" in out diff --git a/api/uv.lock b/api/uv.lock index 5d6f7ac..38daf95 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -93,6 +93,7 @@ dependencies = [ { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "pydub" }, { name = "pyjwt" }, { name = "python-alipay-sdk" }, { name = "redis" }, @@ -133,6 +134,7 @@ requires-dist = [ { name = "psycopg", extras = ["binary"], specifier = ">=3.2.0" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic-settings", specifier = ">=2.13.1" }, + { name = "pydub", specifier = ">=0.25.1" }, { name = "pyjwt", specifier = ">=2.12.0" }, { name = "python-alipay-sdk", specifier = ">=3.4.0" }, { name = "redis", specifier = ">=6.4.0" }, @@ -1919,6 +1921,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] +[[package]] +name = "pydub" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/9a/e6bca0eed82db26562c73b5076539a4a08d3cffd19c3cc5913a3e61145fd/pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f", size = 38326, upload-time = "2021-03-10T02:09:54.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327, upload-time = "2021-03-10T02:09:53.503Z" }, +] + [[package]] name = "pydyf" version = "0.12.1" diff --git a/app-expo/src/app/(tabs)/index.tsx b/app-expo/src/app/(tabs)/index.tsx index c7ca37c..be5d7a3 100644 --- a/app-expo/src/app/(tabs)/index.tsx +++ b/app-expo/src/app/(tabs)/index.tsx @@ -212,14 +212,48 @@ function SwipeableConversationCard({ const SKELETON_COUNT = 3; -/** 列表按最近活动排序,取第一条尚无用户消息的对话,用于「打个招呼」复用 */ +/** 本地日历日是否同一天(用于「以初次创建日」区分会话日) */ +function isSameLocalCalendarDay(aMs: number, bMs: number): boolean { + const a = new Date(aMs); + const b = new Date(bMs); + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +function conversationStartedAtMs(item: ConversationListItem): number { + return item.startedAt ?? item.latestMessageTime; +} + +/** 仅复用「当天创建」且尚无用户消息的对话,跨日则新开(一天一次招呼会话) */ function findReusableEmptyConversationId( items: ConversationListItem[], + nowMs: number = Date.now(), ): string | null { - const found = items.find((c) => c.hasUserMessage === false); + const found = items.find( + (c) => + c.hasUserMessage === false && + isSameLocalCalendarDay(conversationStartedAtMs(c), nowMs), + ); return found?.id ?? null; } +/** 「继续对话」:仅进入当天创建且已有用户消息的对话(列表已按最近活动排序) */ +function findTodayConversationToResume( + items: ConversationListItem[], + nowMs: number = Date.now(), +): ConversationListItem | null { + return ( + items.find( + (c) => + c.hasUserMessage && + isSameLocalCalendarDay(conversationStartedAtMs(c), nowMs), + ) ?? null + ); +} + export default function ConversationsScreen() { const { t } = useTranslation('conversation'); const queryClient = useQueryClient(); @@ -274,10 +308,14 @@ export default function ConversationsScreen() { }; const handleResumeLatestConversation = () => { - const latest = conversations[0]; - if (latest) { - router.push(`/(main)/conversation/${latest.id}`); + const now = Date.now(); + const toResume = findTodayConversationToResume(conversations, now); + if (toResume) { + router.push(`/(main)/conversation/${toResume.id}`); + return; } + // 当日没有可继续的会话(例如会话始于昨日):与「打个招呼」一致,复用当日空会话或新建 + handleCreateConversation(); }; const handleConversationPress = (id: string) => { diff --git a/app-expo/src/features/conversation/hooks.ts b/app-expo/src/features/conversation/hooks.ts index 9452576..7bf1420 100644 --- a/app-expo/src/features/conversation/hooks.ts +++ b/app-expo/src/features/conversation/hooks.ts @@ -99,12 +99,15 @@ export function useCreateConversation() { queryClient.setQueryData( conversationKeys.lists(), (old) => { + const startedMs = Date.parse(newConversation.started_at); + const now = Date.now(); const item: ConversationListItem = { id: newConversation.id, title: '岁月知己', avatarUrl: null, latestMessagePreview: '', - latestMessageTime: Date.now(), + latestMessageTime: now, + startedAt: Number.isFinite(startedMs) ? startedMs : now, unreadCount: 0, isDefaultAssistant: true, hasUserMessage: false, diff --git a/app-expo/src/features/conversation/types.ts b/app-expo/src/features/conversation/types.ts index 24a55f3..dcd3930 100644 --- a/app-expo/src/features/conversation/types.ts +++ b/app-expo/src/features/conversation/types.ts @@ -18,6 +18,8 @@ export interface ConversationListItem { avatarUrl: string | null; latestMessagePreview: string; latestMessageTime: number; + /** 对话初次创建时间(UTC 毫秒),与列表排序用的最近活动无关 */ + startedAt?: number; unreadCount: number; isDefaultAssistant: boolean; /** 是否已有用户发出的文本或语音(仅助手/空会话为 false,用于「打个招呼」复用同一会话) */ diff --git a/app-expo/tests/features/conversation/hooks.test.tsx b/app-expo/tests/features/conversation/hooks.test.tsx index a963582..47f4343 100644 --- a/app-expo/tests/features/conversation/hooks.test.tsx +++ b/app-expo/tests/features/conversation/hooks.test.tsx @@ -59,6 +59,7 @@ const fakeConversations = [ avatarUrl: null, latestMessagePreview: '你好', latestMessageTime: 1000, + startedAt: 1000, unreadCount: 0, isDefaultAssistant: false, hasUserMessage: true,