Files
life-echo/api/app/agents/chat/interview_reply_length.py

312 lines
8.4 KiB
Python
Raw Normal View History

"""
访谈回复长度由用户本轮文本 + 启发式新细节 / 闲聊 / 信息密度决定档位
max_tokensmax_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,
)