490 lines
20 KiB
Python
490 lines
20 KiB
Python
# -*- 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 数据不出厂)。
|
||
|
||
设计说明(Issue #43 雏形,Issue #49 补齐评测):
|
||
- 本版实现规则匹配与分级、模板加载、路由准确率离线评测入口(`evaluate()`);
|
||
- 配套评测报告脚本 `evaluate_routing.py`(Issue #49 交付),验收目标 ≥ 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",
|
||
"dlp_blocked": bool(可选)}, ...]`。
|
||
返回总体准确率 + 每类明细 + 逐样本记录(供评测报告脚本渲染明细,
|
||
配套 `evaluate_routing.py`,Issue #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]] = {}
|
||
sample_records: List[Dict[str, object]] = []
|
||
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
|
||
sample_records.append({
|
||
"query": str(s["query"]),
|
||
"expected": expected,
|
||
"actual": got.target,
|
||
"ok": ok,
|
||
"reason": got.reason,
|
||
"rule_name": got.rule_name,
|
||
"category": got.category,
|
||
})
|
||
return {
|
||
"accuracy": round(correct / total, 4),
|
||
"correct": correct,
|
||
"total": total,
|
||
"by_target": by_target,
|
||
"samples": sample_records,
|
||
}
|
||
|
||
def render_evaluation_report(self, report: Dict[str, object],
|
||
threshold: float = 0.965) -> str:
|
||
"""把 `evaluate()` 的结构化报告渲染为 Markdown 文本(评测报告脚本用)。
|
||
|
||
`threshold`:验收准确率阈值(PRD 5.4 / EPIC #6 目标 ≥ 96.5%)。
|
||
"""
|
||
total = int(report.get("total", 0))
|
||
lines: List[str] = [
|
||
"# LLM 网关 · 敏感度路由准确率评测报告",
|
||
"",
|
||
f"- 生成时间:{datetime.now(timezone.utc).isoformat(timespec='seconds')}",
|
||
f"- 路由规则数:{self.rule_count}(内置保底 + 模板资产)",
|
||
f"- 样本总数:{total}",
|
||
]
|
||
if total:
|
||
acc = float(report.get("accuracy", 0.0))
|
||
passed = acc >= threshold
|
||
lines.append(
|
||
"- 路由准确率 {:.2%}|验收目标 ≥ {:.2%}|**{}**".format(
|
||
acc, threshold, "达标" if passed else "未达标")
|
||
)
|
||
lines.append("")
|
||
|
||
by_target = report.get("by_target") or {}
|
||
if by_target:
|
||
lines += [
|
||
"## 按预期路由目标分解",
|
||
"",
|
||
"| 预期目标 | 样本数 | 正确 | 准确率 |",
|
||
"|---|---|---|---|",
|
||
]
|
||
for target in sorted(by_target):
|
||
g = by_target[target]
|
||
acc_t = (float(g["correct"]) / float(g["total"])
|
||
if g["total"] else 0.0)
|
||
lines.append("| {} | {} | {} | {:.1%} |".format(
|
||
target, g["total"], g["correct"], acc_t))
|
||
lines.append("")
|
||
|
||
bad = [r for r in report.get("samples", []) if not r.get("ok")]
|
||
if bad:
|
||
lines += ["## 未通过样本明细", ""]
|
||
for i, r in enumerate(bad, 1):
|
||
lines.append("{}. **{expected} → {actual}**({query})".format(
|
||
i,
|
||
expected=r.get("expected", "?"),
|
||
actual=r.get("actual", "?"),
|
||
query=r.get("query", "?"),
|
||
))
|
||
lines.append(" - 决策依据:{}(规则:{})".format(
|
||
r.get("reason", "?"), r.get("rule_name") or "—"))
|
||
lines.append("")
|
||
return "\n".join(lines)
|
||
|
||
# -- 审计 --------------------------------------------------------------
|
||
|
||
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"<SensitivityRouter rules={self.rule_count} default={self.default_target}>"
|