codebee 0.1.50 → 0.1.51

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.
@@ -1,15 +1,18 @@
1
- # -*- coding: utf-8 -*-
2
- """智能路由:能力基线 × 历史胜率 × 角色约束 → 选智能体,并给出可解释的理由。"""
3
- from __future__ import annotations
4
-
1
+ # -*- coding: utf-8 -*-
2
+ """智能路由:能力基线 × 历史胜率 × 角色约束 → 选智能体,并给出可解释的理由。"""
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+
5
8
  from . import dispatch, history
6
-
7
- # 各类智能体的能力基线(0-100)。真实 CLI 里官方双雄最高。
8
- CAPABILITY = {
9
- "codex": 84, "claude": 84, "opencode": 74, "qwen": 72,
10
- "aider": 70, "openclaw": 60, "generic": 55, "mock": 10,
11
- }
12
-
9
+
10
+ # 各类智能体的能力基线(0-100)。真实 CLI 里官方双雄最高。
11
+ CAPABILITY = {
12
+ "codex": 84, "claude": 84, "opencode": 74, "qwen": 72,
13
+ "aider": 70, "openclaw": 60, "generic": 55, "mock": 10,
14
+ }
15
+
13
16
  MAX_REPAIR_ROUNDS = 2 # 自动修复循环上限
14
17
  AUTO_CRITIC_LIMIT = 2 # 自动内容评审只取综合分前两名;手工模式仍尊重完整名单
15
18
 
@@ -19,35 +22,43 @@ def _task_type(ttype):
19
22
  if isinstance(ttype, dict):
20
23
  return str(ttype.get("type") or ttype.get("dimension") or "direct")
21
24
  return str(ttype or "direct")
22
-
23
-
25
+
26
+
24
27
  def _binding_bonus(agent_id, dispatch_mode=False):
25
- """绑定链可用性加分/减分:链上有可用条目 +8,解析为空 -25。2026-09-16 实测:
26
- 静态能力基线让配额烧干的 codex 永远压过健康备用 CLI,绑定空的 CLI 更是连
27
- 用户配置的模型都没用上——先按「能不能按配置跑起来」校准。2026-09-17 起
28
- 空链步骤在 pipeline 直接判失败(不再静默回落本机默认),此处只管排序。"""
29
- try:
30
- from . import modelhub
28
+ """绑定链可用性加分/减分:解析出的调用链有备胎(≥2 条)+8;单条 = 单点
29
+ 无降级空间 +0;解析为空 -25。2026-09-16 实测:静态能力基线让配额烧干的
30
+ codex 永远压过健康备用 CLI,绑定空的 CLI 更是连用户配置的模型都没用上
31
+ ——先按「能不能按配置跑起来」校准。2026-09-22 续修:单条链(含空链+
32
+ 仅 provider_id 的旧数据)拿过 +8 是信号失真——2026-09-22 智谱余额清零
33
+ 实测「绑定链可用」的 claude/kimi 全是单点,无一家有第二口气。返回
34
+ (分值, 理由文本)。"""
35
+ try:
36
+ from . import modelhub
31
37
  pref = modelhub._binding_for(agent_id)
32
38
  configured = bool(modelhub._binding_chain(pref) or pref.get("provider_id"))
33
39
  if not configured:
34
- return 0.0 if dispatch_mode else -25.0
40
+ return (0.0, "") if dispatch_mode else (-25.0, ",绑定链为空:相关步骤将判失败(-25.0)")
35
41
  b = modelhub.resolve_binding(agent_id)
36
- return 8.0 if (b and b.get("call_chain")) else -25.0
37
- except Exception:
38
- return 0.0
39
-
40
-
42
+ n = len((b or {}).get("call_chain") or [])
43
+ if n >= 2:
44
+ return 8.0, ",绑定链可用(+8.0)"
45
+ if n == 1:
46
+ return 0.0, ",绑定单点无备胎(+0)"
47
+ return -25.0, ",绑定链为空:相关步骤将判失败(-25.0)"
48
+ except Exception:
49
+ return 0.0, ""
50
+
51
+
41
52
  def _history_bonus(stats, agent_id, ttype):
