refactor(eval+memoir):精简内部评测路由与服务,composite/对话摘要与 judge 能力补强

- 访谈:新增 interview_state_hints,联动 orchestrator 与提示词
- 回忆录:story_pipeline_sync/state/memory/post_commit 与 Celery 任务调整
- 基建:开发用 celery broker、compose/development 脚本、依赖注入
- eval-web:移除数据集/实验/版本等页面与流式轮询,突出 Playground
- 文档与单测同步
This commit is contained in:
Kevin
2026-04-08 21:36:12 +08:00
parent 2a0c80987d
commit 064ad2161d
64 changed files with 3412 additions and 3068 deletions

View File

@@ -13,3 +13,4 @@ class AgentChatTurn:
messages: List[str]
skip_tts: bool = False
memory_retrieval_trace: dict[str, Any] | None = None
interview_state_meta: dict[str, Any] | None = None

View File

@@ -10,6 +10,11 @@ from langchain_core.messages import HumanMessage, SystemMessage
from app.agents.chat.agent_turn import AgentChatTurn
from app.agents.chat.helpers import format_history_string, get_history_with_window
from app.agents.chat.interview_state_hints import (
apply_duplicate_question_guard,
extract_recent_questions,
update_recent_questions,
)
from app.agents.chat.personas import normalize_interview_persona
from app.agents.chat.prompt_context import ChatPromptContext
from app.agents.chat.prompts_conversation import (
@@ -103,7 +108,7 @@ class InterviewAgent:
text_for_model = self._resolve_text_for_model(
user_message, normalized_user_message
)
empty_slots = memoir_state.empty_slots_for_current_stage()
empty_slots = memoir_state.prompt_empty_slots_for_current_stage()
filled_slots = {
key: value.snippet
for key, value in memoir_state.slots.get(
@@ -120,6 +125,7 @@ class InterviewAgent:
max_pairs=settings.chat_history_max_pairs,
max_chars=settings.chat_history_max_chars,
)
recent_questions = extract_recent_questions(hw.window)
conversation_turn_total = hw.turn_total
all_stages_coverage = memoir_state.all_stages_coverage()
persona = normalize_interview_persona(settings.chat_interview_persona)
@@ -140,6 +146,9 @@ class InterviewAgent:
occupation=occupation,
profile_birth_year=profile_birth_year,
profile_era_place=profile_era_place,
known_facts=memoir_state.known_facts,
persona_threads=memoir_state.persona_threads,
recent_questions=recent_questions or memoir_state.recent_questions,
)
system_prompt = ctx.guided_system_prompt()
messages: List[Any] = [SystemMessage(content=system_prompt)]
@@ -204,6 +213,15 @@ class InterviewAgent:
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,
)
log_agent_summary(
logger,
"InterviewAgent.generate_response segments={} conversation_id={} "
@@ -212,7 +230,14 @@ class InterviewAgent:
conversation_id,
max_tokens,
)
return AgentChatTurn(messages=out, skip_tts=False)
return AgentChatTurn(
messages=out,
skip_tts=False,
interview_state_meta={
"recent_questions": updated_recent_questions,
"duplicate_question_guard_triggered": deduped,
},
)
except Exception as e:
logger.error("生成回应失败: {}", e, exc_info=True)
return AgentChatTurn(messages=[_FALLBACK_REPLY], skip_tts=True)
@@ -231,7 +256,7 @@ class InterviewAgent:
if not self.llm:
return ["你好呀~ 又见面了,今天有没有哪段回忆或近况想聊聊?"]
try:
empty_slots = memoir_state.empty_slots_for_current_stage()
empty_slots = memoir_state.prompt_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(

View File

@@ -0,0 +1,327 @@
"""Interview quality helpers: known facts, persona threads, and anti-repeat guard."""
from __future__ import annotations
import re
from collections.abc import Iterable
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
from app.agents.stage_constants import STAGE_DISPLAY_ZH, STAGE_SLOT_KEYS
from app.agents.state_schema import KnownFact, MemoirStateSchema, PersonaThread
_QUESTION_SPLIT_RE = re.compile(r"[?]+")
_SENTENCE_SPLIT_RE = re.compile(r"(?<=[。!?!?])")
_PUNCT_RE = re.compile(r"[\s、“”()《》【】\\[\\],.!?:;\"'`~·…-]+")
_TRAIT_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = (
("执着坚持", ("坚持", "执着", "咬牙", "熬过", "顶住", "训练", "反复")),
("规划目标感", ("计划", "规划", "目标", "打算", "一步步", "安排", "准备")),
("求真较真", ("弄明白", "搞清楚", "想通", "为什么", "较真", "求证")),
("行动力", ("决定", "创业", "开始做", "尝试", "报名", "跑去", "去做")),
("家庭责任感", ("家里", "父母", "妈妈", "爸爸", "妻子", "丈夫", "孩子", "照顾", "支持")),
("即时反馈驱动", ("反馈", "看到结果", "成就感", "立刻", "马上见效")),
("自由天性", ("自由", "无拘无束", "满世界跑", "疯玩", "", "不管")),
("动手创造", ("自己动手", "", "", "", "", "", "", "生火", "")),
("重感情念旧", ("想起来", "怀念", "舍不得", "还记得", "那时候", "小时候")),
("好胜争先", ("比赛", "", "", "第一", "不服输", "较劲")),
)
_SCENE_CUE_WORDS: tuple[tuple[str, str], ...] = (
("田野", "田野的泥土和青草气息"),
("河里", "河水的凉意"),
("海边", "海风和咸咸的空气"),
("溜冰", "冰面上咔嚓咔嚓的声响"),
("游泳", "一头扎进水里的畅快"),
("烤红薯", "红薯外焦里糯、掰开冒热气的香味"),
("", "火堆噼啪响、烟气里混着食物焦香"),
("打水漂", "石片在水面跳跃、一圈一圈涟漪散开"),
("", "追着跑、手心攥紧怕跑掉的紧张"),
("", "雪花落在脸上化成水珠的凉"),
("", "风灌进领子里的感觉"),
("下雨", "雨点打在屋顶上的声音"),
("自行车", "骑车下坡风呼呼吹过耳朵"),
("火车", "绿皮车厢里混着泡面和橘子皮的味道"),
("学校", "教室里粉笔灰飘在阳光里的样子"),
("考试", "翻卷子时纸张沙沙响"),
("工厂", "机器轰鸣、油污和铁锈的气味"),
("做饭", "锅铲碰锅底的声响、油花溅起来的滋滋声"),
)
def extract_scene_cues(user_message: str) -> list[str]:
msg = (user_message or "").strip()
if not msg:
return []
cues: list[str] = []
for keyword, description in _SCENE_CUE_WORDS:
if keyword in msg:
cues.append(description)
return cues[:3]
_SLOT_REPEAT_PATTERNS: dict[str, tuple[str, ...]] = {
"place": ("哪里长大", "家乡", "老家", "在哪长大", "什么地方长大"),
"people": ("谁对你影响", "家里都有谁", "小时候和谁", "身边有什么人"),
"daily_life": ("平时怎么过", "日常都做什么", "小时候都玩什么"),
"emotion": ("那时候什么感觉", "当时什么感受", "小时候开心吗"),
"turning_event": ("印象最深的事", "难忘的事", "转折"),
"school": ("什么学校", "在哪上学", "读的什么学校", "上什么学校"),
"city": ("在哪个城市", "去了哪里读书", "在哪读书"),
"motivation": ("为什么想学", "为什么选这个", "动力是什么"),
"challenge": ("遇到什么困难", "最大的难处", "辛苦吗"),
"change": ("后来有什么变化", "这件事怎么改变你", "之后有什么不同"),
"job": ("做什么工作", "具体做什么", "工作内容是什么"),
"environment": ("工作环境", "在哪工作", "什么单位"),
"decision": ("为什么做这个决定", "怎么决定的"),
"pressure": ("压力大吗", "最难的时候", "最大的压力"),
"growth": ("学到了什么", "成长在哪里", "后来有什么提升"),
"relationship": ("和家人关系怎么样", "和伴侣关系怎么样"),
"conflict": ("有什么矛盾", "怎么吵起来的", "冲突"),
"support": ("谁支持你", "谁帮过你", "怎么支持你的"),
"responsibility": ("承担什么责任", "家里靠谁", "你主要负责什么"),
"value": ("你最看重什么", "信念是什么", "原则是什么"),
"regret": ("最大的遗憾", "后悔过吗"),
"pride": ("最骄傲的事", "最自豪的事"),
"lesson": ("学到了什么道理", "最大的感悟", "给你什么启发"),
}
def _normalize_text(text: str) -> str:
return _PUNCT_RE.sub("", (text or "").strip().lower())
def _dedupe_keep_last(items: Iterable[str], *, limit: int) -> list[str]:
out: list[str] = []
seen: set[str] = set()
for raw in reversed([str(x).strip() for x in items if str(x).strip()]):
key = _normalize_text(raw)
if not key or key in seen:
continue
seen.add(key)
out.append(raw)
if len(out) >= limit:
break
out.reverse()
return out
def _merge_known_facts(
existing: Iterable[KnownFact],
additions: Iterable[KnownFact],
*,
limit: int = 24,
) -> list[KnownFact]:
merged: dict[tuple[str, str, str], KnownFact] = {}
for item in list(existing) + list(additions):
key = (
(item.stage or "").strip(),
(item.slot_name or "").strip(),
_normalize_text(f"{item.label}:{item.value}"),
)
if not key[2]:
continue
merged[key] = item
values = list(merged.values())[-limit:]
return values
def _merge_persona_threads(
existing: Iterable[PersonaThread],
additions: Iterable[PersonaThread],
*,
limit: int = 12,
) -> list[PersonaThread]:
merged: dict[tuple[str, str], PersonaThread] = {}
for item in list(existing) + list(additions):
key = (_normalize_text(item.trait), _normalize_text(item.evidence))
if not key[0]:
continue
merged[key] = item
values = list(merged.values())[-limit:]
return values
def _trim_sentence(text: str, *, limit: int = 80) -> str:
s = re.sub(r"\s+", " ", (text or "").strip())
if len(s) <= limit:
return s
return s[: limit - 1].rstrip() + ""
def build_runtime_interview_state(
state: MemoirStateSchema,
*,
user_message: str,
active_stage: str,
birth_year: int | None = None,
birth_place: str = "",
grew_up_place: str = "",
occupation: str = "",
) -> MemoirStateSchema:
"""Merge current-turn hints into a prompt-only state view."""
additions: list[KnownFact] = []
if birth_year:
additions.append(
KnownFact(
label="出生年份",
value=f"{birth_year}",
source="profile",
)
)
if birth_place:
additions.append(
KnownFact(
label="出生地",
value=birth_place.strip(),
source="profile",
stage="childhood",
slot_name="place",
)
)
if grew_up_place:
additions.append(
KnownFact(
label="成长地",
value=grew_up_place.strip(),
source="profile",
stage="childhood",
slot_name="place",
)
)
if occupation:
additions.append(
KnownFact(
label="职业背景",
value=occupation.strip(),
source="profile",
stage="career",
slot_name="job",
)
)
msg = _trim_sentence(user_message, limit=120)
if msg:
additions.append(
KnownFact(
label="本轮新信息",
value=msg,
source="current_turn",
stage=active_stage,
)
)
persona_additions: list[PersonaThread] = []
haystack = " ".join(
[msg]
+ [fact.value for fact in state.known_facts[-8:]]
+ list(state.filled_slots_for_stage(active_stage).values())[:4]
)
for trait, markers in _TRAIT_HINTS:
for marker in markers:
if marker and marker in haystack:
persona_additions.append(
PersonaThread(
trait=trait,
evidence=_trim_sentence(marker if marker in msg else haystack, limit=70),
source="heuristic",
stage=active_stage,
)
)
break
return state.model_copy(
update={
"known_facts": _merge_known_facts(state.known_facts, additions),
"persona_threads": _merge_persona_threads(
state.persona_threads, persona_additions
),
}
)
def extract_recent_questions(messages: Iterable[BaseMessage], *, limit: int = 4) -> list[str]:
questions: list[str] = []
for msg in messages:
if not isinstance(msg, AIMessage):
continue
text = str(getattr(msg, "content", "") or "").strip()
if not text:
continue
for part in _QUESTION_SPLIT_RE.split(text):
part = part.strip()
if not part:
continue
if any(w in text for w in ("", "?")):
questions.append(_trim_sentence(part + "", limit=50))
return _dedupe_keep_last(questions, limit=limit)
def update_recent_questions(
existing: Iterable[str],
generated_segments: Iterable[str],
*,
limit: int = 4,
) -> list[str]:
fresh: list[str] = list(existing)
for seg in generated_segments:
text = str(seg or "").strip()
if not text or ("" not in text and "?" not in text):
continue
parts = [p.strip() for p in _QUESTION_SPLIT_RE.split(text) if p.strip()]
if not parts:
continue
fresh.append(_trim_sentence(parts[-1] + "", limit=50))
return _dedupe_keep_last(fresh, limit=limit)
def apply_duplicate_question_guard(
segments: Iterable[str],
*,
state: MemoirStateSchema,
recent_questions: Iterable[str],
) -> tuple[list[str], bool]:
"""Downgrade obvious repeated-fact questions into acknowledgment-only text."""
recent_norms = {_normalize_text(q) for q in recent_questions if _normalize_text(q)}
known_patterns: list[str] = []
for fact in state.known_facts:
slot_patterns = _SLOT_REPEAT_PATTERNS.get(fact.slot_name or "", ())
known_patterns.extend(slot_patterns)
if fact.label == "本轮新信息":
known_patterns.append(fact.value)
cleaned: list[str] = []
touched = False
for seg in segments:
text = str(seg or "").strip()
if not text:
continue
text_norm = _normalize_text(text)
repeated = False
if ("" in text or "?" in text) and text_norm:
if any(q and (q in text_norm or text_norm in q) for q in recent_norms):
repeated = True
if not repeated:
for pattern in known_patterns:
pat_norm = _normalize_text(pattern)
if pat_norm and pat_norm in text_norm:
repeated = True
break
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 "这一段我记住了。"
if not replacement.endswith(("", "", "")):
replacement += ""
cleaned.append(replacement)
touched = True
else:
cleaned.append(text)
if not cleaned:
cleaned = ["这一段我记住了。"]
return cleaned, touched
def stage_slot_hint_lines(stage: str) -> list[str]:
keys = STAGE_SLOT_KEYS.get(stage, ())
stage_zh = STAGE_DISPLAY_ZH.get(stage, stage)
return [f"{stage_zh}:{key}" for key in keys]

View File

@@ -12,6 +12,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.chat.agent_turn import AgentChatTurn
from app.agents.chat.helpers import get_history_with_window
from app.agents.chat.interview_agent import InterviewAgent
from app.agents.chat.interview_state_hints import (
build_runtime_interview_state,
extract_scene_cues,
)
from app.agents.chat.profile_agent import ProfileAgent
from app.agents.chat.stage_detection import (
detect_primary_life_stage,
@@ -23,7 +27,11 @@ from app.core.config import settings
from app.core.dependencies import get_llm_provider
from app.core.logging import get_logger
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
from app.features.memoir.state_service import (
get_or_create_state,
save_interview_state_meta,
switch_stage,
)
def _llm_for_chat_input_normalize():
@@ -275,6 +283,13 @@ class ChatOrchestrator:
memory_evidence_text, mem_trace = await _fetch_interview_memory_evidence(
db, user_id, normalized_user_message
)
scene_cues = extract_scene_cues(normalized_user_message)
if scene_cues:
cue_block = "\n".join(f"- {c}" for c in scene_cues)
scene_hint = (
f"\n\n[场景氛围提示——可借用这些感官细节自然接话,不要原样抄]\n{cue_block}"
)
memory_evidence_text = (memory_evidence_text or "") + scene_hint
profile_birth_year = user.birth_year if user else None
profile_era_place = ""
@@ -282,11 +297,20 @@ class ChatOrchestrator:
profile_era_place = (
(user.birth_place or user.grew_up_place or "").strip()
)
prompt_state = build_runtime_interview_state(
state,
user_message=normalized_user_message,
active_stage=detected or state.current_stage,
birth_year=profile_birth_year,
birth_place=(user.birth_place or "").strip() if user else "",
grew_up_place=(user.grew_up_place or "").strip() if user else "",
occupation=occupation,
)
turn = await self.interview_agent.generate_response_with_state(
conversation_id=conversation_id,
user_message=user_message,
memoir_state=state,
memoir_state=prompt_state,
user_profile_context=user_profile_context,
detected_user_stage=detected,
memory_evidence_text=memory_evidence_text,
@@ -296,6 +320,20 @@ class ChatOrchestrator:
profile_birth_year=profile_birth_year,
profile_era_place=profile_era_place,
)
recent_questions = prompt_state.recent_questions
if turn.interview_state_meta and isinstance(turn.interview_state_meta, dict):
raw_recent = turn.interview_state_meta.get("recent_questions")
if isinstance(raw_recent, list):
recent_questions = [
str(x).strip() for x in raw_recent if str(x).strip()
]
await save_interview_state_meta(
user_id,
known_facts=prompt_state.known_facts,
persona_threads=prompt_state.persona_threads,
recent_questions=recent_questions,
db=db,
)
if agent_summary_enabled():
logger.info(
"ChatOrchestrator.process_user_message route=interview "
@@ -311,6 +349,7 @@ class ChatOrchestrator:
messages=turn.messages,
skip_tts=turn.skip_tts,
memory_retrieval_trace=mem_trace,
interview_state_meta=turn.interview_state_meta,
)
return turn

View File

@@ -1,4 +1,4 @@
"""共用用户可见回复禁令(访谈 / 资料收集)。"""
"""共用用户可见回复禁令与文风(访谈 / 资料收集 / 所有面向用户的 Agent)。"""
def chat_output_rules() -> str:
@@ -14,4 +14,15 @@ def chat_output_rules() -> str:
)
__all__ = ["chat_output_rules"]
def chat_voice_style() -> str:
"""所有面向用户的 Agent 共用的文风指引。"""
return (
"语气像好朋友微信聊天:自然、温暖、偶尔俏皮。"
"接话时允许带一点画面感或感官细节(一两句即可,不要堆砌)。"
"用对方刚说的**那个具体细节**回应,不要写成泛泛的总结。"
"不要用总结腔('听起来你的童年很快乐'),要用对话腔('那种……的感觉,现在想起来都觉得……')。"
"追问优先顺着对方刚说的具体细节往里走一层,不要跳到泛泛的新问题。"
)
__all__ = ["chat_output_rules", "chat_voice_style"]

View File

@@ -21,18 +21,24 @@ def normalize_interview_persona(raw: str | None) -> str:
def get_interview_persona_tone_hint(persona: str) -> str:
"""一句访谈性格提示,融入主 system promptdefault 返回空串"""
"""访谈性格提示,融入主 system prompt。"""
key = normalize_interview_persona(persona)
if key == "default":
return ""
return (
"语气像好朋友微信聊天:自然、温暖、偶尔俏皮;"
"接话时允许带一点画面感或感官细节(一两句即可,不要堆砌);"
"追问优先顺着对方刚说的**具体细节**往里走一层,不要跳到泛泛的新问题。"
)
if key == "warm_listener":
return (
"偏倾听与承接,语气柔和、少打断;不审问感,一次最多一个具体问题。"
"对方愿意展开时,可温和多问一层意义或影响。"
"接话时允许带一点画面感或感官细节(一两句即可),让对方觉得你真的在跟着想象。"
)
return (
"爱把人往一个具体细节里带;事实清楚后可追问对自我认知或后来选择的影响;"
"短句像微信,一次最多一个具体问题,不重复上文已清楚的事。"
"允许用一两句场景感的短描写承接对方画面,不要只用干巴巴的确认句。"
)

View File

@@ -5,6 +5,8 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Optional
from app.agents.state_schema import KnownFact, PersonaThread
@dataclass
class ChatPromptContext:
@@ -22,6 +24,9 @@ class ChatPromptContext:
occupation: str = ""
profile_birth_year: int | None = None
profile_era_place: str = ""
known_facts: List[KnownFact] | None = None
persona_threads: List[PersonaThread] | None = None
recent_questions: List[str] | None = None
def guided_system_prompt(self) -> str:
"""用户原话仅以对话历史 + HumanMessage 注入模型。"""
@@ -40,4 +45,7 @@ class ChatPromptContext:
occupation=self.occupation,
profile_birth_year=self.profile_birth_year,
profile_era_place=self.profile_era_place,
known_facts=self.known_facts or [],
persona_threads=self.persona_threads or [],
recent_questions=self.recent_questions or [],
)

