Skip to content

Commit cd1d90a

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/jiuwenswarm-knowledge-depth
2 parents 4e782d4 + ca12c56 commit cd1d90a

9 files changed

Lines changed: 291 additions & 33 deletions

File tree

‎doc/architecture/SPEC/kb_service.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,11 @@ stdlib + PyYAML + `cryptography`(`kb` extra)+ `.adapters` + `.knowledge_serv
2929

3030
## 2026-09-28 intake 与质量门
3131
- `models`:生成与评审模型按 (provider, model, effort) 钉死(默认 `claude-code:claude-opus-5-5`
32-
与 `codex:gpt-6-sol:medium`,评审必须与生成不同家族);不可用/超时/无法解析 → `ModelUnavailable`,
33-
调用方保持排队,绝不降级;每次调用经 recorder 记录输入、输出、用量。
32+
与 `codex:gpt-6-sol:medium`,评审必须与主/备用生成器不同家族);`KB_GENERATOR_FALLBACK`
33+
可显式配置一个备用生成器(如 `zcode:GLM-5.3`),主模型不可用/超时/无法解析时尝试一次。
34+
评审、schema 修复及带花费阈值的调用均不切换(预算只为主模型预留,Zcode 无阈值能力)。
35+
两个生成器都失败时保持排队。每次尝试经 recorder 记录实际模型、输入、输出、用量,
36+
备用调用带 `fallback_from`,intake/精炼/巡检变更集注明实际生成器,重建保留原归属。
3437
- `sources`:知识仓库克隆的只读读取(`knowledge_files`、`external_texts`)、只读 GitHub 客户端
3538
(合并 PR、PR 证据有界摘录、release/tag)、本机 Copilot 运行经验收件目录。
3639
- `intake`:每个事件由生成器起草类型化操作(只允许 add/edit_same_meaning/replace/retire),

‎doc/features/knowledge-service.md‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,18 @@ infermatrix-copilot kb calibrate --repo vllm-omni # 评审模
4949
```
5050

5151
模型:`KB_GENERATOR`(默认 `claude-code:claude-opus-5-5`)、`KB_JUDGE`(默认
52-
`codex:gpt-6-sol:medium`)。每次模型调用写入 `$KB_STATE_DIR/traces/model_calls.jsonl`。
52+
`codex:gpt-6-sol:medium`)。生成器可显式配置一个备用模型:
53+
54+
```bash
55+
export KB_GENERATOR_FALLBACK=zcode:GLM-5.3
56+
```
57+
58+
主生成器缺少 CLI、未登录、调用失败、超时或回复无法解析时,同一次请求尝试备用生成器一次;
59+
两者都不可用则事件继续排队。评审模型不切换,备用生成器也必须与评审来自不同家族。
60+
每次尝试记录实际 provider/model,备用调用还记录 `fallback_from`;变更集注明实际生成器。
61+
未设置此变量时只调用主模型。schema 错误仍由原有修复流程处理;带花费阈值的调用不切换,
62+
因为预算只为主模型预留且 Zcode 无法执行阈值。
63+
调用记录位于 `$KB_STATE_DIR/traces/records/`。
5364

5465
运行经验(来源②):`pr_debug` 的已验证修复记录经 `KB_BUGFIX_DIR`(同机,与运行侧 `KNOWLEDGE_INTAKE_DIR`
5566
配为同一目录)与 `KB_BUGFIX_MAILBOX`(异机,`owner/repo#N`,只接受 `KB_BUGFIX_AUTHORS` 列出的作者)进入 intake。

‎src/infermatrix_copilot/kb_service/intake.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ class Draft:
6565
rationale: str = ""
6666
attempts: list[dict] = field(default_factory=list)
6767
rejected: bool = False # repairs exhausted, as opposed to "nothing to learn"
68+
generator: str = "" # the successful model, including an explicit fallback
6869