42
- s = (stats.get(agent_id) or {}).get(ttype) or {}
43
- if not s.get("runs"):
44
- return 0.0
45
- runs = s["runs"]
46
- win_rate = s["wins"] / runs
47
- # 胜率为主,经验为辅;败率要扣分且随样本量爬满(前 3 次线性加权,防一次
48
- # 偶发失败把智能体埋了)。0/3 全败 = -18:常挂的 CLI 必须排到无历史的新
49
- # 面孔之后(2026-09-17 前败率不扣分,0/3 还拿 +6 经验分,比没跑过还高)。
50
- loss_penalty = 24.0 * (1.0 - win_rate) * min(1.0, runs / 3.0)
53
+ s = (stats.get(agent_id) or {}).get(ttype) or {}
54
+ if not s.get("runs"):
55
+ return 0.0
56
+ runs = s["runs"]
57
+ win_rate = s["wins"] / runs
58
+ # 胜率为主,经验为辅;败率要扣分且随样本量爬满(前 3 次线性加权,防一次
59
+ # 偶发失败把智能体埋了)。0/3 全败 = -18:常挂的 CLI 必须排到无历史的新
60
+ # 面孔之后(2026-09-17 前败率不扣分,0/3 还拿 +6 经验分,比没跑过还高)。
61
+ loss_penalty = 24.0 * (1.0 - win_rate) * min(1.0, runs / 3.0)
51
62
  return round(18.0 * win_rate + min(6.0, runs) - loss_penalty, 1)
52
63
 
53
64
 
@@ -79,20 +90,15 @@ def _online_bonus(agent, role, ttype):
79
90
 
80
91
 
81
92
  def score(agent, role, ttype, stats=None):
82
- """返回 (总分, 理由字符串)。配额惩罚:catalog 里配了
83
- quota_tokens_per_hour 的智能体,本小时用量越接近配额分越低
84
- (封顶 -45,足以盖过历史加分),满额后仅在没有其他选择时才会被选中。"""
93
+ """返回 (总分, 理由字符串)。配额惩罚:catalog 里配了
94
+ quota_tokens_per_hour 的智能体,本小时用量越接近配额分越低
95
+ (封顶 -45,足以盖过历史加分),满额后仅在没有其他选择时才会被选中。"""
85
96
  stats = stats or {}
86
97
  ttype = _task_type(ttype)
87
- base = CAPABILITY.get(agent.get("kind"), 60)
98
+ base = CAPABILITY.get(agent.get("kind"), 60)
88
99
  use_dispatch = bool(agent.get("_dispatch_task_type") or
89
100
  agent.get("dispatch_enabled"))
90
- bb = _binding_bonus(agent.get("id"), dispatch_mode=use_dispatch)
91
- btxt = ""
92
- if bb > 0:
93
- btxt = ",绑定链可用(+%s)" % bb
94
- elif bb < 0:
95
- btxt = ",绑定链为空:相关步骤将判失败(%s)" % bb
101
+ bb, btxt = _binding_bonus(agent.get("id"), dispatch_mode=use_dispatch)
96
102
  hb = _history_bonus(stats, agent.get("id"), ttype)
97
103
  online, online_txt = _online_bonus(agent, role, ttype)
98
104
  # 保持公开 score() 的历史绝对分值;运行级候选由 pipeline 标记画像后
@@ -100,41 +106,120 @@ def score(agent, role, ttype, stats=None):
100
106
  affinity, affinity_txt = (dispatch.agent_affinity(agent.get("kind"), ttype, role)
101
107
  if use_dispatch else (0.0, "兼容模式"))
102
108
  total = base + bb + hb + affinity + online
