Files
iAOP/core/llm-gateway/router.py
T

366 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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 = ""
_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}")
return cls(
name=name,
category=str(m.get("category", "general")),
kind=kind,
pattern=pattern,
target=target,
description=str(m.get("description", "")),
)
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
self._rules: List[RouterRule] = list(merged.values())
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 #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"<SensitivityRouter rules={self.rule_count} default={self.default_target}>"