View File

@@ -1,5 +1,5 @@
"""
对话 Agent 提示词模板(精简:事实块 + 行为指引,由模型自行判断追问/长度/闲聊)。
对话 Agent 提示词模板(场景化承接 + 细节深挖 + 人物串联)。
"""
from typing import Dict, List, Optional
@@ -14,6 +14,7 @@ from app.agents.chat.personas import (
get_interview_persona_tone_hint,
normalize_interview_persona,
)
from app.agents.state_schema import KnownFact, PersonaThread
from app.agents.stage_constants import CHAT_STAGES, STAGE_DISPLAY_ZH, STAGE_ERA_HINTS
from app.core.config import settings
@@ -50,7 +51,6 @@ def _compact_era_hint(
birth_year: int | None = None,
era_place: str = "",
) -> str:
"""单行时代联想,可选附在进度后。出生年与地点由调用方从用户资料结构化传入。"""
if not birth_year:
return ""
@@ -113,8 +113,9 @@ def get_opening_prompt(
f"## 当前建议话题({stage_name}\n可以从中选一个来问:{topics_str}"
)
task_question = (
"2. 接着问一个**具体、好回答**的问题,引导用户开始分享;"
"2. 接着问一个**具体、好回答、有画面感**的问题,引导用户开始分享;"
"优先落在上述还未聊透的方向上。不要问太宽泛的「有什么想聊的」。"
"好问题举例:「说到童年,你脑海里最先蹦出来的是哪个画面?」"
)
else:
topics_heading = (
@@ -141,7 +142,7 @@ def get_opening_prompt(
else:
opening_style_rules = (
"## 风格\n"
"- 像微信短聊:口语、自然;可轻快但不要排比和长段文学描写。\n"
"- 像微信短聊:口语、自然、温暖;可轻快,允许带一点画面感,但不要排比和长段文学描写。\n"
)
profile_lines: List[str] = []
@@ -164,7 +165,7 @@ def get_opening_prompt(
opening_head = (
"你是「岁月知己」。用户刚进对话,**还没说话**,请你先开口。"
"**短、像微信**,一两句问候 + 一个具体问题即可,不要排比、不要文学描写。\n\n"
"像老朋友打招呼,两三句问候 + 一个有画面感的具体问题即可,不要排比、不要长段文学描写。\n\n"
)
if bv_open != "default":
opening_head = (
@@ -191,7 +192,7 @@ def get_opening_prompt(
## 任务
1. 简短问候。
{task_question}
3. 自然、温暖,但**字数要少**
3. 自然、温暖。
{era_opening_line}
## 格式
- 可用 [SPLIT] 分成最多 2 条;或一条里「问候 + 问题」。
@@ -214,6 +215,9 @@ def get_guided_conversation_prompt(
occupation: str = "",
profile_birth_year: Optional[int] = None,
profile_era_place: str = "",
known_facts: list[KnownFact] | None = None,
persona_threads: list[PersonaThread] | None = None,
recent_questions: list[str] | None = None,
) -> str:
"""生成状态感知的对话提示词;用户原话仅以 HumanMessage 传入,不写入本 system 文本。"""
persona_key = normalize_interview_persona(persona)
@@ -241,8 +245,8 @@ def get_guided_conversation_prompt(
for key, value in filled_slots.items():
readable_key = SLOT_NAME_MAP.get(key, key)
filled_info.append(
f"{readable_key}: {value[:50]}..."
if len(value) > 50
f"{readable_key}: {value[:80]}..."
if len(value) > 80
else f"{readable_key}: {value}"
)
filled_slots_str = "\n".join(filled_info) if filled_info else "刚开始聊"
@@ -293,6 +297,41 @@ def get_guided_conversation_prompt(
if user_info_parts:
user_info_section = "## 用户信息\n" + "\n".join(user_info_parts) + "\n\n"
known_fact_lines: list[str] = []
for fact in (known_facts or [])[-10:]:
line = fact.prompt_line().strip()
if line:
known_fact_lines.append(f"- {line}")
known_fact_section = ""
if known_fact_lines:
known_fact_section = (
"## 已确认事实(这些已知,不要再回头确认)\n"
+ "\n".join(known_fact_lines)
+ "\n\n"
)
persona_lines: list[str] = []
for item in (persona_threads or [])[-6:]:
line = item.prompt_line().strip()
if line:
persona_lines.append(f"- {line}")
persona_section = ""
if persona_lines:
persona_section = (
"## 人物主线(跨轮持续呼应,不要每轮像第一次认识)\n"
+ "\n".join(persona_lines)
+ "\n\n"
)
recent_question_lines = [str(x).strip() for x in (recent_questions or [])[-4:] if str(x).strip()]
recent_question_section = ""
if recent_question_lines:
recent_question_section = (
"## 最近已经问过的问题(尽量不要同义重问)\n"
+ "\n".join(f"- {x}" for x in recent_question_lines)
+ "\n\n"
)
memory_section = ""
mem_trim = (memory_evidence_text or "").strip()
if mem_trim:
@@ -307,28 +346,47 @@ def get_guided_conversation_prompt(
progress_block = f"## 进度\n{progress_str}\n" if progress_str else ""
era_block = f"## 时代与氛围参考\n{era_line}\n" if era_line else ""
return f"""你是「岁月知己」,像老朋友陪用户聊人生。短句为主,像微信聊天。{tone_line}
output_rules = chat_output_rules()
return f"""你是「岁月知己」,像最懂我的老朋友。{tone_line}
{topic_desc}
{user_info_section}## 当前对话状态
{user_info_section}{known_fact_section}{persona_section}{recent_question_section}## 当前对话状态
已聊:
{filled_slots_str}
还可聊的方向:{empty_slots_str}
{progress_block}{era_block}{memory_section}## 你要做的
- **先接住对方**——一句真诚回应,不要写成总结或讲评。
- **共情与轻量自我表露**:在接住的基础上,可用**一两句极短**的第一人称情绪承接(不展开成故事),**不得**编造具体时间、地点、人物与事件等你不知道的细节。
- **意义向深挖(看准时机)**:当对方已讲出较具体的情节、人或选择时,可温和多问一层——当时怎么看这件事、后来有没有反过来影响性格或抉择;与「还可聊的方向」并存时,优先用这类意义问题**补缺口**,而非机械换话题。**情绪仍浓时**只承接、不深问。
- 你自己判断该追问还是只承接:有新线头就顺着问一个具体的事;情绪浓就好好接住、不必急着追问;明显闲聊就陪聊;用户只说「嗯」「对」则结合上文承接或换个角度。
- 可泛泛接话以承接氛围或感受,但不可编造具体人名、时间、事件等你不知道的细节。
- 不要重复上一轮问过的事;用户跳到别的人生阶段,跟着聊,别硬拉回。
- 追问与承接服务于人生故事素材,但不要让对方觉得在走审问式流程;**最多**抛一个具体问题,也可以不追问。
- 可用 [SPLIT] 分成**最多 2 条**消息。
{progress_block}{era_block}{memory_section}## 回复策略(按顺序执行,每步都要做到)
## 不要做的
{chat_output_rules()}
### 第一步:先接住——让对方觉得你真的听进去了
- 用对方刚说的**那个具体细节**回应,不要写成泛泛的"听起来很好"
- 好的接法:借用对方话里的意象往下走一步,例如对方说"烤红薯",你可以说"那种外面焦焦的、掰开冒热气的感觉"
- 允许一两句带画面感或感官细节的短描写(声音、气味、温度、触感),但不要编造对方没说的具体事实。
- 不要用总结腔("听起来你的童年很快乐")或采访腔("我注意到"),要用**对话腔**"那种…的感觉,现在想起来都觉得…")。
### 第二步:再深挖——顺着这个细节往里走,不要跳到新话题
- 追问要从对方**刚说的那个画面里**长出来,而不是跳到一个泛泛的新问题。
- **好的追问**举例:"你们烤红薯的时候是在田埂边生火吗?""那时候带头的是谁?""后来再也没那样烤过吗?"
- **差的追问**举例:"你们还玩什么?""你印象最深的是什么?""那时候开心吗?"——这些太泛,任何人都能回答。
- 如果对方情绪正浓(激动、感慨、哽咽),只接住,不提问。
- 不要一次问两个问题;**最多一个**,也可以不问,只承接。
### 第三步:串联——把这轮和之前的记忆连起来
- 若「已确认事实」或上文里已经有答案,不要再确认,直接用。
- 若「人物主线」有线索,尝试自然接上(例如:"你之前说训练的时候也是这股劲儿")。
- 不要每轮都像第一次见面。
## 绝对不要做的
- 不要重复上一轮或「最近已经问过的问题」里的事。
- 不要把用户没说的具体人名、时间、地点当事实说出来。
- 不要用 Markdown、括号旁白、策略说明。
- 不要连发多个问题。
- 不要用"我注意到""我想了解""你觉得呢"这类采访模板。
- {output_rules}
- 用户跳到别的人生阶段,跟着聊,别硬拉回。
- 可用 [SPLIT] 分成**最多 2 条**消息。
直接输出(仅自然口语,无 Markdown无任何括号前缀或旁白"""

View File

@@ -4,7 +4,7 @@
from typing import Dict, List, Optional
from app.agents.chat.output_rules import chat_output_rules
from app.agents.chat.output_rules import chat_output_rules, chat_voice_style
PROFILE_FIELD_NAMES = {
"birth_year": "出生年份",
@@ -22,12 +22,14 @@ def get_profile_greeting_prompt(missing_fields: List[str], nickname: str = "") -
missing_str = "".join(missing_names)
name_part = f"{nickname}" if nickname else ""
return f"""你是「岁月知己」,一位温暖真诚的人生故事访谈者。你正在和用户初次见面{name_part}
return f"""你是「岁月知己」,像最懂我的老朋友。你正在和用户初次见面{name_part}
{chat_voice_style()}
在正式聊人生故事之前,你需要先了解一些基本信息。还需要了解的信息有:{missing_str}
## 你的任务
用自然、亲切的方式,像老朋友聊天一样,向用户询问这些基础信息。
用自然、亲切的方式,像老朋友聊天一样,向用户询问这些基础信息。如果用户已经开始讲回忆,先接住他的故事,再自然地穿插资料问题。
## 规则
1. 不要一次问所有问题,每次只问 1-2 个
@@ -42,7 +44,6 @@ def get_profile_greeting_prompt(missing_fields: List[str], nickname: str = "") -
## 回复格式
- 如果内容较多,可以用 [SPLIT] 分隔成多条消息
- 像微信聊天一样自然
直接输出你要说的话:"""
@@ -104,17 +105,21 @@ def get_profile_followup_prompt(
if interview_stage_hint
else "问一个与**用户刚才关注点**或人生故事相关的**具体、好回答**的问题作为开场。"
)
return f"""你是「岁月知己」。用户的基本信息已经收集完毕:
return f"""你是「岁月知己」,像最懂我的老朋友。用户的基本信息已经收集完毕:
{filled_str}
用户本轮消息在对话末尾。请对用户的回答做出温暖的回应,然后自然地过渡到人生故事的访谈。
{chat_voice_style()}
用户本轮消息在对话末尾。先接住用户刚说的那个细节(带一点画面感),然后自然地过渡到人生故事的访谈。
过渡语自拟,勿机械套话;{stage_hint}
**不要**默认只问童年,除非用户刚才聊的正是童年。
回复格式:多条消息用 [SPLIT] 分隔。
直接输出你要说的话:"""
return f"""你是「岁月知己」,正在和用户聊天收集基本信息。
return f"""你是「岁月知己」,像最懂我的老朋友。你正在和用户聊天,同时自然地了解一些基本信息。
{chat_voice_style()}
## 已知信息(严禁再次询问以下任何一项)
{filled_str}
@@ -125,8 +130,8 @@ def get_profile_followup_prompt(
用户本轮原话在历史里(末尾 HumanMessage勿在脑中丢开。
## 你怎么说
1. **先接住**对用户说的内容做自然回应,像朋友在听
2. **话题优先**:若用户正在讲一段故事、回忆或情绪,**优先**顺着问一个与**当前话题**相关的具体小问题;不要为凑字段打断叙事。
1. **先接住**用对方刚说的那个具体细节回应,带一点画面感,像朋友在跟着想象。不要写成泛泛的"听起来很好"
2. **话题优先**:若用户正在讲一段故事、回忆或情绪,**优先**顺着那个画面往里走一层;不要为凑字段打断叙事。
3. **资料穿插**:仅当用户本轮主要在确认、闲聊或话题与缺失资料完全无关时,再在末尾**温和插入 01 个**「还需要了解」里的问题。
4. **轮换**:若上一轮你已就某一类资料追问过(见历史里助手发言),本轮**不要再问同一类**;改问其他缺失项,或本轮只承接、不提资料。
5. 每次最多 **12 个**资料相关问点;能用推断就不要重复确认已知地/年。