103
- hs = (stats.get(agent.get("id")) or {}).get(ttype)
104
- htxt = (",历史 %d/%d 胜(%s)" % (hs["wins"], hs["runs"], "%+.1f" % hb)) if hs else ",无历史记录"
105
- quota_txt = ""
106
- quota = int(agent.get("quota_tokens_per_hour") or 0)
107
- if quota > 0:
108
- try:
109
- from . import usage
110
- used = usage.agent_tokens_recent(agent.get("id"), hours=1)
111
- except Exception:
112
- used = 0
113
- if used > 0:
114
- ratio = min(1.0, used / float(quota))
115
- penalty = round(-45.0 * ratio, 1)
116
- if penalty:
117
- total += penalty
118
- quota_txt = ",本小时 %d/%d tokens(%s)" % (used, quota, penalty)
109
+ hs = (stats.get(agent.get("id")) or {}).get(ttype)
110
+ htxt = (",历史 %d/%d 胜(%s)" % (hs["wins"], hs["runs"], "%+.1f" % hb)) if hs else ",无历史记录"
111
+ quota_txt = ""
112
+ quota = int(agent.get("quota_tokens_per_hour") or 0)
113
+ if quota > 0:
114
+ try:
115
+ from . import usage
116
+ used = usage.agent_tokens_recent(agent.get("id"), hours=1)
117
+ except Exception:
118
+ used = 0
119
+ if used > 0:
120
+ ratio = min(1.0, used / float(quota))
121
+ penalty = round(-45.0 * ratio, 1)
122
+ if penalty:
123
+ total += penalty
124
+ quota_txt = ",本小时 %d/%d tokens(%s)" % (used, quota, penalty)
119
125
  return total, "能力基线 %d,%s%s%s%s%s,总分 %s" % (
120
126
  base, affinity_txt, btxt, htxt, online_txt, quota_txt, round(total, 1))
121
-
122
-
127
+
128
+
123
129
  def pick(agents, role, ttype, stats=None, exclude=()):
124
- """按分选出最优智能体。返回 (agent, 理由) 或 (None, "")。"""
125
- stats = stats or history.agent_stats()
126
- best, best_reason = None, ""
127
- for a in agents:
128
- if a["id"] in exclude:
129
- continue
130
- total, reason = score(a, role, ttype, stats)
131
- if best is None or total > best[0]:
132
- best, best_reason = (total, a), reason
133
- if best is None:
134
- return None, ""
130
+ """按分选出最优智能体。返回 (agent, 理由) 或 (None, "")。"""
131
+ stats = stats or history.agent_stats()
132
+ best, best_reason = None, ""
133
+ for a in agents:
134
+ if a["id"] in exclude:
135
+ continue
136
+ total, reason = score(a, role, ttype, stats)
137
+ if best is None or total > best[0]:
138
+ best, best_reason = (total, a), reason
139
+ if best is None:
140
+ return None, ""
135
141
  return best[1], best_reason
136
142
 
137
143
 
