feat(api): 访谈人格/回复长度策略、口述归一、背景语气与输入净稿全链路
Chat 访谈 - 新增 persona 系统(default / warm_listener / curious_guide)与 background_voice 语气层 - 回复长度由 compute_reply_plan 统一决策(brief / standard / expanded),融合信息密度启发式 - 输入净稿(input_normalize):编排层可选 rules/llm 归一用户口语后再喂模型与记忆检索 - 记忆证据注入:按用户话检索 memory evidence 并注入 prompt Memoir 回忆录 - 口述归一(oral_normalize):segment 原文保留,story 管线取派生净稿作叙事输入 - segment 入队批次门闸:累计字数 + 最长等待秒数,减少零碎提交 - fidelity_check / prompts / narrative_agent 微调 - Alembic 0005:清理跨章节 story 外键 Infra - Dockerfile 加入 ffmpeg - pyproject.toml 新增依赖并同步 uv.lock - .env.example / .env.production 补全新配置项 Tests - 新增 test_background_voice、test_chat_input_normalize、test_experience_regressions - 扩展 test_interview_prompts、test_interview_reply_length、test_story_route_oral_invariant Made-with: Cursor
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user