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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
327
api/app/agents/chat/interview_state_hints.py
Normal file
327
api/app/agents/chat/interview_state_hints.py
Normal 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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -21,18 +21,24 @@ def normalize_interview_persona(raw: str | None) -> str:
|
||||
|
||||
|
||||
def get_interview_persona_tone_hint(persona: str) -> str:
|
||||
"""一句访谈性格提示,融入主 system prompt;default 返回空串。"""
|
||||
"""访谈性格提示,融入主 system prompt。"""
|
||||
key = normalize_interview_persona(persona)
|
||||
if key == "default":
|
||||
return ""
|
||||
return (
|
||||
"语气像好朋友微信聊天:自然、温暖、偶尔俏皮;"
|
||||
"接话时允许带一点画面感或感官细节(一两句即可,不要堆砌);"
|
||||
"追问优先顺着对方刚说的**具体细节**往里走一层,不要跳到泛泛的新问题。"
|
||||
)
|
||||
if key == "warm_listener":
|
||||
return (
|
||||
"偏倾听与承接,语气柔和、少打断;不审问感,一次最多一个具体问题。"
|
||||
"对方愿意展开时,可温和多问一层意义或影响。"
|
||||
"接话时允许带一点画面感或感官细节(一两句即可),让对方觉得你真的在跟着想象。"
|
||||
)
|
||||
return (
|
||||
"爱把人往一个具体细节里带;事实清楚后可追问对自我认知或后来选择的影响;"
|
||||
"短句像微信,一次最多一个具体问题,不重复上文已清楚的事。"
|
||||
"允许用一两句场景感的短描写承接对方画面,不要只用干巴巴的确认句。"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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 [],
|
||||
)
|
||||
|
||||
@@ -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,无任何括号前缀或旁白):"""
|
||||
|
||||
|
||||
@@ -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. **资料穿插**:仅当用户本轮主要在确认、闲聊或话题与缺失资料完全无关时,再在末尾**温和插入 0~1 个**「还需要了解」里的问题。
|
||||
4. **轮换**:若上一轮你已就某一类资料追问过(见历史里助手发言),本轮**不要再问同一类**;改问其他缺失项,或本轮只承接、不提资料。
|
||||
5. 每次最多 **1~2 个**资料相关问点;能用推断就不要重复确认已知地/年。
|
||||
|
||||
Reference in New Issue
Block a user