144
+ # 空链 CLI 的真实上游在各自本机配置里(注册表里的 provider_id 是 UI 残留,
145
+ # 2026-09-22 实测 qwencode 注册表指 prov-36 本机却指公司网关)。正则探针
146
+ # 只为换将时识别「同上游」,读不到就算未知——未知不参与剔除,宁白试不误杀。
147
+ _LOCAL_ENDPOINT_PROBES = (
148
+ ("kimi-code", "~/.kimi-code/config.toml", r'baseUrl\s*=\s*"([^"]+)"'),
149
+ ("claude-code", "~/.claude/settings.json", r'"ANTHROPIC_BASE_URL"\s*:\s*"([^"]+)"'),
150
+ ("qwencode", "~/.qwen/settings.json", r'"OPENAI_BASE_URL"\s*:\s*"([^"]+)"'),
151
+ ("opencode", "~/.config/opencode/opencode.json", r'"baseURL"\s*:\s*"([^"]+)"'),
152
+ ("opencode", "~/.config/opencode/opencode.jsonc", r'"baseURL"\s*:\s*"([^"]+)"'),
153
+ ("codex-cli", "~/.codex/config.toml", r'base_url\s*=\s*"([^"]+)"'),
154
+ )
155
+
156
+
157
+ def _host_of(url):
158
+ """上游归一:只留 host:port。/api/anthropic 与 /v1 的路径差异不算换上游。"""
159
+ m = re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://([^/?#]+)", str(url or "").strip())
160
+ return m.group(1).lower() if m else ""
161
+
162
+
163
+ def agent_upstreams(agent_id):
164
+ """候选实际会用到的上游集合(host:port 归一)。显式链 > CLI 本机配置
165
+ 端点;两者都拿不到返回空集 = 未知。"""
166
+ ups = set()
167
+ try:
168
+ from . import modelhub
169
+ pref = modelhub._binding_for(agent_id)
170
+ chain = modelhub._binding_chain(pref)
171
+ provs = {p.get("id"): p for p in modelhub.providers()}
172
+ if chain:
173
+ for item in chain:
174
+ pid = (item.get("provider_id") or "").strip()
175
+ h = _host_of((provs.get(pid) or {}).get("base_url"))
176
+ if h:
177
+ ups.add(h)
178
+ except Exception:
179
+ pass
180
+ if not ups:
181
+ for aid, path, pat in _LOCAL_ENDPOINT_PROBES:
182
+ if aid != agent_id:
183
+ continue
184
+ try:
185
+ with open(os.path.expanduser(path), "r", encoding="utf-8",
186
+ errors="ignore") as fh:
187
+ txt = fh.read()
188
+ except OSError:
189
+ continue
190
+ for m in re.finditer(pat, txt):
191
+ h = _host_of(m.group(1))
192
+ if h:
193
+ ups.add(h)
194
+ return ups
195
+
196
+
197
+ def pick_switch_candidate(agents, role, ttype, stats, exclude=(),
198
+ dead_upstreams=()):
199
+ """换将选将。配额类死亡背景下:与死者同上游的候选依次让位,直到找到
200
+ 异上游/上游未知的候选(2026-09-22 实案:kimi 与 claude 同骑智谱,0.1 分
201
+ 之差把异上游 qwencode 压在下面,换将=换壳不换命)。异上游耗尽后同上游
202
+ 候选捡回分数最高者——聊胜于无。"""
203
+ exclude = set(exclude)
204
+ best, reason = pick(agents, role, ttype, stats, exclude=exclude)
205
+ if best is None or not dead_upstreams:
206
+ return best, reason
207
+ deferred = []
208
+ while best is not None:
209
+ ups = agent_upstreams(best.get("id"))
210
+ if not (ups and any(ups & dead for dead in dead_upstreams)):
211
+ break # 异上游或上游未知:就用它
212
+ deferred.append((best, reason))
213
+ best, reason = pick(agents, role, ttype, stats, exclude=exclude | {
214
+ x.get("id") for x, _ in deferred})
215
+ if best is None and deferred:
216
+ best, reason = deferred[0] # 异上游全灭:同上游里挑最高的顶上
217
+ elif deferred:
218
+ reason = ("%s(%s 与死者同上游,延后让位异上游候选)"
219
+ % (reason, deferred[-1][0].get("id")))
220
+ return best, reason
221
+
222
+
138
223
  def route_plan(agents, role, task_spec, stats=None, exclude=(), selected=None,
139
224
  participants=(), selection_reason=""):
140
225
  """生成可审计的候选排序,并可用实际选路覆盖评分预选结果。"""
@@ -177,31 +262,31 @@ def route_plan(agents, role, task_spec, stats=None, exclude=(), selected=None,
177
262
  "candidates": rows,
178
263
  "fallback": [x["agent_id"] for x in rows
179
264
  if x["agent_id"] not in active_ids]}
