# -*- coding: utf-8 -*- """iAOP-Core · LLM 网关 —— 敏感度路由规则引擎(EPIC #6 主体,Issue #43 雏形)。 对应 PRD 5.4「④ LLM 网关 + RAG」与 EPIC #6: 本地 70B(敏感/核心)+ 云端 API(脱敏/通用)**混合**,安全分级路由。 本模块实现**路由决策层**: - 依据「敏感度路由规则」(模板配置资产)对用户 query 做**敏感度分级**, 输出路由目标:`local`(敏感/核心,数据不出厂)/ `cloud`(脱敏/通用)/ `block`(触发高危规则,直接拦截,转人工)。 - 分级规则为**配置点**:`config/router.template.yaml`,换行业只改资产, 内核零改动(对齐 dlp / rag-kb 模板化思想)。 - 路由决策前**强制先过 DLP 出站检查**:query 若命中 DLP block 规则, 一律走本地(fail-closed),云端仅在 DLP 放行时允许(PRD 5.4 数据不出厂)。 设计说明(供子任务 #43 继续细化): - 本版实现规则匹配与分级、模板加载、评估准确率的离线脚本接口; - 子任务 #43 将在此基础上补齐敏感度词库覆盖与准确率 ≥ 96.5% 的调优基线。 测试:`python -m unittest discover -s tests -v`(在 core/llm-gateway 目录下执行)。 """ from __future__ import annotations import re import uuid from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Dict, List, Optional, Tuple # --------------------------------------------------------------------------- # 轻量 YAML 子集解析(零第三方依赖,递归下降):与 dlp.py 同款(模块内自持, # 保持模块零耦合)。足以解析 `config/router.template.yaml` 模板资产。 # --------------------------------------------------------------------------- def _parse_scalar(text: str) -> str: """去掉标量两侧引号与行内注释(`key: value # comment`)。""" t = text.split(" #", 1)[0].strip() if len(t) >= 2 and t[0] == t[-1] and t[0] in ("'", '"'): return t[1:-1] return t def _strip_comments(lines: List[str]) -> List[Tuple[str, int]]: """剔除空行与整行注释,保留行号(1 起)用于报错定位。""" out = [] for i, ln in enumerate(lines): s = ln.strip() if not s or s.startswith("#"): continue out.append((ln, i + 1)) return out def _parse_node(lines: List[Tuple[str, int]], i: int, indent: int): """递归解析从 lines[i] 开始、缩进为 `indent` 的一个节点。 返回 `(value, next_i)`:value 为 dict / list / str,next_i 为下一个 未消费行的下标。 """ text, no = lines[i] lead = len(text) - len(text.lstrip(" ")) # ---- list 节点:`- item` 或 `- key: val`(map 项) ---- if text.lstrip(" ").startswith("- "): items: List[object] = [] while i < len(lines): t, no2 = lines[i] stripped = t.lstrip(" ") if not stripped.startswith("- "): break lead_j = len(t) - len(t.lstrip(" ")) if lead_j != indent: break item_text = stripped[2:].strip() if not item_text: raise ValueError(f"router.yaml 第 {no2} 行:list 项为空") if ":" in item_text: map_indent = len(t) - len(t.lstrip(" ")) + 2 lines[i] = (" " * map_indent + item_text, no2) v, i = _parse_node(lines, i, map_indent) items.append(v) else: items.append(_parse_scalar(item_text)) i += 1 return items, i # ---- map 节点:`key: value` / `key:`(嵌套值) ---- result: Dict[str, object] = {} while i < len(lines): t, no = lines[i] lead_j = len(t) - len(t.lstrip(" ")) if lead_j < indent or t.lstrip(" ").startswith("- "): break if lead_j > indent: raise ValueError(f"router.yaml 第 {no} 行缩进异常(期望 {indent},实际 {lead_j})") if ":" not in t: raise ValueError(f"router.yaml 第 {no} 行不是合法键值对:{t!r}") key, _, rest = t.partition(":") key = key.strip() rest = rest.strip() if rest: result[key] = _parse_scalar(rest) i += 1 continue if i + 1 >= len(lines): raise ValueError(f"router.yaml 第 {no} 行 {key!r} 缺少值") sub_indent = len(lines[i + 1][0]) - len(lines[i + 1][0].lstrip(" ")) if sub_indent <= indent: raise ValueError(f"router.yaml 第 {no} 行 {key!r} 缺少值(无嵌套内容)") v, i = _parse_node(lines, i + 1, sub_indent) result[key] = v return result, i def _load_yaml_text(text: str) -> Dict[str, object]: """解析 YAML 子集 → 嵌套 dict/list。顶层必须为 map。""" lines = _strip_comments(text.splitlines()) if not lines: return {} top_indent = len(lines[0][0]) - len(lines[0][0].lstrip(" ")) value, next_i = _parse_node(lines, 0, top_indent) if not isinstance(value, dict): raise ValueError("router.yaml 顶层必须是 map") if next_i < len(lines): raise ValueError( f"router.yaml 第 {lines[next_i][1]} 行:顶层存在多个节点(缩进不一致)" ) return value # --------------------------------------------------------------------------- # 路由目标与规则模型 # --------------------------------------------------------------------------- class RouteTarget: """路由目标常量。""" LOCAL = "local" # 敏感/核心 → 本地 70B(数据不出厂) CLOUD = "cloud" # 脱敏/通用 → 云端 API BLOCK = "block" # 高危 → 直接拦截,转人工确认 @dataclass(frozen=True) class RouterRule: """一条敏感度路由规则。 - `target`:命中后路由到哪(local / cloud / block); - `kind`:`keyword`(大小写不敏感子串)或 `regex`(正则); - `category`:敏感类别(工艺参数 / 个人信息 / 高危指令等),用于审计分组。 """ name: str category: str kind: str pattern: str target: str description: str = "" priority: int = 0 # 数字越小越优先(issue #43);相同按声明顺序 _compiled: Optional["re.Pattern[str]"] = field(default=None, repr=False, compare=False) @classmethod def from_mapping(cls, m: Dict[str, object]) -> "RouterRule": name = str(m.get("name", "")) if not name: raise ValueError("router 规则缺少 name") kind = str(m.get("kind", "keyword")) pattern = str(m.get("pattern", "")) if not pattern: raise ValueError(f"router 规则 {name} 缺少 pattern") target = str(m.get("target", RouteTarget.LOCAL)) if target not in (RouteTarget.LOCAL, RouteTarget.CLOUD, RouteTarget.BLOCK): raise ValueError(f"router 规则 {name} 的 target 非法:{target!r}") if kind not in ("keyword", "regex"): raise ValueError(f"router 规则 {name} 的 kind 非法:{kind!r}") try: priority = int(m.get("priority", 0)) except (TypeError, ValueError): raise ValueError(f"router 规则 {name} 的 priority 非法:{m.get('priority')!r}") return cls( name=name, category=str(m.get("category", "general")), kind=kind, pattern=pattern, target=target, description=str(m.get("description", "")), priority=priority, ) def _compiled_regex(self) -> "re.Pattern[str]": if self.kind == "regex": return re.compile(self.pattern, re.IGNORECASE) return re.compile(re.escape(self.pattern), re.IGNORECASE) def find(self, text: str) -> List[Tuple[str, int, int]]: """返回 (匹配文本, 起始, 结束) 列表;空串 pattern 返回空。""" if not self.pattern: return [] return [(m.group(0), m.start(), m.end()) for m in self._compiled_regex().finditer(text)] @dataclass(frozen=True) class RouteDecision: """一次路由决策结果(含审计所需上下文)。""" query: str target: str reason: str # rule_hit / no_rule / dlp_blocked rule_name: Optional[str] = None # 命中的规则(rule_hit 时) category: Optional[str] = None decision_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12]) created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) def to_dict(self) -> Dict[str, object]: return { "decision_id": self.decision_id, "created_at": self.created_at, "query": self.query, "target": self.target, "reason": self.reason, "rule_name": self.rule_name, "category": self.category, } # --------------------------------------------------------------------------- # 敏感度路由引擎 # --------------------------------------------------------------------------- class SensitivityRouter: """敏感度路由引擎:对 query 做分级路由(local / cloud / block)。 路由优先级(fail-closed): 1. DLP 出站检查拦截 → `block`(转发人工,绝不发云端); 2. 命中 `block` 路由规则 → `block`; 3. 命中 `local` 路由规则 → `local`(敏感优先本地,规则可覆盖 cloud); 4. 命中 `cloud` 规则 → `cloud`; 5. 未命中任何规则 → 默认 `local`(保守:未知 = 敏感,数据不出厂)。 """ # 内置保底规则:即使未加载任何配置,通用高危/敏感内容默认生效。 ROUTER_DEFAULT_RULES: Tuple[RouterRule, ...] = ( RouterRule( name="rt_id_card", category="pii", kind="regex", pattern=r"\d{17}[\dXx]", target=RouteTarget.LOCAL, description="身份证号(敏感,走本地)", ), RouterRule( name="rt_mobile", category="pii", kind="regex", pattern=r"1[3-9]\d{9}", target=RouteTarget.LOCAL, description="手机号(敏感,走本地)", ), RouterRule( name="rt_emergency_cmd", category="safety", kind="keyword", pattern="停机", target=RouteTarget.BLOCK, description="停机等安全指令(高危,转人工)", ), ) def __init__(self, rules: Optional[List[RouterRule]] = None, default_target: str = RouteTarget.LOCAL, audit: bool = True) -> None: # 内置保底 + 模板规则(同名覆盖内置:模板定制优先) merged: Dict[str, RouterRule] = {r.name: r for r in self.ROUTER_DEFAULT_RULES} for r in (rules or []): merged[r.name] = r # 按 priority 升序(稳定排序:同 priority 保持合并后的声明顺序) self._rules: List[RouterRule] = sorted( merged.values(), key=lambda r: r.priority) self.default_target = default_target self.audit = audit self._audit_log: List[Dict[str, object]] = [] @classmethod def from_template_config(cls, path: str, default_target: str = RouteTarget.LOCAL) -> "SensitivityRouter": """从模板资产加载路由规则(`config/router.template.yaml`)。""" with open(path, "r", encoding="utf-8") as fh: raw = _load_yaml_text(fh.read()) rules = [] for m in raw.get("rules", []): if isinstance(m, dict): rules.append(RouterRule.from_mapping(m)) return cls(rules=rules, default_target=default_target) # -- 决策 -------------------------------------------------------------- def route(self, query: str, dlp_blocked: bool = False) -> RouteDecision: """对单条 query 做路由决策。 `dlp_blocked`:上游 DLP 出站检查结果(true = 已拦截)。 命中 block 或 DLP 拦截时返回 `block`(fail-closed)。 """ # 1) DLP 已拦截 → 直接 block if dlp_blocked: decision = RouteDecision( query=query, target=RouteTarget.BLOCK, reason="dlp_blocked", category="dlp", ) self._record(decision) return decision # 2) 逐条规则(模板配置顺序 = 优先级) for rule in self._rules: if rule.find(query): decision = RouteDecision( query=query, target=rule.target, reason="rule_hit", rule_name=rule.name, category=rule.category, ) self._record(decision) return decision # 3) 无规则命中 → 保守默认 decision = RouteDecision( query=query, target=self.default_target, reason="no_rule", ) self._record(decision) return decision # -- 规则引擎诊断(Issue #43) ----------------------------------------- def validate_rules(self) -> List[str]: """校验规则集合法性,返回问题列表(空 = 合法)。 检查项:规则名重复、pattern 空、target/kind 非法(from_mapping 已拦截)、 priority 排序无冲突(仅报告,不阻断)。 """ problems: List[str] = [] seen: Dict[str, int] = {} for rule in self._rules: seen[rule.name] = seen.get(rule.name, 0) + 1 if not rule.pattern: problems.append(f"规则 {rule.name} pattern 为空") if rule.target not in (RouteTarget.LOCAL, RouteTarget.CLOUD, RouteTarget.BLOCK): problems.append(f"规则 {rule.name} target 非法:{rule.target!r}") for name, count in seen.items(): if count > 1: problems.append(f"规则名重复:{name}({count} 次)") return problems def stats(self) -> Dict[str, object]: """规则集统计(按 target / kind 分类)。""" by_target: Dict[str, int] = {} by_kind: Dict[str, int] = {} for rule in self._rules: by_target[rule.target] = by_target.get(rule.target, 0) + 1 by_kind[rule.kind] = by_kind.get(rule.kind, 0) + 1 return {"total": len(self._rules), "by_target": by_target, "by_kind": by_kind} def describe(self, query: str) -> Dict[str, object]: """命中链诊断:返回 query 命中的全部规则(不改变路由决策)。 用于规则调试/配置台预览:查看同一条 query 命中的多条规则, 理解实际决策是第一条命中的规则(按 priority 排序后)。 """ hits = [] for rule in self._rules: found = rule.find(query) if found: hits.append({ "name": rule.name, "target": rule.target, "category": rule.category, "kind": rule.kind, "priority": rule.priority, "matches": len(found), }) return {"query": query, "hits": hits} # -- 评估(Issue #49 雏形:路由准确率离线评估脚本入口) ---------------- def evaluate(self, samples: List[Dict[str, object]]) -> Dict[str, object]: """离线评估路由准确率(目标 ≥ 96.5%)。 `samples`:`[{"query": str, "expected": "local"|"cloud"|"block"}, ...]`。 返回总体准确率 + 每类明细。子任务 #49 将扩展为评测集与报表脚本。 """ total = len(samples) if total == 0: return {"accuracy": 0.0, "correct": 0, "total": 0, "by_target": {}} correct = 0 by_target: Dict[str, Dict[str, int]] = {} for s in samples: expected = str(s["expected"]) got = self.route(str(s["query"]), dlp_blocked=bool(s.get("dlp_blocked", False))) ok = got.target == expected if ok: correct += 1 agg = by_target.setdefault(expected, {"correct": 0, "total": 0}) agg["total"] += 1 if ok: agg["correct"] += 1 return { "accuracy": round(correct / total, 4), "correct": correct, "total": total, "by_target": by_target, } # -- 审计 -------------------------------------------------------------- def _record(self, decision: RouteDecision) -> None: if self.audit: self._audit_log.append(decision.to_dict()) def drain_audit(self) -> List[Dict[str, object]]: """取走并清空审计记录(对接外部审计管道)。""" out, self._audit_log = self._audit_log, [] return out # -- 只读属性 ---------------------------------------------------------- @property def rule_count(self) -> int: return len(self._rules) @property def rule_names(self) -> List[str]: return [r.name for r in self._rules] def __repr__(self) -> str: # pragma: no cover - 调试辅助 return f""