"""上下文组装(架构 §7.2 步骤③)的纯逻辑:无 IO、无 LLM 调用,可单测。 预算分配(packages/agent/README.md 的预算表): - 画像摘要 ≤ 500 token —— 按中文约 1.6 字符/token 保守取 800 字符上限, 摘要本身的生产侧(report 域)按 ``PROFILE_SUMMARY_MAX_CHARS`` 裁剪; - 历史轮数默认 10 轮(``AGENT_HISTORY_TURNS``),一轮 = user + assistant 两条。 缓存纪律:画像与历史都是**逐用户逐轮变化**的内容,一律走 gateway 的 ``messages`` 通道排在固定 system 前缀之后,绝不进 ``system_prefix``—— 否则缓存命中单价 1/30 的红利会被画像差异完全打掉(README「两件容易踩的事」)。 """ from __future__ import annotations from collections.abc import Sequence from packages.llm.types import Message #: 画像摘要载荷上限(字符),对应 ≤ 500 token 的预算行。 PROFILE_SUMMARY_MAX_CHARS = 800 #: 画像摘要的消息壳。摘要是 JSON 素材(report 域 compose_prompt_material 的 #: 产物),需要这句说明告诉模型怎么用它;措辞保持中性,不引入新规则。 _PROFILE_MESSAGE_TEMPLATE = ( "以下是该用户最近一份美学诊断报告的画像摘要(JSON)," "回答时据此给出贴合其条件的建议;用户未问及时不要主动复述全文。\n" ) #: 单条历史消息的字符上限:用户输入已被 ChatRequest 卡在 2000 字符, #: assistant 回复被提示词约束在 300 字以内,这里只做兜底截断防脏数据。 _HISTORY_MESSAGE_MAX_CHARS = 2000 def build_profile_message(profile_summary: str) -> Message: return Message(role="system", content=_PROFILE_MESSAGE_TEMPLATE + profile_summary) def build_context_messages( *, profile_summary: str | None, history: Sequence[Message], history_turns: int, ) -> list[Message]: """组装 gateway ``messages`` 通道的内容(固定前缀与当前输入由 gateway 自己拼)。 - 画像摘要在最前(无画像用户为 None 即不注入,走通用建议降级路径 FR-AGT-10); - 历史按时间正序,只保留最近 ``history_turns`` 轮(一轮 = user+assistant 两条), 截尾不截头——越早的轮次对当前回答价值越低。 """ messages: list[Message] = [] if profile_summary: messages.append(build_profile_message(profile_summary)) tail = list(history)[-(history_turns * 2) :] if history_turns > 0 else [] for item in tail: messages.append(Message(role=item.role, content=item.content[:_HISTORY_MESSAGE_MAX_CHARS])) return messages