180
-
181
-
182
- def pick_reviewer(agents, impl, ttype, stats=None):
183
- """评审者:跨厂商是硬规则(Codeband 的对抗式配对)——同族评审有同款盲区,
184
- 评审者必须来自与实现者不同的 kind;跨族池为空才回退同厂商并如实备注,
185
- 绝不把回退伪装成跨厂商。"""
186
- stats = stats or history.agent_stats()
187
- impl_kind = impl.get("kind")
188
- real = [a for a in agents if a.get("mode") == "real" and a["id"] != impl["id"]]
189
- cross = [a for a in real if a.get("kind") != impl_kind]
190
- if cross:
191
- best = max(cross, key=lambda a: score(a, "review", ttype, stats)[0])
192
- _, reason = score(best, "review", ttype, stats)
193
- return best, "跨厂商评审(%s ≠ %s):%s" % (best.get("kind"), impl_kind, reason)
194
- if real:
195
- best = max(real, key=lambda a: score(a, "review", ttype, stats)[0])
196
- _, reason = score(best, "review", ttype, stats)
197
- return best, ("(无跨厂商智能体可用,回退同厂商 %s 评审——建议启用其他厂商的 CLI)"
198
- % impl_kind) + reason
199
- mb = next((a for a in agents if a["id"] == "mock-b"), None)
200
- if mb and impl.get("mode") != "mock":
201
- return mb, "(无第二个真实智能体,用 mock 评审)"
202
- return impl, "(自评:仅有实现者一个智能体可用)"
203
-
204
-
265
+
266
+
267
+ def pick_reviewer(agents, impl, ttype, stats=None):
268
+ """评审者:跨厂商是硬规则(Codeband 的对抗式配对)——同族评审有同款盲区,
269
+ 评审者必须来自与实现者不同的 kind;跨族池为空才回退同厂商并如实备注,
270
+ 绝不把回退伪装成跨厂商。"""
271
+ stats = stats or history.agent_stats()
272
+ impl_kind = impl.get("kind")
273
+ real = [a for a in agents if a.get("mode") == "real" and a["id"] != impl["id"]]
274
+ cross = [a for a in real if a.get("kind") != impl_kind]
275
+ if cross:
276
+ best = max(cross, key=lambda a: score(a, "review", ttype, stats)[0])
277
+ _, reason = score(best, "review", ttype, stats)
278
+ return best, "跨厂商评审(%s ≠ %s):%s" % (best.get("kind"), impl_kind, reason)
279
+ if real:
280
+ best = max(real, key=lambda a: score(a, "review", ttype, stats)[0])
281
+ _, reason = score(best, "review", ttype, stats)
282
+ return best, ("(无跨厂商智能体可用,回退同厂商 %s 评审——建议启用其他厂商的 CLI)"
283
+ % impl_kind) + reason
284
+ mb = next((a for a in agents if a["id"] == "mock-b"), None)
285
+ if mb and impl.get("mode") != "mock":
286
+ return mb, "(无第二个真实智能体,用 mock 评审)"
287
+ return impl, "(自评:仅有实现者一个智能体可用)"
288
+
289
+
205
290
  def pick_critics(agents, ttype, stats=None, impl=None):
