"""The answer, rendered so the employee can act on it and rate it. A plain text reply ends the exchange whether or not it worked. Nothing records that it missed, nothing links the document it came from, and an employee it did not help has to compose a new sentence to get anywhere. The help desk this assistant is replacing does better than that, and the gap showed. So a *grounded* answer — one the assistant produced by actually reading the employee's data or the knowledge base — is sent as a card carrying three affordances: * **有用 / 没用** — the only reliable signal that the answer landed. Deflection counts tickets not filed, which cannot tell a satisfied employee from one who gave up. * **参考文档** — the mirrored Feishu docs the answer drew on, linked. The KB search already returns the source URL; rendering it here is deterministic rather than hoping the model repeats it. * **转人工** — one tap to a human when the answer did not do it, instead of re-typing the whole problem. Clarifying questions and one-line acknowledgements stay plain text: a card around 「想好别名叫什么了告诉我」 is ceremony, and the ceremony is what made the earlier version feel heavy. """ from __future__ import annotations from typing import Any from app.core.locale import DEFAULT_LANG, Lang #: Button payload actions, routed by :mod:`app.assistant.card_actions`. ACTION_HELPFUL = "assistant.feedback_helpful" ACTION_UNHELPFUL = "assistant.feedback_unhelpful" ACTION_FEEDBACK_REASON = "assistant.feedback_reason" #: 建工单 —— 异步、有台账、有 SLA。原来这颗按钮叫「转人工」,但它做的一直是 #: 建工单;名字和行为对不上,员工按预期落空。 ACTION_ESCALATE = "assistant.escalate" #: 转人工 —— 同步:AI 归类 → 找到那一类的负责人 → 建群把人拉进来 + 转发这条 #: 回答。它**也**建工单(群里的活必须在台账上有一行),所以它是 escalate 的 #: 超集而不是替代。 ACTION_HANDOFF = "assistant.handoff" #: Answers shorter than this are acknowledgements, not findings. Tuned for #: Chinese, which is dense: a real answer 「你目前持有 2 台电脑,都是 MacBook #: Pro,保修分别到…」 is 57 characters, while 「想好别名叫什么了告诉我」 is 20. #: A 60-character bar — the first guess — would have silenced the real answer. MIN_CHARS_FOR_CARD = 40 #: A reply that ends in a question mark is asking, not answering, however long #: it is. Rating buttons under a question invite a verdict on nothing. _QUESTION_ENDINGS = ("?", "?") #: Never more than this many document links: a card is a glance, and the #: fourth link is one nobody reads. MAX_DOC_LINKS = 3 #: 评价按钮的字要**短**:它们并排在自己那一行,长标签会把行挤到折行。 #: 「赞 / 踩」是用户提的措辞,也确实比「有用 / 没解决」短一半。 _HELPFUL: dict[Lang, str] = {"zh": "👍 赞", "en": "👍 Helpful", "ja": "👍 役立った"} _UNHELPFUL: dict[Lang, str] = { "zh": "👎 踩", "en": "👎 Didn't help", "ja": "👎 解決せず", } #: 动作按钮**不带 emoji**。飞书把按钮标签里的 emoji 渲染成彩色位图,在按钮 #: 尺寸下又大又抢眼 —— 用户的手机截图里 🎫 直接变成一张「演唱会门票」小图, #: 比没有更糟。这两个标签本身已经说清是什么了。 _ESCALATE: dict[Lang, str] = { "zh": "建工单", "en": "File a ticket", "ja": "チケット作成", } _HANDOFF: dict[Lang, str] = { "zh": "转人工", "en": "Ask a human", "ja": "担当者に相談", } _DOCS_TITLE: dict[Lang, str] = { "zh": "参考文档", "en": "Sources", "ja": "参考ドキュメント", } _THANKS: dict[Lang, str] = { "zh": "谢谢,已记下。", "en": "Thanks — noted.", "ja": "ありがとうございます。", } _SORRY: dict[Lang, str] = { "zh": "知道了,已记下。IT 会看到这条,用来补上缺的内容。", "en": "Noted. IT sees this and uses it to fill the gap.", "ja": "承知しました。IT が確認し、不足を補います。", } _REASON_PROMPT: dict[Lang, str] = { "zh": "哪里没有解决?(可选)", "en": "What missed? (optional)", "ja": "不足していた点(任意)", } _REASONS: dict[str, dict[Lang, str]] = { "incorrect": {"zh": "不正确", "en": "Incorrect", "ja": "不正確"}, "outdated": {"zh": "已过期", "en": "Outdated", "ja": "情報が古い"}, "incomplete": {"zh": "不完整", "en": "Incomplete", "ja": "情報不足"}, "still_needs_human": {"zh": "仍需人工", "en": "Need a human", "ja": "人の対応が必要"}, } def _pick(table: dict[Lang, str], lang: Lang) -> str: return table.get(lang) or table[DEFAULT_LANG] def _safe_markdown(text: str) -> str: """Neutralise the two characters Feishu reads as inline HTML. Every other Helix card goes through the notification layer's escaper; this one rendered model output straight into a ``lark_md`` element. The model's context carries third-party text — knowledge-base bodies, ticket titles, tool results — so an injected fragment renders inside a card that looks like the assistant's own answer. """ return text.replace("<", "<").replace(">", ">") def _safe_link_text(text: str) -> str: """A document title cannot be allowed to close its own link. ``f"[{title}]({url})"`` with a title containing ``](`` breaks out of the Markdown link and injects a second one pointing anywhere — a phishing link wearing the assistant's voice, planted by whoever can publish a KB article or name a ticket. """ return _safe_markdown(text).replace("]", "[").replace("[", "[") def _safe_url(url: str) -> str | None: """Only http(s), and only characters that cannot close the link early. Escaping the TITLE was half the job: ``f"[{title}]({url})"`` also ends at the first ``)`` in the URL, so a source whose href carries ``…/a)[Click here](https://evil.com`` renders as two links and the second points anywhere. Same injection round 6 closed, one field over. Percent-encoding rather than dropping, because ``)`` is legal in a URL and a wiki path may genuinely contain one. """ cleaned = url.strip() if cleaned.lower().startswith(("http://", "https://")) is False: return None if any(ch.isspace() for ch in cleaned): # A newline or space inside the href splits the element; no legitimate # mirrored URL has one. return None return cleaned.replace("(", "%28").replace(")", "%29") #: 一句「我answer不了,去找人」里会出现的词。这类回复**必须**带按钮 —— 见 #: :func:`should_render_as_card` 里那段理由。措辞取自 ``support.copilot`` 的两处 #: 兜底文案和模型常写的说法,三语都列。 _ESCALATION_HINTS: tuple[str, ...] = ( "转人工", "开工单", "建工单", "提工单", "联系 IT", "联系IT", "ask a human", "file a ticket", "open a ticket", "contact IT", "担当者", "チケット", ) def recommends_escalation(reply: str) -> bool: """这条回复是不是在让员工去找人 / 开工单。""" lowered = reply.lower() return any(hint.lower() in lowered for hint in _ESCALATION_HINTS) def should_render_as_card( reply: str, *, grounded: bool, has_sources: bool = False, ) -> bool: """Only a substantive, grounded answer earns a card. ``grounded`` means the turn actually called a tool or the server prefetched evidence. A model answering from its own memory has nothing worth rating against the knowledge base. A short answer normally stays plain text, but explicit sources still need the card surface so their links are delivered. 🔴 **一条「建议你转人工 / 开工单」的回复,无论多短,都必须是卡片。** 2026-09-07 生产实测:员工问「380838443567 这个云账号谁负责」,助手答 「这个问题有点复杂,建议「转人工 / 开工单」让 IT 同事帮你处理。」—— **而那条回复下面一个按钮都没有**。它只有 31 个字,低于 ``MIN_CHARS_FOR_CARD`` 的 40,于是被判成「短句,走纯文本」。 这是最刺眼的一种落空:**唯一一条明确叫员工去按按钮的回复,恰恰是不带按钮 的那一条**,而员工得自己回头去翻别的卡片、或者重新问一遍(用户就是这么 发现的:第二次问同一件事才拿到按钮)。字数门槛的本意是「别给『好的,已记下』 套卡」,它对这一类回复完全不适用。 """ text = reply.strip() if not grounded: return False if recommends_escalation(text): return True if len(text) < MIN_CHARS_FOR_CARD and not has_sources: return False return not text.endswith(_QUESTION_ENDINGS) def build_answer_card( *, reply: str, message_id: str, lang: Lang, docs: list[dict[str, str]] | None = None, ) -> dict[str, Any]: """The answer, its sources, and the three ways to respond to it.""" elements = _answer_elements(reply, docs, lang) # 两行。**不是**四颗塞一行 —— 那在手机上会折行,而且折得很难看 # (见 ``_button_row`` 的注释)。 # # 动作行:「转人工」和「建工单」是两件不同的事,所以是两颗按钮 —— 前者要 # 一个人现在就来搭话(建群),后者要一条有 SLA、能查、能交接的记录(工单)。 # 原来只有一颗写着「转人工」而实际建工单,员工按下去的预期和拿到的东西不 # 一样。转人工排在前面且它自己也会建工单,所以「想快」的人不必先想清楚要哪个。 elements.append(_button_row( [ _callback_button( _pick(_HANDOFF, lang), {"action": ACTION_HANDOFF, "message": message_id}, ), _callback_button( _pick(_ESCALATE, lang), {"action": ACTION_ESCALATE, "message": message_id}, ), ], )) # 评价行:自己一行,小字无边框。它是「这次答得怎么样」,比转交低一档。 elements.append(_button_row([ _feedback_button( _pick(_HELPFUL, lang), {"action": ACTION_HELPFUL, "message": message_id}, ), _feedback_button( _pick(_UNHELPFUL, lang), {"action": ACTION_UNHELPFUL, "message": message_id}, ), ])) return _card(elements) def _card(elements: list[dict[str, Any]]) -> dict[str, Any]: """把元素装进一张卡片 2.0 的信封。 整条助手链路必须和通知卡片同一个 schema:飞书把回调返回的内容**当成新卡片 渲染**,而 `card_actions` 的终态分支会用平台渲染器产出的 2.0 卡去替换答案卡 —— 两种 schema 混在同一条消息上没有定义的行为。顺带,2.0 删掉了 ``note`` 与 ``action`` 两个元素,所以下面的小灰字与按钮行都换了形状。 """ return { "schema": "2.0", "config": {"update_multi": True, "width_mode": "fill"}, "body": {"elements": elements}, } def _small_grey(text: str) -> dict[str, Any]: """状态行。2.0 没有 ``note`` 元素了,小灰字靠 notation 字号的 markdown。 走 ``_safe_markdown``:这些文案里混着模型产出与工单号,而 markdown 元素会 解析内联 HTML —— ``note`` 是 plain_text,换过来之后转义就成了必需项。 """ return { "tag": "markdown", "text_size": "notation", "content": f"{_safe_markdown(text)}", } def _button_row( buttons: list[dict[str, Any]], *, mode: str = "flow", ) -> dict[str, Any]: """一行按钮。``mode="bisect"`` 等分,``"flow"`` 各按文字宽度排。 ## 为什么必须分成两行 四颗按钮塞进一个 ``flow`` 行,在**手机上会折行**,而且折得很难看:前三颗挤 在第一行、第四颗孤零零掉到第二行(2026-09-07 用户的手机截图)。飞书的 flow 布局按可用宽度贪心排列,桌面够宽所以看不出来 —— 这类问题只有在窄屏上才现形。 所以现在是**两行,每行只有两颗**,而且两行都走 ``flow``:两颗短按钮在窄屏 上也放得下,不会折行;在宽屏上则紧挨着排。 ⚠️ 动作行**不要**用 ``bisect``。等分会把整卡宽度切成两半,于是桌面端两颗 按钮被推到卡片的左右两端、中间空出一大片(2026-09-07 用户的桌面截图: 「这也好丑啊 空这么多」)。等分只适合「按钮就是这张卡的主操作」的通知卡 (那里每颗都要通栏),不适合答案卡上这种副操作。``mode="bisect"`` 保留给 将来真需要等分的行,当前没有调用方。 """ if len(buttons) == 1: return buttons[0] if mode == "bisect": # pragma: no cover — 见下面那段「两颗就够」的注释 return { "tag": "column_set", "flex_mode": "bisect", "horizontal_spacing": "8px", "columns": [ {"tag": "column", "width": "weighted", "weight": 1, "elements": [b]} for b in buttons ], } return { "tag": "column_set", "flex_mode": "flow", "horizontal_spacing": "12px", "columns": [ {"tag": "column", "width": "auto", "elements": [b]} for b in buttons ], } def _callback_button( label: str, value: dict[str, Any], *, style: str = "default", size: str = "small", ) -> dict[str, Any]: """一颗回传给我们的按钮。 2.0 把 payload 从顶层 ``value`` 移进了 ``behaviors``;``value`` 的内容不变, 仍然只带标识符,不带凭据。``card_actions._action_value`` 两种形状都读,所以 换渲染器之前发出去、还躺在聊天记录里的卡片被点时照样能用。 ``size`` 出厂 ``small``、``style`` 出厂描边:见 :func:`_button_row` 那段 「按钮是注脚」的理由。评价按钮再降一档到 ``text`` + ``tiny``。 """ return { "tag": "button", "text": {"tag": "plain_text", "content": label}, "type": style, "size": size, "behaviors": [{"type": "callback", "value": value}], } def _feedback_button(label: str, value: dict[str, Any]) -> dict[str, Any]: """评价按钮 —— 无边框小字,视觉上比转交动作再低一档。 答案卡上有两类按钮,它们不该长得一样:**转交**改变事情的走向,**评价**只是 告诉我们这次答得怎么样。飞书安全中心那张卡是同一个分层(上面是描边的动作按钮, 底部是一对小字赞/踩),照它的分层做。 """ return _callback_button(label, value, style="text", size="tiny") def _doc_element( docs: list[dict[str, str]] | None, lang: Lang, ) -> dict[str, Any] | None: """The 参考文档 block, or None when there is nothing to cite.""" links = [ (title, safe) for d in (docs or []) if (safe := _safe_url(str(d.get("url") or ""))) and (title := _safe_link_text(str(d.get("title") or "") or safe)) ][:MAX_DOC_LINKS] if not links: return None body = "\n".join(f"· [{title}]({url})" for title, url in links) return { "tag": "markdown", "content": f"**{_pick(_DOCS_TITLE, lang)}**\n{body}", } def _answer_elements( reply: str, docs: list[dict[str, str]] | None, lang: Lang, ) -> list[dict[str, Any]]: """The answer and its sources — everything on the card except the buttons. Shared by every card that REPLACES an answer card. Feishu renders whatever a card callback returns as the new card, so any branch that forgets to re-emit these two elements deletes the answer the employee is reading. """ elements: list[dict[str, Any]] = [ {"tag": "markdown", "content": _safe_markdown(reply)}, ] sources = _doc_element(docs, lang) if sources is not None: elements.extend(({"tag": "hr"}, sources)) return elements def settled_card( reply: str, *, lang: Lang, note: str, docs: list[dict[str, str]] | None = None, ) -> dict[str, Any]: """The answer kept whole, buttons spent, plus one line saying what happened. The shape every terminal tap on an answer card should take. 转人工 did not take it: its receipt was a fresh two-line card, so tapping it replaced the answer, the 参考文档 links, and the question's whole visible outcome with 「已转人工处理」 — the employee scrolled back and the thing they had just been reading was gone. Rating got this right from the start (see :func:`rated_card`); escalation is the same problem and now the same fix. The outcome that needs to be READ later (a ticket number) still goes out as its own message — a note on a card is a status line, not a receipt. """ elements = _answer_elements(reply, docs, lang) elements.extend(({"tag": "hr"}, _small_grey(note))) return _card(elements) def rated_card( reply: str, *, helpful: bool, lang: Lang, docs: list[dict[str, str]] | None = None, message_id: str | None = None, ) -> dict[str, Any]: """The same answer with the buttons spent, so the tap visibly took. The text is kept: replacing an answer with a thank-you would delete what the employee came for. So are the SOURCES — Feishu replaces the whole card with whatever the callback returns, and rebuilding it from the reply text alone meant tapping 👍 silently removed the 参考文档 links the employee was approving. They are read back from the stored message (migration 518). """ elements = _answer_elements(reply, docs, lang) elements.extend(( {"tag": "hr"}, _small_grey(_pick(_THANKS if helpful else _SORRY, lang)), )) if not helpful and message_id: elements.extend(( _small_grey(_pick(_REASON_PROMPT, lang)), _button_row([ _feedback_button(_pick(labels, lang), { "action": ACTION_FEEDBACK_REASON, "message": message_id, "reason": code, }) for code, labels in _REASONS.items() ]), )) return _card(elements)