"""转人工:把问题、人、和负责人放进同一个群里。 ## 这解决的是什么 助手答不上来时,原来只有一条路:建一张工单,然后员工等。工单是对的 —— 一件活 必须在台账上有一行,否则它就是一件没人能查、没有 SLA、交接就消失的活。但工单 **慢**,而很多问题只要把对的人拉进来聊两句就完了。 所以「转人工」现在做三件事:AI 把问题归类 → 按后台配的路由找到那一类的负责人 → 建一个飞书群,把员工和负责人拉进来,**把那条回复原样转发进去**。工单照旧建,群 链接挂在工单上 —— 一条记录,两个面。 ## 分类与路由刻意分开 模型只负责把问题归到一个**已存在的** `category_key` 上(闭集,不允许它发明新类); 「这一类找谁」由 `assistant_escalation_routes` 表决定。于是模型判错的后果是 「找错了人」—— 一个人能一眼看出并改派的错;而不是「凭空产生一个没人负责的类别」。 ## 降级是正式路径,不是兜底 三种情况下没有群:这一类还没指派负责人、建群失败、功能被关掉。**每一种都仍然 建工单**,并且在台账行上记下 `degraded_reason`。员工得到的答复是「已转 IT,工单号 X」而不是一个假装拉到了人的句子 —— 拉不到人时说拉到了,是这条链路最坏的失败。 ## 幂等 飞书会重投它判为慢的回调,而**建群是不可撤销的外部动作**。`assistant_handoffs` 在 `message_id` 上有唯一约束:第二次点击(或第二次回调)拿到的是同一个群,不是 第二个群。 """ from __future__ import annotations import json import logging import uuid from dataclasses import dataclass from typing import Any from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.assistant.models import ( CHAT_REASON_HANDOFF, AssistantEscalationRoute, AssistantHandoff, ) from app.core.locale import DEFAULT_LANG, normalize_lang, pick_variant from app.identity.person_models import IdentityPerson logger = logging.getLogger(__name__) #: 关掉「转人工建群」的开关。关掉之后按钮仍在,但走降级路径(只建工单)—— #: 按钮消失会让员工以为功能坏了,而降级至少仍然把事情交出去了。 SETTING_ENABLED = "assistant.handoff_chat_enabled" #: 分类跑在员工点击的同步路径上(飞书回调只有 3 秒预算,慢了会走 followup)。 #: 拿不到分类会走兜底路由,所以宁可早放手。 _CLASSIFY_TIMEOUT = 8.0 #: 低于这个置信度就走兜底路由。不是丢掉分类结果 —— 它照样记进台账供复核, #: 只是不拿它去拉人。 _MIN_CONFIDENCE = 0.5 @dataclass(frozen=True, slots=True) class Classification: category_key: str | None confidence: float reason: str @dataclass(frozen=True, slots=True) class HandoffResult: """一次转人工的结果,给卡片文案用。""" chat_created: bool owner_name: str | None category_label: str | None degraded_reason: str | None def route_label(route: AssistantEscalationRoute, lang: str | None) -> str: """路由的显示名,按读者的语言。""" raw = route.display_name if isinstance(route.display_name, dict) else {} text = pick_variant( {k: str(v) for k, v in raw.items() if isinstance(v, str)}, normalize_lang(lang) or DEFAULT_LANG, ) return text or str(raw.get("zh") or route.category_key) async def load_routes(db: AsyncSession) -> list[AssistantEscalationRoute]: """启用的路由,按显示顺序。""" return list((await db.execute( select(AssistantEscalationRoute) .where(AssistantEscalationRoute.enabled.is_(True)) .order_by( AssistantEscalationRoute.sort_order, AssistantEscalationRoute.category_key, ) )).scalars().all()) async def classify( db: AsyncSession, *, question: str, answer: str, routes: list[AssistantEscalationRoute], ) -> Classification: """把问题归到一个**已存在的** category_key 上。 闭集:提示词里只给出这些键,并且返回值会被再校验一次 —— 模型给出一个不在 集合里的键(它会的)等同于「没分类出来」,走兜底路由。这一层校验是必须的, 不是防御性编程:一个发明出来的类别键会静默变成「没人负责」。 任何失败(LLM 没配、超时、JSON 坏了)都返回一个空分类而不是抛错 —— 转人工 的价值在于把事情交出去,不该被分类环节挡住。 """ if not routes: return Classification(None, 0.0, "no routes configured") try: # 走 support.copilot 的 ``_one_shot_llm``:它已经是「当前用哪个后端 + # AI 总开关有没有开」的唯一出处,而那件事全平台只该有一处判断。自己再 # 读一遍平台设置就会多出一个会漂移的副本。 from app.domains.support.copilot import _one_shot_llm # noqa: PLC0415 catalogue = "\n".join( f"- {r.category_key}: {route_label(r, 'zh')}" + (f" —— {r.hint}" if r.hint else "") for r in routes ) system = ( "你是 IT 服务台的分派员。把员工的问题归到下面**已有**的类别之一," "只能从给定的 key 里选,不要发明新的 key。\n\n" f"可选类别:\n{catalogue}\n\n" '返回 JSON:{"category": "", ' '"confidence": <0~1 的小数>, "reason": "<一句中文理由>"}。' "拿不准就把 confidence 给低,不要硬选。" ) user = f"【员工的问题】\n{question[:2000]}\n\n【助手没解决的回答】\n{answer[:2000]}" # 分类跑在员工点击的同步路径上,所以给一个短超时:拿不到分类会走兜底 # 路由,而让员工干等一分钟不会。 raw = await _one_shot_llm(db, system, user, timeout=_CLASSIFY_TIMEOUT) if not raw: return Classification(None, 0.0, "llm unavailable") payload = parse_json_object(raw) except Exception as exc: # noqa: BLE001 — 分类不该挡住转交 logger.info("assistant handoff: classification unavailable: %s", exc) return Classification(None, 0.0, f"classifier error: {exc}"[:400]) category = payload.get("category") valid = {r.category_key for r in routes} if not isinstance(category, str) or category not in valid: # 模型发明了一个键,或者诚实地给了 null。两种都当「没分类出来」。 return Classification( None, 0.0, f"model returned unusable category {category!r}", ) try: confidence = float(payload.get("confidence") or 0.0) except (TypeError, ValueError): confidence = 0.0 reason = str(payload.get("reason") or "")[:1000] return Classification(category, max(0.0, min(1.0, confidence)), reason) def _pick_route( routes: list[AssistantEscalationRoute], classification: Classification, ) -> AssistantEscalationRoute | None: """分类结果 → 一条路由。置信度不够或没分出来时用兜底路由。""" if classification.category_key and classification.confidence >= _MIN_CONFIDENCE: for route in routes: if route.category_key == classification.category_key: return route for route in routes: if route.is_default: return route return None async def existing_handoff( db: AsyncSession, *, message_id: uuid.UUID, ) -> AssistantHandoff | None: """这条回复是不是已经转过人工了。 幂等的读侧:飞书重投回调、或员工连点两下,都必须拿到同一个群。 """ return (await db.execute( select(AssistantHandoff).where(AssistantHandoff.message_id == message_id) )).scalars().first() async def hand_off( db: AsyncSession, *, person: IdentityPerson, message_id: uuid.UUID, question: str, answer: str, feishu_message_id: str | None, lang: str | None, ticket: Any, ) -> HandoffResult: """分类 → 找人 → 建群 → 转发 → **把工单指派给这个人**。 调用方**必须**已经建好工单:群是快路径,工单是台账,而台账不能依赖一次外部 调用是否成功。 🔴 **指派是这条链路上一直缺的那一步。** 在此之前,群拉起来了、负责人在里面 聊着,而工单在队列里仍然「无人认领」—— 于是 SLA 的首次响应永远不会被记上, 看板上它是一条没人管的记录,而实际上有人正在处理它。把人拉进房间和把活交给 他,必须是同一个动作。 """ from app.integrations.service import get_platform_setting # noqa: PLC0415 routes = await load_routes(db) classification = await classify( db, question=question, answer=answer, routes=routes, ) route = _pick_route(routes, classification) enabled = await get_platform_setting(SETTING_ENABLED, True, session=db) owner: IdentityPerson | None = None degraded: str | None = None owner_note: str | None = None if not enabled: degraded = "disabled" elif route is None or ( route.owner_person_id is None and route.backup_owner_person_id is None ): # 一条启用了但还没指派负责人的路由是正常状态(刚建好、上一任刚离职)。 degraded = "no_owner" else: # 「他今天在吗」—— 主负责人休假中就拉备份。一个休假两周的负责人会让 # 每一次转人工都把员工和一个不会回话的人关进一个房间,而员工会**以为** # 有人在看。见 ``owner_reach``。 from app.assistant import owner_reach # noqa: PLC0415 choice = await owner_reach.reachable_owner(db, route=route) owner = choice.person owner_note = choice.reason if owner is None: degraded = "no_owner" chat_id: str | None = None if owner is not None: chat_id = await _open_chat( db, person=person, owner=owner, label=route_label(route, lang) if route else "", feishu_message_id=feishu_message_id, question=question, answer=answer, lang=lang, ticket=ticket, owner_note=owner_note, ) if chat_id is None: degraded = "chat_failed" else: # 群成了 ⇒ 这张工单有主了。**顺序是刻意的**:先有房间再指派,这样 # 「指派」这条通知里给出的入口是一个真的存在的群。 await _assign_ticket(db, ticket=ticket, owner=owner) row = AssistantHandoff( message_id=message_id, person_id=person.id, route_id=route.id if route else None, classified_category=classification.category_key, classified_confidence=classification.confidence, classified_reason=classification.reason, feishu_chat_id=chat_id, ticket_id=ticket.id if ticket is not None else None, degraded_reason=degraded, ) db.add(row) try: await db.flush() except IntegrityError: # 唯一约束挡住了第二次 —— 说明另一次回调已经转过了。回滚到那次的结果上, # 而不是报错:员工的两次点击都该看到同一个答复。 await db.rollback() prior = await existing_handoff(db, message_id=message_id) if prior is not None: return HandoffResult( chat_created=bool(prior.feishu_chat_id), owner_name=None, category_label=None, degraded_reason=prior.degraded_reason, ) raise return HandoffResult( chat_created=chat_id is not None, owner_name=(owner.display_name or owner.work_email) if owner else None, category_label=route_label(route, lang) if route else None, degraded_reason=degraded, ) #: 「为什么是这个人」。缺一句解释的后果很具体:员工看到一个陌生的名字会以为 #: 拉错了人,而备份负责人自己也需要知道他是被顶上来的。 _OWNER_NOTE: dict[str, dict[str, str]] = { "owner_on_leave": { "zh": "(这一类的负责人今天在休假,先由备份负责人接手。)", "en": "(The owner for this area is on leave today; the backup is taking it.)", "ja": "(このカテゴリの担当者は休暇中のため、バックアップが対応します。)", }, "owner_on_leave_no_backup": { "zh": "(这一类的负责人今天在休假,可能回复得慢一些。)", "en": "(The owner for this area is on leave today, so a reply may be slower.)", "ja": "(担当者は休暇中のため、返信が遅くなる可能性があります。)", }, "owner_missing": { "zh": "(这一类还没指派负责人,先由备份负责人接手。)", "en": "(This area has no owner assigned yet; the backup is taking it.)", "ja": "(このカテゴリは担当者未割り当てのため、バックアップが対応します。)", }, "owner_unreachable": { "zh": "(这一类的负责人暂时联系不上,先由备份负责人接手。)", "en": "(The owner for this area is unreachable; the backup is taking it.)", "ja": "(担当者に連絡が取れないため、バックアップが対応します。)", }, } #: 群里的开场白。说清三件事:谁在问、为什么拉群、以及下面那条是原始回答。 _CHAT_INTRO: dict[str, str] = { "zh": ( "{who} 在 Helix 助手里问了一个问题,助手没解决,按「{label}」转到你这里。\n" "下面转发的是助手当时的回答,原始提问是:{question}" ), "en": ( "{who} asked Helix's assistant something it could not resolve; routed " "to you as “{label}”.\nThe assistant's answer is forwarded below. The " "original question: {question}" ), "ja": ( "{who} さんの質問をアシスタントが解決できなかったため、「{label}」として" "お繋ぎしました。\n以下にアシスタントの回答を転送します。元の質問:{question}" ), } async def _open_chat( db: AsyncSession, *, person: IdentityPerson, owner: IdentityPerson, label: str, feishu_message_id: str | None, question: str, answer: str, lang: str | None, ticket: Any, owner_note: str | None = None, ) -> str | None: """建群、拉人、发开场白、转发那条回答。失败返回 ``None``(调用方降级)。 建群这件事本身在 :mod:`app.assistant.ticket_chat` ——「工单 ↔ 群」的唯一 注册表在那里,幂等、卡片、解散也都在那里。这个函数只负责**转人工特有的那 部分**:开场白要说清「助手没解决,按某某类转到你这里」,以及把那条回答原样 转发进去。 整段 best-effort:建群是外部调用,而这条链路的价值上限是「快」,不是「一定 有群」。失败时工单已经在了,员工不会掉进黑洞。 """ from app.assistant import ticket_chat # noqa: PLC0415 — cycle if ticket is None: # 没有工单就没有群:群是工单的第二个面,先有台账再有房间。这条分支只在 # 调用方违约时才可能走到(``hand_off`` 的文档要求先建工单)。 logger.warning("assistant handoff: refusing to open a chat with no ticket") return None key = normalize_lang(lang) or DEFAULT_LANG who = person.display_name or person.work_email or "" intro = _CHAT_INTRO.get(key, _CHAT_INTRO["zh"]).format( who=who, label=label or "IT", question=question[:300], ) if owner_note: # 说清「为什么是这个人」。员工看到一个陌生的名字会以为拉错了人,而 # 备份负责人自己也需要知道他是被顶上来的。 note = _OWNER_NOTE.get(owner_note, {}).get(key) if note: intro = f"{intro}\n{note}" result = await ticket_chat.open_for_ticket( db, ticket=ticket, requester=person, members=[owner], opened_by=None, reason=CHAT_REASON_HANDOFF, intro_text=intro, forward_feishu_message_id=feishu_message_id, # 转发失败时贴的那段字。群里**必须**有那段回答。 forward_fallback_text=answer or None, # 群名里给人读的那一段是**类别**,不是工单号:负责人的会话列表里 # 「IT 求助 · 权限与账号 · 张三」一眼能认,「HELP-20260908-0007」不能。 name_hint=label or None, portal_url=f"/me/support/tickets/{ticket.id}", ) if result is None: return None # 同一张工单已经有群了(第二次点、或者飞书重投)时 ``created`` 为假,而 # ``open_for_ticket`` 那一侧不会重发开场白:幂等在这条链路上的含义是 # 「拿到同一个群」,不是「再喊一次」。 return result.chat.feishu_chat_id async def _assign_ticket( db: AsyncSession, *, ticket: Any, owner: IdentityPerson, ) -> None: """把工单指派给被拉进群的那个人。 走支持域自己的 ``assign`` 而不是直接写 ``assignee_id``:那个方法会记一行 时间线、写审计、并在需要时把状态从 ``open`` 推到 ``triaging`` —— 「有人接手 了」这件事在三个地方都要看得见,而绕过服务只改列会让另外两处静默落空。 best-effort:指派失败不该让群和工单都没了。失败时工单仍在队列里(无人认领), 那是一个人能看见并处理的状态。 """ from app.domains.support.service import SupportService # noqa: PLC0415 if ticket is None or getattr(ticket, "assignee_id", None) == owner.id: return try: await SupportService(db).assign( ticket=ticket, assignee=owner, # 指派的执行者是平台自己(是路由表决定了这个人),但支持域的时间线 # 需要一个「谁做的」—— 用被指派人本人,读起来就是「他接手了」。 actor=owner, trace_id="", ) except Exception as exc: # noqa: BLE001 — 群已经有了,指派是加分项 logger.warning( "assistant handoff: could not assign ticket to %s: %s", owner.id, exc, ) def parse_json_object(text: str) -> dict: """从模型回复里取出那个 JSON 对象,容忍 ```json 围栏。""" cleaned = text.strip() if cleaned.startswith("```"): cleaned = cleaned.split("\n", 1)[-1] if cleaned.endswith("```"): cleaned = cleaned[: -3] try: parsed = json.loads(cleaned) except (ValueError, TypeError): return {} return parsed if isinstance(parsed, dict) else {} __all__ = [ "SETTING_ENABLED", "Classification", "HandoffResult", "classify", "existing_handoff", "hand_off", "load_routes", "parse_json_object", "route_label", ]