superagent-build 0.1.0__tar.gz

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.
Files changed (40) hide show
  1. superagent_build-0.1.0/PKG-INFO +91 -0
  2. superagent_build-0.1.0/README.md +72 -0
  3. superagent_build-0.1.0/agent/__init__.py +14 -0
  4. superagent_build-0.1.0/agent/admission.py +116 -0
  5. superagent_build-0.1.0/agent/assessor.py +188 -0
  6. superagent_build-0.1.0/agent/calibration.py +252 -0
  7. superagent_build-0.1.0/agent/engine.py +214 -0
  8. superagent_build-0.1.0/agent/executor.py +47 -0
  9. superagent_build-0.1.0/agent/governance.py +235 -0
  10. superagent_build-0.1.0/agent/metrics.py +50 -0
  11. superagent_build-0.1.0/agent/model.py +107 -0
  12. superagent_build-0.1.0/agent/openai_executor.py +159 -0
  13. superagent_build-0.1.0/agent/outbox.py +60 -0
  14. superagent_build-0.1.0/agent/proactivity.py +150 -0
  15. superagent_build-0.1.0/agent/scheduler.py +129 -0
  16. superagent_build-0.1.0/agent/store.py +116 -0
  17. superagent_build-0.1.0/agent/tickets.py +170 -0
  18. superagent_build-0.1.0/agent/watchdog.py +235 -0
  19. superagent_build-0.1.0/pyproject.toml +30 -0
  20. superagent_build-0.1.0/setup.cfg +4 -0
  21. superagent_build-0.1.0/superagent_build.egg-info/PKG-INFO +91 -0
  22. superagent_build-0.1.0/superagent_build.egg-info/SOURCES.txt +38 -0
  23. superagent_build-0.1.0/superagent_build.egg-info/dependency_links.txt +1 -0
  24. superagent_build-0.1.0/superagent_build.egg-info/requires.txt +2 -0
  25. superagent_build-0.1.0/superagent_build.egg-info/top_level.txt +1 -0
  26. superagent_build-0.1.0/tests/test_admission_outbox.py +111 -0
  27. superagent_build-0.1.0/tests/test_e2e_m0.py +73 -0
  28. superagent_build-0.1.0/tests/test_e2e_m1.py +66 -0
  29. superagent_build-0.1.0/tests/test_e2e_m2.py +69 -0
  30. superagent_build-0.1.0/tests/test_e2e_m3.py +64 -0
  31. superagent_build-0.1.0/tests/test_governance.py +142 -0
  32. superagent_build-0.1.0/tests/test_i1_i4.py +146 -0
  33. superagent_build-0.1.0/tests/test_i2_assessor.py +152 -0
  34. superagent_build-0.1.0/tests/test_i3_executor.py +153 -0
  35. superagent_build-0.1.0/tests/test_i5_calibration.py +174 -0
  36. superagent_build-0.1.0/tests/test_i6_scheduler.py +152 -0
  37. superagent_build-0.1.0/tests/test_integration.py +62 -0
  38. superagent_build-0.1.0/tests/test_proactivity.py +145 -0
  39. superagent_build-0.1.0/tests/test_store.py +70 -0
  40. superagent_build-0.1.0/tests/test_watchdog.py +115 -0
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: superagent-build
3
+ Version: 0.1.0
4
+ Summary: A measurable Super Agent kernel: two-stage admission, outbox delivery triangle, ticket persistence, write-scope governance, watchdog review, proactivity threshold, tick scheduler.
5
+ Author: Liu Zewen
6
+ License: MIT
7
+ Keywords: agent,multi-agent,admission,outbox,governance,watchdog
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ Provides-Extra: openai
19
+
20
+ # superagent-build — 自研 Super Agent(治理内嵌 + Watchdog + 主动阈值)
21
+
22
+ > 定位:把 dsh-superagent-prototype(22k★)宣传了但没做的四件事真的做出来。
23
+ > 证据链:拆解报告见 `../superagent-dig/01-prototype-dissection.md`(文章卖点兑现度表)。
24
+
25
+ ## 与原型的差异(本项目核心增量)
26
+
27
+ | 能力 | 原型 | 本项目 |
28
+ |---|---|---|
29
+ | 跑偏检测 | 全库零实现 | `agent/watchdog.py`:Mandate 对照 + 确定性规则层 + judge 一票否决 |
30
+ | 文件级冲突 | 只在 PR 层暴露 | `agent/governance.py`:写前 claim/check,未声明即 BLOCK |
31
+ | 长期记忆 | 只写不读半成品 | `agent/store.py` 全量落库 + 判定留痕(ruling/watchdog 记录可审计) |
32
+ | 主动性开口 | 无 | `agent/proactivity.py`:p*(t) 临界确证度 + observe 模式攒标注 |
33
+
34
+ 抄原型的(已验证扎实):两级准入、先存后做 outbox 三角、纯幂等 receipt、follow-up 语义、
35
+ bot 门、"Router 少说话"。
36
+
37
+ ## 架构
38
+
39
+ ```
40
+ Input → intake(幂等 receipt)
41
+ → admission 两级:规则 gate(bot 门/白名单/显式指令/follow-up)
42
+ → 研判层(mention 时,硬校验:恰好一条规则+证据+防幻觉规则名)
43
+ → accept → Decision → Ticket(订阅线程)→ Assignment → worker 会话
44
+ → worker report → Watchdog 复核(规则层 → judge)→ 治理判决 → 回复出站(outbox 三角)
45
+ 写前:pre_write → governance.check(未声明 scope 即 BLOCK,落 ruling)
46
+ 开口:任务完成等事件 → proactivity.evaluate → speak/wait/drop(observe 模式只记录)
47
+ ```
48
+
49
+ ## 运行自检(RED→GREEN 纪律:先注入违规证明会红,再验证放行)
50
+
51
+ ```bash
52
+ python3 -m pytest tests/ -q # 新套件:i1_i4 / i2 / i3 / i5 / i6 / integration
53
+ python3 tests/test_store.py # 老套件为自检脚本,逐个跑,末行 FAILURES: none 即过
54
+ python3 tests/test_admission_outbox.py # 持久层:先存后做/幂等/退避/崩溃恢复
55
+ python3 tests/test_e2e_m0.py # 端到端:建票→派工→报告→回复→恢复
56
+ python3 tests/test_governance.py # 写作用域治理(M1)
57
+ python3 tests/test_watchdog.py # 跑偏检测(M2)
58
+ python3 tests/test_proactivity.py # 开口阈值(M3)
59
+ ```
60
+
61
+ ## 里程碑状态
62
+
63
+ - [x] M0 骨架:准入/outbox/ticket/编排,端到端绿(test_e2e_m0)
64
+ - [x] M1 治理内嵌:governance.py 接进 engine.pre_write + intake 携带 mandate_rules(test_e2e_m1)
65
+ - [x] M2 Watchdog:接进 engine.on_worker_report(deviation → 判决留痕 + 票终止 + 不回复)(test_e2e_m2)
66
+ - [x] M3 主动性:接进 engine(observe 模式先行,判定留痕含 p*/u_eff/confidence 三数)(test_e2e_m3)
67
+ - [x] I1 状态回灌:崩溃恢复补上另一半——Tickets/Thread(含订阅)从 SQLite 重载 + seq 防撞号;
68
+ 重启后同线程 follow_up 仍附加原票(test_i1_i4)
69
+ - [x] I2 研判接真:assessor.py——LLMAssessor(OpenAI 兼容端点,transport 可注入)+
70
+ 有界研判上下文(前5条+trigger+后5条,4000 字符封顶);解析失败/幻觉规则名一律 fail-closed(test_i2_assessor)
71
+ - [x] I3 真执行器:openai_executor.py——OpenAI 兼容 tool-loop worker + metrics.py 成本记账
72
+ (逐调用 CallRecord:tokens/延迟/成功率,JSONL 落盘);对接位 = Magpie 网关(test_i3_executor)
73
+ - [x] I4 复审环:deviation 不再一票终止——先给一轮修复机会(违规理由作为修复指令重投 worker,
74
+ 复核转绿则照常回复),次数用尽才终止 + 落 governance ruling;max_repairs=0 保持 M2 旧契约(test_i1_i4)
75
+ - [x] I5 校准机制:calibration.py——ConfidenceEstimator(证据源合成,已接进 engine 替换硬编码 0.95)+
76
+ LabelStore(observe 判定旁路自动攒标注)+ calibrate(网格搜索开口 precision≥0.8)(test_i5_calibration)
77
+ - [x] I6 调度循环:scheduler.py——tick 驱动(max_workers 槽位/idle 超时回收/出站 pump),
78
+ engine 交付后写 idle_at;无 sleep 无线程,clock 注入可测(test_i6_scheduler)
79
+ - [ ] M4 成本对接:`../superagent-dig/02-cost-experiment/framework.py` 的 harness 适配器位
80
+ (metrics.py 的 CallRecord 已是同构对接面)
81
+
82
+ ## 真实环境对接位(本仓库只含可测内核)
83
+
84
+ - `openai_executor.py` 的 `OpenAICompatExecutor`:已实现 OpenAI 兼容 tool-loop worker(默认指向
85
+ Magpie 网关 `http://127.0.0.1:3425`),transport 可注入;换 endpoint/model 即换 worker;
86
+ - `assessor.py` 的 `LLMAssessor`:已实现研判适配器(有界上下文 + fail-closed),接进 `Engine(assessor=...)` 即生效;
87
+ - `watchdog.py` 的 `Judge` Protocol:接独立 LLM judge(语义层"答非所问"检测);
88
+ - `outbox.py` 的 `Deliverer`:接 GitHub/飞书通道(channelGone/channelIgnored 唯二合法放弃);
89
+ - `scheduler.py` 的 `Scheduler.tick`:外部主循环每轮调一次即可驱动全系统(无 sleep 无线程);
90
+ - `metrics.py` 的 `MetricsLedger`:逐调用成本记账(tokens/延迟/成功率,JSONL 落盘),
91
+ 与 `../superagent-dig/02-cost-experiment/framework.py` 的 CallRecord 同构对接。
@@ -0,0 +1,72 @@
1
+ # superagent-build — 自研 Super Agent(治理内嵌 + Watchdog + 主动阈值)
2
+
3
+ > 定位:把 dsh-superagent-prototype(22k★)宣传了但没做的四件事真的做出来。
4
+ > 证据链:拆解报告见 `../superagent-dig/01-prototype-dissection.md`(文章卖点兑现度表)。
5
+
6
+ ## 与原型的差异(本项目核心增量)
7
+
8
+ | 能力 | 原型 | 本项目 |
9
+ |---|---|---|
10
+ | 跑偏检测 | 全库零实现 | `agent/watchdog.py`:Mandate 对照 + 确定性规则层 + judge 一票否决 |
11
+ | 文件级冲突 | 只在 PR 层暴露 | `agent/governance.py`:写前 claim/check,未声明即 BLOCK |
12
+ | 长期记忆 | 只写不读半成品 | `agent/store.py` 全量落库 + 判定留痕(ruling/watchdog 记录可审计) |
13
+ | 主动性开口 | 无 | `agent/proactivity.py`:p*(t) 临界确证度 + observe 模式攒标注 |
14
+
15
+ 抄原型的(已验证扎实):两级准入、先存后做 outbox 三角、纯幂等 receipt、follow-up 语义、
16
+ bot 门、"Router 少说话"。
17
+
18
+ ## 架构
19
+
20
+ ```
21
+ Input → intake(幂等 receipt)
22
+ → admission 两级:规则 gate(bot 门/白名单/显式指令/follow-up)
23
+ → 研判层(mention 时,硬校验:恰好一条规则+证据+防幻觉规则名)
24
+ → accept → Decision → Ticket(订阅线程)→ Assignment → worker 会话
25
+ → worker report → Watchdog 复核(规则层 → judge)→ 治理判决 → 回复出站(outbox 三角)
26
+ 写前:pre_write → governance.check(未声明 scope 即 BLOCK,落 ruling)
27
+ 开口:任务完成等事件 → proactivity.evaluate → speak/wait/drop(observe 模式只记录)
28
+ ```
29
+
30
+ ## 运行自检(RED→GREEN 纪律:先注入违规证明会红,再验证放行)
31
+
32
+ ```bash
33
+ python3 -m pytest tests/ -q # 新套件:i1_i4 / i2 / i3 / i5 / i6 / integration
34
+ python3 tests/test_store.py # 老套件为自检脚本,逐个跑,末行 FAILURES: none 即过
35
+ python3 tests/test_admission_outbox.py # 持久层:先存后做/幂等/退避/崩溃恢复
36
+ python3 tests/test_e2e_m0.py # 端到端:建票→派工→报告→回复→恢复
37
+ python3 tests/test_governance.py # 写作用域治理(M1)
38
+ python3 tests/test_watchdog.py # 跑偏检测(M2)
39
+ python3 tests/test_proactivity.py # 开口阈值(M3)
40
+ ```
41
+
42
+ ## 里程碑状态
43
+
44
+ - [x] M0 骨架:准入/outbox/ticket/编排,端到端绿(test_e2e_m0)
45
+ - [x] M1 治理内嵌:governance.py 接进 engine.pre_write + intake 携带 mandate_rules(test_e2e_m1)
46
+ - [x] M2 Watchdog:接进 engine.on_worker_report(deviation → 判决留痕 + 票终止 + 不回复)(test_e2e_m2)
47
+ - [x] M3 主动性:接进 engine(observe 模式先行,判定留痕含 p*/u_eff/confidence 三数)(test_e2e_m3)
48
+ - [x] I1 状态回灌:崩溃恢复补上另一半——Tickets/Thread(含订阅)从 SQLite 重载 + seq 防撞号;
49
+ 重启后同线程 follow_up 仍附加原票(test_i1_i4)
50
+ - [x] I2 研判接真:assessor.py——LLMAssessor(OpenAI 兼容端点,transport 可注入)+
51
+ 有界研判上下文(前5条+trigger+后5条,4000 字符封顶);解析失败/幻觉规则名一律 fail-closed(test_i2_assessor)
52
+ - [x] I3 真执行器:openai_executor.py——OpenAI 兼容 tool-loop worker + metrics.py 成本记账
53
+ (逐调用 CallRecord:tokens/延迟/成功率,JSONL 落盘);对接位 = Magpie 网关(test_i3_executor)
54
+ - [x] I4 复审环:deviation 不再一票终止——先给一轮修复机会(违规理由作为修复指令重投 worker,
55
+ 复核转绿则照常回复),次数用尽才终止 + 落 governance ruling;max_repairs=0 保持 M2 旧契约(test_i1_i4)
56
+ - [x] I5 校准机制:calibration.py——ConfidenceEstimator(证据源合成,已接进 engine 替换硬编码 0.95)+
57
+ LabelStore(observe 判定旁路自动攒标注)+ calibrate(网格搜索开口 precision≥0.8)(test_i5_calibration)
58
+ - [x] I6 调度循环:scheduler.py——tick 驱动(max_workers 槽位/idle 超时回收/出站 pump),
59
+ engine 交付后写 idle_at;无 sleep 无线程,clock 注入可测(test_i6_scheduler)
60
+ - [ ] M4 成本对接:`../superagent-dig/02-cost-experiment/framework.py` 的 harness 适配器位
61
+ (metrics.py 的 CallRecord 已是同构对接面)
62
+
63
+ ## 真实环境对接位(本仓库只含可测内核)
64
+
65
+ - `openai_executor.py` 的 `OpenAICompatExecutor`:已实现 OpenAI 兼容 tool-loop worker(默认指向
66
+ Magpie 网关 `http://127.0.0.1:3425`),transport 可注入;换 endpoint/model 即换 worker;
67
+ - `assessor.py` 的 `LLMAssessor`:已实现研判适配器(有界上下文 + fail-closed),接进 `Engine(assessor=...)` 即生效;
68
+ - `watchdog.py` 的 `Judge` Protocol:接独立 LLM judge(语义层"答非所问"检测);
69
+ - `outbox.py` 的 `Deliverer`:接 GitHub/飞书通道(channelGone/channelIgnored 唯二合法放弃);
70
+ - `scheduler.py` 的 `Scheduler.tick`:外部主循环每轮调一次即可驱动全系统(无 sleep 无线程);
71
+ - `metrics.py` 的 `MetricsLedger`:逐调用成本记账(tokens/延迟/成功率,JSONL 落盘),
72
+ 与 `../superagent-dig/02-cost-experiment/framework.py` 的 CallRecord 同构对接。
@@ -0,0 +1,14 @@
1
+ """自研 Super Agent——治理内嵌 + Watchdog + 主动阈值的任务执行层。
2
+
3
+ 模块图:
4
+ store.py 持久层(WAL+FULL、receipts、outbox)
5
+ model.py 聚合根(Thread/Ticket/Mandate/Assessment)
6
+ admission.py 两级准入(规则 gate + 研判硬校验)
7
+ outbox.py 效果三角投递(先存后做 + 退避)
8
+ tickets.py Ticket 仓储与订阅扇出
9
+ executor.py worker 会话抽象(EchoExecutor 测试位)
10
+ engine.py 编排(pass 循环、恢复)
11
+ governance.py 写作用域治理(内嵌版)
12
+ watchdog.py 跑偏检测(规则层 + judge)
13
+ proactivity.py 主动性开口阈值(p*(t),observe 模式)
14
+ """
@@ -0,0 +1,116 @@
1
+ """两级准入:第一级确定性规则 gate,第二级模型研判(带硬校验)。
2
+
3
+ 对齐原型 policy.ts / admission.ts 的语义(见 superagent-dig/01):
4
+ - explicit:白名单用户 + 明确 @/指令 → 直接显式接单;
5
+ - mention:白名单用户提到但未显式指令 → 需研判;
6
+ - broadcast:非定向 → 默认忽略(可被 observe 模式记录);
7
+ - bot 门:bot/CI 消息一律不进 eval(机器门 machineGate);
8
+ - follow-up:开放 ticket 所在线程上、同一个人的后续消息 → 不开新 eval,
9
+ 直接作为该 ticket 的输入(原型 policy.ts:206-207)。
10
+
11
+ 第二级研判产 Assessment,硬校验(原型 policy.ts:40-61 的铁律):
12
+ 1. matched_rule 必须恰好命中一条规则(0 条或 2 条都拒);
13
+ 2. accept 必须 intent_evidence 非空;
14
+ 3. 研判声明的规则必须真实存在于 gate 输出里(防幻觉规则名)。
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+ from typing import Optional
20
+
21
+ from .model import Assessment, Input
22
+
23
+
24
+ # ---------- 配置 ----------
25
+
26
+ @dataclass
27
+ class AdmissionConfig:
28
+ whitelist: list[str] = field(default_factory=list) # 用户 id 白名单
29
+ explicit_markers: tuple[str, ...] = ("/do", "/task", "@agent") # 显式指令前缀
30
+ bot_allowlist: list[str] = field(default_factory=list) # 允许进来的 bot(默认全拒)
31
+
32
+
33
+ # ---------- 第一级:规则 gate ----------
34
+
35
+ @dataclass
36
+ class GateResult:
37
+ route: str # explicit / mention / broadcast / bot / follow_up
38
+ matched_rules: list[str]
39
+ open_ticket_id: Optional[str] = None # follow_up 命中的开放 ticket
40
+
41
+
42
+ def gate(inp: Input, cfg: AdmissionConfig,
43
+ thread_open_tickets: Optional[dict[str, str]] = None) -> GateResult:
44
+ """thread_open_tickets: thread_key -> 开放 ticket id(无则为空 dict)。"""
45
+ thread_open_tickets = thread_open_tickets or {}
46
+
47
+ # bot 门:机器消息不进 eval,除非显式白名单
48
+ if inp.is_bot and inp.thread_key not in cfg.bot_allowlist:
49
+ return GateResult("bot", [])
50
+
51
+ # follow-up:同线程已有开放 ticket 的人消息 → 附加到既有 ticket
52
+ if inp.is_human and inp.thread_key in thread_open_tickets:
53
+ return GateResult("follow_up", ["follow_up"],
54
+ open_ticket_id=thread_open_tickets[inp.thread_key])
55
+
56
+ if not inp.is_human:
57
+ return GateResult("bot", [])
58
+
59
+ whitelisted = inp.id.split(":", 1)[0] in cfg.whitelist or inp.thread_key in cfg.whitelist
60
+ if not whitelisted:
61
+ return GateResult("broadcast", [])
62
+
63
+ is_explicit = any(inp.text.startswith(m) or f" {m}" in inp.text
64
+ for m in cfg.explicit_markers)
65
+ if is_explicit:
66
+ return GateResult("explicit", ["whitelist_user", "explicit_marker"])
67
+ return GateResult("mention", ["whitelist_user"])
68
+
69
+
70
+ # ---------- 第二级:研判硬校验 ----------
71
+
72
+ def validate_assessment(a: Assessment, gate_result: GateResult) -> tuple[bool, str]:
73
+ """返回 (ok, reason)。ok=False 时调用方必须拒绝 accept(fail-closed)。"""
74
+ # 铁律1:matched_rule 恰好一条
75
+ if a.matched_rule is None or not isinstance(a.matched_rule, str):
76
+ return False, "missing_rule"
77
+ # 铁律2:accept 必须 intent_evidence 非空
78
+ if a.accepted and not a.intent_evidence.strip():
79
+ return False, "accept_without_evidence"
80
+ # 铁律3:研判引用的规则必须来自 gate 实际输出(防幻觉规则名)
81
+ if a.matched_rule not in gate_result.matched_rules:
82
+ return False, f"hallucinated_rule:{a.matched_rule}"
83
+ # deny 不需要证据,但 accept + broadcast 路由不允许(broadcast 无授权)
84
+ if a.accepted and gate_result.route == "broadcast":
85
+ return False, "accept_on_broadcast"
86
+ return True, "ok"
87
+
88
+
89
+ def decide(inp: Input, cfg: AdmissionConfig,
90
+ thread_open_tickets: Optional[dict[str, str]] = None,
91
+ assessor=None) -> tuple[str, Optional[Assessment]]:
92
+ """完整准入决策。返回 (disposition, assessment)。
93
+
94
+ assessor: Callable(Input, GateResult) -> Assessment
95
+ — None 时用默认保守策略(explicit 即接,mention/broadcast 拒),
96
+ 即"研判层缺位时 fail-closed"。
97
+ disposition: accept / follow_up / rejected_bot / rejected_broadcast / rejected_eval / observe
98
+ """
99
+ g = gate(inp, cfg, thread_open_tickets)
100
+ if g.route == "bot":
101
+ return "rejected_bot", None
102
+ if g.route == "follow_up":
103
+ return "follow_up", None
104
+ if g.route == "explicit":
105
+ return "accept", Assessment(True, "explicit_marker",
106
+ intent_evidence="whitelisted explicit marker",
107
+ confidence=1.0)
108
+
109
+ # mention / broadcast:需要研判层
110
+ if assessor is None:
111
+ return "rejected_eval", None # 无研判层 → fail-closed
112
+ a = assessor(inp, g)
113
+ ok, reason = validate_assessment(a, g)
114
+ if not ok:
115
+ return f"rejected_eval:{reason}", a
116
+ return ("accept", a) if a.accepted else ("rejected_eval", a)
@@ -0,0 +1,188 @@
1
+ """I2:两级准入的第二级——LLM 研判层(严格 JSON 契约 + fail-closed)。
2
+
3
+ 对齐原型 admission-context.ts 的有界上下文语义:
4
+ - 窗口 = trigger 前 5 条 + trigger 本身 + 后 5 条;
5
+ - 整体拼接超 max_chars 时从头部逐条丢弃(最老的先丢),trigger 及其后文不丢;
6
+ - 任何解析失败 / 网络异常 / 超时 / 幻觉规则名 → fail-closed(accepted=False),
7
+ matched_rule 回落 gate_result.matched_rules[0],空则回落 route 本身。
8
+
9
+ 铁律(与 admission.validate_assessment 对齐):
10
+ - matched_rule 只能从 gate_result.matched_rules 里选(防幻觉);
11
+ - assessor 缺位或失联时绝不 accept(研判层缺位 fail-closed)。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import urllib.error
17
+ import urllib.request
18
+ from typing import Callable, Optional
19
+
20
+ from .admission import GateResult
21
+ from .model import Assessment, Input
22
+
23
+ # transport: OpenAI chat completions 请求体 -> 响应体(可注入 fake 做零网络测试)
24
+ Transport = Callable[[dict], dict]
25
+
26
+ SYSTEM_PROMPT = (
27
+ "你是 superagent-build 的准入研判层(admission assessor)。"
28
+ "你只输出一个严格 JSON 对象,不输出任何其他文字、注释或 markdown 代码栅栏。"
29
+ "JSON 字段:accepted(bool)、matched_rule(str)、intent_evidence(str)、"
30
+ "scope_note(str)、confidence(float, 0~1)。"
31
+ "matched_rule 必须且只能从用户消息给出的候选规则列表中选择,禁止编造规则名。"
32
+ "拒绝时 matched_rule 仍须从候选列表中选,intent_evidence 可为空。"
33
+ )
34
+
35
+
36
+ def build_admission_context(inputs: list[Input], trigger: Input,
37
+ max_chars: int = 4000,
38
+ before: int = 5, after: int = 5) -> str:
39
+ """有界线程上下文:trigger 前 before 条 + trigger + 后 after 条。
40
+
41
+ 超过 max_chars 时从头部逐条丢弃(最老的先丢),trigger 附近内容保留。
42
+ trigger 不在 inputs 里时,退化为保留最后 before+after+1 条的尾部窗口。
43
+ """
44
+ idx = next((i for i, m in enumerate(inputs) if m.id == trigger.id), -1)
45
+ if idx < 0:
46
+ window = list(inputs[-(before + after + 1):])
47
+ trigger_idx_in_window = None
48
+ else:
49
+ lo = max(0, idx - before)
50
+ hi = min(len(inputs), idx + after + 1)
51
+ window = inputs[lo:hi]
52
+ trigger_idx_in_window = idx - lo
53
+
54
+ # 行号用原始列表位置(从 1 起),截断后仍可读出被丢弃的范围
55
+ base = max(0, idx) if idx >= 0 else max(0, len(inputs) - len(window))
56
+ lines = [(f"[{base + i + 1}] {m.text}", i) for i, m in enumerate(window)]
57
+
58
+ def joined(items) -> str:
59
+ return "\n".join(t for t, _ in items)
60
+
61
+ # 从头部逐条丢弃非 trigger 行,直到塞进预算(保 trigger 附近内容)
62
+ kept = list(lines)
63
+ while len(joined(kept)) > max_chars and kept:
64
+ drop_i = next((k for k, (_, w) in enumerate(kept)
65
+ if trigger_idx_in_window is None or w != trigger_idx_in_window),
66
+ None)
67
+ if drop_i is None:
68
+ break
69
+ kept.pop(drop_i)
70
+ if trigger_idx_in_window is not None:
71
+ # trigger 已是窗口内唯一不可丢行;其后内容随行保留
72
+ pass
73
+ text = joined(kept)
74
+ if len(text) > max_chars:
75
+ # 兜底:全部行都不可丢仍超界(单条超长),硬截尾部保 trigger 一侧
76
+ text = text[-max_chars:]
77
+ return text
78
+
79
+
80
+ def _extract_content(resp: dict) -> str:
81
+ choice = (resp.get("choices") or [{}])[0]
82
+ msg = choice.get("message") or {}
83
+ content = msg.get("content")
84
+ if not isinstance(content, str):
85
+ raise ValueError("response missing choices[0].message.content")
86
+ return content
87
+
88
+
89
+ def _parse_strict_json(content: str) -> dict:
90
+ """容忍模型套 markdown 代码栅栏,但内容必须是合法 JSON 对象。"""
91
+ text = content.strip()
92
+ if text.startswith("```"):
93
+ # 去掉 ```json / ``` 围栏
94
+ body = text.strip("`").lstrip()
95
+ if body.lower().startswith("json"):
96
+ body = body[4:]
97
+ text = body.strip().rstrip("`").strip()
98
+ data = json.loads(text)
99
+ if not isinstance(data, dict):
100
+ raise ValueError("assessor output is not a JSON object")
101
+ return data
102
+
103
+
104
+ def _fail_closed(gate_result: GateResult, reason: str) -> Assessment:
105
+ fallback = (gate_result.matched_rules[0]
106
+ if gate_result.matched_rules else gate_result.route)
107
+ return Assessment(accepted=False, matched_rule=fallback,
108
+ intent_evidence=f"assessor_unavailable: {reason}",
109
+ scope_note="fail-closed: 研判层不可用,默认拒绝",
110
+ confidence=0.0)
111
+
112
+
113
+ class LLMAssessor:
114
+ """Callable(Input, GateResult) -> Assessment。
115
+
116
+ 研判 prompt 只允许输出严格 JSON;解析失败 / 网络异常 / 超时 / 幻觉规则名
117
+ 一律 fail-closed,绝不 accept。
118
+ """
119
+
120
+ def __init__(self, model: str = "deepseek-v4.1-flash",
121
+ endpoint: str = "http://127.0.0.1:3425/v1/chat/completions",
122
+ api_key: str = "",
123
+ timeout: float = 20.0,
124
+ transport: Optional[Transport] = None):
125
+ self.model = model
126
+ self.endpoint = endpoint
127
+ self.api_key = api_key
128
+ self.timeout = timeout
129
+ self._transport: Transport = transport if transport is not None else self._http_transport
130
+
131
+ # ---------- 默认 HTTP 传输(真实环境用;测试注入 fake 不走这里) ----------
132
+
133
+ def _http_transport(self, payload: dict) -> dict:
134
+ headers = {"Content-Type": "application/json"}
135
+ if self.api_key:
136
+ headers["Authorization"] = f"Bearer {self.api_key}"
137
+ req = urllib.request.Request(
138
+ self.endpoint,
139
+ data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
140
+ headers=headers, method="POST")
141
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
142
+ if resp.status != 200:
143
+ raise RuntimeError(f"assessor_endpoint_http_{resp.status}")
144
+ return json.loads(resp.read().decode("utf-8"))
145
+
146
+ # ---------- 研判 ----------
147
+
148
+ def __call__(self, inp: Input, gate_result: GateResult) -> Assessment:
149
+ prompt = (
150
+ f"候选规则列表(matched_rule 只能从中选择):"
151
+ f"{json.dumps(gate_result.matched_rules, ensure_ascii=False)}\n"
152
+ f"gate 路由:{gate_result.route}\n"
153
+ f"触发消息(id={inp.id}, thread={inp.thread_key}):\n{inp.text}\n\n"
154
+ "只输出严格 JSON:{\"accepted\": bool, \"matched_rule\": str(从候选规则列表选), "
155
+ "\"intent_evidence\": str(accept 时必填非空), \"scope_note\": str, "
156
+ "\"confidence\": float}。"
157
+ )
158
+ payload = {
159
+ "model": self.model,
160
+ "messages": [
161
+ {"role": "system", "content": SYSTEM_PROMPT},
162
+ {"role": "user", "content": prompt},
163
+ ],
164
+ "temperature": 0,
165
+ "stream": False,
166
+ }
167
+ try:
168
+ resp = self._transport(payload)
169
+ data = _parse_strict_json(_extract_content(resp))
170
+ except Exception as e: # 解析失败 / 网络异常 / 超时 → fail-closed
171
+ return _fail_closed(gate_result, f"{type(e).__name__}: {e}")
172
+
173
+ matched_rule = data.get("matched_rule")
174
+ # 防幻觉铁律:模型给的规则名必须真实存在于 gate 输出
175
+ if matched_rule not in gate_result.matched_rules:
176
+ return _fail_closed(gate_result, f"hallucinated_rule:{matched_rule}")
177
+
178
+ try:
179
+ accepted = bool(data["accepted"])
180
+ intent_evidence = str(data.get("intent_evidence", "") or "")
181
+ scope_note = str(data.get("scope_note", "") or "")
182
+ confidence = float(data.get("confidence", 0.0) or 0.0)
183
+ except (KeyError, TypeError, ValueError) as e:
184
+ return _fail_closed(gate_result, f"malformed_assessment: {e}")
185
+
186
+ return Assessment(accepted=accepted, matched_rule=matched_rule,
187
+ intent_evidence=intent_evidence,
188
+ scope_note=scope_note, confidence=confidence)