superagent-build 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.
agent/__init__.py ADDED
@@ -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
+ """
agent/admission.py ADDED
@@ -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)
agent/assessor.py ADDED
@@ -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)
agent/calibration.py ADDED
@@ -0,0 +1,252 @@
1
+ """主动性校准机制(迭代 I5):给开口事件一个真证据源 + 校准闭环。
2
+
3
+ 背景(任务⑤建模,见 superagent-dig/05-proactivity-modeling.md):
4
+ - 开口判定 confidence ≥ p*(t) = (c_false + c_speak) / (u_eff(t) + c_false)。
5
+ - engine.py 里原先硬编码 confidence=0.95 —— 那是个假确证度,没有证据支撑。
6
+ - observe 模式攒下的日志就是"该不该说"的标注数据;用它反推能达标的参数
7
+ (c_false, c_speak, lam),校准目标是开口 precision ≥ target(默认 0.8)。
8
+
9
+ 三个组件:
10
+ 1) ConfidenceEstimator —— 多证据源加权合成确证度(替代硬编码 0.95)。
11
+ 2) LabelStore —— append-only JSONL 标注存储(observe 日志的人工标注)。
12
+ 3) calibrate() —— 网格搜索参数,重放判定,挑出开口 precision 达标的第一组。
13
+
14
+ 设计约束:纯 stdlib、确定性、无网络、无 sleep。校准重放直接复用
15
+ proactivity.ProactivityEngine,把时钟钉在 max(created_at) 以剥离衰减干扰。
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ from typing import Optional
22
+
23
+ # ---------- ConfidenceEstimator 常量 ----------
24
+
25
+ # 默认证据源键(缺省会参与加权平均):
26
+ # watchdog_pass watchdog 复核通过率
27
+ # worker_verified worker 自证比例
28
+ # historical_precision 历史开口准确率
29
+ DEFAULT_SOURCES = ("watchdog_pass", "worker_verified", "historical_precision")
30
+
31
+ _NEUTRAL = 0.5 # 缺证据源时的中性值
32
+ _MISSING_DISCOUNT = 0.8 # 有源缺失时对结果打的折
33
+ _EMPTY_CONSERVATIVE = 0.4 # 空 evidence 的保守值
34
+
35
+
36
+ class ConfidenceEstimator:
37
+ """证据源 → 确证度 p ∈ [0,1] 的合成器。
38
+
39
+ 行为:
40
+ - 配置的证据源逐个取 evidence 里的值;缺失的源按 0.5 中性值参与,并对
41
+ 最终加权平均乘 0.8 打折(缺证据要保守)。
42
+ - 空 evidence 直接返回 0.4(最保守,连有多少源都不知道)。
43
+ - 结果始终 clamp 到 [0,1](宽容越界输入,避免脏数据撑爆阈值判定)。
44
+
45
+ weights: 可选 {"源名": 权重}。None → 三个默认源各 1.0;显式给出则以其为
46
+ 唯一证据源集合(可用于单源/自定义源场景)。
47
+ """
48
+
49
+ def __init__(self, weights: Optional[dict] = None):
50
+ if weights is None:
51
+ self.weights: dict[str, float] = {k: 1.0 for k in DEFAULT_SOURCES}
52
+ else:
53
+ self.weights = dict(weights)
54
+
55
+ def estimate(self, evidence: dict) -> float:
56
+ if not evidence:
57
+ return _EMPTY_CONSERVATIVE
58
+
59
+ total = 0.0
60
+ total_w = 0.0
61
+ missing = False
62
+ for src, w in self.weights.items():
63
+ if w <= 0:
64
+ continue
65
+ if src in evidence:
66
+ val = float(evidence[src])
67
+ else:
68
+ val = _NEUTRAL
69
+ missing = True
70
+ total += w * val
71
+ total_w += w
72
+
73
+ if total_w <= 0:
74
+ return _EMPTY_CONSERVATIVE
75
+
76
+ score = total / total_w
77
+ if missing:
78
+ score *= _MISSING_DISCOUNT
79
+ # clamp 到 [0,1]
80
+ if score < 0.0:
81
+ return 0.0
82
+ if score > 1.0:
83
+ return 1.0
84
+ return score
85
+
86
+
87
+ # ---------- LabelStore 常量 ----------
88
+
89
+ VALID_LABELS = ("speak_ok", "speak_bad", "wait_ok", "wait_bad")
90
+
91
+
92
+ class LabelStore:
93
+ """append-only 标注存储。
94
+
95
+ - path=None → 纯内存(self._mem)。
96
+ - path=str → 追加写 JSONL(每行一条记录;不覆写、不删除)。
97
+ - 每条记录 = decision_dict 的浅拷贝 + 覆盖写入的 "label" 字段,
98
+ 这样 load() 读回的记录可直接喂给 calibrate()(含 utility/confidence/
99
+ created_at/label)。
100
+ """
101
+
102
+ def __init__(self, path: Optional[str] = None):
103
+ self.path = path
104
+ self._mem: list[dict] = []
105
+ if path is not None:
106
+ parent = os.path.dirname(os.path.abspath(path))
107
+ if parent:
108
+ os.makedirs(parent, exist_ok=True)
109
+
110
+ def append(self, decision_dict: dict, label: str) -> None:
111
+ if label not in VALID_LABELS:
112
+ raise ValueError(
113
+ f"非法 label: {label!r},仅允许 {VALID_LABELS}")
114
+ record = dict(decision_dict)
115
+ record["label"] = label
116
+ if self.path is None:
117
+ self._mem.append(record)
118
+ return
119
+ with open(self.path, "a", encoding="utf-8") as f:
120
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
121
+
122
+ def load(self) -> list[dict]:
123
+ if self.path is None:
124
+ return list(self._mem)
125
+ if not os.path.exists(self.path):
126
+ return []
127
+ out: list[dict] = []
128
+ with open(self.path, "r", encoding="utf-8") as f:
129
+ for line in f:
130
+ line = line.strip()
131
+ if not line:
132
+ continue
133
+ out.append(json.loads(line))
134
+ return out
135
+
136
+ def stats(self) -> dict:
137
+ recs = self.load()
138
+ counts = {lb: 0 for lb in VALID_LABELS}
139
+ for r in recs:
140
+ lb = r.get("label")
141
+ if lb in counts:
142
+ counts[lb] += 1
143
+ so = counts["speak_ok"]
144
+ sb = counts["speak_bad"]
145
+ denom = so + sb
146
+ precision = (so / denom) if denom > 0 else None
147
+ result: dict = dict(counts)
148
+ result["speak_precision"] = precision
149
+ return result
150
+
151
+
152
+ # ---------- calibrate ----------
153
+
154
+ DEFAULT_GRID = {
155
+ "c_false": [0.5, 1.0, 2.0],
156
+ "c_speak": [0.1, 0.2, 0.5],
157
+ "lam": [0.05, 0.1, 0.2],
158
+ }
159
+
160
+
161
+ def _replay_precision(labels: list[dict], c_false: float, c_speak: float,
162
+ lam: float, now: float) -> tuple[float, int]:
163
+ """用一组参数重放全部标注,返回 (开口 precision, n_speak)。
164
+
165
+ 判定直接复用 ProactivityEngine(observe=False,时钟钉在 now):
166
+ - action == "speak" 记一次开口;
167
+ - 其中人工 label 为 "speak_ok" 的算命中;
168
+ - precision = 命中数 / n_speak;n_speak==0 时记 0.0(不开口没有可证明的精度)。
169
+ """
170
+ from .proactivity import (CandidateEvent, ProactivityConfig,
171
+ ProactivityEngine)
172
+
173
+ cfg = ProactivityConfig(lam=lam, c_false=c_false, c_speak=c_speak,
174
+ observe=False)
175
+ engine = ProactivityEngine(cfg, clock=lambda: now)
176
+
177
+ n_speak = 0
178
+ tp = 0
179
+ for r in labels:
180
+ ev = CandidateEvent(
181
+ event_id=str(r.get("event_id", "?")),
182
+ utility=float(r["utility"]),
183
+ confidence=float(r["confidence"]),
184
+ created_at=float(r["created_at"]),
185
+ )
186
+ decision = engine.evaluate(ev)
187
+ if decision.action == "speak":
188
+ n_speak += 1
189
+ if r.get("label") == "speak_ok":
190
+ tp += 1
191
+
192
+ precision = (tp / n_speak) if n_speak > 0 else 0.0
193
+ return precision, n_speak
194
+
195
+
196
+ def calibrate(labels: list[dict], target_precision: float = 0.8,
197
+ grid: Optional[dict] = None) -> dict:
198
+ """网格搜索 (c_false, c_speak, lam),找出开口 precision 达标的第一组。
199
+
200
+ 返回:
201
+ 达标 → {"calibrated": True, "c_false", "c_speak", "lam",
202
+ "achieved_precision", "n_speak"}
203
+ 不达标 → {"calibrated": False, "best_precision", "best_params", "n_speak"}
204
+
205
+ 要求 n_speak > 0 才算达标("从不开口"没有可证明的精度,不能靠沉默刷分)。
206
+ """
207
+ g = grid or DEFAULT_GRID
208
+ cf_list = list(g["c_false"])
209
+ cs_list = list(g["c_speak"])
210
+ lam_list = list(g["lam"])
211
+
212
+ if not labels:
213
+ return {"calibrated": False, "best_precision": None,
214
+ "best_params": None, "n_speak": 0}
215
+
216
+ # 固定时钟:now = max(created_at),剥离"事件存放时间"造成的衰减干扰。
217
+ now = max(float(r["created_at"]) for r in labels)
218
+
219
+ best_precision = -1.0
220
+ best_params: Optional[dict] = None
221
+ best_n_speak = 0
222
+
223
+ for c_false in cf_list:
224
+ for c_speak in cs_list:
225
+ for lam in lam_list:
226
+ precision, n_speak = _replay_precision(
227
+ labels, c_false, c_speak, lam, now)
228
+ # 记录最优组(同精度优先更多开口,避免"零开口"占位)
229
+ if (precision > best_precision
230
+ or (precision == best_precision
231
+ and n_speak > best_n_speak)):
232
+ best_precision = precision
233
+ best_params = {"c_false": c_false, "c_speak": c_speak,
234
+ "lam": lam}
235
+ best_n_speak = n_speak
236
+ # 达标(必须有开口且 precision ≥ target)→ 返回第一组
237
+ if n_speak > 0 and precision >= target_precision:
238
+ return {
239
+ "calibrated": True,
240
+ "c_false": c_false,
241
+ "c_speak": c_speak,
242
+ "lam": lam,
243
+ "achieved_precision": precision,
244
+ "n_speak": n_speak,
245
+ }
246
+
247
+ return {
248
+ "calibrated": False,
249
+ "best_precision": best_precision,
250
+ "best_params": best_params,
251
+ "n_speak": best_n_speak,
252
+ }