"""handle 标签:词表、打标签、以及按规则建议任意标签。 ## 这是 585 的修正,不只是加功能 585 用「两值枚举 + 自由文本备注」回答「这个 handle 是什么」。用起来发现 两处撑不住: * 枚举**互斥** —— `crazycs520` 这类 handle 可以同时是「ByteDance 的人」和 「TiDB 的社区 maintainer」,而枚举只能选一个; * 备注是**自由文本** —— 三个人标同一家公司会写成 ``ByteDance`` / ``字节`` / ``bytedance 的人``,于是「把 ByteDance 的人筛出来做年度复核」永远做 不成。 更重要的是**层级错了**。实测:认不出的 217 个 handle 摊在 331 行台账上, ``sre-bot`` / ``ti-chi-bot`` / ``crazycs520`` / ``Mini256`` / ``lance6716`` 各在 5 个组织里。「这个 handle 是什么」是 handle 的属性,不是 (组织, handle) 的属性 —— 挂在成员资格上意味着标一次要写 5 行,而 5 行会 漂移。所以标签挂 handle,一次生效跨所有组织。 ## 与 585 那两列的关系:派生,不是并存 ``account:bot`` / ``account:outside_human`` 这两个标签打上去时,service 会 把该 handle 所有活着的成员资格一起 ``confirm_external``(kind 取自标签)。 反之撤掉标签时一起清。这样: * 治理队列(``count_unlinked``)的语义完全不动 —— 585 那套还是那套; * 而「是哪一种外部」不再需要人对 5 行分别操作。 ## 规则只建议,永不自动落库 规则支持两种匹配:``handle_glob``(命名约定)和 ``profile_company_contains``(GitHub 资料里的 company)。两者都是启发式: ``robot-liu`` 会命中 ``*robot*``,而一个 company 写着 "PingCAP" 的 handle 可能是前员工而不是在职员工。**把一个员工标成机器账号,后果是他离职时不被 回收** —— 所以建议永远只是建议。 """ from __future__ import annotations import fnmatch import logging from typing import Any from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from app.integrations.models import PlatformSetting from . import constants as const from . import service as svc from .models import ( GithubHandleLabel, GithubHandleLabelLink, GithubOrg, GithubOrgMembership, ) logger = logging.getLogger(__name__) RULES_KEY = "github_org.label_rules" #: ``account`` 分类里的两个标签与 585 ``external_kind`` 的对应。 #: 打上其中之一 ⇒ 该 handle 的所有活成员资格一起确认为外部。 ACCOUNT_LABEL_TO_KIND = { "account:bot": const.EXTERNAL_KIND_BOT, "account:outside_human": const.EXTERNAL_KIND_OUTSIDE_HUMAN, } #: 规则是谁写的。AI 提议的规则一律 ``enabled=False`` 落库,由管理员点开。 SOURCE_MANUAL = "manual" SOURCE_AI = "ai" MATCH_HANDLE_GLOB = "handle_glob" MATCH_COMPANY_CONTAINS = "profile_company_contains" # ── 词表 ──────────────────────────────────────────────────────── async def list_labels( db: AsyncSession, *, include_inactive: bool = False, ) -> list[GithubHandleLabel]: q = select(GithubHandleLabel) if not include_inactive: q = q.where(GithubHandleLabel.is_active.is_(True)) return list( ( await db.execute( q.order_by( GithubHandleLabel.category, GithubHandleLabel.sort_order, GithubHandleLabel.key, ) ) ).scalars().all() ) async def get_label(db: AsyncSession, key: str) -> GithubHandleLabel | None: return ( await db.execute( select(GithubHandleLabel).where(GithubHandleLabel.key == key) ) ).scalar_one_or_none() async def upsert_label( db: AsyncSession, *, key: str, category: str, display_zh: str, display_en: str, color: str = "default", description: str | None = None, sort_order: int = 0, is_active: bool = True, actor_person_id: str | None = None, ) -> GithubHandleLabel: """新建或改一个标签。 新建要很容易 —— 不然人就绕过词表去写备注了,而那正是这套东西要消灭的 东西。 """ row = await get_label(db, key) if row is None: row = GithubHandleLabel(key=key, created_by=actor_person_id) db.add(row) row.category = category row.display_zh = display_zh row.display_en = display_en row.color = color or "default" row.description = description row.sort_order = sort_order row.is_active = is_active await db.flush() return row async def deactivate_label(db: AsyncSession, key: str) -> bool: """停用而不是删除 —— 一个被用过的标签删掉之后,历史上那些链接就成了 悬空引用,而「这个人当年被标成什么」正是复核要回看的。""" row = await get_label(db, key) if row is None: return False row.is_active = False await db.flush() return True # ── 打标签 ────────────────────────────────────────────────────── async def labels_for_handles( db: AsyncSession, handles: list[str], ) -> dict[str, list[dict[str, Any]]]: """批量取 handle 的标签,返回 ``handle_lower -> [标签]``。 一次查完而不是每行一次:成员台账一页 20 行,逐行查就是 20 次往返。 """ lows = sorted({(h or "").strip().lower() for h in handles if h}) if not lows: return {} rows = list( ( await db.execute( select(GithubHandleLabelLink, GithubHandleLabel) .join( GithubHandleLabel, GithubHandleLabel.key == GithubHandleLabelLink.label_key, isouter=True, # 悬空引用也要出现,显示成「已停用」 ) .where(GithubHandleLabelLink.github_login_lower.in_(lows)) .order_by(GithubHandleLabelLink.label_key) ) ).all() ) out: dict[str, list[dict[str, Any]]] = {} for link, label in rows: out.setdefault(link.github_login_lower, []).append( { "key": link.label_key, "note": link.note, "category": label.category if label else None, "display_zh": label.display_zh if label else link.label_key, "display_en": label.display_en if label else link.label_key, "color": label.color if label else "default", # 标签被停用了,但这条历史仍然要看得见 —— 见 upsert 的注释。 "retired": (label is None) or (not label.is_active), } ) return out async def _sync_account_kind( db: AsyncSession, handle_lower: str, *, actor_person_id: str | None, trace_id: str | None, ) -> int: """把 ``account:*`` 标签同步到 585 的 external_confirmed_at / kind。 一个动作跨该 handle 的所有组织 —— 这正是把标签挂 handle 而不是挂成员 资格换来的东西。返回受影响的成员资格条数。 """ keys = { r[0] for r in ( await db.execute( select(GithubHandleLabelLink.label_key).where( GithubHandleLabelLink.github_login_lower == handle_lower ) ) ).all() } kind = next( (ACCOUNT_LABEL_TO_KIND[k] for k in keys if k in ACCOUNT_LABEL_TO_KIND), None, ) rows = list( ( await db.execute( select(GithubOrgMembership) .join(GithubOrg, GithubOrg.id == GithubOrgMembership.org_id) .where( GithubOrgMembership.github_login_lower == handle_lower, GithubOrgMembership.state.in_(const.MEMBERSHIP_LIVE_STATES), GithubOrg.deleted_at.is_(None), ) ) ).scalars().all() ) touched = 0 for m in rows: if m.person_id: # 已经认出是谁的行不动 —— 「是某个员工」和「是外部」互斥, # 而人的判断优先于标签规则。 continue if kind is None: if m.external_confirmed_at is not None: await svc.clear_external( db, m, actor_person_id=actor_person_id, trace_id=trace_id, ) touched += 1 continue if m.external_confirmed_at is not None and m.external_kind == kind: continue if m.external_confirmed_at is not None: # 改类型:先清再确认,好让事件流上留下两条而不是一条静默改写。 await svc.clear_external( db, m, actor_person_id=actor_person_id, trace_id=trace_id, ) await svc.confirm_external( db, m, actor_person_id=actor_person_id, note=f"label:{'/'.join(sorted(keys))}"[:255], kind=kind, trace_id=trace_id, ) touched += 1 return touched async def attach( db: AsyncSession, *, github_login: str, label_keys: list[str], note: str | None = None, actor_person_id: str | None = None, trace_id: str | None = None, ) -> dict[str, Any]: """给一个 handle 打标签(可多个)。幂等 —— 重复打不会攒出多行。""" low = (github_login or "").strip().lower() if not low: raise ValueError("github_login is required") known = {lb.key for lb in await list_labels(db, include_inactive=True)} unknown = [k for k in label_keys if k not in known] if unknown: # 拒绝而不是静默创建:词表的价值就在于它是受控的, # 打错一个 key 会静默造出一个只有一个成员的「标签」。 raise ValueError(f"unknown label keys: {', '.join(sorted(unknown))}") existing = { r[0] for r in ( await db.execute( select(GithubHandleLabelLink.label_key).where( GithubHandleLabelLink.github_login_lower == low ) ) ).all() } added = [] for key in label_keys: if key in existing: continue db.add( GithubHandleLabelLink( github_login_lower=low, label_key=key, github_login=github_login.strip(), note=note, created_by=actor_person_id, ) ) added.append(key) await db.flush() touched = await _sync_account_kind( db, low, actor_person_id=actor_person_id, trace_id=trace_id, ) return {"handle": github_login, "added": added, "memberships_synced": touched} async def detach( db: AsyncSession, *, github_login: str, label_keys: list[str], actor_person_id: str | None = None, trace_id: str | None = None, ) -> dict[str, Any]: low = (github_login or "").strip().lower() await db.execute( delete(GithubHandleLabelLink).where( GithubHandleLabelLink.github_login_lower == low, GithubHandleLabelLink.label_key.in_(label_keys), ) ) await db.flush() touched = await _sync_account_kind( db, low, actor_person_id=actor_person_id, trace_id=trace_id, ) return { "handle": github_login, "removed": label_keys, "memberships_synced": touched, } # ── 规则建议 ──────────────────────────────────────────────────── async def get_rules(db: AsyncSession) -> list[dict[str, Any]]: raw = ( await db.execute( select(PlatformSetting.value).where(PlatformSetting.key == RULES_KEY) ) ).scalar_one_or_none() if isinstance(raw, list): out = [ # 老数据里没有 ``enabled`` —— 一律按**关**读。这条默认值的方向 # 是唯一安全的方向:反过来会让 586 播种的 9 条启发式规则在这次 # 升级后立刻开始自动落库,而没有任何人点过同意。 { "match": r["match"], "value": r["value"], "label": r["label"], "enabled": bool(r.get("enabled")), "source": ( r.get("source") if r.get("source") in (SOURCE_MANUAL, SOURCE_AI) else SOURCE_MANUAL ), "rationale": r.get("rationale") or None, } for r in raw if isinstance(r, dict) and r.get("match") and r.get("value") and r.get("label") ] if out: return out if raw is not None: logger.warning( "github_org: %s is not a usable rule list (%r)", RULES_KEY, raw, ) return [] async def set_rules( db: AsyncSession, rules: list[dict[str, Any]], ) -> list[dict[str, Any]]: known = {lb.key for lb in await list_labels(db, include_inactive=True)} cleaned = [] for r in rules: m, v, lb = r.get("match"), str(r.get("value") or "").strip(), r.get("label") if m not in (MATCH_HANDLE_GLOB, MATCH_COMPANY_CONTAINS): raise ValueError(f"unknown match type: {m!r}") if not v: raise ValueError("rule value must not be empty") if lb not in known: raise ValueError(f"rule points at an unknown label: {lb!r}") enabled = bool(r.get("enabled")) if enabled and lb.startswith("account:"): # 🔴 ``account:*`` 永远不自动落。它会把该 handle 所有活着的成员 # 资格一起确认成「外部」,而那是**终态** —— 记录从治理待办里 # 消失,离职回收再也够不着它。把一个在职员工自动标成机器账号, # 后果是他离职时不被回收,而这正是整个域存在的理由。 # # 已知的反例不是假想:``robot-liu`` 命中 ``*robot*``,而他是个人。 raise ValueError( f"{lb!r} 不能设成自动应用 —— account:* 是终态,必须人确认" ) source = str(r.get("source") or SOURCE_MANUAL) if source not in (SOURCE_MANUAL, SOURCE_AI): source = SOURCE_MANUAL cleaned.append({ "match": m, "value": v, "label": lb, "enabled": enabled, "source": source, # AI 提议的规则要带上「凭什么」——一个没有理由的规则,管理员 # 唯一能做的判断是「看起来还行」,而那不是判断。 "rationale": (str(r.get("rationale")).strip() or None) if r.get("rationale") else None, }) row = ( await db.execute( select(PlatformSetting).where(PlatformSetting.key == RULES_KEY) ) ).scalar_one_or_none() if row is None: db.add(PlatformSetting(key=RULES_KEY, value=cleaned)) else: row.value = cleaned await db.flush() return cleaned def rule_hits( login: str, profile: dict[str, Any] | None, rules: list[dict[str, Any]], ) -> list[dict[str, str]]: """这个 handle 命中了哪些规则。每条带**理由** —— 界面上人靠理由判断。""" low = (login or "").lower() company = str((profile or {}).get("company") or "").lower() out: list[dict[str, str]] = [] seen: set[str] = set() for r in rules: label, m, v = r["label"], r["match"], str(r["value"]) if label in seen: continue if m == MATCH_HANDLE_GLOB and low and fnmatch.fnmatch(low, v.lower()): out.append({ "label": label, "reason": f"用户名符合命名约定({v})", }) seen.add(label) elif m == MATCH_COMPANY_CONTAINS and company and v.lower() in company: out.append({ "label": label, "reason": f"GitHub 资料 company 含「{v}」(实际:{(profile or {}).get('company')})", }) seen.add(label) return out async def suggest(db: AsyncSession, *, limit: int = 200) -> dict[str, Any]: """给「还没打任何标签」的认不出 handle 产出标签建议。 **只产出建议。** 规则是启发式:``robot-liu`` 会命中 ``*robot*``, 而 company 写 "PingCAP" 的 handle 可能是前员工。写入永远经过人确认。 """ rules = await get_rules(db) rows = list( ( await db.execute( select(GithubOrgMembership, GithubOrg) .join(GithubOrg, GithubOrg.id == GithubOrgMembership.org_id) .where( GithubOrgMembership.person_id.is_(None), GithubOrgMembership.state.in_(const.MEMBERSHIP_LIVE_STATES), GithubOrg.deleted_at.is_(None), ) .order_by(GithubOrgMembership.github_login) ) ).all() ) by_handle: dict[str, dict[str, Any]] = {} for m, org in rows: e = by_handle.setdefault( m.github_login_lower, { "handle": m.github_login, "profile": ( m.github_profile if isinstance(m.github_profile, dict) else None ), "org_slugs": [], "membership_count": 0, }, ) e["org_slugs"].append(org.slug) e["membership_count"] += 1 already = await labels_for_handles(db, list(by_handle)) items = [] for low, e in by_handle.items(): if already.get(low): continue # 已经有人标过了,不再打扰 hits = rule_hits(e["handle"], e["profile"], rules) if not hits: continue items.append({ "handle": e["handle"], "org_slugs": sorted(set(e["org_slugs"])), "membership_count": e["membership_count"], "profile_name": (e["profile"] or {}).get("name"), "profile_company": (e["profile"] or {}).get("company"), "suggestions": hits, }) items.sort(key=lambda x: x["handle"].lower()) return { "rules": rules, "items": items[:limit], "total_candidates": len(items), "unlabelled_handles": sum( 1 for low in by_handle if not already.get(low) ), } #: 有 company 但等于没有的自由文本。生产实测这一批的真实取值: #: ``N/A`` / ``none`` / ``None`` / ``Home`` / ``Garden Leave`` / ``AGI Villa``。 #: 喂给模型只会换来「为一个 handle 造一条规则」,而一条只命中 1 个人的规则 #: 不是规则 —— 它是一个应该直接打在那个 handle 上的标签。 JUNK_COMPANY = frozenset({ "n/a", "na", "none", "null", "nil", "-", "—", "?", ".", "home", "self", "myself", "freelance", "freelancer", "independent", "unemployed", "student", "nowhere", "earth", "world", "internet", "独立开发者", "自由职业", "无", "暂无", }) async def _identified_pool( db: AsyncSession, ) -> dict[str, dict[str, Any]]: """所有**认不出是谁**的活 handle,不管打过标签没有。 规则提议看这一批,而不是「还没打标签」那一批 —— 规则是给**未来**用的 (下一批加入组织的人),而「还没打标签」会被每一次批量打标签抽干: 2026-09-01 首批打完之后,未打标签的池子里填了 company 的只剩 6 个, 并且 6 个全是垃圾值,于是模型只能提出一条只命中 1 个 handle 的规则。 命中数(:func:`rule_impact`)留在「未打标签」是对的 —— 那是「会打给 谁」的定义;输入圈的范围不该跟着它。 """ rows = list( ( await db.execute( select(GithubOrgMembership, GithubOrg.slug) .join(GithubOrg, GithubOrg.id == GithubOrgMembership.org_id) .where( GithubOrgMembership.person_id.is_(None), GithubOrgMembership.state.in_(const.MEMBERSHIP_LIVE_STATES), GithubOrg.deleted_at.is_(None), ) ) ).all() ) by: dict[str, dict[str, Any]] = {} for m, slug in rows: e = by.setdefault( m.github_login_lower, { "handle": m.github_login, "profile": ( m.github_profile if isinstance(m.github_profile, dict) else None ), "org_slugs": [], }, ) e["org_slugs"].append(slug) return by async def _unlabelled_handles( db: AsyncSession, ) -> dict[str, dict[str, Any]]: """认不出是谁、**而且**一个标签都没有的活 handle。 自动应用与影响预览都只看这一批:已经有人标过的行不该被规则改写 —— 人的判断优先于启发式,而「规则又把我改回去了」会让人不再相信这套 东西,而不相信的结果是他们退回去在备注里写自由文本。 """ by = await _identified_pool(db) already = await labels_for_handles(db, list(by)) return {low: e for low, e in by.items() if not already.get(low)} async def rule_impact(db: AsyncSession) -> list[dict[str, Any]]: """每条规则现在会打给多少个 handle —— 给「启用」这个开关配一个数字。 没有这个数字,「启用」就是一个盲开关:管理员点下去之后才知道它影响 的是 3 个人还是 180 个人,而那时标签已经落库了。 """ rules = await get_rules(db) pool = await _unlabelled_handles(db) out = [] for idx, r in enumerate(rules): hits = [ e["handle"] for e in pool.values() if any(h["label"] == r["label"] for h in rule_hits( e["handle"], e["profile"], [r], )) ] out.append({ **r, "index": idx, "would_label": len(hits), # 样例是给人看的,不是给程序用的 —— 三个足够判断「这条规则 # 在筛什么」,更多只会让开关旁边那行字读不完。 "sample": sorted(hits)[:3], }) return out async def apply_enabled_rules( db: AsyncSession, *, dry_run: bool = False, actor_person_id: str | None = None, trace_id: str | None = None, ) -> dict[str, Any]: """把**已启用**的规则落到还没打标签的 handle 上。 只加,不减。规则改了之后不会去撤回它之前打的标签 —— 撤回意味着一条 改错的规则能悄悄抹掉人工确认过的分类,而那比多一个标签坏得多。要撤 就人工撤,界面上一行一个浮层。 ``account:*`` 在 :func:`set_rules` 那一层就拦住了,不可能启用,所以这里 不会把任何记录推进「已确认外部」那个终态。 """ rules = [r for r in await get_rules(db) if r.get("enabled")] if not rules: return {"enabled_rules": 0, "labelled": 0, "by_label": {}} pool = await _unlabelled_handles(db) by_label: dict[str, int] = {} labelled = 0 for low, e in sorted(pool.items()): hits = rule_hits(e["handle"], e["profile"], rules) if not hits: continue keys = [h["label"] for h in hits] if dry_run: labelled += 1 for k in keys: by_label[k] = by_label.get(k, 0) + 1 continue res = await attach( db, github_login=e["handle"], label_keys=keys, note="; ".join(h["reason"] for h in hits)[:255], actor_person_id=actor_person_id, trace_id=trace_id, ) if res["added"]: labelled += 1 for k in res["added"]: by_label[k] = by_label.get(k, 0) + 1 return { "enabled_rules": len(rules), "labelled": labelled, "by_label": by_label, "dry_run": dry_run, } _RULE_AI_SYSTEM = """你在帮一家公司(PingCAP,产品是 TiDB / TiKV)整理 GitHub 组织成员台账的标签规则。 输入是「还认不出是谁」的 GitHub handle 上、公开资料里 company 字段的实际 取值分布,以及一份**受控词表**。你要输出若干条规则,把这些取值映射到词表 里已有的标签。 硬约束: 1. `label` 必须是给定词表里的 key。发明新 key 一律作废。 2. `match` 只能是 `profile_company_contains`(匹配 company 子串,大小写不敏感) 或 `handle_glob`(匹配用户名,支持 `*`)。 3. `value` 要**足够特指**。像 `a`、`co`、`inc`、`@` 这种会命中一大片的子串 不要给 —— 一条过宽的规则的代价是几十个人被打上错标签。 4. 不要给指向 `account:` 开头标签的规则:那一类是终态,只能由人确认。 5. 学校 / 研究机构 / 基金会用 `community:foundation`,不要归到公司。 6. 每条都要 `rationale`,一句话说清「凭什么」,写给一个不懂技术的管理员看。 7. **一条只命中一个 handle 的规则不是规则** —— 那是一个应该直接打在那个 handle 上的标签。给规则的前提是这个取值代表一个会重复出现的模式:一家 真实存在的公司(即使现在只出现一次,新人也会带着同样的 company 加进来)、 或者一类可归并的写法(`@pingcap` / `PingCAP` / `Pingcap` 归一条)。看不出 模式的自由文本(某人把 company 写成一句玩笑)不要给规则。 只输出这一个对象: {"rules": [{"match": "...", "value": "...", "label": "...", "rationale": "..."}]} """ async def propose_rules_with_ai( db: AsyncSession, *, limit: int = 40, ) -> dict[str, Any]: """让模型基于**真实的 company 取值分布**提议规则,一律 ``enabled=False``。 为什么值得让模型做:实测 217 个认不出的 handle 里,72 个填了 company, 取值有 50 种写法 —— `@pingcap` / `PingCAP` / `Pingcap`、`Antgroup` / `@antgroup`、以及 `IPADS@SJTU` 这种一眼是学校但字面上毫无规律的。手写 规则要一条条数,而这正是模型便宜且擅长的事。 为什么提议出来一律**不启用**:一条过宽的规则(`value` 写成 `co`)会一次 给几十个人打上错标签,而错标签在离职回收那一侧是有代价的。所以模型 只能提议,启用是管理员点的 —— 而开关旁边会显示这条规则现在会命中 多少个 handle(见 :func:`rule_impact`)。 """ from .handle_matching import _llm_client # noqa: PLC0415 client = await _llm_client(db) if client is None: return {"added": [], "skipped": [], "reason": "llm_not_configured"} # 全量认不出的 handle,不是「还没打标签」那一批 —— 见 _identified_pool。 pool = await _identified_pool(db) counts: dict[str, int] = {} for e in pool.values(): comp = str((e["profile"] or {}).get("company") or "").strip() if comp and comp.lower() not in JUNK_COMPANY: counts[comp] = counts.get(comp, 0) + 1 if not counts: return {"added": [], "skipped": [], "reason": "no_company_signal"} vocab = [ lb for lb in await list_labels(db, include_inactive=False) if not lb.key.startswith("account:") ] existing = await get_rules(db) seen = {(r["match"], r["value"].lower()) for r in existing} user = ( "受控词表(只能用这些 key):\n" + "\n".join(f" {lb.key} = {lb.display_zh} / {lb.display_en}" for lb in vocab) + "\n\n认不出是谁的 handle 上,company 字段的实际取值(出现次数;" + "明显的占位文本已经剔除):\n" + "\n".join(f" {n}× {c}" for c, n in sorted(counts.items(), key=lambda kv: -kv[1])) + "\n\n已经存在的规则(不要重复):\n" + ("\n".join(f" {r['match']} {r['value']} -> {r['label']}" for r in existing) or " (没有)") ) try: reply = await client.chat_json( system=_RULE_AI_SYSTEM, user=user, max_tokens=2500, temperature=0.2, ) except Exception as e: # noqa: BLE001 logger.warning("github_org: AI rule proposal failed: %s", e) return {"added": [], "skipped": [], "reason": f"llm_error: {e}"} known = {lb.key for lb in vocab} added: list[dict[str, Any]] = [] skipped: list[dict[str, Any]] = [] for raw in (reply.get("rules") or [])[:limit]: if not isinstance(raw, dict): continue m = str(raw.get("match") or "") v = str(raw.get("value") or "").strip() lb = str(raw.get("label") or "") why = str(raw.get("rationale") or "").strip() or None def _skip(reason: str) -> None: skipped.append({"match": m, "value": v, "label": lb, "reason": reason}) if m not in (MATCH_HANDLE_GLOB, MATCH_COMPANY_CONTAINS): _skip("unknown match type") continue # 🔴 幻觉闸:模型发明的 label key 一律丢掉。放过去的后果是一条 # 永远建议不出东西的规则,而它看起来和「没有匹配」一模一样。 if lb not in known: _skip("label is not in the vocabulary") continue if len(v) < 3: # 过短的子串必然过宽。`co` 会命中 ClickHouse / Databricks / # Microsoft —— 一条这样的规则一次打错几十个人。 _skip("value too short to be specific") continue if (m, v.lower()) in seen: _skip("already exists") continue seen.add((m, v.lower())) added.append({ "match": m, "value": v, "label": lb, "enabled": False, "source": SOURCE_AI, "rationale": why, }) if added: await set_rules(db, [*existing, *added]) return { "added": added, "skipped": skipped, "reason": None if added else "nothing_new", }