plctap 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plctap/__init__.py +6 -0
- plctap/config.py +44 -0
- plctap/conn/__init__.py +1 -0
- plctap/conn/manager.py +165 -0
- plctap/diag/__init__.py +1 -0
- plctap/diag/engine.py +265 -0
- plctap/diag/kb.yaml +593 -0
- plctap/listener.py +279 -0
- plctap/models.py +138 -0
- plctap/pcap.py +127 -0
- plctap/protocols/__init__.py +25 -0
- plctap/protocols/auto.py +76 -0
- plctap/protocols/base.py +154 -0
- plctap/protocols/common.py +53 -0
- plctap/protocols/fins/__init__.py +9 -0
- plctap/protocols/fins/adapter.py +251 -0
- plctap/protocols/fins/codec.py +408 -0
- plctap/protocols/melsec/__init__.py +9 -0
- plctap/protocols/melsec/adapter.py +171 -0
- plctap/protocols/melsec/codec.py +329 -0
- plctap/protocols/modbus/__init__.py +5 -0
- plctap/protocols/modbus/adapter.py +304 -0
- plctap/protocols/modbus/codec.py +601 -0
- plctap/safety.py +48 -0
- plctap/server.py +379 -0
- plctap/streams.py +74 -0
- plctap-0.1.0.dist-info/METADATA +122 -0
- plctap-0.1.0.dist-info/RECORD +31 -0
- plctap-0.1.0.dist-info/WHEEL +4 -0
- plctap-0.1.0.dist-info/entry_points.txt +2 -0
- plctap-0.1.0.dist-info/licenses/LICENSE +21 -0
plctap/__init__.py
ADDED
plctap/config.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""运行配置 (ARCHITECTURE.md 第 7 节)。
|
|
2
|
+
|
|
3
|
+
配置来源: 环境变量 (PLCTAP_*)。
|
|
4
|
+
理由: MCP stdio server 由客户端拉起 (claude_desktop_config.json 的 env 段 /
|
|
5
|
+
Codex config.toml 的 [mcp_servers.plctap].env), 环境变量是两个客户端都原生
|
|
6
|
+
支持的配置通道; 独立 TOML 文件还需约定查找路径与优先级, M1 阶段不引入。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
_TRUE = {"1", "true", "yes", "on"}
|
|
16
|
+
|
|
17
|
+
_DEFAULT_AUDIT_LOG = Path.home() / ".plctap" / "audit.jsonl"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class PlctapConfig:
|
|
22
|
+
"""server 运行参数, 与 ARCHITECTURE.md 第 7 节的配置样例一一对应。"""
|
|
23
|
+
|
|
24
|
+
# 安全闸门 (D5): False 时写类工具根本不注册
|
|
25
|
+
allow_write: bool = False
|
|
26
|
+
# 连接池: 每目标 (protocol,host,port,unit) 同时持有的连接上限 (D1)
|
|
27
|
+
pool_max_per_target: int = 2
|
|
28
|
+
# 空闲连接回收阈值 (D1: 空闲 30s 回收)
|
|
29
|
+
idle_timeout_sec: float = 30.0
|
|
30
|
+
# 默认网络超时 (连接/收发各自独立计时)
|
|
31
|
+
default_timeout_ms: int = 2000
|
|
32
|
+
# JSONL 审计日志路径; 审计不可关 (HANDOFF 红线 2)
|
|
33
|
+
audit_log: Path = _DEFAULT_AUDIT_LOG
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_env(cls, env: dict[str, str] | None = None) -> "PlctapConfig":
|
|
37
|
+
env = os.environ if env is None else env
|
|
38
|
+
return cls(
|
|
39
|
+
allow_write=env.get("PLCTAP_ALLOW_WRITE", "").strip().lower() in _TRUE,
|
|
40
|
+
pool_max_per_target=int(env.get("PLCTAP_POOL_MAX_PER_TARGET", "2")),
|
|
41
|
+
idle_timeout_sec=float(env.get("PLCTAP_IDLE_TIMEOUT_SEC", "30")),
|
|
42
|
+
default_timeout_ms=int(env.get("PLCTAP_DEFAULT_TIMEOUT_MS", "2000")),
|
|
43
|
+
audit_log=Path(env.get("PLCTAP_AUDIT_LOG", str(_DEFAULT_AUDIT_LOG))),
|
|
44
|
+
)
|
plctap/conn/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""连接管理子包 (D1: 无状态工具 + 内部透明连接池)。"""
|
plctap/conn/manager.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""连接池 + 目标级互斥锁 (ARCHITECTURE.md D1)。
|
|
2
|
+
|
|
3
|
+
设计要点:
|
|
4
|
+
- 工具无状态, 每次携带 host/port; 池 key = (protocol, host, port, unit)。
|
|
5
|
+
unit 进入 key 是因为同一 IP:PORT 上不同 unit id 的会话语义不同
|
|
6
|
+
(Modbus 网关场景), 复用连接可能串话。
|
|
7
|
+
- 空闲 idle_timeout_sec 的连接由后台清扫任务关闭; 任务在首次 acquire
|
|
8
|
+
时惰性启动, 进程退出由事件循环一并回收 (stdio server 生命周期即进程)。
|
|
9
|
+
- 每目标互斥锁: 一次请求-响应配对期间独占连接, 防止并发调用串包。
|
|
10
|
+
- 池上限: 活跃+空闲连接达到 max_per_target 后, 新连接标记为 ephemeral,
|
|
11
|
+
用完即弃不入池 (限流而非阻塞)。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import time
|
|
18
|
+
from typing import NamedTuple
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConnectionKey(NamedTuple):
|
|
22
|
+
"""连接池 key (D1): (protocol, host, port, unit)。"""
|
|
23
|
+
|
|
24
|
+
protocol: str
|
|
25
|
+
host: str
|
|
26
|
+
port: int
|
|
27
|
+
unit: int
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def target(self) -> str:
|
|
31
|
+
"""人可读的目标标识, 用于审计与错误信息。"""
|
|
32
|
+
return f"{self.protocol}://{self.host}:{self.port} unit={self.unit}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PooledConnection:
|
|
36
|
+
"""池中一条 TCP 连接。ephemeral=True 的连接用完即关, 不回池。"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
reader: asyncio.StreamReader,
|
|
41
|
+
writer: asyncio.StreamWriter,
|
|
42
|
+
key: ConnectionKey,
|
|
43
|
+
ephemeral: bool = False,
|
|
44
|
+
) -> None:
|
|
45
|
+
self.reader = reader
|
|
46
|
+
self.writer = writer
|
|
47
|
+
self.key = key
|
|
48
|
+
self.ephemeral = ephemeral
|
|
49
|
+
self.last_used = time.monotonic()
|
|
50
|
+
# 协议适配器的每连接会话状态 (如 FINS/TCP 已完成的节点握手);
|
|
51
|
+
# 连接复用时状态随连接保留, 连接销毁即作废
|
|
52
|
+
self.metadata: dict[str, object] = {}
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def closed(self) -> bool:
|
|
56
|
+
return self.writer.is_closing()
|
|
57
|
+
|
|
58
|
+
def close(self) -> None:
|
|
59
|
+
if not self.writer.is_closing():
|
|
60
|
+
self.writer.close()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ConnectionPool:
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
idle_timeout_sec: float = 30.0,
|
|
67
|
+
max_per_target: int = 2,
|
|
68
|
+
sweep_interval_sec: float = 5.0,
|
|
69
|
+
) -> None:
|
|
70
|
+
self.idle_timeout_sec = idle_timeout_sec
|
|
71
|
+
self.max_per_target = max(1, max_per_target)
|
|
72
|
+
self.sweep_interval_sec = sweep_interval_sec
|
|
73
|
+
self._idle: dict[ConnectionKey, list[PooledConnection]] = {}
|
|
74
|
+
self._active: dict[ConnectionKey, int] = {}
|
|
75
|
+
self._locks: dict[ConnectionKey, asyncio.Lock] = {}
|
|
76
|
+
self._sweeper: asyncio.Task[None] | None = None
|
|
77
|
+
|
|
78
|
+
def lock_for(self, key: ConnectionKey) -> asyncio.Lock:
|
|
79
|
+
"""目标级互斥锁 (D1): 覆盖整个请求-响应配对, 防并发串包。"""
|
|
80
|
+
lock = self._locks.get(key)
|
|
81
|
+
if lock is None:
|
|
82
|
+
lock = asyncio.Lock()
|
|
83
|
+
self._locks[key] = lock
|
|
84
|
+
return lock
|
|
85
|
+
|
|
86
|
+
async def acquire(
|
|
87
|
+
self,
|
|
88
|
+
key: ConnectionKey,
|
|
89
|
+
connect_timeout: float,
|
|
90
|
+
) -> PooledConnection:
|
|
91
|
+
"""取一条可用连接: 优先复用空闲连接, 否则新建。新建失败向上抛
|
|
92
|
+
ConnectionRefusedError / TimeoutError 等, 由适配器做失败分类。"""
|
|
93
|
+
# 1) 复用空闲连接 (跳过已被对端关闭的)
|
|
94
|
+
idle = self._idle.get(key)
|
|
95
|
+
while idle:
|
|
96
|
+
conn = idle.pop()
|
|
97
|
+
if not conn.closed:
|
|
98
|
+
self._active[key] = self._active.get(key, 0) + 1
|
|
99
|
+
conn.last_used = time.monotonic()
|
|
100
|
+
return conn
|
|
101
|
+
conn.close() # 确保 socket 资源释放
|
|
102
|
+
|
|
103
|
+
# 2) 新建; 达到池上限则建 ephemeral 连接 (用完即弃)
|
|
104
|
+
ephemeral = self._active.get(key, 0) >= self.max_per_target
|
|
105
|
+
reader, writer = await asyncio.wait_for(
|
|
106
|
+
asyncio.open_connection(key.host, key.port), connect_timeout
|
|
107
|
+
)
|
|
108
|
+
conn = PooledConnection(reader, writer, key, ephemeral=ephemeral)
|
|
109
|
+
self._active[key] = self._active.get(key, 0) + 1
|
|
110
|
+
self._ensure_sweeper()
|
|
111
|
+
return conn
|
|
112
|
+
|
|
113
|
+
def release(self, conn: PooledConnection, *, healthy: bool = True) -> None:
|
|
114
|
+
"""归还连接。healthy=False (协议层出错/流可能失步) 时直接丢弃。"""
|
|
115
|
+
self._active[conn.key] = max(0, self._active.get(conn.key, 1) - 1)
|
|
116
|
+
if not healthy or conn.ephemeral or conn.closed:
|
|
117
|
+
conn.close()
|
|
118
|
+
return
|
|
119
|
+
conn.last_used = time.monotonic()
|
|
120
|
+
self._idle.setdefault(conn.key, []).append(conn)
|
|
121
|
+
|
|
122
|
+
def discard(self, conn: PooledConnection) -> None:
|
|
123
|
+
"""异常路径丢弃连接 (release 的别名, 语义化使用)。"""
|
|
124
|
+
self.release(conn, healthy=False)
|
|
125
|
+
|
|
126
|
+
async def close_all(self) -> None:
|
|
127
|
+
"""关闭全部空闲连接并停掉清扫任务 (测试收尾用)。"""
|
|
128
|
+
if self._sweeper is not None:
|
|
129
|
+
self._sweeper.cancel()
|
|
130
|
+
try:
|
|
131
|
+
await self._sweeper
|
|
132
|
+
except asyncio.CancelledError:
|
|
133
|
+
pass
|
|
134
|
+
self._sweeper = None
|
|
135
|
+
for idle in self._idle.values():
|
|
136
|
+
for conn in idle:
|
|
137
|
+
conn.close()
|
|
138
|
+
self._idle.clear()
|
|
139
|
+
|
|
140
|
+
def _ensure_sweeper(self) -> None:
|
|
141
|
+
"""清扫任务惰性启动: 首次成功建连时才创建, 之后常驻。"""
|
|
142
|
+
if self._sweeper is not None and not self._sweeper.done():
|
|
143
|
+
return
|
|
144
|
+
try:
|
|
145
|
+
loop = asyncio.get_running_loop()
|
|
146
|
+
except RuntimeError:
|
|
147
|
+
return
|
|
148
|
+
self._sweeper = loop.create_task(self._sweep_loop())
|
|
149
|
+
|
|
150
|
+
async def _sweep_loop(self) -> None:
|
|
151
|
+
while True:
|
|
152
|
+
await asyncio.sleep(self.sweep_interval_sec)
|
|
153
|
+
now = time.monotonic()
|
|
154
|
+
for key, idle in list(self._idle.items()): # 快照迭代: release() 可能并发改字典
|
|
155
|
+
keep: list[PooledConnection] = []
|
|
156
|
+
for conn in idle:
|
|
157
|
+
if now - conn.last_used > self.idle_timeout_sec or conn.closed:
|
|
158
|
+
conn.close()
|
|
159
|
+
else:
|
|
160
|
+
keep.append(conn)
|
|
161
|
+
if keep:
|
|
162
|
+
self._idle[key] = keep
|
|
163
|
+
else:
|
|
164
|
+
self._idle.pop(key, None)
|
|
165
|
+
|
plctap/diag/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""诊断引擎子包 (M2: 确定性规则引擎 + kb.yaml, D4)。占位。"""
|
plctap/diag/engine.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""诊断引擎 (D4): 确定性规则匹配 + kb.yaml, 不做任何自然语言生成。
|
|
2
|
+
|
|
3
|
+
输入是结构化观测 (probe 结果 / 帧解析结果 / 日志片段), 输出按置信度
|
|
4
|
+
排序的候选结论。规则全部来自 kb.yaml (与本模块同目录打包):
|
|
5
|
+
- entries: 匹配条件 -> 预审定的静态文案 (symptom/root_cause/action)
|
|
6
|
+
- references: 命中含数值数据的帧时附加的字节序提示 (不是候选结论)
|
|
7
|
+
|
|
8
|
+
匹配语义 (kb.yaml 头注): 单条目所有给出的条件都满足才命中;
|
|
9
|
+
frame 级条件 (解析报错/检查项/字段) 按帧求值, 任一帧命中即算;
|
|
10
|
+
probe 级条件 (failure_class) 对探测结果求值。
|
|
11
|
+
|
|
12
|
+
日志模式: 从文本里提取连续 hex 串 (>=16 hex 字符即 >= 8 字节, 覆盖
|
|
13
|
+
RTU 最短帧) 逐帧解析。纯数字长串 (时间戳) 可能误入, 解析只会产出
|
|
14
|
+
结构化错误并归入 observations, 不影响结论 (事实先行, 噪声留痕)。
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from functools import lru_cache
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import yaml
|
|
25
|
+
|
|
26
|
+
from plctap.models import Candidate, DiagnosticReport, ParseResult, ProbeResult
|
|
27
|
+
from plctap.protocols.auto import parse_auto
|
|
28
|
+
|
|
29
|
+
_KB_PATH = Path(__file__).with_name("kb.yaml")
|
|
30
|
+
|
|
31
|
+
# 连续 hex 串 (偶长): >= 16 个 hex 字符 = 8 字节 (Modbus RTU 最短帧)
|
|
32
|
+
_HEX_TOKEN = re.compile(r"(?<![0-9a-fA-F])[0-9a-fA-F]{16,}(?![0-9a-fA-F])")
|
|
33
|
+
|
|
34
|
+
# 双轨合并时 RTU 侧的 "形似 RTU" 门控: 这些检查全过才把尾字节当 CRC 校验
|
|
35
|
+
_RTU_GATE = frozenset({
|
|
36
|
+
"exception_payload_shape", "request_payload_shape", "response_payload_shape",
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@lru_cache(maxsize=1)
|
|
41
|
+
def _load_kb() -> dict[str, Any]:
|
|
42
|
+
with _KB_PATH.open("r", encoding="utf-8") as f:
|
|
43
|
+
return yaml.safe_load(f)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def kb_entries() -> list[dict[str, Any]]:
|
|
47
|
+
"""知识库条目 (评测与测试用)。"""
|
|
48
|
+
return _load_kb().get("entries", [])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def kb_entry_ids() -> set[str]:
|
|
52
|
+
return {e["id"] for e in kb_entries()}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------- 观测收集
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _FrameFacts:
|
|
59
|
+
"""单帧的观测事实: 方向 + 解析报错 + 校验失败项 + 字段值。"""
|
|
60
|
+
|
|
61
|
+
__slots__ = ("direction", "parse_errors", "failed_checks", "fields")
|
|
62
|
+
|
|
63
|
+
def __init__(self, parsed: ParseResult, failed_checks: list[str]) -> None:
|
|
64
|
+
self.direction = parsed.direction
|
|
65
|
+
self.parse_errors = parsed.errors
|
|
66
|
+
self.failed_checks = failed_checks
|
|
67
|
+
self.fields = {f.name: f.value for f in parsed.fields}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _collect_frame(protocol: str, frame: bytes, pending_request: bytes | None) -> tuple[_FrameFacts, bytes | None]:
|
|
71
|
+
"""解析一帧并收集事实。返回 (帧事实, 本帧若为请求则带回以供配对)。
|
|
72
|
+
|
|
73
|
+
Modbus TCP/RTU 双轨判别 (三档, 决定校验清单与事实来源):
|
|
74
|
+
- TCP 结构合法: 单轨 TCP。合法 TCP 帧的尾字节不是 RTU CRC, 不跑 RTU 轨。
|
|
75
|
+
- TCP 硬解失败但 RTU 能完整解释 (parse_rtu 合法): 单轨 RTU —— 合法
|
|
76
|
+
RTU 帧不该产出 "MBAP 长度不符" 这类按 TCP 硬解的伪结论。
|
|
77
|
+
- 两者都解释不通: 双轨合并。RTU 侧仅在帧 "形似 RTU" (功能码已知且
|
|
78
|
+
payload 形状自洽, 见 validate_rtu) 时纳入, 否则 12 字节 TCP 帧的
|
|
79
|
+
尾字节会被当 CRC 算出 "RTU 校验和错" 噪声, 压过真正的 TCP 结论。
|
|
80
|
+
|
|
81
|
+
配对规则: 响应帧紧跟在同一协议的 TCP 请求帧之后时, 用 codec 的
|
|
82
|
+
request 参数重解析, 交叉校验错误 (tid/sid/节点回显) 才能出现 ——
|
|
83
|
+
这正是串包/网关错路由类故障的观测来源。RTU 帧无事务号, 不参与
|
|
84
|
+
配对 (串口侧按地址/功能码回显核对, 不在本引擎范围)。
|
|
85
|
+
"""
|
|
86
|
+
from plctap.protocols import codec_for
|
|
87
|
+
|
|
88
|
+
codec = codec_for(protocol)
|
|
89
|
+
parsed = parse_auto(protocol, frame)
|
|
90
|
+
track = "tcp"
|
|
91
|
+
if parsed.valid:
|
|
92
|
+
failed = [c.name for c in codec.validate_frame(frame, parsed.direction) if not c.passed]
|
|
93
|
+
facts = _FrameFacts(parsed, failed)
|
|
94
|
+
else:
|
|
95
|
+
rtu = codec.parse_rtu(frame) if protocol == "modbus" else None
|
|
96
|
+
if rtu is not None and rtu.valid:
|
|
97
|
+
track = "rtu"
|
|
98
|
+
failed = [c.name for c in codec.validate_rtu(frame, rtu.direction) if not c.passed]
|
|
99
|
+
facts = _FrameFacts(rtu, failed)
|
|
100
|
+
else:
|
|
101
|
+
failed = [c.name for c in codec.validate_frame(frame, parsed.direction) if not c.passed]
|
|
102
|
+
rtu_checks = codec.validate_rtu(frame) if rtu is not None else []
|
|
103
|
+
gate = _RTU_GATE | {"function_code_known"}
|
|
104
|
+
if all(c.passed for c in rtu_checks if c.name in gate):
|
|
105
|
+
failed.extend(c.name for c in rtu_checks if not c.passed)
|
|
106
|
+
facts = _FrameFacts(parsed, failed)
|
|
107
|
+
if facts.direction == "req" and track == "tcp":
|
|
108
|
+
return facts, frame
|
|
109
|
+
if pending_request is not None:
|
|
110
|
+
reparsed = codec.parse_response(frame, request=pending_request)
|
|
111
|
+
return _FrameFacts(reparsed, facts.failed_checks), None
|
|
112
|
+
return facts, None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------- 规则匹配
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _match_frame(entry: dict[str, Any], frame: _FrameFacts) -> bool:
|
|
119
|
+
m = entry.get("match", {})
|
|
120
|
+
if "exception_code" in m:
|
|
121
|
+
# Modbus 异常码在 exception_code 字段; FINS/MELSEC 在 end_code 字段,
|
|
122
|
+
# 但 KB 用 exception_code 统一表达"设备回的错误码" —— 两个来源都看
|
|
123
|
+
codes = {v for v in (_frame_code(frame, "exception_code"), _frame_code(frame, "end_code")) if v is not None}
|
|
124
|
+
if not codes & set(m["exception_code"]):
|
|
125
|
+
return False
|
|
126
|
+
if "end_code" in m:
|
|
127
|
+
if _frame_code(frame, "end_code") not in m["end_code"]:
|
|
128
|
+
return False
|
|
129
|
+
if "parse_error_contains" in m:
|
|
130
|
+
joined = " | ".join(frame.parse_errors).casefold()
|
|
131
|
+
if not any(s.casefold() in joined for s in m["parse_error_contains"]):
|
|
132
|
+
return False
|
|
133
|
+
if "check_failed" in m:
|
|
134
|
+
if not any(name in frame.failed_checks for name in m["check_failed"]):
|
|
135
|
+
return False
|
|
136
|
+
if "field" in m:
|
|
137
|
+
for name, values in m["field"].items():
|
|
138
|
+
if frame.fields.get(name) not in values:
|
|
139
|
+
return False
|
|
140
|
+
if "direction" in m and frame.direction != m["direction"]:
|
|
141
|
+
return False
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _frame_code(frame: _FrameFacts, name: str) -> int | None:
|
|
146
|
+
v = frame.fields.get(name)
|
|
147
|
+
return v if isinstance(v, int) else None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _match_probe(entry: dict[str, Any], probe: ProbeResult | None) -> bool:
|
|
151
|
+
if "failure_class" not in entry.get("match", {}):
|
|
152
|
+
return True # 条目不约束探测结果
|
|
153
|
+
if probe is None or not probe.failure_class:
|
|
154
|
+
return False
|
|
155
|
+
return probe.failure_class in entry["match"]["failure_class"]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _evidence(entry_id: str, frames: list[tuple[int, _FrameFacts]], probe: ProbeResult | None) -> list[str]:
|
|
159
|
+
ev: list[str] = []
|
|
160
|
+
for idx, f in frames:
|
|
161
|
+
ev.append(f"frame[{idx}] direction={f.direction}")
|
|
162
|
+
ev.extend(f"frame[{idx}] parse_error: {e}" for e in f.parse_errors)
|
|
163
|
+
ev.extend(f"frame[{idx}] check_failed: {c}" for c in f.failed_checks)
|
|
164
|
+
if probe is not None and probe.failure_class:
|
|
165
|
+
ev.append(f"probe failure_class={probe.failure_class}")
|
|
166
|
+
if probe.exception_code is not None:
|
|
167
|
+
ev.append(f"probe exception_code={probe.exception_code:#x}")
|
|
168
|
+
return ev or [f"matched kb entry {entry_id} (no extra evidence)"]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------- 主入口
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def diagnose(
|
|
175
|
+
protocol: str,
|
|
176
|
+
frames_hex: list[str] | None = None,
|
|
177
|
+
log_snippet: str | None = None,
|
|
178
|
+
probe_result: ProbeResult | None = None,
|
|
179
|
+
) -> DiagnosticReport:
|
|
180
|
+
"""从观测推导候选结论。三种输入可任意组合, 全缺则返回空报告
|
|
181
|
+
(知识库未覆盖时如实为空, 不硬凑结论)。"""
|
|
182
|
+
observations: list[str] = []
|
|
183
|
+
frames: list[tuple[int, _FrameFacts]] = []
|
|
184
|
+
saw_values = False
|
|
185
|
+
pending_request: bytes | None = None
|
|
186
|
+
|
|
187
|
+
for hexstr in frames_hex or []:
|
|
188
|
+
frame = _coerce_frame(hexstr, observations)
|
|
189
|
+
if frame is None:
|
|
190
|
+
continue
|
|
191
|
+
facts, pending_request = _collect_frame(protocol, frame, pending_request)
|
|
192
|
+
frames.append((len(frames), facts))
|
|
193
|
+
if "word_values" in facts.fields:
|
|
194
|
+
saw_values = True
|
|
195
|
+
observations.append(f"frame[{frames[-1][0]}] {len(frame)}B parsed (direction={facts.direction})")
|
|
196
|
+
|
|
197
|
+
if log_snippet:
|
|
198
|
+
tokens = _HEX_TOKEN.findall(log_snippet)
|
|
199
|
+
observations.append(f"log_snippet: extracted {len(tokens)} hex token(s)")
|
|
200
|
+
for tok in tokens:
|
|
201
|
+
frame = _coerce_frame(tok, observations)
|
|
202
|
+
if frame is None:
|
|
203
|
+
continue
|
|
204
|
+
facts, pending_request = _collect_frame(protocol, frame, pending_request)
|
|
205
|
+
frames.append((len(frames), facts))
|
|
206
|
+
if "word_values" in facts.fields:
|
|
207
|
+
saw_values = True
|
|
208
|
+
|
|
209
|
+
candidates: list[Candidate] = []
|
|
210
|
+
for entry in kb_entries():
|
|
211
|
+
if not _match_probe(entry, probe_result):
|
|
212
|
+
continue
|
|
213
|
+
matched = [
|
|
214
|
+
(idx, f) for idx, f in frames if _match_frame(entry, f)
|
|
215
|
+
]
|
|
216
|
+
if not matched and _needs_frame(entry):
|
|
217
|
+
continue
|
|
218
|
+
c = entry["candidate"]
|
|
219
|
+
candidates.append(
|
|
220
|
+
Candidate(
|
|
221
|
+
symptom=c["symptom"],
|
|
222
|
+
root_cause=c["root_cause"],
|
|
223
|
+
evidence=_evidence(entry["id"], matched, probe_result),
|
|
224
|
+
confidence=float(c["confidence"]),
|
|
225
|
+
suggested_action=c["suggested_action"],
|
|
226
|
+
next_tools=c.get("next_tools", []),
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
candidates.sort(key=lambda c: c.confidence, reverse=True)
|
|
231
|
+
|
|
232
|
+
next_tools: list[str] = []
|
|
233
|
+
for c in candidates:
|
|
234
|
+
for t in c.next_tools:
|
|
235
|
+
if t not in next_tools:
|
|
236
|
+
next_tools.append(t)
|
|
237
|
+
|
|
238
|
+
if saw_values:
|
|
239
|
+
observations.extend(_reference_notes(protocol))
|
|
240
|
+
|
|
241
|
+
if not candidates and not observations:
|
|
242
|
+
observations.append("no observation provided; give frame_hex / log_snippet / probe inputs")
|
|
243
|
+
if not candidates and observations:
|
|
244
|
+
observations.append("no kb entry matched; knowledge base does not cover this pattern")
|
|
245
|
+
|
|
246
|
+
return DiagnosticReport(candidates=candidates, next_tools=next_tools, observations=observations)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _needs_frame(entry: dict[str, Any]) -> bool:
|
|
250
|
+
"""条目含任一帧级条件就必须有帧命中; 仅 probe 条目的条目无需帧。"""
|
|
251
|
+
m = entry.get("match", {})
|
|
252
|
+
return any(k in m for k in ("exception_code", "end_code", "parse_error_contains", "check_failed", "field", "direction"))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _coerce_frame(hexstr: str, observations: list[str]) -> bytes | None:
|
|
256
|
+
try:
|
|
257
|
+
return bytes.fromhex(hexstr)
|
|
258
|
+
except ValueError:
|
|
259
|
+
observations.append(f"skipped non-hex token: {hexstr[:16]}...")
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _reference_notes(protocol: str) -> list[str]:
|
|
264
|
+
notes = [r["note"] for r in _load_kb().get("references", []) if r.get("protocol") == protocol]
|
|
265
|
+
return ["reference: " + " ".join(n.split()) for n in notes]
|