"""把一屋子人的讨论变成工单上的一段记录。 ## 为什么这件事值得单独做 工单群里发生的才是**真正的处理过程**:问题怎么被复现的、试过什么、卡在哪、最后 是什么解决的。而工单上原来只有最初那句提问和最后一句「已解决」—— 中间那段是这条 链路上信息密度最高的部分,却只存在于一个迟早会被解散的房间里。 于是三处一起受损:交接的人要从头问一遍、知识库拿不到「这类问题怎么修」的语料、 复盘时没有任何过程可看。**总结写回工单**修的是这三处的同一个根。 ## 硬规矩:只写记录里出现过的东西 总结会成为工单上的一段**正式记录**,而工单是台账。所以提示词里最强的一条是 「不知道就写未提及」——一段编出来的「已尝试重启网关」比没有总结更糟:它会被下一个 人当成事实,而没有人会回头去核。 模型不可用时**不写任何东西**,并且在群里说清楚。写一段占位的假总结,是这条链路 上最坏的失败。 ## 输出是结构,不是散文 四段:问题 / 已经做了什么 / 待办 / 结论。结构化的好处不是好看 —— 是「待办」这一 栏空着的时候,一屋子人能一眼看出这件事其实没有下一步。 """ from __future__ import annotations import logging from dataclasses import dataclass from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from app.assistant.room_history import RoomTranscript from app.core.locale import DEFAULT_LANG, Lang logger = logging.getLogger(__name__) #: 总结跑在员工的一次群消息上,而群里的人正等着。比分类那 8 秒宽,因为它要读 #: 几十条消息;但仍然是**有界**的 —— 超时的代价是「这次没总结成」,不是把房间 #: 挂在那里。 _TIMEOUT = 45.0 _SYSTEM: dict[Lang, str] = { "zh": ( "你在给一个 IT 工单群里的讨论做记录。读下面的聊天记录,输出 JSON:\n" '{"problem": "…", "done": ["…"], "todo": ["…"], "conclusion": "…"}\n\n' "硬规矩:\n" "1. **只写聊天记录里出现过的信息**。没提到的一律写「未提及」或留空数组。\n" "2. 不要编测试步骤、错误码、时间、人名或结论。这段总结会成为工单上的\n" " 正式记录,下一个人会把它当成事实。\n" "3. done 是**已经发生**的事,todo 是**还没做**的事。分不清就放 todo。\n" "4. 每条一句话,不要客套,不要复述这条指令。\n" "5. 用中文。" ), "en": ( "You are keeping the record for a discussion in an IT ticket group. " "Read the transcript and output JSON:\n" '{"problem": "…", "done": ["…"], "todo": ["…"], "conclusion": "…"}\n\n' "Hard rules:\n" "1. **Only write what appears in the transcript.** Anything not " "mentioned is \"not mentioned\" or an empty array.\n" "2. Never invent steps, error codes, times, names or conclusions. This " "becomes a formal record on the ticket and the next person will read " "it as fact.\n" "3. done = already happened; todo = not done yet. If unsure, put it in " "todo.\n" "4. One sentence per item. No pleasantries.\n" "5. Write in English." ), "ja": ( "IT チケットのグループでの議論を記録します。以下のログを読み、JSON を" "出力してください:\n" '{"problem": "…", "done": ["…"], "todo": ["…"], "conclusion": "…"}\n\n' "厳守事項:\n" "1. **ログに現れた情報のみ**。触れられていないものは「未記載」または" "空配列。\n" "2. 手順・エラーコード・時刻・氏名・結論を創作しないこと。これはチケット" "上の正式な記録になります。\n" "3. done は既に起きたこと、todo は未実施のこと。迷ったら todo。\n" "4. 各項目は一文で。\n" "5. 日本語で書いてください。" ), } _LABELS: dict[str, dict[Lang, str]] = { "problem": {"zh": "问题", "en": "Problem", "ja": "課題"}, "done": {"zh": "已经做了", "en": "Done so far", "ja": "実施済み"}, "todo": {"zh": "待办", "en": "Still to do", "ja": "未対応"}, "conclusion": {"zh": "结论", "en": "Conclusion", "ja": "結論"}, } _HEADING: dict[Lang, str] = { "zh": "【群里的讨论小结】", "en": "[Summary of the room discussion]", "ja": "【グループ議論のまとめ】", } _TRUNCATED: dict[Lang, str] = { "zh": "(只读了最近的一部分消息,更早的讨论没有包含在内)", "en": "(only the most recent messages were read; earlier discussion is not included)", "ja": "(直近のメッセージのみを対象としています)", } _NOTHING: dict[Lang, str] = { "zh": "未提及", "en": "not mentioned", "ja": "未記載", } def _pick(table: dict[Lang, str], lang: Lang) -> str: return table.get(lang) or table[DEFAULT_LANG] @dataclass(frozen=True, slots=True) class RoomSummary: problem: str done: tuple[str, ...] todo: tuple[str, ...] conclusion: str truncated: bool def render(self, lang: Lang = DEFAULT_LANG) -> str: """给群里看、也给工单时间线看的那段文本。 一份文本两处用:群里发的和写进工单的必须逐字相同 —— 两个版本的总结会 让「群里说的」和「台账上写的」对不上,而那正是台账存在的意义。 """ def _line(key: str, value: str) -> str: return f"· {_pick(_LABELS[key], lang)}:{value or _pick(_NOTHING, lang)}" def _block(key: str, values: tuple[str, ...]) -> str: if not values: return _line(key, "") head = f"· {_pick(_LABELS[key], lang)}:" body = "\n".join(f" - {v}" for v in values) return f"{head}\n{body}" parts = [ _pick(_HEADING, lang), _line("problem", self.problem), _block("done", self.done), _block("todo", self.todo), _line("conclusion", self.conclusion), ] if self.truncated: parts.append(_pick(_TRUNCATED, lang)) return "\n".join(parts) def _clean_list(value: Any, *, limit: int = 8) -> tuple[str, ...]: if not isinstance(value, list): return () out: list[str] = [] for item in value: text = str(item or "").strip() if text: out.append(text[:300]) return tuple(out[:limit]) async def summarize( db: AsyncSession, *, transcript: RoomTranscript, ticket_title: str, lang: Lang = DEFAULT_LANG, ) -> RoomSummary | None: """把一段讨论总结成四栏。模型不可用或记录为空时返回 ``None``。 返回 ``None`` 的两种情况都必须由调用方**说出来**:一份空白的总结贴进工单, 读的人会以为讨论过而没有结论。 """ from app.domains.support.copilot import one_shot_json # noqa: PLC0415 body = transcript.render() if not body.strip() or not transcript.human_messages: return None payload = await one_shot_json( db, _pick(_SYSTEM, lang), f"【工单】{ticket_title}\n\n【聊天记录】\n{body}", timeout=_TIMEOUT, max_tokens=1200, ) if not payload: logger.info("assistant: room summary unavailable (no model answer)") return None return RoomSummary( problem=str(payload.get("problem") or "").strip()[:1000], done=_clean_list(payload.get("done")), todo=_clean_list(payload.get("todo")), conclusion=str(payload.get("conclusion") or "").strip()[:1000], truncated=transcript.truncated, ) __all__ = ["RoomSummary", "summarize"]