"""Hand the conversation to a human — the assistant's first write path. Deliberately keyword-triggered and deterministic. "Get me a person" is the one request that must work when the model is down, mis-configured, or confused, so nothing on this path consults it: the ticket draft is composed from the stored conversation by a pure function that already ships for the web copilot, and the employee still reviews the result before anything is filed. The write itself never happens here. This module only *offers* — it stores a proposal and sends the confirmation card. Pressing the button in that card is what reaches :mod:`app.assistant.proposals` and, through it, the support domain's own service. """ from __future__ import annotations import logging import uuid from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.assistant import channel, confirm_card, messages, session from app.assistant.actions import ACTION_SUPPORT_TICKET, get_action from app.assistant.models import CHANNEL_FEISHU, AssistantConversation from app.assistant.proposals import create_proposal from app.core.locale import DEFAULT_LANG, Lang from app.identity.person_models import IdentityPerson logger = logging.getLogger(__name__) #: Literal phrases, not intent detection. A request for a human must not depend #: on the model being available to recognise it. ESCALATION_COMMANDS = frozenset({ "转人工", "转人工客服", "人工", "开工单", "提工单", "找 it", "找it", "/ticket", "/human", "human", "raise a ticket", "open a ticket", "有人吗", "人に代わって", "チケット", "担当者", }) #: 打字说「转人工」和按答案卡上的「💬 转人工」是**两件不同的事**,所以这段话 #: 必须说清它做的是哪一件。 #: #: 打字这条路是自由文本的请求 —— 助手不知道你指的是刚才哪一次问答,所以它按整段 #: 会话拟一张工单让你先看一眼(而且这条路刻意不依赖模型:「给我找个人」这件事 #: 必须在模型挂掉时照样能用)。答案卡上那颗按钮知道具体是哪一条回答,所以它能 #: 直接把负责人拉进群并把那条回答转过去。 #: #: 两边都叫「转人工」而做的事不一样,员工按下去的预期就会落空 —— 这正是那颗 #: 按钮从「转人工」拆成「建工单 / 转人工」的同一个理由。 _OFFER_SENT: dict[Lang, str] = { "zh": ( "我按我们刚才聊的内容拟了一张工单,点卡片里的按钮看一眼,确认了才会提交。\n" "如果你想直接把人拉进来聊,可以在某条回答下点「💬 转人工」。" ), "en": ( "I've drafted a ticket from our conversation — open the card to read it, " "and nothing is filed until you confirm.\n" "If you'd rather pull a person in right now, tap 「💬 Ask a human」 under " "one of my answers." ), "ja": ( "これまでのやり取りから依頼票を作成しました。カードから内容をご確認ください。" "確認するまで送信されません。\n" "すぐに担当者を呼びたい場合は、回答の下の「💬 担当者に相談」をタップして" "ください。" ), } def is_escalation_request(text: str) -> bool: return text.strip().lower() in {c.lower() for c in ESCALATION_COMMANDS} def _pick(table: dict[Lang, str], lang: Lang) -> str: return table.get(lang) or table[DEFAULT_LANG] async def _current_conversation_id( db: AsyncSession, *, person: IdentityPerson, chat_id: str, ) -> uuid.UUID | None: row = ( await db.execute( select(AssistantConversation.id) .where( AssistantConversation.person_id == person.id, AssistantConversation.channel == CHANNEL_FEISHU, AssistantConversation.chat_id == chat_id, ) .order_by(AssistantConversation.last_message_at.desc()) .limit(1), ) ).scalars().first() return row async def offer_ticket( db: AsyncSession, *, person: IdentityPerson, chat_id: str, open_id: str, lang: Lang, trace_id: str, extra_user_message: str = "", ) -> str: """Draft a ticket from the thread and send it for confirmation. Returns ``offered`` when the card is on its way, ``failed`` when it could not be delivered — in which case the employee is told where to go instead rather than left with silence. ``extra_user_message`` is the sentence the employee typed *as* the description, after being asked for one. It joins the thread for drafting only — the turn that would have written it to history never ran, because a request for a human must not depend on the model. **An empty thread does not become a ticket.** 转人工 is reachable before a single word has been exchanged (a button on the help card, or the phrase typed as the first message), and drafting from nothing produced a ticket titled 「AI 求助升级」 whose body was one line of boilerplate. IT then has to ask what happened — the exact round trip this path exists to remove. So that case asks for one sentence first and returns ``awaiting_detail``; the next message is turned into the ticket without a model hop. """ from app.core.rbac import is_active_employee # noqa: PLC0415 from app.domains.support.copilot import ( # noqa: PLC0415 draft_ticket_from_history, ) # The same gate the propose path applies, because this path reaches the same # domain service. ``create_proposal`` is called directly here — deliberately, # so 「转人工」 works when the model does not — which also meant it skipped the # check: a pre-hire or a confirmed leaver tapping 转人工 got a card, approved # it, and then met a red 「没能提交」 with no explanation at execute time. Read # access stays; only the submission is refused, with a reason. if not is_active_employee(person): logger.info( "assistant: escalation refused, %s is not an active employee (%s)", person.id, person.employment_status, ) await channel.send_text( db, to=channel.ReplyTarget.dm(open_id), text=messages.cannot_submit_yet(lang), ) return "not_active_employee" history = await session.load_history(db, person_id=person.id, chat_id=chat_id) if extra_user_message.strip(): history = [*history, {"role": "user", "content": extra_user_message.strip()}] if not history: armed = await session.arm_handoff(person_id=person.id, chat_id=chat_id) await channel.send_text( db, to=channel.ReplyTarget.dm(open_id), # 记不下这一笔(Redis 不可用)时换一句措辞:那种情况下员工的下一句 # 会照常交给模型,所以不能承诺「下一条直接变工单」——承诺一件做不到 # 的事,比少一句承诺更伤。 text=( messages.handoff_needs_detail(lang) if armed else messages.handoff_describe_it(lang) ), ) return "awaiting_detail" title, body = draft_ticket_from_history(history) action = get_action(ACTION_SUPPORT_TICKET) assert action is not None # noqa: S101 — registry constant, not input params = action.normalise({ "title": title, "body": body, "category": "other", "priority": "normal", }) issued = await create_proposal( db, person=person, action_key=ACTION_SUPPORT_TICKET, params=params, conversation_id=await _current_conversation_id( db, person=person, chat_id=chat_id, ), trace_id=trace_id, ) await db.commit() url = await confirm_card.confirm_url( db, token=issued.token, digest=issued.proposal.params_hash, ) card = confirm_card.build_confirmation_card( action=action, params=params, digest=issued.proposal.params_hash, url=url, lang=lang, # Without this the card gets ONLY the URL button — and since the # confirm page now requires a Helix session, this flow (the one an # employee reaches AFTER self-service already failed them) would be # the single path in the product that costs an SSO round trip in the # Feishu in-app browser. ``propose_tools`` always passed it; this call # site did not, which quietly made the round-6 docstring's "the page is # a fallback behind a native button" false exactly here. proposal_id=str(issued.proposal.id), ) delivered = await channel.send_card( db, to=channel.ReplyTarget.dm(open_id), card=card, idempotency_key=f"assistant-proposal-{issued.proposal.id}", ) if not delivered: await channel.send_text( db, to=channel.ReplyTarget.dm(open_id), text=messages.offer_failed(lang), ) # Distinct from a model failure: "the card never arrived" and "the turn # blew up" need different answers when someone reads the audit later. return "card_undelivered" await channel.send_text( db, to=channel.ReplyTarget.dm(open_id), text=_pick(_OFFER_SENT, lang), ) return "offered"