diff --git a/doc/architecture/SPEC/kb_service.md b/doc/architecture/SPEC/kb_service.md index 0412d029..5ef366ab 100644 --- a/doc/architecture/SPEC/kb_service.md +++ b/doc/architecture/SPEC/kb_service.md @@ -642,6 +642,12 @@ documented_manual;人工步骤须来自已有文档并标明本轮未执行。 与验证类型,仍受两页和 6,000 字符预算约束。 共享的源码/测试/文档索引绑定 pin、政策、完整生产范围、版本与摘要;只建立一次。 +轻量深化还从该索引定位功能源码父目录中的实际项目文档,到最近组件指南为止, +包括相邻的 SKILL、README、设计文档和 reference;有局部正文时先保留它们,否则 +使用通用 owner 文档,不扩展到其他组件的兄弟目录。各文档共享 8 KB 完整行段预算, +优先实际入口文件名附近的行段及评审反馈,最终组包保留已选片段;没有既有操作和 +可观察预期的文档仍保持未知,补读不表示执行测试。strict 和未配置共享索引的旧输入不变。 +旧草稿和原生审批仍核对原始保存的输入,不重写上下文或重置累计修正次数。 轻量组包保持完整小片段,修正优先补读评审指出的符号和范围。每个缺口至多首轮加三次 自动修正,计数在派发前落盘,恢复或显式 retry 不清零;通过的维度冻结,失败只留在该项。 未派发评审的草稿可在原轮恢复。逐批核对格式、引用、哈希与计数,完整验收集中在交付前。 diff --git a/plugins/infermatrix-copilot/skills/imkbinit/SKILL.md b/plugins/infermatrix-copilot/skills/imkbinit/SKILL.md index 14d2eef4..0d8c0a54 100644 --- a/plugins/infermatrix-copilot/skills/imkbinit/SKILL.md +++ b/plugins/infermatrix-copilot/skills/imkbinit/SKILL.md @@ -307,6 +307,15 @@ points, and separately measures paths-only ambiguity. Retrieval remains bounded to two documents and 6,000 content characters. Distinguish all `available_facets` from actually delivered `included_facets`, `not_injected_facets`, and their bases; read the remaining relevant facets through the existing bounded doc tools. +Lightweight depth also reads existing indexed project documents in each source +file's ancestor directories up to the nearest component guide, including +adjacent SKILL, README, design and reference guides. Generic owner documents +are the fallback when local text is unavailable. Documents share the existing +8 KB budget, favor real source filename windows, and retain exact pinned ranges +in the final packet. Documented manual operations +and observable expected results may support an explicitly unexecuted manual +validation entry; unrelated sibling components and invented tests cannot. +Keep saved native inputs and cumulative correction counts unchanged on resume. `verified_gaps` describe absent evidence, not tests passed; missing facets remain unknown. Save reports outside `knowledge/` and report retrieval separately from breadth/depth and actual PR quality. This audit does not establish bug recall. diff --git a/src/infermatrix_copilot/kb_service/depth_inputs.py b/src/infermatrix_copilot/kb_service/depth_inputs.py index 8e3ab02c..fd47640b 100644 --- a/src/infermatrix_copilot/kb_service/depth_inputs.py +++ b/src/infermatrix_copilot/kb_service/depth_inputs.py @@ -586,7 +586,7 @@ def _indexed_test_association(self, feature, docs): return json.loads(json.dumps(result)) def document_slices(self, docs, *, paths=(), limit=8_000, previous_review=None, evidence_round=0, - facets=(), symbols=()): + facets=(), symbols=(), preferred_ranges=()): """Select contiguous real lines from full indexed docs, then fallback input. References in a rejection take priority over a document prefix. The @@ -605,6 +605,7 @@ def document_slices(self, docs, *, paths=(), limit=8_000, previous_review=None, patterns = [re.compile(_FACET_PATTERNS[facet], re.I) for facet in facets if facet in _FACET_PATTERNS] references = [(match[1], int(match[2]), int(match[3] or match[2])) for match in re.finditer(r"([\w./-]+):L?(\d+)(?:[-:]L?(\d+))?", review)] + references.extend(preferred_ranges) # indexed filename matches, not approval evidence ranges, lines_by_path = [], {} for path in names: if not safe_source_path(path): @@ -638,6 +639,16 @@ def document_slices(self, docs, *, paths=(), limit=8_000, previous_review=None, rotated.extend(tied[shift:] + tied[:shift]) ranges = rotated selected, used = {}, 0 + for path, start, end in preferred_ranges: + lines = lines_by_path.get(path, {}) + if not all(number in lines for number in range(start, end + 1)): + continue + pending = {number: lines[number] for number in range(start, end + 1) + if number not in selected.get(path, {})} + size = sum(len(line.encode("utf-8")) + 1 for line in pending.values()) + if used + size <= limit: + selected.setdefault(path, {}).update(pending) + used += size for _, path, start, end in ranges: for number in range(start, end + 1): line = lines_by_path[path][number] diff --git a/src/infermatrix_copilot/kb_service/init_knowledge_depth.py b/src/infermatrix_copilot/kb_service/init_knowledge_depth.py index 80b1c5f6..e4fe3065 100644 --- a/src/infermatrix_copilot/kb_service/init_knowledge_depth.py +++ b/src/infermatrix_copilot/kb_service/init_knowledge_depth.py @@ -9,7 +9,7 @@ import time from collections import Counter from dataclasses import dataclass, replace -from pathlib import Path +from pathlib import Path, PurePosixPath from ..knowledge_service.lifecycle import Page from ..knowledge_service.ops import page_over_capacity @@ -397,7 +397,65 @@ def _slot_result(entry, facet, status, reason): if visits[key] >= 3: slot["blocked"] = "three unsuccessful repairs with the same pinned evidence; needs new evidence or a corrected candidate" - def _docs(self, tree: Path, feature, owner) -> list[dict]: + def _docs(self, tree: Path, feature, owner, *, context=None, facets=(), + previous_review=None, evidence_round=0) -> list[dict]: + if context is not None and context.mode == "lightweight" and context.index is not None: + # A source-only feature can still have a README/SKILL/design guide + # beside its implementation. Select only real indexed ancestor + # documents, never another component's sibling directory. + by_parent = {} + for path, fact in context.index.files.items(): + document = PurePosixPath(path) + if (document.suffix.lower() not in + {".md", ".mdx", ".rst", ".txt", ".adoc"} or fact["sha256"] is None): + continue + name = document.stem.lower() + priority = next((number for number, prefix in enumerate( + ("skill", "readme", "design", "architecture", "reference")) + if name.startswith(prefix)), 5) + by_parent.setdefault(document.parent.as_posix(), []).append((priority, path)) + adjacent = [] + for source in context._scope(feature): + for distance, parent in enumerate(PurePosixPath(source).parents): + siblings = by_parent.get(parent.as_posix(), ()) + adjacent.extend((distance, priority, path) for priority, path in siblings) + if any(priority < 4 for priority, _ in siblings): + break # the nearest component guide is the local boundary + paths = list(dict.fromkeys([*feature.docs, *(path for _, _, path in sorted(adjacent))])) + filenames = {PurePosixPath(path).name for path in context._scope(feature)} + preferred = [] + for path in paths: + matches, examples, fenced = [], [], False + for number, line in enumerate(context.index.files.get(path, {}).get("lines", ()), 1): + if line.lstrip().startswith(("```", "~~~")): + fenced = not fenced + if any(name in line for name in filenames): + matches.append(number) + if fenced: + examples.append(number) + # Existing executable examples beat prose lists of unrelated + # tools. Nearby output shapes/criteria remain real line ranges. + preferred.extend((path, max(1, number - 6), + min(len(context.index.files[path]["lines"]), number + 6)) + for number in (examples or matches)) + # The immutable index supplies complete real line ranges, including + # late manual acceptance steps. The existing 8KB packet cap applies. + docs, used = [], 0 + for number, path in enumerate(paths): + selected = context.document_slices([], paths=(path,), + limit=(8000 - used) // (len(paths) - number), facets=facets, + previous_review=previous_review, evidence_round=evidence_round, + preferred_ranges=preferred) + docs.extend(selected) + used += sum(len(line.encode("utf-8")) + 1 for item in selected for line in item["text"]) + if docs: + # build() rereads whole indexed documents. Offering a generic + # owner path here would let that reread crowd out the local + # command/criteria despite this first selection's byte cap. + return docs + generic = [item for item in self._docs_for(tree, owner) if item["path"] not in paths] + return context.document_slices(generic, limit=8000, facets=facets, + previous_review=previous_review, evidence_round=evidence_round) docs = self._sources(tree, list(feature.docs), MAX_DOC_BYTES // 2) used = sum(len(d["text"].encode("utf-8")) for d in docs) for item in self._docs_for(tree, owner): @@ -444,17 +502,28 @@ def _attempt(self, tree, context, feature, entry): owner = next((o for o in self.owners if o.owner == feature.owner), Owner(feature.owner, feature.page, tuple(p.split("*", 1)[0] for p in feature.source_globs))) guidance = getattr(self, "_repair_guidance", None) - docs = [] if guidance else self._docs(tree, feature, owner) + requested = entry.get("requested_facets") or [f for f in FACETS if f not in old_blocks] + docs = [] if guidance else self._docs(tree, feature, owner, context=context, + facets=requested, previous_review=entry.get("reason"), + evidence_round=entry.get("evidence_round", 0)) existing = self._bounded_context(self._existing_knowledge(owner, self.head.get(feature.page, ""))) if lightweight: docs = self._bounded_slices(docs, 8000) existing = self._bounded_texts(existing, 4000) - requested = entry.get("requested_facets") or [f for f in FACETS if f not in old_blocks] retrieval = context.guided(feature, requested, guidance) if guidance else context.build( feature, self.head.get(feature.page, ""), docs, facets=requested, previous_review=entry.get("reason"), evidence_round=entry.get("evidence_round", 0)) if lightweight: - docs = retrieval.get("docs", docs) + if context.index is not None and not guidance: + # Preserve the already selected local document ranges; a second + # full-index rerank can replace their commands/criteria. Bind + # the actual final packet, without changing any saved payload. + retrieval["docs"] = docs + retrieval["context_sha256"] = hashlib.sha256(json.dumps( + {key: value for key, value in retrieval.items() if key != "context_sha256"}, + sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest() + else: + docs = retrieval.get("docs", docs) files = retrieval["files"] if not files and not (lightweight and docs): entry["attempts"] = entry.get("attempts", 0) + 1 diff --git a/test/test_kb_depth_adjacent_docs.py b/test/test_kb_depth_adjacent_docs.py new file mode 100644 index 00000000..41f65019 --- /dev/null +++ b/test/test_kb_depth_adjacent_docs.py @@ -0,0 +1,213 @@ +"""Existing adjacent manuals are retrieval evidence, never invented tests.""" + +from types import SimpleNamespace + +import pytest + +from infermatrix_copilot.kb_service.depth_inputs import DepthContext, SYSTEM_DEPTH, prompt, system_prompt +from infermatrix_copilot.kb_service.init_knowledge_depth import _KnowledgeDepth +from test_kb_depth_shared_index import PIN, POLICY, _index, _write +from test_kb_init_skeleton import world # noqa: F401 + + +def _stage(generic=()): + stage = object.__new__(_KnowledgeDepth) + stage._docs_for = lambda tree, owner: list(generic) + return stage + + +def _feature(source, docs=()): + return SimpleNamespace(id="tool", entry_points=(source,), source_globs=(source,), docs=tuple(docs)) + + +def _context(root, source, index, mode="lightweight", **kwargs): + return DepthContext(root, [source], index=index, pin=PIN, policy_sha256=POLICY, mode=mode, **kwargs) + + +def _docs(stage, root, feature, context): + return stage._docs(root, feature, None, context=context, facets=("validation",)) + + +def _assert_real_spans(root, docs): + assert sum(len(line.encode("utf-8")) + 1 for d in docs for line in d["text"]) <= 8000 + for d in docs: + assert d["text"] == (root / d["path"]).read_text().splitlines()[d["start"] - 1:d["end"]] + + +def test_source_only_skill_gets_late_existing_manual_commands_before_owner_docs(tmp_path): + root = tmp_path / "repo" + source = "skills/docx/scripts/main.py" + _write(root, source, "def inspect(path):\n return path\n") + skill = "skills/docx/SKILL.md" + _write(root, skill, "context only\n" * 1200 + "## 完成后的验证\nRun inspect output.docx.\n预期标题树与表格数一致,残留计数为0。\n") + generic = "docs/owner.md" + _write(root, generic, "Generic navigation.\n" * 3000) + _write(root, "TESTING.md", "验证 pytest expected assert manual 测试步骤预期。\n" * 3000) + foreign = "skills/other/SKILL.md" + _write(root, foreign, "验证 foreign tool; expected everything passed.\n") + index = _index(root, [source]) + context = _context(root, source, index) + stage = _stage([{"path": generic, "start": 1, "text": (root / generic).read_text(), "end": 3000}]) + docs = _docs(stage, root, _feature(source), context) + _assert_real_spans(root, docs) + assert any(d["path"] == skill and d["end"] > 1200 and "Run inspect output.docx." in d["text"] for d in docs) + assert all(d["path"] != foreign for d in docs) + assert all(d["path"] != "TESTING.md" for d in docs) + packet = context.build(_feature(source), "", docs, facets=("validation",)) + assert any(d["path"] == skill and d["end"] > 1200 for d in packet["docs"]) + assert packet["source_index"]["sha256"] == index.sha256 + assert packet["source_bytes"] <= 24000 + _assert_real_spans(root, packet["docs"]) + + +@pytest.mark.parametrize("manual", ["SKILL.md", "README.zh-CN.rst", "DESIGN_中文.adoc"]) +def test_ancestor_docs_are_language_independent_and_direct_sibling_reference_is_available(tmp_path, manual): + root = tmp_path / "repo" + source = "pkg/tool/scripts/run.unknown" + _write(root, source, "RUN operation\n") + path = "pkg/tool/" + manual + _write(root, path, "## 验证步骤\nmanual run tool --check\nexpected point_map.json has points with labels.\n") + reference = "pkg/tool/reference.md" + _write(root, reference, "manual run tool input output\nexpected output includes point IDs.\n") + index = _index(root, [source]) + docs = _docs(_stage(), root, _feature(source), _context(root, source, index)) + assert {path, reference}.issubset({d["path"] for d in docs}) + _assert_real_spans(root, docs) + + +def test_explicit_docs_remain_first_and_unreadable_or_foreign_docs_are_not_offered(tmp_path): + root = tmp_path / "repo" + source = "pkg/tool/main.py" + _write(root, source, "def run():\n return 1\n") + _write(root, "guide.md", "manual expected output equals one.\n") + _write(root, "pkg/tool/SKILL.md", "manual expected result is one.\n") + _write(root, "pkg/other/SKILL.md", "manual expected result is anything.\n") + (root / "pkg/tool/DESIGN.md").write_bytes(b"\xff") + index = _index(root, [source]) + docs = _docs(_stage(), root, _feature(source, ("guide.md",)), _context(root, source, index)) + assert docs[0]["path"] == "guide.md" + assert not {"pkg/tool/DESIGN.md", "pkg/other/SKILL.md"}.intersection(d["path"] for d in docs) + _assert_real_spans(root, docs) + with pytest.raises(ValueError, match="identity"): + DepthContext(root, [source], index=index, pin="c" * 40, policy_sha256=POLICY, mode="lightweight") + + +def test_strict_and_no_index_paths_keep_legacy_numbered_input_and_prompt(tmp_path): + root = tmp_path / "repo" + source = "pkg/main.py" + _write(root, source, "def run():\n return 1\n") + _write(root, "guide.md", "Existing guide.\nmanual expected one.\n") + _write(root, "pkg/SKILL.md", "manual expected two.\n") + feature = _feature(source, ("guide.md",)) + stage = _stage() + legacy = stage._docs(root, feature, None) + index = _index(root, [source]) + assert _docs(stage, root, feature, _context(root, source, index, mode="strict")) == legacy + assert _docs(stage, root, feature, DepthContext(root, [source], mode="lightweight")) == legacy + assert legacy[0]["text"] == ["1: Existing guide.", "2: manual expected one."] + assert system_prompt("strict") == SYSTEM_DEPTH + payload = {"feature": {"id": "saved"}, "files": [], "docs": legacy} + assert prompt(payload) == prompt(payload, mode="strict") + + +def test_final_packet_cannot_reexpand_generic_owner_doc_over_local_manual(tmp_path): + root = tmp_path / "repo" + source = "skills/tool/scripts/main.py" + _write(root, source, "def inspect(value):\n return value\n") + skill = "skills/tool/SKILL.md" + _write(root, skill, "## 验证步骤\nmanual run inspect output.\n预期标题与表格计数一致。\n") + generic = "docs/owner.md" + _write(root, generic, "验证 pytest expected assert manual 测试步骤预期。\n" * 3000) + index = _index(root, [source]) + context = _context(root, source, index) + stage = _stage([{"path": generic, "start": 1, "end": 3000, "text": (root / generic).read_text()}]) + docs = _docs(stage, root, _feature(source), context) + packet = context.build(_feature(source), "", docs, facets=("validation",)) + assert packet["docs"] == [{"path": skill, "start": 1, "end": 3, + "text": (root / skill).read_text().splitlines()}] + _assert_real_spans(root, packet["docs"]) + + +def test_stage_injects_selected_manual_once_and_resumes_saved_drafts_unchanged(world, monkeypatch): + from dataclasses import replace + import hashlib + import json + from infermatrix_copilot.kb_service.depth_index import build_depth_index + from infermatrix_copilot.kb_service.init_stages import run_stage + from infermatrix_copilot.kb_service.knowledge_coverage import inventory, policy_path + from test_kb_knowledge_depth import _baseline, _complete_breadth + from test_kb_lightweight_runtime import LightGateway + from test_kb_init_modules import _modules_lifecycle + from test_kb_init_skeleton import _commit, _git, _runtime + + built = [] + real_build = DepthContext.build + + def capture(context, *args, **kwargs): + result = real_build(context, *args, **kwargs) + built.append(result) + return result + + monkeypatch.setattr(DepthContext, "build", capture) + policy = _baseline(world) + manual = "## 验证步骤\nmanual run Engine.step with integer input.\n预期返回递增整数;本轮未执行。\n" + _commit(world["upstream"], {"pkg/SKILL.md": manual}, "add actual existing manual") + _complete_breadth(world, policy) + pin = _git(world["upstream"], "rev-parse", "HEAD") + raw_policy = (world["origin"] / policy_path("toy")).read_text() + cache = world["tmp"] / "retrieval-index.json" + build_depth_index(world["upstream"], inventory(world["upstream"], policy), pin=pin, + policy_sha256=hashlib.sha256(raw_policy.encode()).hexdigest(), cache_path=cache) + gateway = LightGateway(reject="api") + rt = _runtime(world, gateway, state_dir=world["tmp"] / "depth-adjacent") + lifecycle = replace(_modules_lifecycle(), init=replace(_modules_lifecycle().init, budget_usd=10)) + args = dict(dry_run=True, from_existing=True, subscription_generator=True, + acceptance_mode="lightweight", depth_index_path=cache) + record = run_stage(rt, lifecycle, "knowledge-deepen", **args) + assert len(gateway.depth_calls) == 4 + packet = gateway.depth_calls[0] + assert any(d["path"] == "pkg/SKILL.md" and d["text"] == manual.splitlines() for d in packet["docs"]) + retrieval = {key: value for key, value in built[0].items() if key != "context_sha256"} + assert packet["context_sha256"] == hashlib.sha256(json.dumps(retrieval, sort_keys=True, + separators=(",", ":"), ensure_ascii=False).encode()).hexdigest() + drafts = json.dumps(record.depth, sort_keys=True) + resumed = run_stage(rt, lifecycle, "knowledge-deepen", retry_unfinished=True, **args) + assert len(gateway.depth_calls) == 4 + assert json.dumps(resumed.depth, sort_keys=True) == drafts + + +def test_reference_command_and_observable_output_survive_giant_skill_and_prose_list(tmp_path): + root = tmp_path / "repo" + source = "skills/trace/scripts/preprocess.py" + _write(root, source, "def preprocess(value):\n return value\n") + _write(root, "skills/trace/SKILL.md", "验证 pytest expected assert manual 测试步骤预期。\n" * 2000) + reference = "skills/trace/reference.md" + _write(root, reference, ("preprocess.py is one tool; expected manual check. " * 90 + "\n") * 20 + + "## 现有运行与验证步骤\n```bash\npython preprocess.py input output\n```\n" + + "预期 output/point_map.json 具有以下结构,本轮未执行。\n```json\n{\"points\": {\"1\": {\"label\": \"work\"}}}\n```\n") + index = _index(root, [source]) + context = _context(root, source, index) + docs = _docs(_stage(), root, _feature(source), context) + manual = "\n".join(line for d in docs if d["path"] == reference for line in d["text"]) + assert "python preprocess.py input output" in manual and '"points"' in manual + _assert_real_spans(root, docs) + + +@pytest.mark.parametrize("round_number", [0, 1, 2, 3]) +def test_preferred_real_command_window_is_reserved_before_keyword_rotation(tmp_path, round_number): + root = tmp_path / "repo" + source = "pkg/main.py" + _write(root, source, "def run():\n return 1\n") + path = "pkg/SKILL.md" + _write(root, path, "manual pytest expected assert 测试验证步骤预期。\n" * 100 + + "```bash\npython main.py --check\n```\n预期输出 points 非空;本轮未执行。\n") + index = _index(root, [source]) + context = _context(root, source, index) + docs = context.document_slices([], paths=(path,), facets=("validation",), + evidence_round=round_number, limit=400, + preferred_ranges=((path, 101, 104),)) + content = "\n".join(line for d in docs for line in d["text"]) + assert "python main.py --check" in content and "预期输出 points 非空" in content + assert sum(len(line.encode()) + 1 for d in docs for line in d["text"]) <= 400 + for d in docs: + assert d["text"] == (root / path).read_text().splitlines()[d["start"]-1:d["end"]]