6970
@property
7071
def empty(self) -> bool:
@@ -189,7 +190,8 @@ def draft_changes(*, repo: str, repo_dir: str, event_id: int, evidence: dict,
189190
break
190191
else:
191192
if not operations:
192-
return Draft([event_id], [], None, str(reply.data.get("rationale") or ""), attempts)
193+
return Draft([event_id], [], None, str(reply.data.get("rationale") or ""), attempts,
194+
generator=reply.role.label())
193195
try:
194196
result = apply_operations(files, operations, release=release, today=today)
195197
except LifecycleError as exc:
@@ -198,7 +200,8 @@ def draft_changes(*, repo: str, repo_dir: str, event_id: int, evidence: dict,
198200
attempts.append({"attempt": attempt, "error": str(exc)})
199201
continue
200202
accept_attempt(attempt) # only this call's reply became the change
201-
return Draft([event_id], operations, result, str(reply.data.get("rationale") or ""), attempts)
203+
return Draft([event_id], operations, result, str(reply.data.get("rationale") or ""), attempts,
204+
generator=reply.role.label())
202205
attempts.append({"attempt": attempt, "error": feedback.strip()})
203206
return Draft([event_id], [], None, "rejected after repairs", attempts, rejected=True)
204207

‎src/infermatrix_copilot/kb_service/merge.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,7 @@ def to_people(reason: str) -> None:
336336
draft_keys=[],
337337
# a rebuild continues the change's refine lineage: the
338338
# limit never resets because context moved meanwhile
339-
extra_detail={k: detail[k] for k in ("refine_round", "refine_history")
339+
extra_detail={k: detail[k] for k in ("refine_round", "refine_history", "generator")
340340
if k in detail})
341341
staged = rt.ledger.changeset(new_id)
342342
if staged["status"] not in ("gated", *IN_FLIGHT):

‎src/infermatrix_copilot/kb_service/models.py‎

Lines changed: 70 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,47 @@
1-
"""Pinned model roles for the knowledge service, with no silent fallback.
1+
"""Pinned model roles with an explicitly configured generator fallback.
22
33
The generator drafts knowledge changes; the judge (a different model family)
44
grades them. Both are pinned by (provider, model, reasoning effort) and
5-
recorded on every call. An unavailable backend, a timeout, a call stopped
6-
at its spend cap or an unparseable reply raises ``ModelUnavailable``; the caller leaves the work queued. It never
7-
switches to a weaker model: a verdict must always say which model made it.
5+
recorded on every call. ``KB_GENERATOR_FALLBACK`` may name one pinned model
6+
to try when generation is unavailable. Judges never fall back, and schema
7+
repair and spend limits retain their existing behavior. If both generators
8+
are unavailable, the caller leaves the work queued.
89
"""
910

1011
from __future__ import annotations
1112

1213
import json
14+
import logging
1315
import os
1416
import re
1517
import time
16-
from dataclasses import dataclass
18+
from dataclasses import dataclass, replace
1719
from typing import Any, Callable
1820

1921
GENERATOR_ENV = "KB_GENERATOR" # "provider:model[:effort]"
22+
GENERATOR_FALLBACK_ENV = "KB_GENERATOR_FALLBACK"
2023
JUDGE_ENV = "KB_JUDGE"
2124
DEFAULT_GENERATOR = "claude-code:claude-opus-5-5"
2225
DEFAULT_JUDGE = "codex:gpt-6-sol:medium"
2326
_JSON_FENCE = re.compile(r"```(?:json)?\s*(\{.*\})\s*```", re.DOTALL)
27+
logger = logging.getLogger(__name__)
2428

2529

2630
class ModelUnavailable(RuntimeError):
2731
"""The pinned backend could not produce a usable reply; work stays queued."""
2832

33+
def __init__(self, message: str, *, allow_fallback: bool = True):
34+
super().__init__(message)
35+
self.allow_fallback = allow_fallback
36+
2937

3038
@dataclass(frozen=True)
3139
class ModelRole:
3240
name: str
3341
provider: str
3442
model: str
3543
effort: str = ""
44+
fallback: ModelRole | None = None
3645

3746
@classmethod
3847
def parse(cls, name: str, spec: str) -> "ModelRole":
@@ -48,8 +57,16 @@ def label(self) -> str:
4857
def roles_from_env() -> tuple[ModelRole, ModelRole]:
4958
generator = ModelRole.parse("generator", os.environ.get(GENERATOR_ENV, DEFAULT_GENERATOR))
5059
judge = ModelRole.parse("judge", os.environ.get(JUDGE_ENV, DEFAULT_JUDGE))
51-
if generator.provider == judge.provider and generator.model.split("-")[0] == judge.model.split("-")[0]:
52-
raise ValueError("the judge must come from a different model family than the generator")
60+
fallback_spec = os.environ.get(GENERATOR_FALLBACK_ENV, "").strip()
61+
if fallback_spec:
62+
fallback = ModelRole.parse("generator", fallback_spec)
63+
if fallback.label().casefold() == generator.label().casefold():
64+
raise ValueError("the generator fallback must differ from the primary generator")
65+
generator = replace(generator, fallback=fallback)
66+
for candidate in (generator, generator.fallback):
67+
if candidate is not None and candidate.provider == judge.provider \
68+
and candidate.model.casefold().split("-")[0] == judge.model.casefold().split("-")[0]:
69+
raise ValueError("the judge must come from a different model family than every generator")
5370
return generator, judge
5471

5572

@@ -108,27 +125,56 @@ def _transport(self, provider: str):
108125
def call_json(self, role: ModelRole, *, system: str, prompt: str,
109126
validate: Callable[[dict], None] | None = None,
110127
max_budget_usd: float | None = None, record_payload: bool = True) -> ModelReply:
128+
"""Try the primary, then one configured generator on unavailability.
129+
130+
Both attempts use the same validation and payload policy. Calls with
131+
a spend threshold and schema errors never trigger a second model call:
132+
the caller has reserved for one model, and Zcode cannot enforce a cap.
133+
"""
134+
kwargs = dict(system=system, prompt=prompt, validate=validate,
135+
max_budget_usd=max_budget_usd, record_payload=record_payload)
136+
try:
137+
return self._call_json(role, **kwargs)
138+
except ModelUnavailable as primary_error:
139+
if role.name != "generator" or role.fallback is None \
140+
or max_budget_usd is not None or not primary_error.allow_fallback:
141+
raise
142+
logger.warning("Knowledge generator %s unavailable; trying configured fallback %s",
143+
role.label(), role.fallback.label())
144+
try:
145+
return self._call_json(role.fallback, fallback_from=role.label(), **kwargs)
146+
except ModelUnavailable as fallback_error:
147+
raise ModelUnavailable(
148+
f"{role.label()} unavailable: {primary_error}; "
149+
f"fallback {role.fallback.label()} unavailable: {fallback_error}",
150+
allow_fallback=False) from fallback_error
151+
152+
def _call_json(self, role: ModelRole, *, system: str, prompt: str,
153+
validate: Callable[[dict], None] | None = None,
154+
max_budget_usd: float | None = None, record_payload: bool = True,
155+
fallback_from: str = "") -> ModelReply:
111156
"""``max_budget_usd`` is a per-call STOP THRESHOLD, not a hard cap:
112157
the transport starts no further API request once the call's spend
113158
reaches it, but the request that crosses it is billed in full. A
114159
caller enforcing a hard ceiling must reserve the threshold plus one
115160
request's worst case before calling. Requesting a threshold from a
116161
transport that cannot stop at one (``stops_at_spend`` False) is
117162
refused before dispatch; it is never approximated with ``max_tokens``."""
118-
transport = self._subscription_transports.pop(role.label(), None)
119-
if transport is None:
120-
transport = self._transport(role.provider)
121-
cap: dict = {}
122-
if max_budget_usd is not None:
123-
if not max_budget_usd > 0:
124-
raise ModelUnavailable(f"{role.label()}: max_budget_usd must be positive")
125-
if not getattr(transport, "stops_at_spend", False):
126-
raise ModelUnavailable(f"{role.provider} cannot stop a call at a spend threshold")
127-
cap = {"max_budget_usd": max_budget_usd}
128163
started = time.time()
129164
identity = {"role": role.name, "requested": role.label(), "provider": role.provider,
130-
"model": role.model, "effort": role.effort}
165+
"model": role.model, "effort": role.effort, "fallback_from": fallback_from}
131166
try:
167+
transport = self._subscription_transports.pop(role.label(), None)
168+
if transport is None:
169+
transport = self._transport(role.provider)
170+
cap: dict = {}
171+
if max_budget_usd is not None:
172+
if not max_budget_usd > 0:
173+
raise ModelUnavailable(f"{role.label()}: max_budget_usd must be positive", allow_fallback=False)
174+
if not getattr(transport, "stops_at_spend", False):
175+
raise ModelUnavailable(f"{role.provider} cannot stop a call at a spend threshold",
176+
allow_fallback=False)
177+
cap = {"max_budget_usd": max_budget_usd}
132178
reply = transport.complete(
133179
system=system, messages=[{"role": "user", "content": prompt}],
134180
model=role.model, effort=role.effort, role=role.name, **cap)
@@ -140,6 +186,8 @@ def call_json(self, role: ModelRole, *, system: str, prompt: str,
140186
"system": system if record_payload else "",
141187
"prompt": prompt if record_payload else "", "reply": "",
142188
"error": str(exc)[:2000] if record_payload else "transport failed (payload omitted)"})
189+
if isinstance(exc, ModelUnavailable):
190+
raise
143191
raise ModelUnavailable(f"{role.label()} failed: {exc}") from exc
144192
seconds = time.time() - started
145193
text = "".join(getattr(block, "text", "") or "" for block in getattr(reply, "blocks", []))
@@ -160,7 +208,8 @@ def call_json(self, role: ModelRole, *, system: str, prompt: str,
160208
failure: ModelUnavailable | None = None
161209
data: dict = {}
162210
if getattr(reply, "stop_reason", "") == "max_budget":
163-
failure = ModelUnavailable(f"{role.label()} stopped at its spend threshold ${max_budget_usd}")
211+
failure = ModelUnavailable(f"{role.label()} stopped at its spend threshold ${max_budget_usd}",
212+
allow_fallback=False)
164213
elif getattr(reply, "stop_reason", "") == "max_tokens" or not text.strip():
165214
failure = ModelUnavailable(f"{role.label()} timed out or returned nothing")
166215
else:
@@ -170,7 +219,8 @@ def call_json(self, role: ModelRole, *, system: str, prompt: str,
170219
try:
171220
validate(data)
172221
except Exception as exc: # any malformed shape is a controlled refusal
173-
raise ModelUnavailable(f"{role.label()} reply failed its schema: {exc!r}") from exc
222+
raise ModelUnavailable(f"{role.label()} reply failed its schema: {exc!r}",
223+
allow_fallback=False) from exc
174224
except ModelUnavailable as exc:
175225
failure = exc
176226
if self._recorder is not None:

‎src/infermatrix_copilot/kb_service/refine.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,8 @@ def to_people(why: str) -> None:
136136
external=external, operations=draft.operations, result=draft.result,
137137
evidence=[*evidence, mark], event_ids=[], release=release,
138138
draft_keys=[accepted_key(key, holder)] if accepted_key(key, holder) else [],
139-
extra_detail={"refine_round": round_, "refine_history": history})
139+
extra_detail={"refine_round": round_, "refine_history": history,
140+
"generator": draft.generator})
140141
open_pr = changeset.get("pr_number") is not None
141142
rt.ledger.update_changeset(changeset["id"], status="superseding" if open_pr else "refined",
142143
detail={**detail, "refined_as": new_id, "refine_history": history,

‎src/infermatrix_copilot/kb_service/runtime.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,8 @@ def record(entry: dict) -> None:
132132
# (None when unset/unknown), on failures too
133133
result={"stop_reason": entry.get("stop_reason", ""),
134134
"max_budget_usd": entry.get("max_budget_usd"),
135-
"cost_usd": entry.get("cost_usd")},
135+
"cost_usd": entry.get("cost_usd"),
136+
**({"fallback_from": entry["fallback_from"]} if entry.get("fallback_from") else {})},
136137
error=entry.get("error", ""))
137138
except Exception: # noqa: BLE001 - tracing never breaks a model call
138139
pass
@@ -278,7 +279,8 @@ def _run_intake_locked(rt: KbRuntime, lifecycle: RepoLifecycle, *, max_events: i
278279
return gate_and_stage(rt, lifecycle, owner, kind="intake", base=base, base_sha=base_sha,
279280
external=external, operations=operations, result=result,
280281
evidence=evidence, event_ids=sorted(kept_ids), release=release,
281-
draft_keys=[accepted[i] for i in sorted(kept_ids) if i in accepted])
282+
draft_keys=[accepted[i] for i in sorted(kept_ids) if i in accepted],
283+
extra_detail={"generator": ", ".join(dict.fromkeys(d.generator for d in kept))})
282284

283285

284286
def draft_key_for_event(repo: str, event_id) -> str:
@@ -328,7 +330,8 @@ def gate_and_stage(rt: KbRuntime, lifecycle: RepoLifecycle, owner: str, *, kind:
328330
"operations": operations_json(operations), "event_ids": event_ids,
329331
**(extra_detail or {}),
330332
"base_sha": base_sha, "release": release, "decision": decision.to_dict(),
331-
"generator": rt.generator.label(), "judge": rt.judge.label(),
333+
"generator": (extra_detail or {}).get("generator") or rt.generator.label(),
334+
"judge": rt.judge.label(),
332335
})
333336
except Exception:
334337
rt.changeset_path(changeset_id).unlink(missing_ok=True)

‎src/infermatrix_copilot/kb_service/sweep.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ def page_scope(files: dict[str, str], repo_dir: str, page: str) -> list[str]:
187187

188188

189189
def sweep_page(rt, lifecycle, *, page: str, files: dict[str, str], diff: str, hints: list[dict],
190-
sweep: dict, release: str, today: str):
190+
sweep: dict, release: str, today: str, generated_by: dict[str, str] | None = None):
191191
"""Ask the generator about one page. Returns (operations, result), or None
192192
when every rule is kept; raises SweepPageFailed when no valid answer came."""
193193
page_text = files[page]
@@ -223,6 +223,8 @@ def sweep_page(rt, lifecycle, *, page: str, files: dict[str, str], diff: str, hi
223223
try:
224224
result = apply_operations(files, operations, release=release, today=today)
225225
accept_attempt(attempt)
226+
if generated_by is not None:
227+
generated_by[page] = reply.role.label()
226228
return operations, result
227229
except LifecycleError as exc:
228230
feedback = f"\n\nYour previous answer was rejected by the knowledge base: {exc}. Fix exactly that."
@@ -310,6 +312,7 @@ def _run_sweep(rt, lifecycle, owner: str, sweep: dict, upstream: UpstreamRepo,
310312
from ..trace_store import accepted_key, trace_context
311313

312314
accepted: dict[str, str] = {} # page -> the accepted generator call's key
315+
generated_by: dict[str, str] = {}
313316

314317
"""Run (or resume) one sweep. Progress is per page: a page is done once its
315318
answer is staged or it is kept; a page whose evaluation failed is retried on
@@ -349,7 +352,7 @@ def _run_sweep(rt, lifecycle, owner: str, sweep: dict, upstream: UpstreamRepo,
349352
with trace_context(draft_key=key, _accepted=holder, step="draft"):
350353
outcome = sweep_page(rt, lifecycle, page=page, files=base, diff=diff_cache[prefixes],
351354
hints=(audit_hints or {}).get(page, []), sweep=sweep,
352-
release=release, today=today)
355+
release=release, today=today, generated_by=generated_by)
353356
if accepted_key(key, holder):
354357
accepted[page] = accepted_key(key, holder)
355358
except SweepPageFailed as exc:
@@ -380,7 +383,8 @@ def _run_sweep(rt, lifecycle, owner: str, sweep: dict, upstream: UpstreamRepo,
380383
changeset_id = gate_and_stage(rt, lifecycle, owner, kind=kind, base=base, base_sha=base_sha,
381384
external=external, operations=operations, result=result,
382385
evidence=evidence, event_ids=[], release=release, hold=True,
383-
draft_keys=[accepted[key]] if key in accepted else [])
386+
draft_keys=[accepted[key]] if key in accepted else [],
387+
extra_detail={"generator": generated_by.get(key, "")})
384388
progress.setdefault("held", []).append(changeset_id)
385389
if key == "#t1":
386390
progress["t1_done"] = True

0 commit comments

Comments
 (0)