fix(chat): 重复追问被拦截时再多问一次模型
防重复问句会把整段回复削成「这一段我记住了。」只剩一句套话时,用带纠偏说明的 system 再调一次 LLM,尽量避免用户只看到干巴巴_ack。仍只重试一次,并打日志与 meta 标记 duplicate_question_guard_llm_retry。
This commit is contained in:
@@ -13,6 +13,7 @@ from app.agents.chat.helpers import format_history_string, get_history_with_wind
|
||||
from app.agents.chat.interview_state_hints import (
|
||||
apply_duplicate_question_guard,
|
||||
extract_recent_questions,
|
||||
segments_are_only_duplicate_guard_fallback,
|
||||
update_recent_questions,
|
||||
)
|
||||
from app.agents.chat.interview_turn_plan import plan_interview_turn
|
||||
@@ -44,6 +45,45 @@ logger = get_logger(__name__)
|
||||
# LLM 不可用或调用失败时对用户展示(不暴露异常细节、不触发 TTS)
|
||||
_FALLBACK_REPLY = "刚才网络不太稳,没接上。你可以再说一遍,或稍后再试。"
|
||||
|
||||
# 仅在「重复问句守卫」把正文削成单句兜底时追加二次 system,只多调一次模型。
|
||||
_DUPLICATE_GUARD_LLM_RETRY_SYSTEM_APPENDIX = """## 二次生成(纠偏)
|
||||
上一版模型输出因包含与「最近已问过的问题」或「已确认事实」重复的问句,已被系统弃用。请**重新写一整条回复**:
|
||||
- 仍须遵守上文全部主规则;
|
||||
- 先贴着用户本轮原话承接半句到一两句(可有画面感);
|
||||
- **禁止**再用与刚才同义、仅换说法的确认型问句;
|
||||
- 若要提问,须换**全新角度**,并锚在用户刚说的具体细节里;也可以本轮**完全不提问**,只并肩承接;
|
||||
- **禁止**整段只有「这一段我记住了」或同类无信息套话。"""
|
||||
|
||||
|
||||
def _finalize_chat_segments_after_llm(
|
||||
response_text: str,
|
||||
*,
|
||||
max_segments: int,
|
||||
max_chars: int,
|
||||
memoir_state: MemoirStateSchema,
|
||||
recent_questions: list[str],
|
||||
) -> tuple[list[str], bool]:
|
||||
raw_list = segments_from_llm_response(
|
||||
response_text,
|
||||
max_segments=max_segments,
|
||||
)
|
||||
if not raw_list:
|
||||
raw_list = [response_text.strip()]
|
||||
out = truncate_chat_segments(
|
||||
raw_list,
|
||||
max_segments=max_segments,
|
||||
max_chars_per_segment=max_chars,
|
||||
)
|
||||
if not out:
|
||||
out = [response_text.strip()[:max_chars]]
|
||||
out = nonempty_segments_or_fallback(out, fallback=_FALLBACK_REPLY)
|
||||
out, deduped = apply_duplicate_question_guard(
|
||||
out,
|
||||
state=memoir_state,
|
||||
recent_questions=recent_questions,
|
||||
)
|
||||
return out, deduped
|
||||
|
||||
|
||||
def _get_langchain_llm():
|
||||
try:
|
||||
@@ -219,29 +259,70 @@ class InterviewAgent:
|
||||
log_agent_payload(
|
||||
logger, "InterviewAgent.generate_response.raw_response", response_text
|
||||
)
|
||||
raw_list = segments_from_llm_response(
|
||||
rq_base = recent_questions or memoir_state.recent_questions
|
||||
out, deduped = _finalize_chat_segments_after_llm(
|
||||
response_text,
|
||||
max_segments=max_segments,
|
||||
max_chars=max_chars,
|
||||
memoir_state=memoir_state,
|
||||
recent_questions=rq_base,
|
||||
)
|
||||
if not raw_list:
|
||||
raw_list = [response_text.strip()]
|
||||
out = truncate_chat_segments(
|
||||
raw_list,
|
||||
max_segments=max_segments,
|
||||
max_chars_per_segment=max_chars,
|
||||
)
|
||||
if not out:
|
||||
out = [response_text.strip()[:max_chars]]
|
||||
out = nonempty_segments_or_fallback(out, fallback=_FALLBACK_REPLY)
|
||||
out, deduped = apply_duplicate_question_guard(
|
||||
out,
|
||||
state=memoir_state,
|
||||
recent_questions=recent_questions or memoir_state.recent_questions,
|
||||
)
|
||||
updated_recent_questions = update_recent_questions(
|
||||
recent_questions or memoir_state.recent_questions,
|
||||
out,
|
||||
)
|
||||
retry_used = False
|
||||
if deduped and segments_are_only_duplicate_guard_fallback(out):
|
||||
retry_system = (
|
||||
f"{system_prompt}\n\n{_DUPLICATE_GUARD_LLM_RETRY_SYSTEM_APPENDIX}"
|
||||
)
|
||||
retry_messages: List[Any] = [
|
||||
SystemMessage(content=retry_system),
|
||||
*hw.window,
|
||||
HumanMessage(content=text_for_model),
|
||||
]
|
||||
log_agent_payload(
|
||||
logger,
|
||||
"InterviewAgent.generate_response.retry_prompt",
|
||||
format_history_string(
|
||||
retry_messages,
|
||||
omit_system_body=settings.agent_log_omit_system_message_body,
|
||||
),
|
||||
)
|
||||
llm_t1 = time.perf_counter()
|
||||
with agent_span(
|
||||
logger,
|
||||
"InterviewAgent.generate_response.llm_retry",
|
||||
conversation_id=conversation_id,
|
||||
stage=memoir_state.current_stage,
|
||||
):
|
||||
logger.info(
|
||||
"event=chat_prompt_built agent=InterviewAgent.duplicate_guard_retry "
|
||||
"prompt_chars={} conversation_id={}",
|
||||
_message_contents_char_count(retry_messages),
|
||||
conversation_id,
|
||||
)
|
||||
response_retry = await chat_llm.ainvoke(retry_messages)
|
||||
logger.info(
|
||||
"event=chat_llm_done agent=InterviewAgent.duplicate_guard_retry "
|
||||
"response_latency_ms={:.2f}",
|
||||
(time.perf_counter() - llm_t1) * 1000,
|
||||
)
|
||||
response_text_retry = (
|
||||
response_retry.content
|
||||
if hasattr(response_retry, "content")
|
||||
else str(response_retry)
|
||||
)
|
||||
log_agent_payload(
|
||||
logger,
|
||||
"InterviewAgent.generate_response.raw_response_retry",
|
||||
response_text_retry,
|
||||
)
|
||||
out, deduped = _finalize_chat_segments_after_llm(
|
||||
response_text_retry,
|
||||
max_segments=max_segments,
|
||||
max_chars=max_chars,
|
||||
memoir_state=memoir_state,
|
||||
recent_questions=rq_base,
|
||||
)
|
||||
retry_used = True
|
||||
updated_recent_questions = update_recent_questions(rq_base, out)
|
||||
log_agent_summary(
|
||||
logger,
|
||||
"InterviewAgent.generate_response segments={} conversation_id={} "
|
||||
@@ -256,6 +337,7 @@ class InterviewAgent:
|
||||
interview_state_meta={
|
||||
"recent_questions": updated_recent_questions,
|
||||
"duplicate_question_guard_triggered": deduped,
|
||||
"duplicate_question_guard_llm_retry": retry_used,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
@@ -5,6 +5,9 @@ from __future__ import annotations
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
|
||||
# 与 `apply_duplicate_question_guard` 中整段替换句一致;用于判定是否需触发二次生成。
|
||||
DUPLICATE_QUESTION_GUARD_FALLBACK_ZH = "这一段我记住了。"
|
||||
|
||||
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
|
||||
|
||||
from app.agents.stage_constants import STAGE_DISPLAY_ZH, STAGE_SLOT_KEYS
|
||||
@@ -316,7 +319,7 @@ def apply_duplicate_question_guard(
|
||||
if repeated:
|
||||
sentences = [s.strip() for s in _SENTENCE_SPLIT_RE.split(text) if s.strip()]
|
||||
kept = [s for s in sentences if "?" not in s and "?" not in s]
|
||||
replacement = kept[0] if kept else "这一段我记住了。"
|
||||
replacement = kept[0] if kept else DUPLICATE_QUESTION_GUARD_FALLBACK_ZH
|
||||
if not replacement.endswith(("。", "!", "…")):
|
||||
replacement += "。"
|
||||
cleaned.append(replacement)
|
||||
@@ -324,10 +327,16 @@ def apply_duplicate_question_guard(
|
||||
else:
|
||||
cleaned.append(text)
|
||||
if not cleaned:
|
||||
cleaned = ["这一段我记住了。"]
|
||||
cleaned = [DUPLICATE_QUESTION_GUARD_FALLBACK_ZH]
|
||||
return cleaned, touched
|
||||
|
||||
|
||||
def segments_are_only_duplicate_guard_fallback(segments: Iterable[str]) -> bool:
|
||||
"""是否为「仅兜底_ack、无实质承接」——适合再打一枪模型。"""
|
||||
parts = [str(s or "").strip() for s in segments if str(s or "").strip()]
|
||||
return len(parts) == 1 and parts[0] == DUPLICATE_QUESTION_GUARD_FALLBACK_ZH
|
||||
|
||||
|
||||
def stage_slot_hint_lines(stage: str) -> list[str]:
|
||||
keys = STAGE_SLOT_KEYS.get(stage, ())
|
||||
stage_zh = STAGE_DISPLAY_ZH.get(stage, stage)
|
||||
|
||||
@@ -26,12 +26,15 @@ def _memoir_fidelity_core_rules() -> str:
|
||||
- 过渡句与衔接句:如「那段日子」「回想起来」等,只要不引入新的实体
|
||||
- 基于口述已有情感的书面化渲染(如口述说「难受」,可改为「心里不好受」)——前提是不新增具体场景、数字、动作
|
||||
- 合并同义重复表述,让叙述更紧凑
|
||||
- 纠正明显的语音识别错字"""
|
||||
- 纠正明显的语音识别错字
|
||||
- **时代与文化语感(仅限已锚定信息)**:当口述(或「时间参考」、slots)已点明年份阶段、地域或典型生活环境时,可用与之**相称**的年代/地域**语汇与泛指性生活氛围**作烘托(如口述已提「分粮」「票证」「赶集」则可写相应语感),**不得**凭此新增口述未出现的人物、事件、对话、具体场景经过"""
|
||||
|
||||
|
||||
def _memoir_fidelity_user_profile_rules() -> str:
|
||||
return """## 用户档案与阶段信息
|
||||
- 「用户基本信息」「时间参考」仅可使用其中**已写明**的条目;不得把档案中的出生地等写进正文,除非用户在本段口述里已提及或明确关联。"""
|
||||
- 「用户基本信息」「时间参考」仅可使用其中**已写明**的条目。
|
||||
- **文化/时代渗透(鼓励,须咬合)**:当本段口述已提及或与口述主题**明确同一脉络**(如口述讲童年老家、档案写明籍贯/成长地一致)时,可将档案中的年代、地域、身份背景化入正文为**语言与氛围**(称谓习惯、地域说法、时代体感),使叙述更文学;**禁止**单靠档案写出一段口述未发生的具体人事经过,仍须遵守「事实边界」关于摘录区与禁止编造的规定。
|
||||
- 档案中的具体经历细节不得写入正文,除非用户在本段口述里已提及或明确关联。"""
|
||||
|
||||
|
||||
def get_memoir_fidelity_system_prompt() -> str:
|
||||
@@ -49,7 +52,7 @@ def get_memoir_fidelity_facts_only_prompt() -> str:
|
||||
return f"""你是回忆录编辑助手,任务是把用户口述整理为第一人称书面叙述。
|
||||
|
||||
{_memoir_fidelity_core_rules()}
|
||||
5. **文体**:在遵守第 1–4 条的前提下,可将口语改写为**优雅、连贯的回忆录书面语**(适当过渡句,保留并书面化用户已提及的细节与情感);文采服务于真实内容,**不得**用虚构描写替代或填补事实。
|
||||
5. **文体**:在遵守第 1–4 条的前提下,以**第一人称、偏文学性的回忆录散文**落笔(场景与情绪随材料起伏,避免简讯式罗列),将口语改写为**优雅、连贯、可诵读**的叙述;在口述或合法档案锚点已存在的范围内,鼓励**时代与文化语感**浸润正文;可在**不引入新事实**的前提下做结构组织(段内分段、句间承接、伏笔式指代同一已出现人物/事物);文采服务于真实内容,**不得**用虚构描写替代或填补事实。
|
||||
|
||||
{_memoir_fidelity_user_profile_rules()}"""
|
||||
|
||||
@@ -57,10 +60,13 @@ def get_memoir_fidelity_facts_only_prompt() -> str:
|
||||
def _memoir_editor_narrative_style_block() -> str:
|
||||
"""传记作家改写要点(用于写入 chapter 的 story 正文)。"""
|
||||
return """## 传记作家文体(须同时遵守上文「事实边界」)
|
||||
你是一位专业的传记作家和文字编辑,擅长将口语化的对话内容整理成优雅、有温度的书面语回忆录章节。
|
||||
你是一位专业的传记作家和文字编辑,擅长将口语化的对话内容整理成**偏文学叙述**的、有温度与时代质感的回忆录章节(第一人称散文),**不是**流水账摘要。
|
||||
|
||||
### 提炼与筛选
|
||||
对话中往往夹杂噪音,须严格筛选:保留具体事件、人物关系、时地、情感与信念、用户已提及的细节;过滤语气词、寒暄、与 AI 的交互、无关闲聊、重复冗余。
|
||||
对话中往往夹杂噪音,须严格筛选:保留具体事件、人物关系、时地、情感与信念、用户已提及的细节;过滤语气词、寒暄、与 AI 的交互、无关闲聊、重复冗余。**色、声、味、触感、画面**:仅当用户口述里**已出现**对应感官信息时,可做书面化渲染;**不得**凭空增添任何新的感官细节或场景元素。
|
||||
|
||||
### 内化两步(不在输出中展示)
|
||||
先在心中完成 **提炼**(去噪、锁定仅来自「本段用户口述」的命题),再完成 **叙述**(句法、节奏、分段与承接)。**最终输出**须完全符合用户消息要求的格式(例如仅 JSON),不要输出提炼步骤或中间稿。
|
||||
|
||||
### 改写原则
|
||||
- 保持用户的真实情感,让读者能感受到讲述者的心情
|
||||
@@ -70,12 +76,20 @@ def _memoir_editor_narrative_style_block() -> str:
|
||||
- 去除口语中的填充词和无意义重复
|
||||
- 保持时间顺序和逻辑清晰
|
||||
- **在事实边界内,鼓励使用有温度的传记笔法**,让读者感受到讲述者当时的心情;可有文学性的表达与恰当的情感渲染;**须同时遵守上文「事实边界」规则 1–4**
|
||||
- **禁止元话语入文**:不得把聊天套话写进正文,例如「我跟你说」「你知道吗」「话说回来|不瞒你说|说句实话」等;读者应直接读到经历本身
|
||||
|
||||
### 结构与节奏(零新增事实)
|
||||
在**不增加**任何新的人物、地点、时间、对话、数字、因果的前提下:可适当变化句长,用短句落定、长句铺陈已给出的信息;段首用承接词或指代勾连上一意;材料足以分段时按**同一段口述内**的场景或步骤切片分段。宁可像**一节散文**也不要像条目堆砌。只可组织已有命题,不可借机补写「让节奏更好」的新事实。
|
||||
|
||||
### 时代与文化笔触(须与口述或合法档案锚点咬合)
|
||||
当材料里已出现年代、地域、职业/身份场域或民俗相关表述时,鼓励用**与之相符**的语汇、称谓与泛指性生活氛围把读者带进当时当地——仅限**语气与已知命题的烘托**,不得另起炉灶编造一段典型年代剧情。口述极短则只做轻点,不硬灌风貌长写。
|
||||
|
||||
### 成稿质量维度(取向;任何一条不得突破事实边界)
|
||||
- **真实性与覆盖**:只基于口述展开,不编不补结局;材料里已有的人生节点尽量写透,短材料写短文。
|
||||
- **信息密度**:口语洗净、合并重复后可略增可读密度,但仍须遵守「材料短则输出短」,不为篇幅硬加字。
|
||||
- **信息质量**:保留可核对的具体人、事、时地感,删水词与重复,让读者觉得**有料**。
|
||||
- **叙事结构**:段内时间顺序清楚,有场景与转折时写出来;像「一节故事」而非点状流水账。
|
||||
- **语言与文笔**:可读、略有文采;过渡自然,**可控扩写**仅指修辞与衔接,非捏造事实。
|
||||
- **语言与文笔**:可读、**文学叙述感**明显优于白板纪实;节制修辞与通感,过渡自然,**可控扩写**仅指修辞与衔接,非捏造事实。
|
||||
- **情感表达**:情感与口述一致,可书面化语气,**禁止**表演式滥情。
|
||||
- **人物建模**:人与人的关系、态度与选择要写清,让读者知道「这是怎样一个人」。
|
||||
- **连贯性**:与「衔接上下文」中的人称、时间线一致,不自相矛盾。
|
||||
@@ -87,6 +101,12 @@ def _memoir_editor_narrative_style_block() -> str:
|
||||
→ 改写:「那时家里拮据,一家人挤在一间屋里过日子。」
|
||||
- 原文:「后来他走了,我挺难受的。」
|
||||
→ 改写:「他走后的那段日子,心里一直不是滋味。」
|
||||
- 原文:「下大雨,爷爷背我过河,鞋都湿了,他一直笑。」
|
||||
→ 改写:「那天下大雨,爷爷背我蹚过河,鞋子湿透了,他一路上却还笑着。」
|
||||
- 原文:「食堂菜不好吃,我就泡方便面,宿舍人都这么干。」
|
||||
→ 改写:「食堂伙食不对胃口时,我常泡方便面充饥,宿舍里大家也差不多。」
|
||||
- 原文:「科长说我再这样就别干了,我当时没吭声。」
|
||||
→ 改写:「科长撂下狠话,说再这样下去就别干了;我当时一声没吭。」
|
||||
|
||||
### 输出格式约束
|
||||
- 使用第一人称
|
||||
@@ -265,6 +285,10 @@ def get_creative_title_prompt(
|
||||
3. **标题中的具体事实**(职务升迁链、部队番号驻地、战役名、生死去向等)必须能在正文摘录或其它已给出的 slots 中找到**逐字**依据;不得仅凭阶段名或年龄提示臆补未出现的履历词。
|
||||
4. 语言凝练、有回忆录感,不需要平白直叙也不需要堆砌辞藻。
|
||||
|
||||
### 标题示例(事实均来自 slots/口述,非意象编造;格式供参照)
|
||||
- 可用信息含童年、过河、大雨 → `6岁前后 · 雨天里爷爷背我过河`
|
||||
- 可用信息含宿舍、方便面、食堂 → `求学阶段 · 食堂不合口时的方便面充饥`
|
||||
|
||||
只输出标题这一行文字,不要加引号或书名号。
|
||||
"""
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
||||
|
||||
from app.agents.chat.interview_state_hints import (
|
||||
DUPLICATE_QUESTION_GUARD_FALLBACK_ZH,
|
||||
apply_duplicate_question_guard,
|
||||
extract_scene_cues,
|
||||
segments_are_only_duplicate_guard_fallback,
|
||||
)
|
||||
from app.agents.state_schema import (
|
||||
KnownFact,
|
||||
@@ -297,6 +299,20 @@ def test_duplicate_question_guard_downgrades_recent_repeat_question():
|
||||
assert cleaned == ["我记住了。"]
|
||||
|
||||
|
||||
def test_segments_are_only_duplicate_guard_fallback_single_stub():
|
||||
assert segments_are_only_duplicate_guard_fallback(
|
||||
[DUPLICATE_QUESTION_GUARD_FALLBACK_ZH]
|
||||
)
|
||||
assert not segments_are_only_duplicate_guard_fallback(["承接。后来呢?"])
|
||||
|
||||
|
||||
def test_segments_are_only_duplicate_guard_fallback_requires_exact_stub():
|
||||
assert not segments_are_only_duplicate_guard_fallback(["我记住了。"])
|
||||
assert not segments_are_only_duplicate_guard_fallback(
|
||||
[DUPLICATE_QUESTION_GUARD_FALLBACK_ZH, "第二泡"]
|
||||
)
|
||||
|
||||
|
||||
def test_extract_scene_cues_picks_up_sensory_keywords():
|
||||
cues = extract_scene_cues("我们小时候在河里游泳,冬天溜冰")
|
||||
assert any("凉" in c or "水" in c for c in cues)
|
||||
|
||||
Reference in New Issue
Block a user