Files
life-echo/api/app/agents/chat/interview_reply_length.py
Kevin 69a673e6c6 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
2026-03-31 23:55:26 +08:00

358 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
访谈回复长度:由用户本轮文本 + 启发式(新细节 / 闲聊 / 信息密度)决定档位,
与 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
)