206
291
  """自动内容评审组选综合分前两名(排除作者本人)。
207
292
 
@@ -214,9 +299,9 @@ def pick_critics(agents, ttype, stats=None, impl=None):
214
299
  ranked = sorted(real, key=lambda a: score(a, "review", ttype, stats)[0], reverse=True)
215
300
  real = ranked[:AUTO_CRITIC_LIMIT]
216
301
  note = "按成功率与延迟选择前 %d 名评审(共 %d 名候选)" % (len(real), len(ranked))
217
- if impl and impl.get("mode") == "real" \
218
- and all(a.get("kind") == impl.get("kind") for a in real):
219
- note += ";评审组与作者同为 %s,建议启用其他厂商 CLI 交叉评审" % impl.get("kind")
220
- return real, note
221
- mocks = [a for a in agents if a.get("mode") == "mock"] or agents[:2]
222
- return mocks, "(无真实智能体,用内置 mock 演示)"
302
+ if impl and impl.get("mode") == "real" \
303
+ and all(a.get("kind") == impl.get("kind") for a in real):
304
+ note += ";评审组与作者同为 %s,建议启用其他厂商 CLI 交叉评审" % impl.get("kind")
305
+ return real, note
306
+ mocks = [a for a in agents if a.get("mode") == "mock"] or agents[:2]
307
+ return mocks, "(无真实智能体,用内置 mock 演示)"
@@ -903,6 +903,33 @@ def _rate_limited(err):
903
903
  return any(k in err for k in _RATE_LIMIT)
904
904
 
905
905
 
906
+ # 审计日志错误行探针:只认强信号,避免把回显提示词里的普通词当错误
907
+ _LOG_ERR_HINT = re.compile(
908
+ r"(429|402|rate[_ ]?limit|quota|余额|欠费|insufficient|billing|arrears"
909
+ r"|ENOTFOUND|ECONNREFUSED|failed to run prompt|api_error|overloaded)",
910
+ re.I)
911
+
912
+
913
+ def _augment_error_from_log(out, log_path, limit=300):
914
+ """stdout/stderr 尾段没有错误信号时,从流式审计日志里补最后一条错误行。
915
+ 真错误有时只走流式管道(kimi 撞 429 时捕获输出只剩启动横幅),不补的
916
+ 话路由层与用户都只看到「退出码 1;stderr/stdout: kimi version 2.0.2」。"""
917
+ if not log_path or not out.get("error"):
918
+ return
919
+ try:
920
+ with open(log_path, "rb") as fh:
921
+ fh.seek(0, 2)
922
+ size = fh.tell()
923
+ fh.seek(max(0, size - 8192))
924
+ blob = fh.read().decode("utf-8", "ignore")
925
+ except OSError:
926
+ return
927
+ hits = [ln.strip() for ln in blob.splitlines() if _LOG_ERR_HINT.search(ln)]
928
+ if not hits:
929
+ return
930
+ out["error"] = (out["error"] + ";日志错误行: " + hits[-1][:limit])[-800:]
931
+
932
+
906
933
  def _grace_wait(cancel_event, seconds, log_path=None, why=""):
907
934
  """链尾限流宽限等待:小片睡眠随时响应取消。返回 False=已取消(别再重试)。"""
908
935
  if log_path:
@@ -1370,6 +1397,13 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1370
1397
  pass
1371
1398
  except Exception:
1372
1399
  pass
1400
+ if not (_quota_error(out["error"]) or _transient_error(out["error"])
1401
+ or _rate_limited(out["error"])):
1402
+ # 尾段清洗后没有错误信号而流式审计日志里有:补日志错误行。
1403
+ # 2026-09-22 实测 kimi 撞 429 时 stderr/stdout 只剩启动横幅
1404
+ # (真错误只走了流式管道),路由层看不到配额关键词,换将
1405
+ # 判死因全靠猜。
1406
+ _augment_error_from_log(out, log_path)
1373
1407
  out["error_code"] = _classify_failure(res, kind=kind)
1374
1408
  break
1375
1409
  if kind == "codex":
@@ -33,9 +33,10 @@ DEFAULTS = {"max_concurrent_jobs": 12, "default_workdir": "", "hooks_token": "",
33
33
  # 桌宠形象白名单(与 app/pet.py 的 SKINS 对齐;这里不 import pet 模块,避免
34
34
  # core 反向依赖 app 根目录脚本)
35
35
  PET_SKINS = ("plush", "robot")
36
- # 并发保护上限 12:任务有空位即直接启动,满载明确返回忙,不进入等待队列;
37
- # 同任务单飞守卫在 jobs 层。默认取上限,对齐「默认不排队」的使用预期。
38
- MIN_WORKERS, MAX_WORKERS = 1, 12
36
+ # 并发保护上限 24:编排任务有空位即直接启动,满载回滚 queued 排队自动补跑,
37
+ # 不秒判失败;同任务单飞守卫在 jobs 层。该上限保护的是本机资源(CLI 子进程
38
+ # 数),与各 API 厂商的限流无关;direct 对话走 jobs 轻量池不占此额度。
39
+ MIN_WORKERS, MAX_WORKERS = 1, 24
39
40
 
40
41
 
41
42
  def builtin_workdir():
@@ -300,11 +300,26 @@ def list_lessons(scope=None, only_enabled=False, category=None):
300
300
  return items
301
301
 
302
302
 
303
+ def _title_containment(a, b):
304
+ """标题近似度:字符 bigram 包含度(交集/较短者)。与 knowledge._title_sim
305
+ 同款度量(追加后缀形态 Jaccard 漏、包含度=1.0),教训/知识去重口径一致。"""
306
+ ga, gb = _text_bigrams(a), _text_bigrams(b)
307
+ if not ga or not gb:
308
+ return 0.0
309
+ return len(ga & gb) / float(min(len(ga), len(gb)))
310
+
311
+
312
+ _LESSON_NEAR_DUP = 0.8 # 同 scope 教训标题包含度 ≥0.8 视为同一条(近似题合并)
313
+
314
+
303
315
  def upsert_lesson(scope, title, content, source="", category=None, dim=None):
304
316
  """写入/合并一条教训:同 scope 同标题视为同一条(seen+1,内容取新的)。
