"""「这件事该走哪个流程、找谁」 — server-fetched before the turn. The gap this closes, from a real transcript: an employee asked 「我想加入 git 仓库怎么办」 and the assistant answered 「公司知识库中没有关于如何加入 Git 仓库的说明文档」. The company runs an approval flow called *Request to join the GitHub organization*, owned by a named colleague, and the registry knew both — but the assistant only searched documents. Two reasons a tool alone would not have fixed it: * **The model has to think of asking.** The knowledge-base tool is the one the prompt points at for 「怎么…」 questions; when it comes back empty the natural next move is to offer a ticket. Pre-fetching makes the flow list part of what the model already knows this turn. * **Keyword search cannot bridge the languages.** The question is Chinese (「git 仓库」), the flow name is English (*Request to join the GitHub organization*) — they share no tokens. So this module does NOT try to pick the right flow. It hands over a bounded catalogue of what the company actually runs and lets the model do the matching, which is the one part of this it is genuinely good at. """ from __future__ import annotations import logging import re from dataclasses import dataclass from typing import Any from sqlalchemy.ext.asyncio import AsyncSession logger = logging.getLogger(__name__) #: How many flows to put in front of the model. Enough to cover a #: mid-size company's whole register, small enough that the prompt stays #: a prompt. Keyword matches come first, then the rest by recent #: activity, so the cut falls on the flows nobody uses. MAX_FLOWS = 60 #: Questions that are asking "how do I get / do X here". Deliberately #: broad on the Chinese side and narrow on the English: 「怎么」「如何」 #: 「想要」 are how the question actually arrives in this company, and a #: false positive costs one extra prompt block, while a false negative #: costs the employee the answer. #: #: 分成两组是给**编号接地**用的,而不是为了好看:「这个账号谁负责」和「开通这类 #: 账号走哪条流程」是两个问题,而 2026-09-07 的错答就是拿后者的答案答了前者。 #: 编号查到行之后,只有**纯认领**的问句该撤掉流程清单;混着问的(「这个账号怎么 #: 申请权限」)两份材料都要给 —— 撤掉它等于把员工真正问的那一半答案扔了。 #: #: 并集是**算出来的**,所以往任一组里加词都不会让 ``_ASK_PATTERNS`` 漏掉它 —— #: 这里曾经是一条手写的长正则,而两条手写正则一定会漂移。 #: 「这件事怎么办、走哪条流程」。 _PROCESS_ALTERNATIVES = ( "怎么办", "怎么弄", "怎么申请", "如何申请", "怎样申请", "如何", "怎么", "想加入", "想申请", "要申请", "需要申请", "能不能申请", "走什么流程", "走哪个流程", "有没有流程", "审批", "申请", "开通", "加入", "授权", "权限", "报销", "how do i", "how to", "request access", "apply for", ) #: 「这个东西归谁」。**只有**这一组命中、而且编号已经查到行时,流程清单才撤掉。 _IDENTIFICATION_ALTERNATIVES = ( "谁负责", "找谁", "归谁管", "who owns", "who is responsible", ) _ASK_PATTERNS = re.compile( "(" + "|".join(_PROCESS_ALTERNATIVES + _IDENTIFICATION_ALTERNATIVES) + ")", re.IGNORECASE, ) _PROCESS_PATTERNS = re.compile( "(" + "|".join(_PROCESS_ALTERNATIVES) + ")", re.IGNORECASE, ) @dataclass(frozen=True) class FlowGrounding: """The company's approval flows, as evidence for one turn.""" system_context: str flows: list[dict[str, Any]] def looks_like_a_how_do_i_question(question: str) -> bool: return bool(_ASK_PATTERNS.search(question or "")) def asks_how_a_process_works(question: str) -> bool: """问的里面有没有「这件事怎么办 / 走哪条流程」这一层。 编号接地的抑制只针对**纯认领**问句。「380838443567 这个账号怎么申请权限」 两件事都在问:那一行是「哪个账号」的答案,流程清单是「怎么申请」的答案, 少给哪一份都是答了一半。 """ return bool(_PROCESS_PATTERNS.search(question or "")) def _score(flow: Any, needle: str) -> int: """Cheap lexical relevance, used only for ORDERING. It is not a filter: the whole point is that the right flow often scores zero because the question and the flow name are in different languages. """ if not needle: return 0 haystack = " ".join( part.lower() for part in ( flow.name or "", flow.category or "", flow.source_group or "", flow.description or "", ) ) score = 0 for token in {t for t in re.split(r"\W+", needle.lower()) if len(t) > 1}: if token in haystack: score += 2 # Chinese has no spaces, so token-splitting misses it entirely — # fall back to character bigrams for the CJK half of the question. for i in range(len(needle) - 1): bigram = needle[i:i + 2] if "一" <= bigram[0] <= "鿿" and bigram.lower() in haystack: score += 1 return score async def build_flow_grounding( db: AsyncSession, question: str, ) -> FlowGrounding | None: """The published flow register, ordered by rough relevance. Returns ``None`` when the register is empty — an assistant that announces a directory nobody has filled in is worse than one that says nothing. """ try: from app.domains.approval_registry import constants as const from app.domains.approval_registry import service as svc from app.domains.approval_registry.serializers import ( feishu_admins, owner_state, ) rows = await svc.list_published(db) except Exception: # noqa: BLE001 — grounding must never break a turn logger.warning("flow grounding: registry read failed", exc_info=True) return None if not rows: return None ranked = sorted( rows, key=lambda f: (-_score(f, question), -(f.observed_instances or 0), f.name), ) picked = ranked[:MAX_FLOWS] lines: list[str] = [] payload: list[dict[str, Any]] = [] for flow in picked: owner = flow.owner # ``owner_state`` is derived (source + employment + freshness), # not a column — read it through the serializer so the assistant # and the pages cannot disagree about how sure we are. state = owner_state(flow) owner_name = ( (owner.display_name or owner.work_email) if owner is not None else None ) # 流程管理员 from the Feishu definition. Not the same claim as an # owner — nobody has agreed to answer for the flow here — but it # is who the Feishu console shows, and an assistant that says # 「还没有人认领」 about a flow whose manager is named upstream is # sending the employee to IT for something IT will look up in # the same place. live_admins = [ a["name"] for a in feishu_admins(flow) if a.get("active") ] admin_list = "、".join(live_admins) # The owner's confidence travels with the name. Handing over a # colleague's name without saying how sure the register is, is # how a machine's guess becomes folklore. if owner_name is None: who = ( f"台账里还没人认领,但飞书流程管理员是 {admin_list},可以先找他们" if live_admins else "还没有人认领(让员工找 IT)" ) elif state == "departed": who = ( f"{owner_name}(已离职;飞书流程管理员:{admin_list})" if live_admins else f"{owner_name}(已离职,让员工找 IT)" ) elif state == "unconfirmed": # Which KIND of machine, spelled out. 「平台推断」 on a name # Feishu's console declares is the same false claim the # ``stale`` branch below guards against, pointed the other # way: it tells the employee we guessed when we read. source = ( "飞书审批定义里的流程管理员" if flow.owner_source == const.OWNER_SOURCE_FEISHU_ADMIN else "平台根据实际审批记录推断" ) who = f"{owner_name}({source},尚未本人确认)" elif state == "stale": # NOT 「平台推断」: this person did personally confirm it, just # longer ago than the validity horizon. Telling a colleague # their name was guessed when they stood behind it is a # different — and false — claim. who = f"{owner_name}(本人确认过,但已超过复核周期)" else: who = owner_name category = flow.category or flow.source_group or "未分类" note = f" — {flow.description}" if flow.description else "" stopped = "(已停用)" if flow.status != "active" else "" lines.append(f"- [{category}] {flow.name}{stopped} → 负责人:{who}{note}") payload.append({ "name": flow.name, "category": category, "owner_name": owner_name, "owner_email": owner.work_email if owner is not None else None, "owner_state": state, "feishu_admins": live_admins, "status": flow.status, }) more = ( f"\n(共 {len(rows)} 条,此处列出最相关的 {len(picked)} 条)" if len(rows) > len(picked) else "" ) return FlowGrounding( flows=payload, system_context=( "\n\nCOMPANY APPROVAL FLOWS — FETCHED BEFORE THIS TURN\n" "These are the approval flows this company actually runs in " "Feishu, with the person recorded as responsible for each. The " "employee's question may not share a single word with the right " "flow — the register is bilingual and they will ask in their own " "words — so match on MEANING, not on keywords.\n" "If one of these is what they are asking for, answer with: the " "flow's exact name (so they can find it in Feishu 审批), who to " "ask, and that they can go to that person with questions. Do not " "invent a flow that is not on this list, and do not offer a " "support ticket when a flow covers it.\n" "When the responsible person is marked 尚未本人确认, say the name " "and that it is the platform's best information — never present " "it as settled. When nobody is named, say so and point at IT " "rather than guessing.\n" f"{chr(10).join(lines)}{more}\n" ), )