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