305
317
 
306
318
  category 为闭集枚举之一(见 LESSON_CATEGORIES);输入非法时退到 dim 关键词映射,
307
319
  仍归不出则落「未分类」。id 仍只按 scope+标题哈希,故老教训再沉淀会合并而非分裂。
320
+ 近似题合并(与知识库同款防膨胀):自动复盘每次 done 运行都跑,「节奏拖沓」与
321
+ 「节奏拖沓问题」会各占一条;同 scope 标题包含度 ≥0.8 也视为同一条,合并语义
322
+ 与精确同题一致。<4 字符短题只认精确(bigram 噪声大)。
308
323
  """
309
324
  title = str(title or "").strip()[:60]
310
325
  content = str(content or "").strip()[:1200]
@@ -315,18 +330,27 @@ def upsert_lesson(scope, title, content, source="", category=None, dim=None):
315
330
  with _LOCK:
316
331
  data = _load()
317
332
  items = data.setdefault("lessons", [])
318
- for it in items:
319
- if it.get("id") == lid:
320
- it["content"] = content
321
- it["seen"] = int(it.get("seen") or 1) + 1
322
- it["updated_at"] = _now()
323
- # 合并时不降级已有分类:除非本次归到了明确类别,或该条原本没有分类
324
- if cat != LESSON_UNCATEGORIZED or not it.get("category"):
325
- it["category"] = cat
326
- if source:
327
- it["source"] = source
328
- _save(data)
329
- return it
333
+ hit = next((x for x in items if x.get("id") == lid), None)
334
+ if hit is None and len(title) >= 4:
335
+ for x in items:
336
+ if x.get("scope") != scope:
337
+ continue
338
+ xt = str(x.get("title") or "")
339
+ if len(xt) >= 4 and _title_containment(title, xt) >= _LESSON_NEAR_DUP:
340
+ hit = x
341
+ break
342
+ if hit is not None:
343
+ it = hit
344
+ it["content"] = content
345
+ it["seen"] = int(it.get("seen") or 1) + 1
346
+ it["updated_at"] = _now()
347
+ # 合并时不降级已有分类:除非本次归到了明确类别,或该条原本没有分类
348
+ if cat != LESSON_UNCATEGORIZED or not it.get("category"):
349
+ it["category"] = cat
350
+ if source:
351
+ it["source"] = source
352
+ _save(data)
353
+ return it
330
354
  it = {"id": lid, "scope": scope, "title": title, "content": content,
331
355
  "source": source, "hits": 0, "seen": 1, "enabled": True,
332
356
  "category": cat,