From 76c0030c3806412e8e604dccfc2913ee52cd6aad Mon Sep 17 00:00:00 2001 From: Khemchand Joshi Date: Fri, 10 Jul 2026 10:22:05 +0530 Subject: [PATCH 01/11] feat-016: graduate FA-006 -> grounded doc generation spec (accepted) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Graduate FA-006 (grounded documentation generation) into a feat-016 spec targeting 0.7.0. Scope: full Phase 1 (all four descriptive doc types — ai-context, architecture, component, design) with grounding + citations + draft/promote gate + dirty-aware `ckg docs update` + opt-in `ckg docs sync` flywheel. ADR drafts (Phase 3) and CI-hardened flywheel (Phase 2) explicitly deferred. Add the tracker row (feat-016, accepted, deps 006/007/010/012/004) and a 0.7.0 milestone entry. Design doc (design-016) is next. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/features/TRACKER.md | 3 + .../feat-016-grounded-doc-generation.md | 306 ++++++++++++++++++ 2 files changed, 309 insertions(+) create mode 100644 docs/features/feat-016-grounded-doc-generation.md diff --git a/docs/features/TRACKER.md b/docs/features/TRACKER.md index 4114f7b..ba0ba80 100644 --- a/docs/features/TRACKER.md +++ b/docs/features/TRACKER.md @@ -60,6 +60,7 @@ Legend: `proposed` → `accepted` → `in-progress` → `shipped` (also | [013](feat-013-agent-auto-configuration.md) | Agent auto-configuration & frictionless first run | 4 adoption | 0.6.2 | **shipped (0.6.2)** | 008 | — | ✅ | | [014](feat-014-watch-and-ci-indexing.md) | Watch mode (local) + CI-triggered central indexing | 4 adoption | 0.6.3 | **shipped (0.6.3)** | 004 | — | ✅ | | [015](feat-015-read-only-graph-query.md) | Read-only graph query (`ckg query --graph` / `ckg_query`) | 1 serve | 0.6.4 | **shipped (0.6.4)** | 001,003,008 | — | ✅ | +| [016](feat-016-grounded-doc-generation.md) | Grounded documentation generation (`ckg docs …`) | 3 diff | 0.7.0 | **accepted** | 006,007,010,012,004 | — | ✅ | --- @@ -143,6 +144,8 @@ ride alongside, not on, this chain. | **0.6** ✅ RELEASED | **Workspace build** — the build/setup side of org-central knowledge | ENH-021, ENH-022, ENH-023, ENH-024, ENH-026 | Stand up a multi-repo CKG from one `workspace.yaml` + one config + `ckg build --workspace` (members local or git URL); fail-fast `ckg doctor`; config/CLI-controlled logging/tracing | | **0.6.2** ✅ RELEASED | **Adoption** — shrink "installed" → "an agent is querying my repo" | feat-013 (FA-002 + FA-001 P1) | `ckg setup` auto-writes the agent's MCP config (Claude Code first) + optional `--hooks` nudges toward the graph tools; `uvx`/`pipx` zero-install trial blessed | | **0.6.3** ✅ RELEASED | **Freshness / ops** — keep the graph current without remembering to re-index | feat-014 (FA-005) | `ckg watch` re-indexes the local working copy on a conditional trigger (commit/idle/save, structural-only by default), refusing central/read-only stores; `ckg ci init` scaffolds a single-writer CI workflow that refreshes the central index on merge | +| **0.6.4** ✅ RELEASED | **Query** — an escape hatch past the fixed typed verbs | feat-015 (FA-003) | Read-only Cypher-subset query surface (`ckg query --graph` / `ckg_query`), validated AST → compiled to native Cypher (Kuzu/Neo4j) or interpreted over the GraphStore ABC (SurrealDB); identical rows across backends, bounded + read-only, capability-gated MCP tool | +| **0.7.0** 🔨 in progress | **Grounded docs** — turn the graph outward into the standard dev docs | feat-016 (FA-006) | `ckg docs generate/update/promote/sync` produces AI-context + architecture + component + design docs as a cited projection of the graph; every claim traces to a real symbol/edge; drafts land under `output_root` behind a human promote gate; dirty-aware `update` reuses feat-004; opt-in flywheel | --- diff --git a/docs/features/feat-016-grounded-doc-generation.md b/docs/features/feat-016-grounded-doc-generation.md new file mode 100644 index 0000000..70be779 --- /dev/null +++ b/docs/features/feat-016-grounded-doc-generation.md @@ -0,0 +1,306 @@ +# feat-016: Grounded documentation generation (`ckg docs …`) + +## Metadata + +| Field | Value | +|---|---| +| **ID** | feat-016 | +| **Title** | Grounded documentation generation — docs as a cited projection of the graph | +| **Status** | accepted | +| **Target version** | 0.7.0 | +| **Layer** | 3 differentiator (framework layer) — turns the graph *outward* into the standard dev docs | +| **Area** | new `agentforge_graph.docgen` (framework layer; imports `agentforge` for LLM calls) · CLI (`ckg docs …`) | +| **Depends on** | feat-006 (retrieval), feat-007 (repo map), feat-010 (doc/ADR ingestion + GOVERNS/DESCRIBES), feat-012 (LLM enrichment + budget rails), feat-004 (DirtySet staleness) | +| **Graduated from** | [FA-006](../feature-analysis/FA-006-grounded-documentation-generation.md) | +| **Relates to** | feat-013 (FA-002 — the AI-context file this generates is what `ckg setup` wires up), feat-014 (FA-005 — CI regeneration), KL-001 (LLM hallucination) | + +--- + +## 1. Why this feature + +The CKG already contains the raw material that documentation *is* — typed +structure, framework semantics (routes/ORM/DI), module/repo summaries, +design-pattern tags, existing ADRs/docstrings, centrality, and history. Yet +teams still hand-write (and let rot) the standard development docs: the +AI-assistant context file, the architecture overview, per-component docs, and +design documents. + +This feature generates those docs as a **grounded projection of the graph.** +Not "an LLM writes a README from scratch" — instead, assemble a *cited* context +pack from graph facts and have the model **compose prose over facts it can +attribute to real symbols/edges**, with provenance. The result is documentation +that is **verifiable, regenerable, and linked back to the code it describes** — +and, opt-in, can be **fed back into the graph** to make the codebase +progressively more self-documenting. + +This is the natural capstone on enrichment (feat-012) and doc ingestion +(feat-010): we already summarize and ingest docs; this turns the graph outward +into the artifacts developers and their agents need to reason well. + +## 2. Why it ships in the engine + +- **Grounding is only possible from inside the engine.** The value over a + generic doc-bot is that every claim cites a real graph fact with provenance + (ADR-0004). That requires the graph + retrieval + repo map, which only the + engine has. +- **The flywheel closes a loop the engine already half-owns.** feat-010 ingests + + embeds + links docs; this produces the docs that feed it. Keeping both + in-engine makes the round-trip coherent (and lets us prevent the echo chamber + — §8). +- **Staleness must reuse one mechanism.** Generated docs go stale exactly like + embeddings/summaries do; they ride the same feat-004 `DirtySet`, not a + parallel staleness story. +- **Layering (ADR-0001):** `docgen` is the **framework layer** — it composes + LLM calls over the deterministic engine's retrieve/repomap/store plus the + feat-012 budget rails. The deterministic engine stays framework-free; only + `docgen` imports `agentforge`. + +## 3. How consumers benefit + +- **Developer:** `ckg docs generate` produces an architecture overview, + per-component docs, and design docs grounded in the real code — and keeps them + fresh (`ckg docs update` regenerates only what changed). +- **AI assistant:** the generated CLAUDE.md/AGENTS.md orients the agent to the + codebase automatically (and feat-013 `ckg setup` wires it in). Better context + → better designs from the agent. +- **The graph itself:** once a developer opts to sync accepted docs back in + (§10), retrieval and future generations get richer, compounding context. + +## 4. Feature specifications + +### 4.1 User-facing experience + +```bash +# Generate — writes DRAFTS to a marked location; never overwrites human docs +ckg docs generate --type ai-context # CLAUDE.md / AGENTS.md draft +ckg docs generate --type architecture # system overview +ckg docs generate --type component --scope src/pkg # per-module doc(s) +ckg docs generate --type design --scope # "how it works + why" +ckg docs generate --all # every configured type + +# Keep fresh — regenerate only docs whose underlying code changed (feat-004 DirtySet) +ckg docs update + +# Review workflow +ckg docs list # generated docs + their synced-commit + staleness +ckg docs diff # what changed vs the last generated version +ckg docs promote # mark a reviewed draft as accepted (human gate) + +# Opt-in flywheel — re-ingest+embed ACCEPTED generated docs into the graph +ckg docs sync # explicit; honors docgen.round_trip (default off) +``` + +ADR drafts get a ratification-flavored flow, **deferred to Phase 3** (§11) — +out of scope for feat-016 / 0.7.0. + +### 4.2 Public API / contract + +```python +class DocTarget: # what to document + type: DocType # ai_context | architecture | component | design + scope: str | None # repo | package/path | subsystem | symbol id + +class DocArtifact: + type: DocType + path: str # under the generated-docs root + synced_commit: str # the indexed commit this was generated from + citations: list[SymbolRef] # every claim traces to graph facts + provenance: str # always "llm" / generated + status: str # draft | accepted + stale: bool # underlying symbols changed since synced_commit + +class DocGenerator: + async def generate(self, target: DocTarget) -> DocArtifact + async def update(self) -> list[DocArtifact] # regenerate dirty docs only + async def sync(self) -> int # re-ingest+embed ACCEPTED docs (opt-in) +``` + +- **Generated docs are `source: llm` and `status: draft`** until a human + promotes them — never silently authoritative. +- **Citations are mandatory:** a generated section that cannot cite a graph fact + is flagged, not emitted as confident prose. +- **Write surface is CLI/API only.** Generation mutates the working tree, so it + is *not* an auto-invoked, agent-facing MCP tool (preserves feat-008's + read-only tool discipline). A future opt-in write tool is out of scope here. + +### 4.3 Internal mechanics + +Per doc target: + +1. **Assemble a grounded context pack.** Run a doc-type-specific *recipe* over + the graph: `architecture` pulls layers + top-PageRank nodes + framework + topology (routes/models/services) + repo summary; `component` pulls a + module's CONTAINS/IMPORTS/public-API + its routes/models + summary; `design` + pulls a subsystem's call graph + pattern tags + relevant ADRs/docstrings. + Retrieval (feat-006) fills semantic gaps. `ai-context` pulls the repo map + + layer topology + entry points + conventions surfaced from existing docs. +2. **Ground preferentially on high-provenance facts.** Prefer parsed/resolved + code facts over `llm`-sourced chunks (anti-echo-chamber, §8). Carry each + fact's `SymbolRef` so the renderer can cite it. +3. **Render via a doc-type template.** A template defines the section skeleton; + the LLM fills sections *from the cited pack only*, attributing claims. + Templates keep output consistent and reviewable. +4. **Emit a draft artifact** to the generated-docs root with a "generated by + CKG, synced @, do not edit by hand" header, inline citations (symbol + links), `status: draft`, and a provenance stamp. +5. **Register staleness.** The doc's source symbols are recorded; when they + change, feat-004 `DirtySet` marks the doc dirty → `ckg docs update` + regenerates only those. +6. **(Opt-in) sync.** `ckg docs sync` feeds **accepted** docs through feat-010 + ingestion → embedded (feat-005) → DESCRIBES/GOVERNS edges, tagged as + generated so they never masquerade as ground truth. + +Budget + idempotency reuse feat-012 rails: per-run USD cap, resumable on trip, +clear-before-rewrite so regeneration produces no duplicate artifacts. + +### 4.4 Module packaging + +`agentforge_graph.docgen` — **framework layer** (imports `agentforge` for LLM +calls; ADR-0001). Built on the deterministic engine's retrieve/repomap/store +plus the enrich budget rails. `ckg docs …` console subcommands. Ships in the +base install; the LLM provider is provider-selected exactly as feat-012 already +is (fake/scripted for CI; live provider env-gated). + +### 4.5 Configuration + +```yaml +docgen: + output_root: docs/_generated # drafts land here; human docs untouched + types: [ai-context, architecture, component, design] # Phase-1 set (opt-in per type) + ai_context_targets: [CLAUDE.md, AGENTS.md] + component_granularity: package # package (default) | file | hybrid + hybrid_min_symbols: 20 # hybrid: drill into files above this size + require_citations: true # sections without a citable fact are flagged + round_trip: off # opt-in flywheel; `ckg docs sync` honors this + promote_required: true # docs are drafts until `ckg docs promote` + budget_usd: 5.0 # per-run cap (feat-012 BudgetPolicy) + regenerate_on_ci: false # feat-014 CI can flip this on (commit the diff in a PR) +``` + +Read via the engine's framework-free config path (ADR-0001): `app.docgen.*` or a +standalone `ckg.yaml` `docgen:` block. (The **engine-side** blocks stay +`agentforge`-free; `docgen` itself is framework layer and may read config via +the framework, but reuses the same `_Block` discovery for consistency.) + +## 5. Plug-and-play & upgrade story + +- New doc types register as **recipe + template pairs** (data, not core + rewrites) — the same extensibility stance as framework packs / providers. +- Generated docs carry their `synced_commit`, so an upgrade that changes a + template re-generates cleanly and the diff is reviewable. +- `round_trip: off` by default means adopting the flywheel is a deliberate, + reversible choice. + +## 6. Cross-language parity + +Generation is graph-driven, so it works for any indexed language. Output quality +tracks how richly that language is modeled (a language with framework packs gets +routes/models in its component docs; one without still gets structure + +summaries). + +## 7. Test strategy + +- **Grounding/citation test (load-bearing):** every emitted section maps to ≥1 + real `SymbolRef`; an invented claim with no backing fact is flagged, not + published. Fixture repo with a known graph. +- **No-overwrite test:** generation only ever writes under `output_root`; + human-authored docs are byte-unchanged. +- **Staleness test:** edit a module → its component doc is marked dirty → + `ckg docs update` regenerates *that* doc and not others (feat-004 reuse). +- **Draft/promote gate test:** generated docs are `status: draft`; `sync` + refuses to round-trip un-promoted docs; `promote` flips status. +- **Anti-echo-chamber test:** with round-trip on, a second generation still + grounds on code facts and tags generated docs distinctly (a generated doc is + never cited as ground truth for a code claim). +- **Determinism/CI:** scripted-LLM fakes (feat-012 pattern) render templates + with no model calls/creds, so the suite is hermetic; a live, env-gated test + exercises a real model end-to-end. +- **Budget test:** a capped run trips the breaker and resumes idempotently (no + duplicate/partial docs). + +## 8. Risks & open questions + +| Risk / Question | Mitigation / Decision | +|---|---| +| **Hallucination** (KL-001 amplified) | Ground on cited facts; `require_citations`; everything `source: llm` + `draft`; review gate before authoritative | +| **Doc drift / staleness** | `synced_commit` stamp + feat-004 dirty-tracking + `stale` flag + `ckg docs update` | +| **Echo chamber / model collapse** (round-trip) | Ground preferentially on high-provenance code facts; tag generated docs distinctly; never cite generated prose as ground truth; round-trip is opt-in | +| **Overwriting human docs** | Drafts land only under `docgen.output_root`; promotion is explicit | +| **Cost** | feat-012 budget rails: per-run cap, resumable, idempotent; `update` touches only dirty docs | +| **"Generate everything" scope creep** | Phased (§11); feat-016 is the four descriptive types only; ADR drafts + flywheel-hardening are later phases | +| Open: per-component doc granularity | Default per-package; `--scope` allows finer; `component_granularity` knob. Validate on a real repo during implementation | +| Open: house template style | Seed templates from the repo's own doc shapes; confirm during implementation | + +## 9. Out of scope + +- **ADR draft generation + ratification** — Phase 3 (§11); its own surface. +- **The opt-in flywheel hardening + CI regeneration** — Phase 2 (§11). feat-016 + ships `ckg docs sync` as the opt-in mechanism with `round_trip: off` default, + but the *proven anti-echo-chamber CI loop* is a later phase. +- An auto-invoked, agent-facing *write* MCP tool (generation stays CLI/API; + feat-008 tools stay read-only). +- Translating/localizing docs; publishing to an external docs site. +- Generating *test* code or code fixes (this feature documents; it does not + write product code). +- Fabricating ADR rationale (we reconstruct candidates for humans — never assert + a decision the team didn't make). + +## 10. Design notes — resolved decisions + +**A. Round-trip is OPT-IN.** Generated docs do **not** auto-feed the graph. +`round_trip: off` by default; `ckg docs sync` re-ingests + embeds **accepted** +docs only, tagged as generated. The flywheel is powerful but the echo-chamber +risk is real — make enabling it a deliberate, reversible choice. + +**B. feat-016 ships all four descriptive types:** **AI-context +(CLAUDE.md/AGENTS.md), architecture overview, component docs, AND design +documents.** Design docs carry slightly higher synthesis/hallucination risk, so +they lean hardest on `require_citations` and the review gate — but they're in +scope. ADR drafts remain Phase 3. + +**C. Review gate + draft status.** Generated docs land as clearly-marked +**drafts** under `output_root`; a human promotes them (`ckg docs promote`), or +in CI they're committed via a **PR for review** (feat-014), never auto-published +in place. Nothing generated becomes authoritative without a human in the loop. +`promote_required: true` by default. + +**D. Documentation is a projection of the graph** — cited and provenance- +stamped, kept fresh by the same dirty-tracking as embeddings, and (opt-in) fed +back to make the codebase self-documenting. This is the thesis; the design doc +(design-016) resolves the file layout, recipe/template seam, citation model, and +chunk plan. + +## 11. Phasing + +feat-016 delivers **Phase 1** in full for 0.7.0: + +1. **Phase 1 — descriptive docs (feat-016 / 0.7.0):** AI-context, architecture + overview, component docs, design docs. Grounding + citations + draft/review + gate + dirty-aware `update` + opt-in `sync`. Delivers "auto-update my AI + assistance" immediately. +2. **Phase 2 — the flywheel, hardened (later):** proven anti-echo-chamber + grounding + CI regeneration (feat-014) that opens a docs PR on merge-to-main. +3. **Phase 3 — ADR drafts + ratification (later):** decision reconstruction → + draft ADRs → human ratify → GOVERNS linkage (feat-010). + +**Component-doc granularity is configurable, opt-in first.** Component docs only +generate if `component` is in `docgen.types`. *How* they generate is a config +knob (`component_granularity`: `package` default | `file` | `hybrid`), with a +per-run `--scope` override. + +## Implementation status + +_Not started. Design doc: `design-016-grounded-doc-generation.md` (next)._ + +## 12. References + +- [FA-006](../feature-analysis/FA-006-grounded-documentation-generation.md) — source analysis. +- [feat-006](feat-006-hybrid-retrieval.md) — retrieval that fills semantic gaps in the context pack. +- [feat-007](feat-007-repo-map-summarization.md) — repo map / centrality feeding the architecture recipe. +- [feat-010](feat-010-adr-and-docs-ingestion.md) — doc ingestion + GOVERNS/DESCRIBES the flywheel reuses. +- [feat-012](feat-012-llm-enrichment.md) — LLM enrichment + budget rails this composes over. +- [feat-004](feat-004-incremental-indexing.md) — DirtySet staleness the docs ride. +- [feat-013](feat-013-agent-auto-configuration.md) — `ckg setup` that wires the generated AI-context file. +- [feat-014](feat-014-watch-and-ci-indexing.md) — CI regeneration path (Phase 2). +- ADR-0001 (framework-free engine core), ADR-0004 (provenance). +- design-016 (the *how*: file layout, recipe/template seam, citation model, chunk plan) — written next. From ff753e3cdac01c23198950f723d03748757018e8 Mon Sep 17 00:00:00 2001 From: Khemchand Joshi Date: Fri, 10 Jul 2026 12:30:41 +0530 Subject: [PATCH 02/11] =?UTF-8?q?feat-016:=20design=20doc=20(accepted)=20?= =?UTF-8?q?=E2=80=94=20grounded=20doc=20generation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit design-016: how docgen works — docs as a grounded projection of the graph. Approved decisions baked in: (1) writer is a multi-turn agentforge.Agent loop over a read-only, provenance-floored ckg toolset (grounding enforced at the tool boundary, not a fixed pack); (2) citations are a structured footnote block verified against the captured ProvenanceSet; (3) ai-context is draft-only to output_root; (4) a doc_lang_version stamps the template/citation contract. Reuse over reinvention: agentforge.Agent (wraps BudgetPolicy) + the feat-008 Tool instances + KnowledgeIngestor (feat-010) for opt-in sync + DirtySet "docs" consumer for staleness. No locked-kinds change (ADR-0005) — docs are files + a sidecar manifest. 8-chunk plan. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/design/README.md | 1 + .../design-016-grounded-doc-generation.md | 470 ++++++++++++++++++ 2 files changed, 471 insertions(+) create mode 100644 docs/design/design-016-grounded-doc-generation.md diff --git a/docs/design/README.md b/docs/design/README.md index 8030d60..5872fd0 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -32,5 +32,6 @@ history. Supersede with a new doc; never delete. | [design-001-core-contracts-module](design-001-core-contracts-module.md) | feat-001 | accepted | | [design-009-temporal-evolution-layer](design-009-temporal-evolution-layer.md) | feat-009 | accepted | | [design-015-read-only-graph-query](design-015-read-only-graph-query.md) | feat-015 | accepted | +| [design-016-grounded-doc-generation](design-016-grounded-doc-generation.md) | feat-016 | accepted | _(Add a row per feature as its design is written.)_ diff --git a/docs/design/design-016-grounded-doc-generation.md b/docs/design/design-016-grounded-doc-generation.md new file mode 100644 index 0000000..e487b21 --- /dev/null +++ b/docs/design/design-016-grounded-doc-generation.md @@ -0,0 +1,470 @@ +# design-016: Grounded documentation generation (`ckg docs …`) + +Mirrors [feat-016](../features/feat-016-grounded-doc-generation.md). The *how*: +file layout, exact types, resolved decisions, chunk plan. Grounded in a code +map of the existing enrich / retrieve / repomap / knowledge / incremental / +serve surfaces (verified against source). + +| Field | Value | +|---|---| +| **Status** | accepted | +| **Target** | 0.7.0 | +| **Scope** | Phase 1 — four descriptive doc types (ai-context, architecture, component, design) + grounded generation + citations + draft/promote gate + dirty-aware `update` + opt-in `sync`. ADR drafts (Phase 3) and CI-hardened flywheel (Phase 2) deferred. | + +## Resolved decisions (approved at design review) + +1. **Writer = multi-turn `agentforge.Agent` loop** over a **read-only, grounded + ckg toolset**, *not* a single-shot `ClaudeClient.invoke`. The recipe seeds the + context; the Agent expands it by calling the graph tools. Grounding is enforced + at the **tool boundary** (§Grounding). +2. **Citations = a structured footnote block** (per-section footnote markers + + a bottom References block), *not* inline `[F#]` links. +3. **`ai-context` is draft-only** to `output_root` (promote + `ckg setup` place + the accepted file); no `--in-place` in Phase 1. +4. **`doc_lang_version`** is stamped (manifest + `ckg_status`) so a template/ + citation-contract change is detectable, like feat-015's `query_lang_version`. + +--- + +## The one idea + +**Documentation is a grounded projection of the graph — the model may only cite +facts a read-only graph tool actually returned.** A doc-type **recipe** assembles +a **seed pack** of the highest-value facts, then an `agentforge.Agent` composes +the doc while **expanding that pack through a read-only, provenance-floored ckg +toolset** (the same `Tool` instances feat-008 serves). We **capture every +`SymbolRef` the tools return** (seed ∪ tool results = the *provenance set*), and +the model attributes claims via a **footnote block**. Post-render we **verify +every footnote resolves to a fact in the provenance set** and flag any section +that cites nothing. The draft lands under `output_root` with the git commit it +was built from + the provenance set as its staleness key, riding the **same +feat-004 dirty-tracking** as embeddings/summaries. + +``` +DocTarget ─recipe─▶ seed GroundedPack ─┐ + (graph │ ┌──────── read-only ckg tools (min_provenance ≥ parsed) ────────┐ + queries) ▼ │ ckg_search · ckg_symbol · ckg_neighbors · ckg_impact · │ + agentforge.Agent ◀┤ ckg_repo_map · ckg_decisions → each result carries SymbolRef │ + (budget_usd, └───────────────────────────────────────────────────────────────┘ + max_iterations) │ capture transcript → provenance set = seed ∪ results + │ │ + ▼ ▼ + draft.md + footnote block ─verify─▶ every footnote ∈ provenance set? (else reject) + │ every section has ≥1 footnote? (require_citations) + ▼ + emit under output_root + manifest entry {status:draft, synced_commit, + source_ids = provenance set, footnotes} + │ + feat-004 DirtySet("docs"): source_ids ∩ dirty ⇒ `ckg docs update` regenerates that doc + + (opt-in) `ckg docs sync`: ACCEPTED docs ─KnowledgeIngestor(feat-010)─▶ DocChunk + DESCRIBES, tagged Provenance.llm +``` + +**Why the tool boundary keeps it grounded.** The Agent has no way to learn a fact +except by calling a ckg tool, and those tools return only real graph rows, each +with a `SymbolRef`, filtered at `min_provenance ≥ parsed`. So the model cannot +invent a symbol — it can only cite one the graph actually produced, and it can +never cite an llm-sourced fact as ground truth (anti-echo-chamber, §Grounding). +The **honest limit**: the verifier proves a footnote points at a *real retrieved +fact*, not that the fact is the *semantically correct* support — machine-checking +relevance is out of reach, so the **human promote gate stays load-bearing**. +(A single-shot-over-fixed-pack writer would be structurally tighter; we chose the +richer agent-loop and make the tool boundary + verifier + promote gate carry it.) + +## Guiding constraints + +- **Reuse the rails; don't reinvent (project CLAUDE.md).** `docgen` composes over + the engine using framework rails that already exist: `agentforge.Agent` (which + wraps `BudgetPolicy` internally, `agent.py:156`) for the budgeted multi-turn + loop, and the **feat-008 `Tool` instances** (`serve/tools.py`) for the grounded + toolset — the same read-only tools an MCP client gets. It does **not** rebuild a + provider/budget/tool stack. +- **Layering.** `docgen` is a **framework-layer** module (imports `agentforge`), + like `serve/`. It *consumes* the deterministic engine (`CodeGraph`, `Retriever`, + `RepoMap`, `KnowledgeIngestor`) and adds no coupling back into it. The engine + core (`core`/`ingest`/`store`/`retrieve`) is untouched (ADR-0001). +- **No locked-vocabulary change (ADR-0005).** Phase 1 adds **no new + `NodeKind`/`EdgeKind`.** Generated docs are markdown **files** under + `output_root` + a **sidecar manifest**; opt-in `sync` reuses feat-010's existing + `DocChunk` + `DESCRIBES`. +- **No-overwrite is an invariant.** Every generator writes **only** under + `docgen.output_root`. Human files (incl. a real `CLAUDE.md`/`AGENTS.md` at repo + root) are never touched by generation. +- **Read-only tools only.** The Agent's toolset is the feat-008 read-only set; + the Agent is never given a write tool, so generation can query but not mutate the + graph (it only writes doc files). `sync` (which *does* write the graph) is a + separate, explicit, non-agentic step. + +### Extensibility principles (no effort-driven workarounds) + +- **Add a doc type** → new `Recipe` subclass + a template + one registry entry. No + edits to the generator, the Agent runner, the citation verifier, or other + recipes. +- **Add/adjust the grounded toolset** → change the tool-selection list in one + place; every recipe inherits it. Tools are the feat-008 instances — no bespoke + docgen tools. +- **Grounding is a required contract.** Every emitted section must resolve ≥1 + footnote to a fact in the provenance set (`require_citations`), enforced by a + shared verifier + a load-bearing test. +- **Staleness reuses one mechanism.** Generated docs are a `DirtySet` consumer + (`"docs"`) like `embeddings`/`patterns`/`summaries`. + +## Package layout + +``` +src/agentforge_graph/docgen/ + __init__.py # exports: DocGenerator, DocTarget, DocArtifact, DocType, + # GroundedPack, SymbolRef, DocgenError (+ subclasses) + types.py # frozen dataclasses: DocType, DocTarget, DocArtifact, + # SymbolRef, GroundedFact, GroundedPack, Footnote, ProvenanceSet + errors.py # DocgenError -> UngroundedError | BadCitationError | DocDisabled | PromoteRequired + manifest.py # sidecar .ckg-docs.json: per-doc status/synced_commit/ + # source_ids/footnotes/doc_lang_version; read/write; list/diff/promote + recipes/ + base.py # Recipe ABC: async seed(cg, target) -> GroundedPack; RECIPES registry + ai_context.py # repo map + layer topology + entry points + conventions + architecture.py # layers + top-PageRank + framework topology + repo summary + component.py # a module's CONTAINS/IMPORTS/public-API/routes/models/summary + design.py # a subsystem's call graph + pattern tags + relevant ADRs/docstrings + templates/ + base.py # Template: section skeleton + citation/footnote instructions (data) + *.md.tmpl # one section-skeleton per doc type (house style, reviewable) + toolset.py # grounded_tools(cg, min_provenance=PARSED) -> list[Tool] (feat-008 instances, read-only) + runner.py # AgentDocRunner: builds agentforge.Agent(tools, budget_usd, max_iterations), + # runs the compose loop, CAPTURES the tool transcript -> ProvenanceSet. + # ScriptedDocModel fake provider for hermetic CI. + citations.py # footnote parse + verify against ProvenanceSet; require_citations gate + generator.py # DocGenerator: generate() / update() / list() / diff() / promote() / sync() + staleness.py # map a doc's source_ids <-> DirtySet("docs") + +# extended (not new) files: + ingest/incremental/dirty.py # + "docs" in DEFAULT_CONSUMERS + ingest/codegraph.py # + docs_generate/docs_update/docs_list/docs_diff/docs_promote/docs_sync + config.py # + DocGenConfig(_Block, KEY="docgen") + cli.py # + `docs` nested subparser (generate/update/list/diff/promote/sync) + knowledge/ingest.py # sync path: allow Provenance.llm override for generated docs +``` + +## Key types (`types.py`) + +```python +class DocType(StrEnum): + AI_CONTEXT = "ai-context"; ARCHITECTURE = "architecture" + COMPONENT = "component"; DESIGN = "design" + +@dataclass(frozen=True) +class DocTarget: + type: DocType + scope: str | None = None # repo | package/path | subsystem | symbol id + +@dataclass(frozen=True) +class SymbolRef: # a citable pointer into the graph + id: str # SymbolID string (core/symbols.py) + kind: NodeKind + name: str + path: str | None + span: tuple[int, int] | None + +@dataclass(frozen=True) +class GroundedFact: + text: str # the fact, phrased for the model + ref: SymbolRef + source: Source # only >= parsed is ground truth + +@dataclass(frozen=True) +class GroundedPack: # the SEED — the Agent expands it via tools + target: DocTarget + facts: tuple[GroundedFact, ...] + notes: tuple[str, ...] = () # non-citable framing + +@dataclass(frozen=True) +class ProvenanceSet: # everything the tools surfaced this run + refs: dict[str, SymbolRef] # keyed by SymbolID; seed refs ∪ tool-result refs + def contains(self, symbol_id: str) -> bool: ... + +@dataclass(frozen=True) +class Footnote: + marker: str # e.g. "f3" + ref: SymbolRef # must resolve into the ProvenanceSet + +@dataclass(frozen=True) +class DocArtifact: + type: DocType + path: str # under output_root + status: str # "draft" | "accepted" + synced_commit: str # git commit the run was built from + doc_lang_version: str # template/citation contract version + source_ids: tuple[str, ...] # ProvenanceSet keys — the staleness key + footnotes: tuple[Footnote, ...] + stale: bool +``` + +`SymbolRef` is built from a `core.models.Node` or a `retrieve.pack.ContextItem` +(both already carry `id`/`kind`/`name`/`path`/`span`/`provenance`). + +## Recipes — the seed (`recipes/`) + +A recipe turns a `DocTarget` into a **seed** `GroundedPack` — the facts worth +handing the Agent up front so it does not start cold. It is pure graph +assembly, **no LLM**, unit-testable against a fixture graph. + +```python +class Recipe(ABC): + doc_type: ClassVar[DocType] + async def seed(self, cg: CodeGraph, target: DocTarget) -> GroundedPack: ... + +RECIPES: dict[DocType, Recipe] # registry; out-of-tree types via entry points later +``` + +| Recipe | Seeds from (existing APIs) | +|---|---| +| `architecture` | `cg.ranked_symbols(k)` / `cg.repo_map(...)`, `cg.services()`/`cg.routes()`/`cg.models()`, `cg.summaries(level="repo")` | +| `component` | graph adjacency (CONTAINS/IMPORTS/public API) for the scope, `cg.routes()/models()` filtered, `cg.summaries(level="file")` | +| `design` | `cg.retrieve(mode="context")` over the subsystem, `cg.tagged(pattern)`, `cg.decisions(...)`, docstrings via `DOC_CHUNK`/`DESCRIBES` | +| `ai-context` | `cg.repo_map(...)` + layer topology + entry points + conventions from ingested docs; targets `docgen.ai_context_targets` | + +Seed facts are requested at `min_provenance ≥ parsed` +(`Retriever.retrieve(..., min_provenance=Source.PARSED)` already filters +`Source.LLM`), so the seed carries no llm-sourced fact as ground truth. + +**Component granularity** (`docgen.component_granularity`): `package` (default), +`file`, or `hybrid` (package doc + drill-down for files above +`hybrid_min_symbols`); `--scope` overrides per run. + +## Grounded generation (`toolset.py`, `runner.py`) — the trust boundary + +**The toolset is the boundary.** `grounded_tools(cg)` returns the read-only +feat-008 `Tool` instances (`ckg_search`, `ckg_symbol`, `ckg_neighbors`, +`ckg_impact`, `ckg_repo_map`, `ckg_decisions`), each configured to ground at +`min_provenance ≥ parsed`. No write tool is ever included. Every tool result is +captured. + +```python +class AgentDocRunner: + def __init__(self, cg, cfg: DocGenConfig, model=None): ... # model=None -> real provider + async def compose(self, pack: GroundedPack, template: Template) -> tuple[str, ProvenanceSet]: + tools = grounded_tools(self.cg) + captured = _CaptureProxy(tools) # wraps each Tool: records returned SymbolRefs + agent = Agent(tools=captured, budget_usd=self.cfg.budget_usd, + max_iterations=self.cfg.max_iterations) # framework budget + loop bound + prompt = template.render(seed=pack) # seed facts + section skeleton + cite rules + text = await agent.run(prompt) + return text, captured.provenance_set(seed=pack) +``` + +- **Budget + loop bound** ride `agentforge.Agent` (wraps `BudgetPolicy`): a per-run + USD cap and `max_iterations` cap the tool-call sprawl. On budget trip the Agent + stops; the generator reports `budget_tripped` and leaves already-finished docs + intact (idempotent resume — the next run regenerates only the unfinished/dirty + ones). +- **`_CaptureProxy`** is the single place tool results are recorded, so the + provenance set is complete by construction — a claim can only be grounded if its + symbol passed through here. +- **Hermetic CI:** `ScriptedDocModel` is a fake framework provider that drives the + Agent loop deterministically (a fixed sequence of tool calls + a final doc body), + credential-free — the docgen analog of enrich's `ScriptedJudge`. All unit/ + integration tests use it; a live provider is env-gated. + +### Citations (`citations.py`) + +The template instructs: *attribute each claim with a footnote marker `[^fN]`; +define every marker in a `## References` block at the end as +`[^fN]: :`; if a section has no supporting fact, emit +its header with `` rather than inventing prose.* + +Verification after the run: + +1. Parse the References block → `Footnote`s; parse per-section markers. +2. **Every footnote's symbol must be in the `ProvenanceSet`** (seed ∪ captured + tool results). A footnote citing a symbol the tools never returned → + `BadCitationError` (the model fabricated a citation). +3. **Every section must carry ≥1 footnote marker.** A section with none → + ungrounded → `UngroundedError` when `require_citations: true` (default), else + emitted with the `UNGROUNDED` marker preserved. +4. Footnote definitions are rendered to human-facing links (path + span) in the + final artifact. + +This is the constructive-grounding stance (feat-015's "validate, don't sanitize" +applied to prose): the doc is trusted because every claim points at a real, +provenance-floored graph fact the tools produced — not because we caught +hallucinations after the fact. + +## Persistence & staleness (`manifest.py`, `staleness.py`) + +**Artifacts are files; metadata is a sidecar manifest.** Developers review, diff, +and commit markdown under `output_root`. Per-doc metadata lives in +`output_root/.ckg-docs.json` (mirrors the `.ckg/dirty.json` pattern): + +```jsonc +{ "docs/_generated/architecture.md": { + "type": "architecture", "status": "draft", + "synced_commit": "a1b2c3d", "doc_lang_version": "1.0", + "source_ids": ["scip:py …", …], + "footnotes": [{"marker":"f1","ref":{…}}, …] } } +``` + +- **`synced_commit`** = `CodeGraph._git_commit(repo_path)` at generation. +- **`source_ids`** = the `ProvenanceSet` keys — everything the run grounded on + (richer than a fixed pack: it captures whatever the Agent chose to read). +- **Staleness.** Add `"docs"` to `DirtySet.DEFAULT_CONSUMERS` + (`ingest/incremental/dirty.py:22`). `docs_update` mirrors `CodeGraph.summarize` + (`codegraph.py:864`): drain `dirty.dirty_for("docs")` → find manifest docs whose + `source_ids` intersect the dirty set → regenerate only those → + `dirty.mark_clean("docs", regenerated_ids)`. `stale` = `source_ids ∩ + dirty_for("docs") ≠ ∅` or `synced_commit != HEAD` or `doc_lang_version` bumped. +- **list / diff / promote** are pure manifest ops: `list` = docs + status + + staleness; `diff` = working file vs last generated bytes; `promote` flips + `status: draft → accepted` (the human gate; `promote_required: true`). + +## Sync flywheel (opt-in) & anti-echo-chamber (`generator.py` + `knowledge/ingest.py`) + +`ckg docs sync` re-ingests **accepted** docs into the graph — **opt-in** +(`round_trip: off` default; `sync` refuses when off) and **gated** (refuses +un-promoted docs → `PromoteRequired`). + +- **Reuse feat-010.** `sync` runs accepted docs through `KnowledgeIngestor` + (`knowledge/ingest.py:_ingest_docs`) → `DocChunk` + `DESCRIBES` + embedding. +- **One seam-touch:** `_ingest_docs` currently stamps `Provenance.parsed( + "doc-ingestor", commit)`; sync must stamp **`Provenance.llm("docgen@", 1.0, + commit)`** so generated docs are `Source.LLM`. This makes anti-echo-chamber real: + the grounded toolset floors at `≥ parsed`, so a synced generated doc can inform + semantic search but **can never be returned as a citable fact** for a future + generation. We add a provenance-source parameter (default unchanged), not a fork. +- **Refused on read-only stores** via `_refuse_write_if_read_only` (sync writes the + graph; `generate`/`update` only write files). + +## CLI (`cli.py`) — nested `docs` subparser + +Model: the `ci` command (`cli.py:1223`) — a subcommand with sub-subparsers. +Handlers are `async def _docs_*(args) -> int`. + +``` +ckg docs generate --type {ai-context|architecture|component|design} [--scope P] [--all] [--budget-usd N] [--format text|json] +ckg docs update +ckg docs list +ckg docs diff +ckg docs promote +ckg docs sync +``` + +Reuses `_add_repo_arg`, `_preflight_or_exit`, `_refuse_write_if_read_only` (sync), +and the `cli_format` `render_table`/`render_json` helpers. `DocgenError` → stderr +"why" + exit 2 (ENH-026 convention). + +## Facade (`ingest/codegraph.py`) + +Thin pass-throughs mirroring `enrich`/`summarize`, constructing a `DocGenerator` +from config (the `AgentDocRunner`, `RECIPES`, and the store/retriever/repomap it +already owns): + +```python +async def docs_generate(self, target, *, budget_usd=None) -> DocArtifact +async def docs_update(self) -> list[DocArtifact] +async def docs_list(self) -> list[DocArtifact] +async def docs_diff(self, path) -> str +async def docs_promote(self, path) -> DocArtifact +async def docs_sync(self) -> int +``` + +## Config (`config.py`) + +```python +class DocGenConfig(_Block): + KEY: ClassVar[str] = "docgen" + output_root: str = "docs/_generated" + types: list[str] = ["ai-context", "architecture", "component", "design"] + ai_context_targets: list[str] = ["CLAUDE.md", "AGENTS.md"] + component_granularity: str = "package" # package | file | hybrid + hybrid_min_symbols: int = 20 + require_citations: bool = True + round_trip: bool = False + promote_required: bool = True + budget_usd: float = 5.0 + max_iterations: int = 24 # Agent loop bound per doc + regenerate_on_ci: bool = False + # provider selection (reuses enrich builders / framework provider): + provider: str = "bedrock" # scripted | bedrock | anthropic + model: str = "us.anthropic.claude-haiku-4-5-20251001-v1:0" + region: str | None = None + assume_role_arn: str | None = None + base_url: str | None = None + api_key_env: str | None = None +``` + +Auto-discovered by `block_keys()`. `ckg_status` gains `docgen.{enabled, types, +doc_lang_version}`. + +## Test strategy / conformance + +- **Grounding/citation (load-bearing):** with a `ScriptedDocModel` over a fixture + graph — a footnote citing a symbol the tools never returned → `BadCitationError`; + a section with no footnote → `UngroundedError` under `require_citations`; a valid + run → every footnote resolves into the `ProvenanceSet` and rewrites to a link. +- **Tool-boundary capture:** the `ProvenanceSet` equals exactly the union of seed + refs + the SymbolRefs the scripted tool calls returned (no more, no less). +- **No-overwrite:** generation writes only under `output_root`; a repo-root + `CLAUDE.md` fixture is byte-unchanged after `generate --type ai-context`. +- **Staleness:** edit a module → its doc's `source_ids` intersect + `dirty_for("docs")` → `docs_update` regenerates that doc only; `mark_clean` + clears it; a `doc_lang_version` bump marks all docs stale. +- **Draft/promote gate:** docs are `status: draft`; `sync` refuses un-promoted + (`PromoteRequired`) and refuses when `round_trip: off`; `promote` flips status. +- **Anti-echo-chamber:** a synced generated doc is ingested `Source.LLM`; a later + grounded run's tools (floored at `≥ parsed`) never return it as a fact. +- **Budget:** a capped `ScriptedDocModel` run trips the Agent's `BudgetPolicy` and + stops idempotently — no duplicate/partial files; finished docs survive. +- **Determinism/CI:** entire suite on `provider: scripted` (`ScriptedDocModel`) — + no creds, no SDK import. One env-gated live test end-to-end. +- **Config + status:** `DocGenConfig.load` round-trips; `ckg_status` reports the + docgen block + `doc_lang_version`. + +## Chunk plan + +| # | Chunk | Lands | +|---|---|---| +| 1 | **Types + manifest + config + staleness seam** (`types.py`, `errors.py`, `manifest.py`, `DocGenConfig`, `"docs"` in `DEFAULT_CONSUMERS`, `staleness.py`) | Metadata + persistence + dirty-tracking core, LLM-free, fully unit-tested. | +| 2 | **Recipe seam + first seed recipe (`architecture`) + citation verifier** (`recipes/base.py` + registry, `recipes/architecture.py`, `citations.py`) | Deterministic graph→seed pack + the footnote/`ProvenanceSet` verifier, proven against a fixture (no LLM). | +| 3 | **Grounded toolset + Agent runner + `generate`** (`toolset.py`, `runner.py` with `ScriptedDocModel`, `templates/`, `generator.py` generate-path) | First end-to-end slice: `architecture` doc composed via a scripted Agent loop over captured read-only tools, citations verified, budget/iteration-bounded, idempotent write. **Heaviest chunk.** | +| 4 | **Remaining recipes + templates** (`ai_context.py`, `component.py`, `design.py` + templates; component granularity) | All four descriptive types, each with a grounding test. | +| 5 | **Dirty-aware `update` + `list`/`diff`/`promote`** (`generator.py` rest, facade methods) | Freshness loop (feat-004 reuse) + review surface. | +| 6 | **CLI** (`ckg docs …` nested subparser + format wiring + read-only guards) | Power-user + reviewer surface. | +| 7 | **Opt-in `sync` flywheel** (`generator.sync`, `_ingest_docs` provenance override, `round_trip`/promote gates, read-only refusal, CLI `docs sync`) | The graph round-trip, anti-echo-chamber-safe by provenance. | +| 8 | **Status + docs + polish** (`ckg_status` docgen block + `doc_lang_version`, guide 14, README, CHANGELOG `[Unreleased]`, feat-016 Implementation-status) | Ship polish. | + +Each chunk is a self-contained Conventional Commit; the gate (ruff/mypy/pytest/ +≥90%) passes per chunk. Chunks 1–3 carry the seams + first slice; 4 adds breadth +as pure additions; 5–8 are surfaces + the flywheel. + +## Extensibility seams (summary) + +| To add… | You touch | You do **not** touch | +|---|---|---| +| A doc type | 1 `Recipe` subclass + 1 template + 1 registry entry | generator, runner, citation verifier, other recipes | +| A grounded tool | the `grounded_tools()` list | recipes, runner, verifier | +| An LLM provider | reuse the `enrich`/framework provider registry | `docgen` (no provider branching) | +| A guardrail (e.g. per-doc token cap) | 1 field on `DocGenConfig` + `runner.py` | recipes, manifest | +| A CLI output format | the shared `cli_format` helper | every `docs` verb | + +## Alternatives considered + +| Option | Why not (or why chosen) | +|---|---| +| **Single-shot over a fixed pack** (writer sees only pre-assembled facts) | Structurally tighter grounding, but the recipe must anticipate every fact up front; poorer coverage on questions the model realizes it needs mid-write. **Rejected at review in favor of the agent-loop**, whose grounding is preserved at the tool boundary + verifier + promote gate. | +| **Multi-turn `agentforge.Agent` over read-only tools** | **Chosen.** Richer, self-directed fact-gathering; reuses the framework Agent + budget + the feat-008 toolset; grounding enforced because tools are the only fact source and are provenance-floored. | +| Persist docs as graph nodes (new `GeneratedDoc` kind) | Needs an ADR-0005 kinds change; the reviewed/committed artifact is a *file*. Files + sidecar manifest now; a graph kind later if the flywheel needs it. | +| Inline `[F#]` citations | Tighter per-claim locality, but review chose a **footnote block** (cleaner prose, References section a reviewer can scan). Verifier works the same against the `ProvenanceSet`. | +| Auto-sync generated docs into the graph | Echo-chamber risk. Round-trip is opt-in *and* provenance-gated (`Source.LLM`); synced docs never become citable ground truth. | +| Overwrite `CLAUDE.md`/`AGENTS.md` in place | Breaks no-overwrite. Drafts land under `output_root`; promotion + `ckg setup` wire the accepted file. | +| Separate staleness store for docs | Diverges from embeddings/summaries. Docs are one more `DirtySet` consumer. | + +## Risks + +| Risk | Mitigation | +|---|---| +| Agent cites a real-but-irrelevant fact (semantic mis-grounding) | Verifier proves footnotes are *real retrieved facts*; the **human promote gate** is load-bearing for relevance. Templates constrain sections to the seed's topic. | +| Agent wanders / runs up cost | `Agent(budget_usd, max_iterations)` bounds tokens *and* loop length; grounded toolset is read-only; on trip, finished docs survive, resume is idempotent. | +| Hallucinated citation | Tool-boundary capture: a footnote's symbol must be in the `ProvenanceSet`, else `BadCitationError`. The model can't cite a symbol the tools never returned. | +| Echo chamber (round-trip) | Opt-in + synced as `Source.LLM`; tools floor at `≥ parsed` so generated prose is never a citable fact. | +| Hermetic testing of an Agent loop is heavier than enrich's scripted judge | `ScriptedDocModel` drives a fixed tool-call + final-body sequence; the `_CaptureProxy` is provider-agnostic, so verification is tested without a live model. | +| Design docs over-synthesize | Highest-risk type leans hardest on `require_citations` + promote gate; the verifier is type-agnostic. | +| Provider/credential coupling in CI | `provider: scripted` default test path; live provider env-gated, as enrich tests are. | From e3c4d46e0d6437eb6927e7abf17de6a118a797fc Mon Sep 17 00:00:00 2001 From: Khemchand Joshi Date: Fri, 10 Jul 2026 12:38:17 +0530 Subject: [PATCH 03/11] feat-016 chunk 1: docgen types + manifest + config + staleness seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LLM-free metadata core for grounded doc generation: - docgen/types.py — DocType, DocTarget, SymbolRef, GroundedFact, GroundedPack, ProvenanceSet (the citation trust boundary + staleness key), Footnote, DocArtifact; DOC_LANG_VERSION="1.0". - docgen/errors.py — DocgenError hierarchy (Ungrounded/BadCitation/ DocDisabled/PromoteRequired). - docgen/manifest.py — sidecar output_root/.ckg-docs.json (mirrors the .ckg/dirty.json pattern): typed round-trip, upsert/get/all/remove/ promote, content_sha drift key. `stale` is computed, never serialized. - docgen/staleness.py — pure join between a doc's source_ids and the DirtySet "docs" cursor; is_stale / stale_docs. - config DocGenConfig(_Block, KEY="docgen") — auto-discovered; validates component_granularity. - DirtySet: "docs" added to DEFAULT_CONSUMERS (generated docs go stale like summaries; indexing already fans changed symbols to it). Framework-adjacent but this chunk imports only core (no agentforge yet). Full gate: 1114 passed / 69 skipped / 94.43% cov; ruff + mypy strict clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/agentforge_graph/config.py | 44 +++++ src/agentforge_graph/docgen/__init__.py | 66 ++++++++ src/agentforge_graph/docgen/errors.py | 32 ++++ src/agentforge_graph/docgen/manifest.py | 155 ++++++++++++++++++ src/agentforge_graph/docgen/staleness.py | 36 ++++ src/agentforge_graph/docgen/types.py | 142 ++++++++++++++++ .../ingest/incremental/dirty.py | 5 +- tests/docgen/__init__.py | 0 tests/docgen/test_config.py | 52 ++++++ tests/docgen/test_manifest.py | 95 +++++++++++ tests/docgen/test_staleness.py | 67 ++++++++ tests/docgen/test_types.py | 68 ++++++++ 12 files changed, 760 insertions(+), 2 deletions(-) create mode 100644 src/agentforge_graph/docgen/__init__.py create mode 100644 src/agentforge_graph/docgen/errors.py create mode 100644 src/agentforge_graph/docgen/manifest.py create mode 100644 src/agentforge_graph/docgen/staleness.py create mode 100644 src/agentforge_graph/docgen/types.py create mode 100644 tests/docgen/__init__.py create mode 100644 tests/docgen/test_config.py create mode 100644 tests/docgen/test_manifest.py create mode 100644 tests/docgen/test_staleness.py create mode 100644 tests/docgen/test_types.py diff --git a/src/agentforge_graph/config.py b/src/agentforge_graph/config.py index 395cf27..086ad87 100644 --- a/src/agentforge_graph/config.py +++ b/src/agentforge_graph/config.py @@ -334,6 +334,50 @@ def to_settings(self, limit: int | None = None) -> Any: ) +class DocGenConfig(_Block): + """The ``docgen:`` block of ckg.yaml (feat-016 — grounded doc generation). + + Drives ``ckg docs …``. ``docgen`` is the ADR-0001 *framework* layer (like + ``serve``/``setup`` — it runs an ``agentforge.Agent`` loop), but this block is + still read framework-free via the shared ``_read_block`` reader. Generated + drafts land under ``output_root`` and never overwrite human docs; a doc is a + draft until ``ckg docs promote`` (``promote_required``).""" + + KEY: ClassVar[str] = "docgen" + output_root: str = "docs/_generated" + types: list[str] = Field( + default_factory=lambda: ["ai-context", "architecture", "component", "design"] + ) + ai_context_targets: list[str] = Field(default_factory=lambda: ["CLAUDE.md", "AGENTS.md"]) + component_granularity: str = "package" # package | file | hybrid + hybrid_min_symbols: int = 20 + require_citations: bool = True # a section with no citable fact fails generation + round_trip: bool = False # opt-in flywheel; `ckg docs sync` honors this + promote_required: bool = True # docs are drafts until `ckg docs promote` + budget_usd: float = 5.0 # per-run cap (agentforge.Agent BudgetPolicy) + max_iterations: int = 24 # Agent tool-call loop bound per doc + regenerate_on_ci: bool = False # feat-014 CI can flip this on (commit the diff in a PR) + # Provider selection — reuses the enrich builders / framework provider. + provider: str = "bedrock" # scripted | bedrock | anthropic + model: str = "us.anthropic.claude-haiku-4-5-20251001-v1:0" + region: str | None = None + assume_role_arn: str | None = None + base_url: str | None = None + api_key_env: str | None = None + + @field_validator("component_granularity") + @classmethod + def _check_granularity(cls, v: str) -> str: + allowed = {"package", "file", "hybrid"} + if v not in allowed: + raise ValueError(f"component_granularity must be one of {sorted(allowed)}, got {v!r}") + return v + + def enabled_types(self) -> list[str]: + """The configured doc types, order preserved.""" + return list(self.types) + + class SetupConfig(_Block): """The ``setup:`` block (feat-013 — agent auto-configuration). diff --git a/src/agentforge_graph/docgen/__init__.py b/src/agentforge_graph/docgen/__init__.py new file mode 100644 index 0000000..047cdf1 --- /dev/null +++ b/src/agentforge_graph/docgen/__init__.py @@ -0,0 +1,66 @@ +"""agentforge_graph.docgen — grounded documentation generation (feat-016). + +Documentation as a *grounded projection of the graph*: a doc-type recipe seeds a +context pack, an ``agentforge.Agent`` composes the doc while expanding that pack +through a read-only, provenance-floored ckg toolset, and every claim is attributed +to a real graph fact (verified against the captured provenance set). Drafts land +under ``docgen.output_root`` behind a human promote gate and ride the feat-004 +``DirtySet`` for freshness. + +This is the **framework layer** (it imports ``agentforge`` for the Agent loop), +built over the deterministic engine — it adds no coupling back into ``core`` / +``ingest`` / ``store`` / ``retrieve`` (ADR-0001). +""" + +from __future__ import annotations + +from .errors import ( + BadCitationError, + DocDisabled, + DocgenError, + PromoteRequired, + UngroundedError, +) +from .manifest import Manifest, content_sha +from .staleness import DOCS_CONSUMER, is_stale, stale_docs +from .types import ( + DOC_LANG_VERSION, + STATUS_ACCEPTED, + STATUS_DRAFT, + DocArtifact, + DocTarget, + DocType, + Footnote, + GroundedFact, + GroundedPack, + ProvenanceSet, + SymbolRef, +) + +__all__ = [ + # types + "DocType", + "DocTarget", + "SymbolRef", + "GroundedFact", + "GroundedPack", + "ProvenanceSet", + "Footnote", + "DocArtifact", + "DOC_LANG_VERSION", + "STATUS_DRAFT", + "STATUS_ACCEPTED", + # persistence + "Manifest", + "content_sha", + # staleness + "DOCS_CONSUMER", + "is_stale", + "stale_docs", + # errors + "DocgenError", + "UngroundedError", + "BadCitationError", + "DocDisabled", + "PromoteRequired", +] diff --git a/src/agentforge_graph/docgen/errors.py b/src/agentforge_graph/docgen/errors.py new file mode 100644 index 0000000..0de4624 --- /dev/null +++ b/src/agentforge_graph/docgen/errors.py @@ -0,0 +1,32 @@ +"""Structured errors for grounded doc generation (feat-016). + +Every failure a caller can act on is a typed ``DocgenError`` carrying a +human-readable *why* — surfaced by the CLI as a stderr message + exit 2 +(the ENH-026 convention), never a stack trace. +""" + +from __future__ import annotations + + +class DocgenError(Exception): + """Base for every doc-generation error.""" + + +class UngroundedError(DocgenError): + """A generated section cites no supporting fact while ``require_citations`` + is on — we refuse to publish confident prose we cannot attribute.""" + + +class BadCitationError(DocgenError): + """A footnote cites a symbol that never appeared in the run's provenance set + (seed ∪ captured tool results) — a fabricated citation.""" + + +class DocDisabled(DocgenError): + """Doc generation is disabled, or the requested doc type is not in + ``docgen.types``.""" + + +class PromoteRequired(DocgenError): + """An operation (``sync``) needs an *accepted* doc, but the doc is still a + draft — promote it first (the human review gate).""" diff --git a/src/agentforge_graph/docgen/manifest.py b/src/agentforge_graph/docgen/manifest.py new file mode 100644 index 0000000..66dd2d4 --- /dev/null +++ b/src/agentforge_graph/docgen/manifest.py @@ -0,0 +1,155 @@ +"""The generated-docs manifest — a sidecar record of every draft (feat-016). + +Generated docs are markdown **files** under ``docgen.output_root`` (what a human +reviews and commits); their metadata — status, the commit they were synced from, +the symbols they ground on, and their verified footnotes — lives beside them in +``output_root/.ckg-docs.json``. This mirrors the ``.ckg/dirty.json`` side-file +pattern (feat-004): a status/promote update never rewrites the docs themselves. + +The manifest is the source for ``ckg docs list`` / ``diff`` / ``promote`` and the +staleness join (a doc's ``source_ids`` vs the ``DirtySet("docs")`` cursor). +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + +from agentforge_graph.core import NodeKind + +from .types import ( + STATUS_ACCEPTED, + DocArtifact, + DocType, + Footnote, + SymbolRef, +) + +_MANIFEST = ".ckg-docs.json" +_SCHEMA_VERSION = 1 + + +def content_sha(text: str) -> str: + """sha256 of a doc's bytes — lets ``list``/``diff`` detect a human edit since + generation without storing a second copy.""" + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +# --- (de)serialization ------------------------------------------------------- + + +def _ref_to_dict(ref: SymbolRef) -> dict[str, Any]: + return { + "id": ref.id, + "kind": str(ref.kind), + "name": ref.name, + "path": ref.path, + "span": list(ref.span) if ref.span is not None else None, + } + + +def _ref_from_dict(d: dict[str, Any]) -> SymbolRef: + span = d.get("span") + return SymbolRef( + id=d["id"], + kind=NodeKind(d["kind"]), + name=d["name"], + path=d.get("path"), + span=(span[0], span[1]) if span else None, + ) + + +def _artifact_to_record(a: DocArtifact) -> dict[str, Any]: + # ``stale`` is computed, never persisted. + return { + "type": str(a.type), + "status": a.status, + "synced_commit": a.synced_commit, + "doc_lang_version": a.doc_lang_version, + "source_ids": list(a.source_ids), + "footnotes": [{"marker": f.marker, "ref": _ref_to_dict(f.ref)} for f in a.footnotes], + "content_sha": a.content_sha, + } + + +def _artifact_from_record(path: str, r: dict[str, Any]) -> DocArtifact: + return DocArtifact( + type=DocType(r["type"]), + path=path, + status=r["status"], + synced_commit=r.get("synced_commit", ""), + doc_lang_version=r.get("doc_lang_version", ""), + source_ids=tuple(r.get("source_ids", [])), + footnotes=tuple( + Footnote(marker=f["marker"], ref=_ref_from_dict(f["ref"])) + for f in r.get("footnotes", []) + ), + content_sha=r.get("content_sha", ""), + ) + + +# --- the manifest ------------------------------------------------------------ + + +class Manifest: + """The ``output_root/.ckg-docs.json`` record, keyed by repo-relative doc path. + + Mutating ops persist immediately (like :class:`DirtySet`), so a crash never + leaves the manifest out of sync with the files on disk.""" + + def __init__(self, output_root: str | Path) -> None: + self._root = Path(output_root) + self._path = self._root / _MANIFEST + self._docs: dict[str, DocArtifact] = self._load() + + def _load(self) -> dict[str, DocArtifact]: + if not self._path.exists(): + return {} + data = json.loads(self._path.read_text()) + docs = data.get("docs", {}) if isinstance(data, dict) else {} + return {p: _artifact_from_record(p, r) for p, r in docs.items()} + + def _save(self) -> None: + self._root.mkdir(parents=True, exist_ok=True) + payload = { + "version": _SCHEMA_VERSION, + "docs": {p: _artifact_to_record(a) for p, a in sorted(self._docs.items())}, + } + tmp = self._path.with_name(_MANIFEST + ".tmp") + tmp.write_text(json.dumps(payload, indent=2, sort_keys=True)) + tmp.replace(self._path) + + def get(self, path: str) -> DocArtifact | None: + return self._docs.get(path) + + def all(self) -> list[DocArtifact]: + return [self._docs[p] for p in sorted(self._docs)] + + def upsert(self, artifact: DocArtifact) -> None: + self._docs[artifact.path] = artifact + self._save() + + def remove(self, path: str) -> None: + if path in self._docs: + del self._docs[path] + self._save() + + def promote(self, path: str) -> DocArtifact: + """Flip a draft to ``accepted`` (the human review gate). Idempotent for an + already-accepted doc; ``KeyError`` if the doc is unknown.""" + current = self._docs[path] + promoted = DocArtifact( + type=current.type, + path=current.path, + status=STATUS_ACCEPTED, + synced_commit=current.synced_commit, + doc_lang_version=current.doc_lang_version, + source_ids=current.source_ids, + footnotes=current.footnotes, + content_sha=current.content_sha, + ) + self._docs[path] = promoted + self._save() + return promoted diff --git a/src/agentforge_graph/docgen/staleness.py b/src/agentforge_graph/docgen/staleness.py new file mode 100644 index 0000000..d1630ae --- /dev/null +++ b/src/agentforge_graph/docgen/staleness.py @@ -0,0 +1,36 @@ +"""Staleness join for generated docs (feat-016) — one mechanism, reused. + +Generated docs are a :class:`DirtySet` consumer (``"docs"``), exactly like +``embeddings`` / ``patterns`` / ``summaries``. Indexing already fans every changed +symbol id to all consumers; this module holds the *pure* join between a manifest +doc's ``source_ids`` and the ``"docs"`` dirty cursor. The async drain / regenerate +/ ``mark_clean`` loop lives in the generator (it mirrors ``CodeGraph.summarize``). +""" + +from __future__ import annotations + +from collections.abc import Iterable + +from .types import DocArtifact + +#: The ``DirtySet`` consumer name for generated docs (added to +#: ``DirtySet.DEFAULT_CONSUMERS``). +DOCS_CONSUMER = "docs" + + +def is_stale(artifact: DocArtifact, dirty: set[str], head_commit: str) -> bool: + """A doc is stale when a source symbol was dirtied, or the index moved past + the commit it was generated from. (A ``doc_lang_version`` bump is folded in by + the caller, which knows the current version.)""" + if dirty and any(sid in dirty for sid in artifact.source_ids): + return True + return bool(head_commit and artifact.synced_commit and artifact.synced_commit != head_commit) + + +def stale_docs(artifacts: Iterable[DocArtifact], dirty: Iterable[str]) -> list[DocArtifact]: + """The docs whose ``source_ids`` intersect the dirty set — the regeneration + work-list for ``ckg docs update``.""" + dirty_set = set(dirty) + if not dirty_set: + return [] + return [a for a in artifacts if any(sid in dirty_set for sid in a.source_ids)] diff --git a/src/agentforge_graph/docgen/types.py b/src/agentforge_graph/docgen/types.py new file mode 100644 index 0000000..661d3f1 --- /dev/null +++ b/src/agentforge_graph/docgen/types.py @@ -0,0 +1,142 @@ +"""Value types for grounded doc generation (feat-016). + +Frozen dataclasses, ``core``-typed where they reference the graph. These are the +vocabulary the recipes / runner / citation verifier / manifest all speak: + +- :class:`DocTarget` — *what* to document. +- :class:`GroundedFact` / :class:`GroundedPack` — the recipe **seed**: real graph + facts (each carrying a :class:`SymbolRef`) handed to the Agent up front. +- :class:`ProvenanceSet` — everything the read-only tools surfaced during a run + (seed ∪ tool results); the citation trust boundary and the staleness key. +- :class:`Footnote` — a verified citation (marker → :class:`SymbolRef`). +- :class:`DocArtifact` — the emitted draft + its metadata (what the manifest + persists). + +This module is pure data — no LLM, no I/O — so it is trivially unit-testable and +imports only ``core``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import StrEnum + +from agentforge_graph.core import NodeKind + +#: The template + citation contract version. Bumped when the section skeletons or +#: the citation model change in a way that makes an existing doc worth +#: regenerating (mirrors feat-015's ``query_lang_version``). Stamped into every +#: manifest entry and reported by ``ckg_status``. +DOC_LANG_VERSION = "1.0" + +#: Doc lifecycle states. A generated doc is a ``draft`` until a human promotes it. +STATUS_DRAFT = "draft" +STATUS_ACCEPTED = "accepted" + + +class DocType(StrEnum): + """The four descriptive doc types feat-016 ships (Phase 1).""" + + AI_CONTEXT = "ai-context" # CLAUDE.md / AGENTS.md + ARCHITECTURE = "architecture" # system overview + COMPONENT = "component" # per-module doc(s) + DESIGN = "design" # "how it works + why" for a subsystem + + +@dataclass(frozen=True) +class DocTarget: + """What to document: a doc type + an optional scope (repo | package/path | + subsystem | symbol id).""" + + type: DocType + scope: str | None = None + + +@dataclass(frozen=True) +class SymbolRef: + """A citable pointer into the graph — built from a ``core.models.Node`` or a + ``retrieve.pack.ContextItem`` (both carry id/kind/name/path/span).""" + + id: str # a SymbolID string + kind: NodeKind + name: str + path: str | None = None + span: tuple[int, int] | None = None + + +@dataclass(frozen=True) +class GroundedFact: + """One seed fact: text phrased for the model + the symbol it cites + the + provenance of the underlying graph fact (only ``>= parsed`` is ground + truth).""" + + text: str + ref: SymbolRef + source: str # a core.Source value ("parsed" | "resolved" | "llm" | "manual") + + +@dataclass(frozen=True) +class GroundedPack: + """The recipe **seed** — high-value facts handed to the Agent up front. The + Agent expands it by calling the read-only ckg toolset. ``notes`` is + non-citable framing (recipe-level context).""" + + target: DocTarget + facts: tuple[GroundedFact, ...] = () + notes: tuple[str, ...] = () + + +@dataclass(frozen=True) +class ProvenanceSet: + """Every ``SymbolRef`` the run grounded on — the seed refs plus every ref a + read-only tool returned during composition. A footnote may cite a symbol only + if it is ``contains``-ed here (the citation trust boundary), and its keys are + the doc's staleness ``source_ids``.""" + + refs: dict[str, SymbolRef] = field(default_factory=dict) + + def contains(self, symbol_id: str) -> bool: + return symbol_id in self.refs + + def source_ids(self) -> tuple[str, ...]: + return tuple(sorted(self.refs)) + + @classmethod + def build(cls, *groups: dict[str, SymbolRef]) -> ProvenanceSet: + """Union several ``{id: SymbolRef}`` maps (seed + captured tool results) + into one set; earlier groups win on a key collision.""" + merged: dict[str, SymbolRef] = {} + for group in reversed(groups): + merged.update(group) + return cls(refs=merged) + + +@dataclass(frozen=True) +class Footnote: + """A verified citation: a footnote marker (e.g. ``"f3"``) → the symbol it + resolves to. Present in a ``DocArtifact`` only after verification against the + :class:`ProvenanceSet`.""" + + marker: str + ref: SymbolRef + + +@dataclass(frozen=True) +class DocArtifact: + """A generated doc + its metadata (what the manifest persists). ``stale`` is + *computed* (never serialized) — a doc is stale when a source symbol changed, + the index moved past ``synced_commit``, or the ``doc_lang_version`` bumped.""" + + type: DocType + path: str # repo-relative, under docgen.output_root + status: str # STATUS_DRAFT | STATUS_ACCEPTED + synced_commit: str # git commit the run was built from + doc_lang_version: str + source_ids: tuple[str, ...] # ProvenanceSet keys — the staleness key + footnotes: tuple[Footnote, ...] = () + content_sha: str = "" # sha256 of the generated bytes (drift detection) + stale: bool = False # computed, not serialized + + @property + def accepted(self) -> bool: + return self.status == STATUS_ACCEPTED diff --git a/src/agentforge_graph/ingest/incremental/dirty.py b/src/agentforge_graph/ingest/incremental/dirty.py index b3e279d..e1c8267 100644 --- a/src/agentforge_graph/ingest/incremental/dirty.py +++ b/src/agentforge_graph/ingest/incremental/dirty.py @@ -18,8 +18,9 @@ class DirtySet: - # Known enrichment consumers: embeddings (feat-005), patterns + summaries (feat-012). - DEFAULT_CONSUMERS = ["embeddings", "patterns", "summaries"] + # Known enrichment consumers: embeddings (feat-005), patterns + summaries + # (feat-012), docs (feat-016 — generated docs go stale like summaries do). + DEFAULT_CONSUMERS = ["embeddings", "patterns", "summaries", "docs"] def __init__(self, root: str | Path, consumers: list[str] | None = None) -> None: self._path = Path(root) / _DIRTY diff --git a/tests/docgen/__init__.py b/tests/docgen/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/docgen/test_config.py b/tests/docgen/test_config.py new file mode 100644 index 0000000..2e68a03 --- /dev/null +++ b/tests/docgen/test_config.py @@ -0,0 +1,52 @@ +"""feat-016 chunk 1: the docgen: config block.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from agentforge_graph.config import DocGenConfig, block_keys + + +def test_defaults() -> None: + c = DocGenConfig.load(None) + assert c.output_root == "docs/_generated" + assert c.types == ["ai-context", "architecture", "component", "design"] + assert c.ai_context_targets == ["CLAUDE.md", "AGENTS.md"] + assert c.component_granularity == "package" + assert c.require_citations is True + assert c.round_trip is False + assert c.promote_required is True + assert c.budget_usd == 5.0 + assert c.max_iterations == 24 + assert c.provider == "bedrock" + + +def test_parses_block(tmp_path: Path) -> None: + y = tmp_path / "ckg.yaml" + y.write_text( + "docgen:\n" + " output_root: docs/auto\n" + " types: [architecture]\n" + " round_trip: true\n" + " budget_usd: 2.5\n" + " provider: scripted\n" + ) + c = DocGenConfig.load(y) + assert c.output_root == "docs/auto" + assert c.enabled_types() == ["architecture"] + assert c.round_trip is True + assert c.budget_usd == 2.5 + assert c.provider == "scripted" + + +def test_component_granularity_validated() -> None: + DocGenConfig(component_granularity="hybrid") # ok + with pytest.raises(ValidationError): + DocGenConfig(component_granularity="nonsense") + + +def test_block_key_discovered() -> None: + assert "docgen" in block_keys() diff --git a/tests/docgen/test_manifest.py b/tests/docgen/test_manifest.py new file mode 100644 index 0000000..db5a300 --- /dev/null +++ b/tests/docgen/test_manifest.py @@ -0,0 +1,95 @@ +"""feat-016 chunk 1: the generated-docs sidecar manifest.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from agentforge_graph.core import NodeKind +from agentforge_graph.docgen import ( + DOC_LANG_VERSION, + STATUS_ACCEPTED, + STATUS_DRAFT, + DocArtifact, + DocType, + Footnote, + Manifest, + SymbolRef, + content_sha, +) + + +def _artifact(path: str, status: str = STATUS_DRAFT) -> DocArtifact: + ref = SymbolRef(id="scip:s1", kind=NodeKind.CLASS, name="Repo", path="a.py", span=(1, 9)) + return DocArtifact( + type=DocType.ARCHITECTURE, + path=path, + status=status, + synced_commit="abc123", + doc_lang_version=DOC_LANG_VERSION, + source_ids=("scip:s1", "scip:s2"), + footnotes=(Footnote(marker="f1", ref=ref),), + content_sha=content_sha("hello"), + ) + + +def test_content_sha_stable() -> None: + assert content_sha("hello") == content_sha("hello") + assert content_sha("hello") != content_sha("world") + + +def test_upsert_get_all(tmp_path: Path) -> None: + m = Manifest(tmp_path) + assert m.all() == [] + a = _artifact("docs/_generated/architecture.md") + m.upsert(a) + assert m.get(a.path) == a + assert [x.path for x in m.all()] == [a.path] + + +def test_persist_and_reload_roundtrip(tmp_path: Path) -> None: + m = Manifest(tmp_path) + a = _artifact("docs/_generated/architecture.md") + m.upsert(a) + # a fresh Manifest reads the same bytes back, fully typed + m2 = Manifest(tmp_path) + got = m2.get(a.path) + assert got is not None + assert got.type is DocType.ARCHITECTURE + assert got.source_ids == ("scip:s1", "scip:s2") + assert got.footnotes[0].marker == "f1" + assert got.footnotes[0].ref.kind is NodeKind.CLASS + assert got.footnotes[0].ref.span == (1, 9) + assert got.content_sha == content_sha("hello") + + +def test_manifest_file_shape(tmp_path: Path) -> None: + m = Manifest(tmp_path) + m.upsert(_artifact("docs/_generated/architecture.md")) + data = json.loads((tmp_path / ".ckg-docs.json").read_text()) + assert data["version"] == 1 + rec = data["docs"]["docs/_generated/architecture.md"] + assert "stale" not in rec # computed, never serialized + assert rec["status"] == STATUS_DRAFT + + +def test_remove(tmp_path: Path) -> None: + m = Manifest(tmp_path) + a = _artifact("docs/_generated/x.md") + m.upsert(a) + m.remove(a.path) + assert m.get(a.path) is None + m.remove("nonexistent") # no-op, no raise + + +def test_promote_flips_status(tmp_path: Path) -> None: + m = Manifest(tmp_path) + a = _artifact("docs/_generated/x.md", status=STATUS_DRAFT) + m.upsert(a) + promoted = m.promote(a.path) + assert promoted.status == STATUS_ACCEPTED and promoted.accepted + # persisted, and everything else preserved + assert Manifest(tmp_path).get(a.path).status == STATUS_ACCEPTED # type: ignore[union-attr] + assert promoted.source_ids == a.source_ids + # idempotent + assert m.promote(a.path).status == STATUS_ACCEPTED diff --git a/tests/docgen/test_staleness.py b/tests/docgen/test_staleness.py new file mode 100644 index 0000000..a6d2f89 --- /dev/null +++ b/tests/docgen/test_staleness.py @@ -0,0 +1,67 @@ +"""feat-016 chunk 1: the docs staleness join + the DirtySet "docs" consumer.""" + +from __future__ import annotations + +from pathlib import Path + +from agentforge_graph.core import NodeKind +from agentforge_graph.docgen import ( + DOC_LANG_VERSION, + DOCS_CONSUMER, + DocArtifact, + DocType, + Footnote, + SymbolRef, + is_stale, + stale_docs, +) +from agentforge_graph.docgen.staleness import DOCS_CONSUMER as CONST +from agentforge_graph.ingest.incremental.dirty import DirtySet + + +def _doc(path: str, source_ids: tuple[str, ...], commit: str = "abc") -> DocArtifact: + ref = SymbolRef(id=source_ids[0], kind=NodeKind.FUNCTION, name="f") + return DocArtifact( + type=DocType.COMPONENT, + path=path, + status="draft", + synced_commit=commit, + doc_lang_version=DOC_LANG_VERSION, + source_ids=source_ids, + footnotes=(Footnote(marker="f1", ref=ref),), + ) + + +def test_docs_consumer_name() -> None: + assert DOCS_CONSUMER == "docs" == CONST + + +def test_is_stale_on_dirty_intersection() -> None: + doc = _doc("x.md", ("s1", "s2")) + assert is_stale(doc, {"s2"}, head_commit="abc") is True + assert is_stale(doc, {"other"}, head_commit="abc") is False + + +def test_is_stale_on_commit_moved() -> None: + doc = _doc("x.md", ("s1",), commit="abc") + assert is_stale(doc, set(), head_commit="def") is True # index moved on + assert is_stale(doc, set(), head_commit="abc") is False + assert is_stale(doc, set(), head_commit="") is False # unknown head → not stale + + +def test_stale_docs_worklist() -> None: + a = _doc("a.md", ("s1", "s2")) + b = _doc("b.md", ("s3",)) + c = _doc("c.md", ("s4",)) + got = stale_docs([a, b, c], ["s2", "s3"]) + assert {d.path for d in got} == {"a.md", "b.md"} + assert stale_docs([a, b, c], []) == [] # nothing dirty → empty + + +async def test_dirtyset_fans_to_docs_consumer(tmp_path: Path) -> None: + ds = DirtySet(tmp_path) + assert "docs" in DirtySet.DEFAULT_CONSUMERS + await ds.add(["s1", "s2"]) + assert set(await ds.dirty_for("docs")) == {"s1", "s2"} + await ds.mark_clean("docs", ["s1"]) + assert await ds.dirty_for("docs") == ["s2"] diff --git a/tests/docgen/test_types.py b/tests/docgen/test_types.py new file mode 100644 index 0000000..74ac06a --- /dev/null +++ b/tests/docgen/test_types.py @@ -0,0 +1,68 @@ +"""feat-016 chunk 1: docgen value types.""" + +from __future__ import annotations + +from agentforge_graph.core import NodeKind +from agentforge_graph.docgen import ( + DOC_LANG_VERSION, + STATUS_ACCEPTED, + STATUS_DRAFT, + DocArtifact, + DocTarget, + DocType, + Footnote, + GroundedFact, + GroundedPack, + ProvenanceSet, + SymbolRef, +) + + +def _ref(sid: str, name: str = "f") -> SymbolRef: + return SymbolRef(id=sid, kind=NodeKind.FUNCTION, name=name, path="a.py", span=(1, 3)) + + +def test_doc_type_values() -> None: + assert DocType("ai-context") is DocType.AI_CONTEXT + assert {t.value for t in DocType} == {"ai-context", "architecture", "component", "design"} + + +def test_doc_lang_version_is_set() -> None: + assert DOC_LANG_VERSION == "1.0" + + +def test_provenance_set_build_and_contains() -> None: + seed = {"s1": _ref("s1")} + tools = {"t1": _ref("t1"), "s1": _ref("s1", name="dup")} + ps = ProvenanceSet.build(seed, tools) + assert ps.contains("s1") and ps.contains("t1") + assert not ps.contains("missing") + # earlier group (seed) wins the collision + assert ps.refs["s1"].name == "f" + assert ps.source_ids() == ("s1", "t1") # sorted + + +def test_grounded_pack_defaults() -> None: + pack = GroundedPack(target=DocTarget(type=DocType.ARCHITECTURE)) + assert pack.facts == () and pack.notes == () + + +def test_grounded_fact_carries_ref_and_source() -> None: + f = GroundedFact(text="Repo.save persists", ref=_ref("s1", "save"), source="parsed") + assert f.ref.name == "save" and f.source == "parsed" + + +def test_doc_artifact_accepted_flag() -> None: + base = dict( + type=DocType.ARCHITECTURE, + path="docs/_generated/architecture.md", + synced_commit="abc123", + doc_lang_version=DOC_LANG_VERSION, + source_ids=("s1",), + footnotes=(Footnote(marker="f1", ref=_ref("s1")),), + ) + draft = DocArtifact(status=STATUS_DRAFT, **base) + accepted = DocArtifact(status=STATUS_ACCEPTED, **base) + assert draft.accepted is False + assert accepted.accepted is True + assert draft.stale is False # default, computed later From 6a0aff617eb13b4e93438afb68f0e49612b3b51d Mon Sep 17 00:00:00 2001 From: Khemchand Joshi Date: Fri, 10 Jul 2026 12:47:04 +0530 Subject: [PATCH 04/11] feat-016 chunk 2: recipe seam + architecture recipe + citation verifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deterministic trust boundary — graph facts in, verified citations out, no LLM: - docgen/recipes/base.py — Recipe ABC (async seed -> GroundedPack) + RECIPES registry + register/get_recipe. Add a doc type = a subclass + one register call; nothing shared changes. - docgen/recipes/architecture.py — the architecture seed: top-PageRank symbols (feat-007) + routes/models topology (feat-011) as citable >=parsed facts; repo summary (llm) + DI services as non-citable notes (anti-echo-chamber in the seed). - docgen/citations.py — verify_citations: parse GFM footnote defs + per-section markers, prove every footnote resolves into the ProvenanceSet (BadCitationError on a fabricated/dangling cite), flag uncited sections (UngroundedError under require_citations), rewrite the References block to human-facing links. Pure text-in/text-out. Tests: 11 new (citation verifier over crafted markdown incl. bad/dangling/ ungrounded cases + refs-block isolation; architecture recipe over a real tiny Kuzu index — real symbol ids/paths, source=parsed, no llm leak). Full gate: 1124 passed / 69 skipped / 94.37% cov; ruff + mypy strict clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/agentforge_graph/docgen/__init__.py | 10 ++ src/agentforge_graph/docgen/citations.py | 118 ++++++++++++++++++ .../docgen/recipes/__init__.py | 14 +++ .../docgen/recipes/architecture.py | 90 +++++++++++++ src/agentforge_graph/docgen/recipes/base.py | 51 ++++++++ tests/docgen/test_citations.py | 97 ++++++++++++++ tests/docgen/test_recipes.py | 64 ++++++++++ 7 files changed, 444 insertions(+) create mode 100644 src/agentforge_graph/docgen/citations.py create mode 100644 src/agentforge_graph/docgen/recipes/__init__.py create mode 100644 src/agentforge_graph/docgen/recipes/architecture.py create mode 100644 src/agentforge_graph/docgen/recipes/base.py create mode 100644 tests/docgen/test_citations.py create mode 100644 tests/docgen/test_recipes.py diff --git a/src/agentforge_graph/docgen/__init__.py b/src/agentforge_graph/docgen/__init__.py index 047cdf1..67eefc0 100644 --- a/src/agentforge_graph/docgen/__init__.py +++ b/src/agentforge_graph/docgen/__init__.py @@ -14,6 +14,7 @@ from __future__ import annotations +from .citations import VerifiedDoc, verify_citations from .errors import ( BadCitationError, DocDisabled, @@ -22,6 +23,7 @@ UngroundedError, ) from .manifest import Manifest, content_sha +from .recipes import RECIPES, Recipe, get_recipe, register from .staleness import DOCS_CONSUMER, is_stale, stale_docs from .types import ( DOC_LANG_VERSION, @@ -53,6 +55,14 @@ # persistence "Manifest", "content_sha", + # recipes + "Recipe", + "RECIPES", + "get_recipe", + "register", + # citations + "verify_citations", + "VerifiedDoc", # staleness "DOCS_CONSUMER", "is_stale", diff --git a/src/agentforge_graph/docgen/citations.py b/src/agentforge_graph/docgen/citations.py new file mode 100644 index 0000000..5e8dea8 --- /dev/null +++ b/src/agentforge_graph/docgen/citations.py @@ -0,0 +1,118 @@ +"""Citation verification — the trust boundary for generated prose (feat-016). + +The model attributes claims with GFM-style footnote markers (``[^f1]``) and +defines each in a ``## References`` block at the end +(``[^f1]: …``). This module is the constructive-grounding gate +(feat-015's "validate, don't sanitize", applied to prose): + +1. every footnote's symbol must be in the run's :class:`ProvenanceSet` (seed ∪ + captured tool results) — a footnote citing a symbol the tools never returned + is a fabricated citation (:class:`BadCitationError`); +2. every inline marker must have a definition (no dangling citations); +3. every content section must carry ≥1 citation — an uncited section is + *ungrounded* (:class:`UngroundedError` when ``require_citations``); +4. the References block is rewritten to human-facing links (symbol → path:span). + +It is pure text-in / text-out — no LLM, no I/O — so the guarantee is unit-tested +without a model. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +from .errors import BadCitationError, UngroundedError +from .types import Footnote, ProvenanceSet, SymbolRef + +_HEADING = re.compile(r"^(#{1,6})\s+(.*)$", re.M) +_REF_DEF = re.compile(r"^\[\^([^\]]+)\]:\s*(.+?)\s*$", re.M) +_INLINE = re.compile(r"\[\^([^\]]+)\]") +_REFERENCES_TITLE = "references" + + +@dataclass(frozen=True) +class VerifiedDoc: + """Result of verifying a rendered doc: the citation-rewritten body, the + verified footnotes, and any sections that carried no citation (empty when + ``require_citations`` held).""" + + body: str + footnotes: tuple[Footnote, ...] + ungrounded_sections: tuple[str, ...] + + +def _split_references(body: str) -> tuple[str, str]: + """Return ``(content, references_block)``. The references block is the earliest + ``References`` heading onward; ``("", body)`` semantics: if there is no such + heading, the references block is empty.""" + for m in _HEADING.finditer(body): + if m.group(2).strip().lower() == _REFERENCES_TITLE: + return body[: m.start()], body[m.start() :] + return body, "" + + +def _sections(content: str) -> list[tuple[str, str]]: + """Split content into ``(title, text)`` sections by ATX heading. Text before + the first heading (preamble) is not a section.""" + heads = list(_HEADING.finditer(content)) + out: list[tuple[str, str]] = [] + for i, h in enumerate(heads): + end = heads[i + 1].start() if i + 1 < len(heads) else len(content) + out.append((h.group(2).strip(), content[h.start() : end])) + return out + + +def _render_def(marker: str, ref: SymbolRef) -> str: + loc = ref.path or "" + if ref.path and ref.span: + loc = f"{ref.path}:{ref.span[0]}-{ref.span[1]}" + tail = f" — `{loc}`" if loc else "" + return f"[^{marker}]: **{ref.name}** ({ref.kind.value}){tail} · `{ref.id}`" + + +def verify_citations(body: str, prov: ProvenanceSet, *, require_citations: bool) -> VerifiedDoc: + content, refs = _split_references(body) + + # (1) parse + resolve every footnote definition against the provenance set. + footnotes: list[Footnote] = [] + defined: dict[str, SymbolRef] = {} + for m in _REF_DEF.finditer(refs): + marker = m.group(1) + symbol_id = m.group(2).split()[0] if m.group(2).split() else "" + if not prov.contains(symbol_id): + raise BadCitationError( + f"footnote [^{marker}] cites {symbol_id!r}, which no tool returned " + f"(not in the provenance set of {len(prov.refs)} facts)" + ) + ref = prov.refs[symbol_id] + defined[marker] = ref + footnotes.append(Footnote(marker=marker, ref=ref)) + + # (2) every inline marker in the content must have a definition. + used = {m.group(1) for m in _INLINE.finditer(content)} + dangling = sorted(used - set(defined)) + if dangling: + raise BadCitationError( + f"citation marker(s) {dangling} used in the body have no References entry" + ) + + # (3) every content section must carry a valid citation. + ungrounded = tuple(title for title, text in _sections(content) if not _INLINE.search(text)) + if ungrounded and require_citations: + raise UngroundedError( + "ungrounded section(s) with no citable fact: " + ", ".join(repr(s) for s in ungrounded) + ) + + # (4) rewrite the References block to human-facing links. + rewritten_refs = _REF_DEF.sub( + lambda m: ( + _render_def(m.group(1), defined[m.group(1)]) if m.group(1) in defined else m.group(0) + ), + refs, + ) + return VerifiedDoc( + body=content + rewritten_refs, + footnotes=tuple(footnotes), + ungrounded_sections=ungrounded, + ) diff --git a/src/agentforge_graph/docgen/recipes/__init__.py b/src/agentforge_graph/docgen/recipes/__init__.py new file mode 100644 index 0000000..a83675d --- /dev/null +++ b/src/agentforge_graph/docgen/recipes/__init__.py @@ -0,0 +1,14 @@ +"""Doc-type recipes (feat-016) — the seed assemblers, registered by doc type. + +Importing this package registers every built-in recipe into ``RECIPES``. A new +doc type registers itself here without touching the generator or runner. +""" + +from __future__ import annotations + +from .architecture import ArchitectureRecipe +from .base import RECIPES, Recipe, get_recipe, register + +register(ArchitectureRecipe()) + +__all__ = ["RECIPES", "Recipe", "get_recipe", "register", "ArchitectureRecipe"] diff --git a/src/agentforge_graph/docgen/recipes/architecture.py b/src/agentforge_graph/docgen/recipes/architecture.py new file mode 100644 index 0000000..9d9e2c3 --- /dev/null +++ b/src/agentforge_graph/docgen/recipes/architecture.py @@ -0,0 +1,90 @@ +"""The ``architecture`` recipe — a system-overview seed (feat-016). + +Seeds the Agent with the repository's *shape*: its most central symbols +(feat-007 PageRank), its framework topology (routes/models, feat-011), and — as +non-citable framing — its repo-level summary (feat-012, llm-sourced) and DI +services. Structural facts are ``>= parsed`` by construction (PageRank ranks code +symbols over CALLS/REFERENCES/INHERITS; llm nodes are never in that set), so the +seed carries no llm fact as ground truth. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +from agentforge_graph.core import NodeKind + +from ..types import DocTarget, DocType, GroundedFact, GroundedPack, SymbolRef +from .base import Recipe + +if TYPE_CHECKING: + from agentforge_graph.ingest import CodeGraph + +#: How many top-ranked symbols to seed. The Agent expands beyond these via tools. +_TOP_K = 40 + + +class ArchitectureRecipe(Recipe): + doc_type: ClassVar[DocType] = DocType.ARCHITECTURE + + async def seed(self, cg: CodeGraph, target: DocTarget) -> GroundedPack: + facts: list[GroundedFact] = [] + + # Most central symbols — the orientation backbone. + for rs in await cg.ranked_symbols(k=_TOP_K): + sig = rs.signature.strip() if rs.signature else rs.name + facts.append( + GroundedFact( + text=f"{rs.name} — {rs.kind.value} in {rs.path}: {sig}", + ref=SymbolRef(id=rs.id, kind=rs.kind, name=rs.name, path=rs.path), + source="parsed", + ) + ) + + # Framework topology (feat-011): routes cite their handler symbol. + for r in await cg.routes(): + if not r.handler: + continue + facts.append( + GroundedFact( + text=f"Route {r.method} {r.path_pattern} → handler {r.handler} ({r.framework})", + ref=SymbolRef( + id=r.handler, + kind=NodeKind.ROUTE, + name=f"{r.method} {r.path_pattern}", + path=r.file, + span=(r.line, r.line) if r.line else None, + ), + source="parsed", + ) + ) + + # Data models cite their backing class symbol. + for m in await cg.models(): + if not m.cls: + continue + fields = ", ".join(m.fields[:8]) if m.fields else "" + facts.append( + GroundedFact( + text=f"DataModel {m.name} ({m.framework}) fields: {fields}", + ref=SymbolRef( + id=m.cls, + kind=NodeKind.DATA_MODEL, + name=m.name, + path=m.file, + span=(m.line, m.line) if m.line else None, + ), + source="parsed", + ) + ) + + # Non-citable framing: the repo summary (llm) + DI services. + notes: list[str] = [] + for s in await cg.summaries(level="repo"): + if s.text: + notes.append(f"Repo summary: {s.text}") + for svc in await cg.services(): + sites = len(svc.injected_into) + notes.append(f"Service {svc.name} ({svc.framework}) injected into {sites} site(s)") + + return GroundedPack(target=target, facts=tuple(facts), notes=tuple(notes)) diff --git a/src/agentforge_graph/docgen/recipes/base.py b/src/agentforge_graph/docgen/recipes/base.py new file mode 100644 index 0000000..cb9b0f7 --- /dev/null +++ b/src/agentforge_graph/docgen/recipes/base.py @@ -0,0 +1,51 @@ +"""Recipe seam — one per doc type, additive (feat-016). + +A recipe turns a :class:`DocTarget` into a **seed** :class:`GroundedPack`: the +high-value graph facts worth handing the Agent up front so it does not start +cold. It is pure graph assembly — **no LLM** — so it is unit-testable against a +fixture graph with exact expected facts. The Agent later *expands* the seed by +calling the read-only ckg toolset (chunk 3). + +Adding a doc type = a new :class:`Recipe` subclass + one :func:`register` call. +No edits to the generator, the runner, the citation verifier, or other recipes. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, ClassVar + +from ..errors import DocDisabled +from ..types import DocTarget, DocType, GroundedPack + +if TYPE_CHECKING: # avoid an import cycle — the facade imports docgen (chunk 5) + from agentforge_graph.ingest import CodeGraph + + +class Recipe(ABC): + """Assembles the seed grounded pack for one doc type.""" + + doc_type: ClassVar[DocType] + + @abstractmethod + async def seed(self, cg: CodeGraph, target: DocTarget) -> GroundedPack: + """Query the graph into a seed pack of citable facts (+ non-citable + framing ``notes``). Facts are drawn from ``>= parsed`` provenance only; + llm-sourced material (summaries) belongs in ``notes``.""" + + +RECIPES: dict[DocType, Recipe] = {} + + +def register(recipe: Recipe) -> Recipe: + """Register a recipe instance under its ``doc_type``.""" + RECIPES[recipe.doc_type] = recipe + return recipe + + +def get_recipe(doc_type: DocType) -> Recipe: + """The recipe for a doc type, or :class:`DocDisabled` if none is registered.""" + recipe = RECIPES.get(doc_type) + if recipe is None: + raise DocDisabled(f"no recipe registered for doc type {doc_type.value!r}") + return recipe diff --git a/tests/docgen/test_citations.py b/tests/docgen/test_citations.py new file mode 100644 index 0000000..1c1bd88 --- /dev/null +++ b/tests/docgen/test_citations.py @@ -0,0 +1,97 @@ +"""feat-016 chunk 2: the citation verifier — the grounding trust boundary.""" + +from __future__ import annotations + +import pytest + +from agentforge_graph.core import NodeKind +from agentforge_graph.docgen import ( + BadCitationError, + ProvenanceSet, + SymbolRef, + UngroundedError, + verify_citations, +) + + +def _prov() -> ProvenanceSet: + return ProvenanceSet( + refs={ + "s1": SymbolRef(id="s1", kind=NodeKind.CLASS, name="Repo", path="a.py", span=(1, 9)), + "s2": SymbolRef(id="s2", kind=NodeKind.FUNCTION, name="save", path="a.py", span=(3, 5)), + } + ) + + +_GOOD = """\ +## Overview + +The `Repo` class owns persistence [^f1]. + +## Behaviour + +`save` writes a row [^f2]. + +## References + +[^f1]: s1 the repo +[^f2]: s2 +""" + + +def test_valid_doc_builds_footnotes_and_rewrites() -> None: + v = verify_citations(_GOOD, _prov(), require_citations=True) + assert {f.marker for f in v.footnotes} == {"f1", "f2"} + assert v.ungrounded_sections == () + # references rewritten to human-facing links; symbol id retained + assert "**Repo** (Class)" in v.body + assert "`a.py:3-5`" in v.body + assert "`s2`" in v.body + # inline markers preserved for GFM footnote rendering + assert "[^f1]" in v.body and "[^f2]" in v.body + + +def test_bad_citation_symbol_not_in_provenance() -> None: + body = _GOOD.replace("[^f1]: s1 the repo", "[^f1]: s99") + with pytest.raises(BadCitationError, match="s99"): + verify_citations(body, _prov(), require_citations=True) + + +def test_dangling_inline_marker_without_definition() -> None: + body = _GOOD.replace("[^f2]", "[^f9]", 1) # used in body, but only [^f2] defined + with pytest.raises(BadCitationError, match=r"f9"): + verify_citations(body, _prov(), require_citations=True) + + +_UNGROUNDED = """\ +## Grounded + +Backed by a fact [^f1]. + +## Speculative + +No citation here at all. + +## References + +[^f1]: s1 +""" + + +def test_ungrounded_section_raises_when_required() -> None: + with pytest.raises(UngroundedError, match="Speculative"): + verify_citations(_UNGROUNDED, _prov(), require_citations=True) + + +def test_ungrounded_section_reported_when_not_required() -> None: + v = verify_citations(_UNGROUNDED, _prov(), require_citations=False) + assert v.ungrounded_sections == ("Speculative",) + assert {f.marker for f in v.footnotes} == {"f1"} + + +def test_references_block_markers_do_not_count_as_content_citations() -> None: + # A doc whose only [^..] usage is inside the References block → the single + # content section is ungrounded (the split must exclude the refs block). + body = "## Lonely\n\nProse with no marker.\n\n## References\n\n[^f1]: s1\n" + with pytest.raises(UngroundedError, match="Lonely"): + verify_citations(body, _prov(), require_citations=True) diff --git a/tests/docgen/test_recipes.py b/tests/docgen/test_recipes.py new file mode 100644 index 0000000..43d88a3 --- /dev/null +++ b/tests/docgen/test_recipes.py @@ -0,0 +1,64 @@ +"""feat-016 chunk 2: the recipe seam + the architecture recipe over a real index.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest + +from agentforge_graph.docgen import DocTarget, DocType, get_recipe +from agentforge_graph.docgen.errors import DocDisabled +from agentforge_graph.docgen.recipes import ArchitectureRecipe +from agentforge_graph.ingest import CodeGraph + +_SRC = """\ +class Repo: + def save(self): + return validate() + + +def validate(): + return True +""" + + +@pytest.fixture +async def cg(tmp_path: Path) -> AsyncIterator[CodeGraph]: + (tmp_path / "app.py").write_text(_SRC) + graph = await CodeGraph.index(repo_path=tmp_path) + yield graph + await graph.close() + + +def test_registry_lookup() -> None: + recipe = get_recipe(DocType.ARCHITECTURE) + assert isinstance(recipe, ArchitectureRecipe) + + +def test_unregistered_doc_type_raises() -> None: + # DESIGN has no recipe registered until chunk 4. + with pytest.raises(DocDisabled, match="design"): + get_recipe(DocType.DESIGN) + + +async def test_architecture_seed_grounds_on_real_symbols(cg: CodeGraph) -> None: + pack = await get_recipe(DocType.ARCHITECTURE).seed(cg, DocTarget(type=DocType.ARCHITECTURE)) + assert pack.target.type is DocType.ARCHITECTURE + assert pack.facts, "expected the seed to carry structural facts" + + names = {f.ref.name for f in pack.facts} + assert names & {"Repo", "save", "validate"} + + for f in pack.facts: + assert f.source == "parsed" # structural facts are >= parsed by construction + assert f.ref.id # a real symbol id + assert f.ref.path == "app.py" + + +async def test_architecture_seed_has_no_llm_notes_without_enrichment(cg: CodeGraph) -> None: + # No creds / no enrichment in CI → no repo summary → notes empty (llm framing + # only appears once summaries exist), and no llm fact leaks into `facts`. + pack = await get_recipe(DocType.ARCHITECTURE).seed(cg, DocTarget(type=DocType.ARCHITECTURE)) + assert pack.notes == () + assert all(f.source != "llm" for f in pack.facts) From 2b8bf063d8a6836fe271d10e53b5c6951c1980b5 Mon Sep 17 00:00:00 2001 From: Khemchand Joshi Date: Fri, 10 Jul 2026 13:05:46 +0530 Subject: [PATCH 05/11] feat-016 chunk 3: grounded Agent runner + generate (first e2e slice) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multi-turn agent loop, proven end to end and hermetic: - docgen/toolset.py — grounded_tools (the read-only feat-008 Tool instances, standalone) + capture_refs: scan tool-observation JSON for citable facts (id + kind + name + path + span), applying the >=parsed provenance floor (llm items dropped — anti-echo-chamber at the tool boundary, tool-agnostic, no second store handle). - docgen/templates/ — Template objects (section skeleton) + SYSTEM_PROMPT carrying the grounding/citation contract the verifier enforces. Architecture template. (Python objects, not .md.tmpl — no artifact packaging.) - docgen/runner.py — AgentDocRunner.compose: builds agentforge.Agent (strategy=react, tools, budget_usd, max_iterations) over the seed task, runs it, captures the ProvenanceSet (seed refs U tool-result refs), wraps budget/iteration trip (BudgetExceeded/GuardrailViolation -> DocgenError). agentforge imported lazily. First non-test framework-Agent consumer in the repo. - docgen/generator.py — DocGenerator.generate: seed -> compose -> verify citations -> write stamped draft under output_root -> manifest upsert. Hermetic CI via agentforge.testing.MockLLMClient (scripted tool-calls + final body, no creds). Config: docgen.provider defaults to a resolvable framework provider (anthropic), model_ref() builds the Agent model string. [BUG-CARRIED, same chunk] citation parser took split()[0] of a footnote def — but symbol ids are 5 SPACE-joined fields, so it grabbed only "ckg". Fixed: the whole remainder of a `[^fN]: ` line is the id. Tests: capture floor (llm dropped, malformed skipped, dedupe) + full generate via MockLLMClient (real Agent + real tools -> draft written, manifest recorded, links rewritten; fabricated citation -> BadCitationError; budget trip -> DocgenError). Full gate: 1132 passed / 69 skipped / 94.37% cov; ruff + mypy strict clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/agentforge_graph/config.py | 16 +-- src/agentforge_graph/docgen/__init__.py | 13 +++ src/agentforge_graph/docgen/citations.py | 4 +- src/agentforge_graph/docgen/generator.py | 101 +++++++++++++++++ src/agentforge_graph/docgen/runner.py | 91 +++++++++++++++ .../docgen/templates/__init__.py | 17 +++ .../docgen/templates/architecture.py | 23 ++++ src/agentforge_graph/docgen/templates/base.py | 88 +++++++++++++++ src/agentforge_graph/docgen/toolset.py | 101 +++++++++++++++++ tests/docgen/test_citations.py | 4 +- tests/docgen/test_config.py | 3 +- tests/docgen/test_runner.py | 105 ++++++++++++++++++ tests/docgen/test_toolset.py | 75 +++++++++++++ 13 files changed, 630 insertions(+), 11 deletions(-) create mode 100644 src/agentforge_graph/docgen/generator.py create mode 100644 src/agentforge_graph/docgen/runner.py create mode 100644 src/agentforge_graph/docgen/templates/__init__.py create mode 100644 src/agentforge_graph/docgen/templates/architecture.py create mode 100644 src/agentforge_graph/docgen/templates/base.py create mode 100644 src/agentforge_graph/docgen/toolset.py create mode 100644 tests/docgen/test_runner.py create mode 100644 tests/docgen/test_toolset.py diff --git a/src/agentforge_graph/config.py b/src/agentforge_graph/config.py index 086ad87..a0fecb1 100644 --- a/src/agentforge_graph/config.py +++ b/src/agentforge_graph/config.py @@ -357,13 +357,15 @@ class DocGenConfig(_Block): budget_usd: float = 5.0 # per-run cap (agentforge.Agent BudgetPolicy) max_iterations: int = 24 # Agent tool-call loop bound per doc regenerate_on_ci: bool = False # feat-014 CI can flip this on (commit the diff in a PR) - # Provider selection — reuses the enrich builders / framework provider. - provider: str = "bedrock" # scripted | bedrock | anthropic - model: str = "us.anthropic.claude-haiku-4-5-20251001-v1:0" - region: str | None = None - assume_role_arn: str | None = None - base_url: str | None = None - api_key_env: str | None = None + # Provider selection — resolved to the framework Agent's `model` string, + # ":" (agentforge's registered provider is `anthropic`). + # Tests inject a scripted LLMClient instead (hermetic, no creds). + provider: str = "anthropic" # anthropic | + model: str = "claude-haiku-4-5" + + def model_ref(self) -> str: + """The framework Agent ``model=`` string for this config.""" + return f"{self.provider}:{self.model}" @field_validator("component_granularity") @classmethod diff --git a/src/agentforge_graph/docgen/__init__.py b/src/agentforge_graph/docgen/__init__.py index 67eefc0..8d97bf2 100644 --- a/src/agentforge_graph/docgen/__init__.py +++ b/src/agentforge_graph/docgen/__init__.py @@ -22,9 +22,13 @@ PromoteRequired, UngroundedError, ) +from .generator import DocGenerator from .manifest import Manifest, content_sha from .recipes import RECIPES, Recipe, get_recipe, register +from .runner import AgentDocRunner from .staleness import DOCS_CONSUMER, is_stale, stale_docs +from .templates.base import SYSTEM_PROMPT, Template, get_template +from .toolset import capture_refs, grounded_tools from .types import ( DOC_LANG_VERSION, STATUS_ACCEPTED, @@ -63,6 +67,15 @@ # citations "verify_citations", "VerifiedDoc", + # templates + "Template", + "get_template", + "SYSTEM_PROMPT", + # generation (Agent loop) + "DocGenerator", + "AgentDocRunner", + "grounded_tools", + "capture_refs", # staleness "DOCS_CONSUMER", "is_stale", diff --git a/src/agentforge_graph/docgen/citations.py b/src/agentforge_graph/docgen/citations.py index 5e8dea8..5c9377f 100644 --- a/src/agentforge_graph/docgen/citations.py +++ b/src/agentforge_graph/docgen/citations.py @@ -79,7 +79,9 @@ def verify_citations(body: str, prov: ProvenanceSet, *, require_citations: bool) defined: dict[str, SymbolRef] = {} for m in _REF_DEF.finditer(refs): marker = m.group(1) - symbol_id = m.group(2).split()[0] if m.group(2).split() else "" + # A symbol id is 5 space-joined fields, so the whole remainder of the + # line IS the id — the contract is `[^fN]: `, nothing after. + symbol_id = m.group(2).strip() if not prov.contains(symbol_id): raise BadCitationError( f"footnote [^{marker}] cites {symbol_id!r}, which no tool returned " diff --git a/src/agentforge_graph/docgen/generator.py b/src/agentforge_graph/docgen/generator.py new file mode 100644 index 0000000..c3b1fab --- /dev/null +++ b/src/agentforge_graph/docgen/generator.py @@ -0,0 +1,101 @@ +"""``DocGenerator`` (feat-016) — orchestrates one grounded doc: seed → compose → +verify → emit draft + manifest entry. + +Chunk 3 lands the ``generate`` path (a single doc, end to end); ``update`` / +``list`` / ``diff`` / ``promote`` / ``sync`` land in later chunks. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import TYPE_CHECKING + +from .citations import verify_citations +from .manifest import Manifest, content_sha +from .recipes import get_recipe +from .runner import AgentDocRunner +from .templates.base import get_template +from .types import DOC_LANG_VERSION, STATUS_DRAFT, DocArtifact, DocTarget, DocType + +if TYPE_CHECKING: + from agentforge_core.contracts.llm import LLMClient + + from agentforge_graph.config import ConfigSource, DocGenConfig + from agentforge_graph.ingest import CodeGraph + + +def _slug(scope: str) -> str: + return re.sub(r"[^a-zA-Z0-9]+", "-", scope).strip("-") or "root" + + +class DocGenerator: + """Generates grounded docs for one repository/config.""" + + def __init__( + self, + cg: CodeGraph, + cfg: DocGenConfig, + *, + repo_path: str | Path, + config: ConfigSource = None, + model: str | LLMClient | None = None, + ) -> None: + self._cg = cg + self._cfg = cfg + self._repo_path = Path(repo_path) + self._runner = AgentDocRunner(cfg, repo_path=str(repo_path), config=config, model=model) + + async def generate(self, target: DocTarget) -> DocArtifact: + """Generate one doc: seed the pack, run the grounded Agent loop, verify + citations, write the draft under ``output_root``, and record it.""" + recipe = get_recipe(target.type) + pack = await recipe.seed(self._cg, target) + template = get_template(target.type) + + body, prov = await self._runner.compose(pack, template) + verified = verify_citations(body, prov, require_citations=self._cfg.require_citations) + + commit = self._git_commit() + rel = self._doc_relpath(target) + full = self._stamp(verified.body, target, commit) + abs_path = self._repo_path / rel + abs_path.parent.mkdir(parents=True, exist_ok=True) + abs_path.write_text(full) + + artifact = DocArtifact( + type=target.type, + path=rel, + status=STATUS_DRAFT, + synced_commit=commit, + doc_lang_version=DOC_LANG_VERSION, + source_ids=prov.source_ids(), + footnotes=verified.footnotes, + content_sha=content_sha(full), + ) + self._manifest().upsert(artifact) + return artifact + + # --- helpers ------------------------------------------------------------- + + def _manifest(self) -> Manifest: + return Manifest(self._repo_path / self._cfg.output_root) + + def _doc_relpath(self, target: DocTarget) -> str: + root = self._cfg.output_root.rstrip("/") + if target.type is DocType.COMPONENT and target.scope: + return f"{root}/component/{_slug(target.scope)}.md" + return f"{root}/{target.type.value}.md" + + def _stamp(self, body: str, target: DocTarget, commit: str) -> str: + header = ( + "\n\n" + ) + return header + body.strip() + "\n" + + def _git_commit(self) -> str: + from agentforge_graph.ingest.codegraph import _git_commit + + return _git_commit(self._repo_path) diff --git a/src/agentforge_graph/docgen/runner.py b/src/agentforge_graph/docgen/runner.py new file mode 100644 index 0000000..646b316 --- /dev/null +++ b/src/agentforge_graph/docgen/runner.py @@ -0,0 +1,91 @@ +"""The Agent doc runner (feat-016) — where the multi-turn loop runs. + +Builds an ``agentforge.Agent`` over the read-only ckg toolset, hands it the seed +task, and captures the run's provenance set from the tool observations. The Agent +is the repo's first non-test framework-Agent consumer; ``tests/serve/ +test_live_agent.py`` is the live-wiring reference. For hermetic CI a scripted +``LLMClient`` is injected via ``model=`` (no creds, no network). + +``agentforge.Agent`` and ``agentforge_core`` are imported lazily inside +:meth:`compose`, so importing :mod:`docgen` stays framework-free until a run +actually happens. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .errors import DocgenError +from .templates.base import SYSTEM_PROMPT, Template +from .toolset import capture_refs, grounded_tools +from .types import GroundedPack, ProvenanceSet + +if TYPE_CHECKING: + from agentforge_core.contracts.llm import LLMClient + + from agentforge_graph.config import ConfigSource, DocGenConfig + + +class AgentDocRunner: + """Runs one grounded compose loop → ``(body, ProvenanceSet)``.""" + + def __init__( + self, + cfg: DocGenConfig, + *, + repo_path: str, + config: ConfigSource = None, + model: str | LLMClient | None = None, + ) -> None: + self._cfg = cfg + self._repo_path = repo_path + self._config = config + self._model = model # None → resolve from cfg; str | LLMClient → use as-is + + def _resolve_model(self) -> str | LLMClient: + if self._model is not None: + return self._model + if self._cfg.provider == "scripted": + raise DocgenError( + "docgen provider 'scripted' requires an injected model (test-only); " + "set docgen.provider to a real provider for generation" + ) + return self._cfg.model_ref() + + async def compose(self, pack: GroundedPack, template: Template) -> tuple[str, ProvenanceSet]: + from agentforge import Agent + from agentforge_core.production.exceptions import BudgetExceeded, GuardrailViolation + + tools = grounded_tools(self._repo_path, self._config) + task = template.build_task(pack) + + try: + async with Agent( + model=self._resolve_model(), + strategy="react", + tools=tools, + system_prompt=SYSTEM_PROMPT, + budget_usd=self._cfg.budget_usd, + max_iterations=self._cfg.max_iterations, + install_log_filter=False, + ) as agent: + result = await agent.run(task) + except (BudgetExceeded, GuardrailViolation) as exc: + raise DocgenError( + f"budget/iteration cap reached before the doc completed: {exc}" + ) from exc + + body = result.output if isinstance(result.output, str) else str(result.output) + + # The tool boundary: capture every citable fact the tools returned, from + # the run's step trace (fact-bearing ckg tools return JSON with id + + # provenance; capture_refs applies the >= parsed floor). + observations = [ + content + for step in result.steps + if isinstance(content := getattr(step, "content", None), str) + ] + captured = capture_refs(observations) + seed_refs = {f.ref.id: f.ref for f in pack.facts} + prov = ProvenanceSet.build(seed_refs, captured) + return body, prov diff --git a/src/agentforge_graph/docgen/templates/__init__.py b/src/agentforge_graph/docgen/templates/__init__.py new file mode 100644 index 0000000..4842545 --- /dev/null +++ b/src/agentforge_graph/docgen/templates/__init__.py @@ -0,0 +1,17 @@ +"""Doc templates (feat-016) — registered by doc type on import.""" + +from __future__ import annotations + +from .architecture import ARCHITECTURE_TEMPLATE +from .base import SYSTEM_PROMPT, TEMPLATES, Template, get_template, register_template + +register_template(ARCHITECTURE_TEMPLATE) + +__all__ = [ + "SYSTEM_PROMPT", + "TEMPLATES", + "Template", + "get_template", + "register_template", + "ARCHITECTURE_TEMPLATE", +] diff --git a/src/agentforge_graph/docgen/templates/architecture.py b/src/agentforge_graph/docgen/templates/architecture.py new file mode 100644 index 0000000..5d41232 --- /dev/null +++ b/src/agentforge_graph/docgen/templates/architecture.py @@ -0,0 +1,23 @@ +"""The ``architecture`` template — a system overview skeleton (feat-016).""" + +from __future__ import annotations + +from ..types import DocType +from .base import Template + +ARCHITECTURE_TEMPLATE = Template( + doc_type=DocType.ARCHITECTURE, + title="Architecture Overview", + sections=( + "Overview", + "Layers & Components", + "Key Modules", + "Entry Points & Interfaces", + "Data & Framework Topology", + ), + guidance=( + "Describe how the system is structured and how its parts fit together — " + "orient a new engineer. Prefer the most central symbols and the framework " + "topology (routes/models) as anchors." + ), +) diff --git a/src/agentforge_graph/docgen/templates/base.py b/src/agentforge_graph/docgen/templates/base.py new file mode 100644 index 0000000..9d14cb1 --- /dev/null +++ b/src/agentforge_graph/docgen/templates/base.py @@ -0,0 +1,88 @@ +"""Doc templates — section skeleton + the grounding/citation contract (feat-016). + +A template is *data*: a title, an ordered set of section headers the model must +fill, and optional per-type guidance. :meth:`Template.build_task` turns a seed +:class:`GroundedPack` into the concrete task prompt; :data:`SYSTEM_PROMPT` carries +the doc-type-independent grounding discipline. Templates are Python objects (not +``.md.tmpl`` files) — same "data, not core" property, no packaging of template +artifacts. + +Adding a doc type = a new :class:`Template` + one :func:`register_template` call. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from ..errors import DocDisabled +from ..types import DocType, GroundedPack + +#: The grounding discipline every doc-generation run is bound by. The citation +#: rules here are what :mod:`docgen.citations` verifies after the run. +SYSTEM_PROMPT = """\ +You are a precise technical-documentation writer for a software repository. +You write ONLY from facts grounded in the code knowledge graph — never from +assumption or prior knowledge of similar projects. + +Workflow: +- Use the provided ckg_* tools (ckg_search, ckg_symbol, ckg_neighbors, + ckg_impact, ckg_repo_map, ckg_routes, ckg_decisions) to gather the facts you + need before writing. The seed facts you are given are a starting point, not the + whole picture — expand with the tools. + +Grounding rules (STRICT): +- Every substantive claim MUST cite a real fact with a footnote marker like + [^f1] placed at the end of the sentence. +- Define every marker in a final "## References" section, one per line, as: + [^f1]: + where is an EXACT id that appeared in a tool result or a seed fact. +- NEVER invent a symbol id, and NEVER cite an id no tool returned. +- If you cannot ground a section from the available facts, write its heading + followed by a line containing exactly `` instead of guessing. + +Output ONLY the final Markdown document (headings, prose, and the References +block). Do not include commentary about your process. +""" + + +@dataclass(frozen=True) +class Template: + doc_type: DocType + title: str + sections: tuple[str, ...] + guidance: str = "" + + def build_task(self, pack: GroundedPack) -> str: + lines: list[str] = [f"Write the **{self.title}** for this repository."] + if pack.target.scope: + lines.append(f"Scope: `{pack.target.scope}`.") + if self.guidance: + lines.append(self.guidance) + + if pack.facts: + lines.append("\nSeed facts (cite by the id in brackets; verify/expand with tools):") + for f in pack.facts: + lines.append(f"- {f.text} [id: {f.ref.id}]") + if pack.notes: + lines.append("\nContext (framing only — NOT citable):") + lines.extend(f"- {n}" for n in pack.notes) + + lines.append("\nProduce these sections, each grounded with footnote citations:") + lines.extend(f"## {s}" for s in self.sections) + lines.append("\nEnd with a `## References` block defining every [^fN] marker you used.") + return "\n".join(lines) + + +TEMPLATES: dict[DocType, Template] = {} + + +def register_template(template: Template) -> Template: + TEMPLATES[template.doc_type] = template + return template + + +def get_template(doc_type: DocType) -> Template: + template = TEMPLATES.get(doc_type) + if template is None: + raise DocDisabled(f"no template registered for doc type {doc_type.value!r}") + return template diff --git a/src/agentforge_graph/docgen/toolset.py b/src/agentforge_graph/docgen/toolset.py new file mode 100644 index 0000000..74d588c --- /dev/null +++ b/src/agentforge_graph/docgen/toolset.py @@ -0,0 +1,101 @@ +"""The grounded toolset + provenance capture (feat-016). + +The Agent expands the seed pack by calling the read-only feat-008 ckg tools — +the *same* ``Tool`` instances an MCP client gets, standalone-usable +(``code_graph_tools`` docstring: "Pass straight to ``Agent(tools=…)``"). Those +tools return JSON whose fact items carry ``id`` + ``provenance``, so we capture +the run's provenance set by scanning the observations — **tool-agnostic, no +second store handle**. + +Two guarantees live here: + +- **The tool boundary is the only fact source.** A symbol is citable only if it + appears in a captured tool result (or the seed) — the model cannot invent one. +- **The anti-echo-chamber floor.** A captured item whose ``provenance`` is + ``llm`` is dropped, so a generated/summarised (llm-sourced) fact can inform the + model's search but is never a citable ground-truth fact. +""" + +from __future__ import annotations + +import json +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any + +from agentforge_graph.core import NodeKind, Source, SymbolID + +from .types import SymbolRef + +if TYPE_CHECKING: + from agentforge_graph.config import ConfigSource + + +def grounded_tools(repo_path: str, config: ConfigSource = None) -> list[Any]: + """The read-only ckg ``Tool`` instances the doc Agent may call. These are the + feat-008 tools verbatim (all read-only); no write tool is ever included.""" + from agentforge_graph.serve import code_graph_tools + + return code_graph_tools(repo_path, config) + + +def _valid_symbol_id(value: str) -> bool: + try: + SymbolID.parse(value) + except ValueError: + return False + return True + + +def _ref_from_obj(symbol_id: str, obj: dict[str, Any]) -> SymbolRef | None: + kind_raw = obj.get("kind") + if not isinstance(kind_raw, str): + return None + try: + kind = NodeKind(kind_raw) + except ValueError: + return None + span = obj.get("span") + span_t = (span[0], span[1]) if isinstance(span, (list, tuple)) and len(span) == 2 else None + path = obj.get("path") or SymbolID.parse(symbol_id).path + return SymbolRef(id=symbol_id, kind=kind, name=str(obj.get("name", "")), path=path, span=span_t) + + +def _collect(node: Any, out: dict[str, SymbolRef]) -> None: + if isinstance(node, list): + for v in node: + _collect(v, out) + return + if not isinstance(node, dict): + return + sid = node.get("id") + if ( + isinstance(sid, str) + and sid not in out + and _valid_symbol_id(sid) + # Anti-echo-chamber floor: an llm-sourced fact is never citable. + and node.get("provenance") != Source.LLM.value + ): + ref = _ref_from_obj(sid, node) + if ref is not None: + out[sid] = ref + for v in node.values(): + _collect(v, out) + + +def capture_refs(observations: Iterable[str]) -> dict[str, SymbolRef]: + """Scan tool observation strings for citable graph facts. + + Each fact-bearing ckg tool returns ``ContextPack.to_dict()``-style JSON whose + items carry ``id`` / ``kind`` / ``name`` / ``path`` / ``span`` / ``provenance``. + We walk every observation, keep items with a valid :class:`SymbolID` and a + ``>= parsed`` provenance, and build a ``{id: SymbolRef}`` map. Non-JSON + observations (e.g. the rendered repo map, or the model's prose) parse-fail and + are skipped.""" + out: dict[str, SymbolRef] = {} + for text in observations: + try: + data = json.loads(text) + except (json.JSONDecodeError, TypeError): + continue + _collect(data, out) + return out diff --git a/tests/docgen/test_citations.py b/tests/docgen/test_citations.py index 1c1bd88..013c6e2 100644 --- a/tests/docgen/test_citations.py +++ b/tests/docgen/test_citations.py @@ -34,7 +34,7 @@ def _prov() -> ProvenanceSet: ## References -[^f1]: s1 the repo +[^f1]: s1 [^f2]: s2 """ @@ -52,7 +52,7 @@ def test_valid_doc_builds_footnotes_and_rewrites() -> None: def test_bad_citation_symbol_not_in_provenance() -> None: - body = _GOOD.replace("[^f1]: s1 the repo", "[^f1]: s99") + body = _GOOD.replace("[^f1]: s1", "[^f1]: s99") with pytest.raises(BadCitationError, match="s99"): verify_citations(body, _prov(), require_citations=True) diff --git a/tests/docgen/test_config.py b/tests/docgen/test_config.py index 2e68a03..24b72f8 100644 --- a/tests/docgen/test_config.py +++ b/tests/docgen/test_config.py @@ -21,7 +21,8 @@ def test_defaults() -> None: assert c.promote_required is True assert c.budget_usd == 5.0 assert c.max_iterations == 24 - assert c.provider == "bedrock" + assert c.provider == "anthropic" + assert c.model_ref() == "anthropic:claude-haiku-4-5" def test_parses_block(tmp_path: Path) -> None: diff --git a/tests/docgen/test_runner.py b/tests/docgen/test_runner.py new file mode 100644 index 0000000..a7df195 --- /dev/null +++ b/tests/docgen/test_runner.py @@ -0,0 +1,105 @@ +"""feat-016 chunk 3: the grounded Agent loop end to end (hermetic). + +Drives a real ``agentforge.Agent`` over the real read-only ckg tools with a +scripted ``MockLLMClient`` (no creds, no network): the mock requests a tool call +(exercising the tool boundary) then emits the final doc, which is verified, +written under ``output_root``, and recorded in the manifest. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest +from agentforge.testing import MockLLMClient + +from agentforge_graph.config import DocGenConfig +from agentforge_graph.docgen import DocGenerator, get_recipe +from agentforge_graph.docgen.errors import BadCitationError, DocgenError +from agentforge_graph.docgen.types import STATUS_DRAFT, DocTarget, DocType +from agentforge_graph.ingest import CodeGraph + +_SRC = """\ +class Repo: + def save(self): + return validate() + + +def validate(): + return True +""" + + +@pytest.fixture +async def indexed(tmp_path: Path) -> AsyncIterator[tuple[CodeGraph, Path]]: + (tmp_path / "app.py").write_text(_SRC) + cg = await CodeGraph.index(repo_path=tmp_path) + yield cg, tmp_path + await cg.close() + + +async def _seed_id(cg: CodeGraph) -> str: + pack = await get_recipe(DocType.ARCHITECTURE).seed(cg, DocTarget(type=DocType.ARCHITECTURE)) + assert pack.facts + return pack.facts[0].ref.id + + +def _script(final_doc: str) -> MockLLMClient: + # turn 1: request a (deterministic, embedding-free) tool call; turn 2: the doc. + return MockLLMClient.from_script( + [ + { + "text": "Let me look at the repo map.", + "tool_calls": [{"name": "ckg_repo_map", "args": {"budget_tokens": 400}}], + }, + {"text": final_doc, "stop_reason": "end_turn"}, + ] + ) + + +async def test_generate_writes_grounded_draft(indexed: tuple[CodeGraph, Path]) -> None: + cg, repo = indexed + sid = await _seed_id(cg) + final = ( + "## Overview\n\nThe system centers on this symbol [^f1].\n\n" + f"## References\n\n[^f1]: {sid}\n" + ) + gen = DocGenerator(cg, DocGenConfig(), repo_path=repo, model=_script(final)) + + art = await gen.generate(DocTarget(type=DocType.ARCHITECTURE)) + + assert art.type is DocType.ARCHITECTURE + assert art.status == STATUS_DRAFT + assert art.path == "docs/_generated/architecture.md" + assert {f.marker for f in art.footnotes} == {"f1"} + assert sid in art.source_ids + + written = (repo / art.path).read_text() + assert "Generated by CKG docgen" in written # provenance header + assert "[^f1]" in written + assert "do not edit by hand" in written + + # recorded in the sidecar manifest as a draft + from agentforge_graph.docgen import Manifest + + m = Manifest(repo / "docs/_generated") + assert m.get(art.path) is not None + assert m.get(art.path).status == STATUS_DRAFT # type: ignore[union-attr] + + +async def test_generate_rejects_fabricated_citation(indexed: tuple[CodeGraph, Path]) -> None: + cg, repo = indexed + # cite an id the seed/tools never produced → BadCitationError + bad = "## Overview\n\nClaim [^f1].\n\n## References\n\n[^f1]: ckg py repo ghost.py Ghost#\n" + gen = DocGenerator(cg, DocGenConfig(), repo_path=repo, model=_script(bad)) + with pytest.raises(BadCitationError): + await gen.generate(DocTarget(type=DocType.ARCHITECTURE)) + + +async def test_generate_budget_trip_raises(indexed: tuple[CodeGraph, Path]) -> None: + cg, repo = indexed + cfg = DocGenConfig(budget_usd=0.0) # trips before the first LLM call + gen = DocGenerator(cg, cfg, repo_path=repo, model=MockLLMClient.deterministic("x")) + with pytest.raises(DocgenError, match="budget"): + await gen.generate(DocTarget(type=DocType.ARCHITECTURE)) diff --git a/tests/docgen/test_toolset.py b/tests/docgen/test_toolset.py new file mode 100644 index 0000000..d00e5fa --- /dev/null +++ b/tests/docgen/test_toolset.py @@ -0,0 +1,75 @@ +"""feat-016 chunk 3: provenance capture from tool observations (the floor).""" + +from __future__ import annotations + +import json + +from agentforge_graph.core import NodeKind, Source, SymbolID +from agentforge_graph.docgen.toolset import capture_refs + +_ID_A = SymbolID.for_symbol("py", "repo", "app.py", "Repo#") +_ID_B = SymbolID.for_symbol("py", "repo", "app.py", "save().") +_ID_LLM = SymbolID.for_symbol("summary", "repo", "app.py", "summary.") + + +def _search_obs() -> str: + # mimics ckg_search / ckg_symbol ContextPack.to_dict() JSON + return json.dumps( + { + "items": [ + { + "id": _ID_A, + "kind": NodeKind.CLASS.value, + "name": "Repo", + "path": "app.py", + "span": [1, 9], + "provenance": Source.PARSED.value, + }, + { + "id": _ID_B, + "kind": NodeKind.METHOD.value, + "name": "save", + "path": "app.py", + "span": [3, 5], + "provenance": Source.RESOLVED.value, + }, + { + "id": _ID_LLM, + "kind": NodeKind.SUMMARY.value, + "name": "summary:app.py", + "provenance": Source.LLM.value, # must be dropped (echo-chamber floor) + }, + ], + "indexed_commit": "abc", + } + ) + + +def test_capture_extracts_parsed_and_resolved() -> None: + refs = capture_refs([_search_obs()]) + assert set(refs) == {_ID_A, _ID_B} # llm item dropped + assert refs[_ID_A].kind is NodeKind.CLASS + assert refs[_ID_A].name == "Repo" + assert refs[_ID_A].span == (1, 9) + assert refs[_ID_B].path == "app.py" + + +def test_capture_drops_llm_provenance() -> None: + refs = capture_refs([_search_obs()]) + assert _ID_LLM not in refs # anti-echo-chamber: llm fact never citable + + +def test_capture_skips_non_json_and_malformed() -> None: + assert capture_refs(["not json at all", "# repo map text\nfoo bar"]) == {} + # a dict without a valid symbol id contributes nothing + assert capture_refs([json.dumps({"items": [{"id": "not-a-symbol", "kind": "Class"}]})]) == {} + + +def test_capture_skips_item_without_kind() -> None: + obs = json.dumps({"items": [{"id": _ID_A, "name": "Repo", "provenance": "parsed"}]}) + assert capture_refs([obs]) == {} # no kind → cannot build a typed ref + + +def test_capture_dedupes_across_observations() -> None: + refs = capture_refs([_search_obs(), _search_obs()]) + assert set(refs) == {_ID_A, _ID_B} From 5188f8a42961c406a60d9e56aba87bd906bb4784 Mon Sep 17 00:00:00 2001 From: Khemchand Joshi Date: Fri, 10 Jul 2026 13:11:48 +0530 Subject: [PATCH 06/11] feat-016 chunk 4: remaining recipes + templates (all four doc types) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure additions behind the recipe/template seams — no shared-code edits: - recipes/_common.py — shared graph-based, provenance-floored (>= parsed) seed helpers: symbol_facts (structural symbols under a path scope via GraphQuery min_source=PARSED) + decision_facts (ADRs) + node_ref. - recipes/ai_context.py — orient an AI assistant: central symbols + decisions; repo summary as framing. - recipes/component.py — one module: structural symbols under scope + routes/models rooted there; file summaries as framing. - recipes/design.py — subsystem how+why: scoped symbols + governing decisions (highest-synthesis type; leans on require_citations + promote). - templates/{ai_context,component,design}.py — section skeletons; all four registered. Tests: seed grounding per type over a real index (ai-context central symbols; component scoped to a path incl. empty-for-unknown-scope; design scoped); all four registered in RECIPES + TEMPLATES; build_task embeds seed ids/sections/non-citable notes; DocDisabled on an unregistered type. Full gate: 1140 passed / 69 skipped / 94.39% cov; ruff + mypy strict clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../docgen/recipes/__init__.py | 17 ++++- .../docgen/recipes/_common.py | 74 +++++++++++++++++++ .../docgen/recipes/ai_context.py | 38 ++++++++++ .../docgen/recipes/component.py | 64 ++++++++++++++++ src/agentforge_graph/docgen/recipes/design.py | 33 +++++++++ .../docgen/templates/__init__.py | 9 +++ .../docgen/templates/ai_context.py | 22 ++++++ .../docgen/templates/component.py | 22 ++++++ .../docgen/templates/design.py | 23 ++++++ tests/docgen/test_recipes.py | 41 +++++++++- tests/docgen/test_templates.py | 42 +++++++++++ 11 files changed, 382 insertions(+), 3 deletions(-) create mode 100644 src/agentforge_graph/docgen/recipes/_common.py create mode 100644 src/agentforge_graph/docgen/recipes/ai_context.py create mode 100644 src/agentforge_graph/docgen/recipes/component.py create mode 100644 src/agentforge_graph/docgen/recipes/design.py create mode 100644 src/agentforge_graph/docgen/templates/ai_context.py create mode 100644 src/agentforge_graph/docgen/templates/component.py create mode 100644 src/agentforge_graph/docgen/templates/design.py create mode 100644 tests/docgen/test_templates.py diff --git a/src/agentforge_graph/docgen/recipes/__init__.py b/src/agentforge_graph/docgen/recipes/__init__.py index a83675d..715bbf5 100644 --- a/src/agentforge_graph/docgen/recipes/__init__.py +++ b/src/agentforge_graph/docgen/recipes/__init__.py @@ -6,9 +6,24 @@ from __future__ import annotations +from .ai_context import AiContextRecipe from .architecture import ArchitectureRecipe from .base import RECIPES, Recipe, get_recipe, register +from .component import ComponentRecipe +from .design import DesignRecipe register(ArchitectureRecipe()) +register(AiContextRecipe()) +register(ComponentRecipe()) +register(DesignRecipe()) -__all__ = ["RECIPES", "Recipe", "get_recipe", "register", "ArchitectureRecipe"] +__all__ = [ + "RECIPES", + "Recipe", + "get_recipe", + "register", + "ArchitectureRecipe", + "AiContextRecipe", + "ComponentRecipe", + "DesignRecipe", +] diff --git a/src/agentforge_graph/docgen/recipes/_common.py b/src/agentforge_graph/docgen/recipes/_common.py new file mode 100644 index 0000000..189c66f --- /dev/null +++ b/src/agentforge_graph/docgen/recipes/_common.py @@ -0,0 +1,74 @@ +"""Shared seed-assembly helpers for the recipes (feat-016). + +Graph-based (no embeddings), provenance-floored at ``>= parsed`` — so seeds are +deterministic and credential-free, and no llm-sourced fact enters as ground +truth. The Agent expands beyond the seed via the tools. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from agentforge_graph.core import GraphQuery, NodeKind, Source, SymbolID + +from ..types import GroundedFact, SymbolRef + +if TYPE_CHECKING: + from agentforge_graph.core import Node + from agentforge_graph.ingest import CodeGraph + +_STRUCTURAL = [ + NodeKind.CLASS, + NodeKind.INTERFACE, + NodeKind.FUNCTION, + NodeKind.METHOD, + NodeKind.TYPE_ALIAS, + NodeKind.VARIABLE, +] + + +def node_ref(node: Node) -> SymbolRef: + return SymbolRef( + id=node.id, + kind=node.kind, + name=node.name, + path=SymbolID.parse(node.id).path, + span=node.span, + ) + + +async def symbol_facts( + cg: CodeGraph, *, path_prefix: str = "", limit: int = 60 +) -> list[GroundedFact]: + """Structural symbols under ``path_prefix`` (whole repo when empty), each a + citable ``>= parsed`` fact.""" + res = await cg.store.graph.query( + GraphQuery( + kinds=_STRUCTURAL, + path_prefix=path_prefix or None, + min_source=Source.PARSED, + limit=limit, + ) + ) + facts: list[GroundedFact] = [] + for n in res.nodes: + ref = node_ref(n) + sig = str(n.attrs.get("signature", "")).strip() or n.name + facts.append( + GroundedFact( + text=f"{n.name} — {n.kind.value} in {ref.path}: {sig}", + ref=ref, + source=n.provenance.source.value, + ) + ) + return facts + + +async def decision_facts(cg: CodeGraph, scope: str | None = None) -> list[GroundedFact]: + """ADR decisions (optionally scoped) as citable facts.""" + facts: list[GroundedFact] = [] + for d in await cg.decisions(scope=scope): + ref = SymbolRef(id=d.id, kind=NodeKind.DECISION, name=d.title or d.adr_id, path=d.path) + text = f"Decision {d.adr_id} '{d.title}' ({d.status}) governs {len(d.governs)} symbol(s)" + facts.append(GroundedFact(text=text, ref=ref, source="parsed")) + return facts diff --git a/src/agentforge_graph/docgen/recipes/ai_context.py b/src/agentforge_graph/docgen/recipes/ai_context.py new file mode 100644 index 0000000..90d4a12 --- /dev/null +++ b/src/agentforge_graph/docgen/recipes/ai_context.py @@ -0,0 +1,38 @@ +"""The ``ai-context`` recipe — orient an AI assistant to the codebase (feat-016). + +Seeds the agent-context draft (CLAUDE.md / AGENTS.md) with the repo's most +central symbols and its recorded decisions, so the generated file points a coding +agent at the real structure + conventions. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +from ..types import DocTarget, DocType, GroundedFact, GroundedPack, SymbolRef +from ._common import decision_facts +from .base import Recipe + +if TYPE_CHECKING: + from agentforge_graph.ingest import CodeGraph + +_TOP_K = 30 + + +class AiContextRecipe(Recipe): + doc_type: ClassVar[DocType] = DocType.AI_CONTEXT + + async def seed(self, cg: CodeGraph, target: DocTarget) -> GroundedPack: + facts: list[GroundedFact] = [] + for rs in await cg.ranked_symbols(k=_TOP_K): + facts.append( + GroundedFact( + text=f"{rs.name} — {rs.kind.value} in {rs.path}", + ref=SymbolRef(id=rs.id, kind=rs.kind, name=rs.name, path=rs.path), + source="parsed", + ) + ) + facts.extend(await decision_facts(cg)) + + notes = [f"Repo summary: {s.text}" for s in await cg.summaries(level="repo") if s.text] + return GroundedPack(target=target, facts=tuple(facts), notes=tuple(notes)) diff --git a/src/agentforge_graph/docgen/recipes/component.py b/src/agentforge_graph/docgen/recipes/component.py new file mode 100644 index 0000000..aa305a9 --- /dev/null +++ b/src/agentforge_graph/docgen/recipes/component.py @@ -0,0 +1,64 @@ +"""The ``component`` recipe — document one module/package (feat-016). + +Seeds from the structural symbols under the target scope plus any framework +elements (routes/models) rooted there, with file summaries as framing. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +from agentforge_graph.core import NodeKind + +from ..types import DocTarget, DocType, GroundedFact, GroundedPack, SymbolRef +from ._common import symbol_facts +from .base import Recipe + +if TYPE_CHECKING: + from agentforge_graph.ingest import CodeGraph + + +class ComponentRecipe(Recipe): + doc_type: ClassVar[DocType] = DocType.COMPONENT + + async def seed(self, cg: CodeGraph, target: DocTarget) -> GroundedPack: + scope = target.scope or "" + facts = await symbol_facts(cg, path_prefix=scope, limit=80) + + for r in await cg.routes(): + if r.handler and r.file.startswith(scope): + facts.append( + GroundedFact( + text=f"Route {r.method} {r.path_pattern} → handler {r.handler}", + ref=SymbolRef( + id=r.handler, + kind=NodeKind.ROUTE, + name=f"{r.method} {r.path_pattern}", + path=r.file, + span=(r.line, r.line) if r.line else None, + ), + source="parsed", + ) + ) + for m in await cg.models(): + if m.cls and m.file.startswith(scope): + facts.append( + GroundedFact( + text=f"DataModel {m.name} ({m.framework})", + ref=SymbolRef( + id=m.cls, + kind=NodeKind.DATA_MODEL, + name=m.name, + path=m.file, + span=(m.line, m.line) if m.line else None, + ), + source="parsed", + ) + ) + + notes = [ + f"File summary ({s.path}): {s.text}" + for s in await cg.summaries(level="file") + if s.text and s.path.startswith(scope) + ] + return GroundedPack(target=target, facts=tuple(facts), notes=tuple(notes)) diff --git a/src/agentforge_graph/docgen/recipes/design.py b/src/agentforge_graph/docgen/recipes/design.py new file mode 100644 index 0000000..69271b6 --- /dev/null +++ b/src/agentforge_graph/docgen/recipes/design.py @@ -0,0 +1,33 @@ +"""The ``design`` recipe — "how it works + why" for a subsystem (feat-016). + +Seeds from the subsystem's structural symbols plus the decisions that govern it; +the Agent expands the call graph and rationale via the tools. The highest- +synthesis doc type — it leans hardest on ``require_citations`` + the promote gate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +from ..types import DocTarget, DocType, GroundedPack +from ._common import decision_facts, symbol_facts +from .base import Recipe + +if TYPE_CHECKING: + from agentforge_graph.ingest import CodeGraph + + +class DesignRecipe(Recipe): + doc_type: ClassVar[DocType] = DocType.DESIGN + + async def seed(self, cg: CodeGraph, target: DocTarget) -> GroundedPack: + scope = target.scope or "" + facts = await symbol_facts(cg, path_prefix=scope, limit=60) + facts.extend(await decision_facts(cg, scope=scope or None)) + + notes = [ + f"Summary ({s.path}): {s.text}" + for s in await cg.summaries() + if s.text and s.path.startswith(scope) + ] + return GroundedPack(target=target, facts=tuple(facts), notes=tuple(notes)) diff --git a/src/agentforge_graph/docgen/templates/__init__.py b/src/agentforge_graph/docgen/templates/__init__.py index 4842545..80c4392 100644 --- a/src/agentforge_graph/docgen/templates/__init__.py +++ b/src/agentforge_graph/docgen/templates/__init__.py @@ -2,10 +2,16 @@ from __future__ import annotations +from .ai_context import AI_CONTEXT_TEMPLATE from .architecture import ARCHITECTURE_TEMPLATE from .base import SYSTEM_PROMPT, TEMPLATES, Template, get_template, register_template +from .component import COMPONENT_TEMPLATE +from .design import DESIGN_TEMPLATE register_template(ARCHITECTURE_TEMPLATE) +register_template(AI_CONTEXT_TEMPLATE) +register_template(COMPONENT_TEMPLATE) +register_template(DESIGN_TEMPLATE) __all__ = [ "SYSTEM_PROMPT", @@ -14,4 +20,7 @@ "get_template", "register_template", "ARCHITECTURE_TEMPLATE", + "AI_CONTEXT_TEMPLATE", + "COMPONENT_TEMPLATE", + "DESIGN_TEMPLATE", ] diff --git a/src/agentforge_graph/docgen/templates/ai_context.py b/src/agentforge_graph/docgen/templates/ai_context.py new file mode 100644 index 0000000..a1c541e --- /dev/null +++ b/src/agentforge_graph/docgen/templates/ai_context.py @@ -0,0 +1,22 @@ +"""The ``ai-context`` template — a CLAUDE.md / AGENTS.md skeleton (feat-016).""" + +from __future__ import annotations + +from ..types import DocType +from .base import Template + +AI_CONTEXT_TEMPLATE = Template( + doc_type=DocType.AI_CONTEXT, + title="AI Assistant Context (CLAUDE.md / AGENTS.md)", + sections=( + "Project Overview", + "Architecture & Key Modules", + "Conventions & Decisions", + "How to Navigate", + ), + guidance=( + "Write the orientation an AI coding assistant needs before working in this " + "repository: what it is, how it is structured, the conventions/decisions to " + "respect, and where to look. Keep it concise and navigational." + ), +) diff --git a/src/agentforge_graph/docgen/templates/component.py b/src/agentforge_graph/docgen/templates/component.py new file mode 100644 index 0000000..7ca40d4 --- /dev/null +++ b/src/agentforge_graph/docgen/templates/component.py @@ -0,0 +1,22 @@ +"""The ``component`` template — a per-module doc skeleton (feat-016).""" + +from __future__ import annotations + +from ..types import DocType +from .base import Template + +COMPONENT_TEMPLATE = Template( + doc_type=DocType.COMPONENT, + title="Component Documentation", + sections=( + "Purpose", + "Public API", + "Key Types & Functions", + "Dependencies & Framework Elements", + ), + guidance=( + "Document the module in scope: what it is responsible for, the surface other " + "code depends on, and its notable types/functions and framework elements " + "(routes/models). Stay within the scope." + ), +) diff --git a/src/agentforge_graph/docgen/templates/design.py b/src/agentforge_graph/docgen/templates/design.py new file mode 100644 index 0000000..0143563 --- /dev/null +++ b/src/agentforge_graph/docgen/templates/design.py @@ -0,0 +1,23 @@ +"""The ``design`` template — a "how it works + why" skeleton (feat-016).""" + +from __future__ import annotations + +from ..types import DocType +from .base import Template + +DESIGN_TEMPLATE = Template( + doc_type=DocType.DESIGN, + title="Design Document", + sections=( + "Overview", + "How It Works", + "Key Components", + "Design Decisions & Rationale", + ), + guidance=( + "Explain how the subsystem in scope works and why it is built that way. " + "Anchor 'why' claims in recorded decisions; anchor 'how' claims in the real " + "symbols and their relationships. Do not speculate on rationale that is not " + "grounded in a decision or the code." + ), +) diff --git a/tests/docgen/test_recipes.py b/tests/docgen/test_recipes.py index 43d88a3..809a26a 100644 --- a/tests/docgen/test_recipes.py +++ b/tests/docgen/test_recipes.py @@ -36,8 +36,16 @@ def test_registry_lookup() -> None: assert isinstance(recipe, ArchitectureRecipe) -def test_unregistered_doc_type_raises() -> None: - # DESIGN has no recipe registered until chunk 4. +def test_all_four_types_registered() -> None: + from agentforge_graph.docgen.recipes import RECIPES + + assert set(RECIPES) == set(DocType) # architecture + ai-context + component + design + + +def test_unregistered_doc_type_raises(monkeypatch: pytest.MonkeyPatch) -> None: + from agentforge_graph.docgen.recipes import RECIPES + + monkeypatch.delitem(RECIPES, DocType.DESIGN) with pytest.raises(DocDisabled, match="design"): get_recipe(DocType.DESIGN) @@ -62,3 +70,32 @@ async def test_architecture_seed_has_no_llm_notes_without_enrichment(cg: CodeGra pack = await get_recipe(DocType.ARCHITECTURE).seed(cg, DocTarget(type=DocType.ARCHITECTURE)) assert pack.notes == () assert all(f.source != "llm" for f in pack.facts) + + +async def test_ai_context_seed_grounds_on_central_symbols(cg: CodeGraph) -> None: + pack = await get_recipe(DocType.AI_CONTEXT).seed(cg, DocTarget(type=DocType.AI_CONTEXT)) + assert pack.facts + assert {f.ref.name for f in pack.facts} & {"Repo", "save", "validate"} + assert all(f.source != "llm" for f in pack.facts) + + +async def test_component_seed_scoped_to_path(cg: CodeGraph) -> None: + pack = await get_recipe(DocType.COMPONENT).seed( + cg, DocTarget(type=DocType.COMPONENT, scope="app.py") + ) + assert pack.facts + assert all(f.ref.path == "app.py" for f in pack.facts) + assert {f.ref.name for f in pack.facts} >= {"Repo", "validate"} + + +async def test_component_seed_empty_for_unknown_scope(cg: CodeGraph) -> None: + pack = await get_recipe(DocType.COMPONENT).seed( + cg, DocTarget(type=DocType.COMPONENT, scope="nonexistent/") + ) + assert pack.facts == () + + +async def test_design_seed_grounds_on_scope(cg: CodeGraph) -> None: + pack = await get_recipe(DocType.DESIGN).seed(cg, DocTarget(type=DocType.DESIGN, scope="app.py")) + assert pack.facts + assert all(f.source in {"parsed", "resolved"} for f in pack.facts) diff --git a/tests/docgen/test_templates.py b/tests/docgen/test_templates.py new file mode 100644 index 0000000..5ea0295 --- /dev/null +++ b/tests/docgen/test_templates.py @@ -0,0 +1,42 @@ +"""feat-016 chunk 4: templates registered for every doc type + task rendering.""" + +from __future__ import annotations + +import pytest + +from agentforge_graph.core import NodeKind +from agentforge_graph.docgen.errors import DocDisabled +from agentforge_graph.docgen.templates.base import TEMPLATES, get_template +from agentforge_graph.docgen.types import ( + DocTarget, + DocType, + GroundedFact, + GroundedPack, + SymbolRef, +) + + +def test_all_four_types_have_templates() -> None: + assert set(TEMPLATES) == set(DocType) + + +def test_unregistered_template_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delitem(TEMPLATES, DocType.DESIGN) + with pytest.raises(DocDisabled, match="design"): + get_template(DocType.DESIGN) + + +def test_build_task_embeds_seed_facts_and_sections() -> None: + ref = SymbolRef(id="ckg py repo app.py Repo#", kind=NodeKind.CLASS, name="Repo", path="app.py") + pack = GroundedPack( + target=DocTarget(type=DocType.COMPONENT, scope="app.py"), + facts=(GroundedFact(text="Repo is the store", ref=ref, source="parsed"),), + notes=("File summary: x",), + ) + task = get_template(DocType.COMPONENT).build_task(pack) + assert "ckg py repo app.py Repo#" in task # the citable id is offered + assert "Repo is the store" in task + assert "Scope: `app.py`" in task + assert "## References" in task + assert "## Public API" in task # a template section + assert "NOT citable" in task # notes framing marked non-citable From c8b9d3c66925a726d1dfabb5866d07f405b97452 Mon Sep 17 00:00:00 2001 From: Khemchand Joshi Date: Fri, 10 Jul 2026 13:18:20 +0530 Subject: [PATCH 07/11] feat-016 chunk 5: dirty-aware update + list/diff/promote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The freshness + review surface on DocGenerator: - update() — drain DirtySet("docs"), regenerate only the docs whose source_ids intersect the dirty set (stale_docs), then mark the covered symbols clean. Mirrors CodeGraph.summarize's drain->run->mark_clean (feat-004 reuse); DirtySet rooted via resolve_root(repo, StoreConfig). - list_docs() — every doc with `stale` computed (dirty source, moved index commit, or bumped doc_lang_version). - diff(path) — unified diff of the on-disk file vs a fresh regeneration (what update would change); DocgenError on an unknown path. - promote(path) — flip draft -> accepted (human gate). - generate() refactored to share _compose() with diff. - DocArtifact gains `scope` (persisted) so a doc can be regenerated with its original target; manifest + promote carry it through. Tests via a repeating _ConstLLM (MockLLMClient exhausts across the multiple composes update/diff need): update regenerates only dirty docs and clears the cursor; list flags stale after a source dirties; promote flips + errors on unknown path; diff is empty for a fresh doc and surfaces a hand-edit. Full gate: 1145 passed / 69 skipped / 94.40% cov; ruff + mypy strict clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/agentforge_graph/docgen/generator.py | 124 +++++++++++++++++---- src/agentforge_graph/docgen/manifest.py | 3 + src/agentforge_graph/docgen/types.py | 1 + tests/docgen/test_update.py | 134 +++++++++++++++++++++++ 4 files changed, 242 insertions(+), 20 deletions(-) create mode 100644 tests/docgen/test_update.py diff --git a/src/agentforge_graph/docgen/generator.py b/src/agentforge_graph/docgen/generator.py index c3b1fab..713f99a 100644 --- a/src/agentforge_graph/docgen/generator.py +++ b/src/agentforge_graph/docgen/generator.py @@ -1,28 +1,40 @@ -"""``DocGenerator`` (feat-016) — orchestrates one grounded doc: seed → compose → -verify → emit draft + manifest entry. +"""``DocGenerator`` (feat-016) — orchestrates grounded docs. -Chunk 3 lands the ``generate`` path (a single doc, end to end); ``update`` / -``list`` / ``diff`` / ``promote`` / ``sync`` land in later chunks. +seed → compose (Agent loop) → verify citations → emit draft + manifest entry, +plus the freshness/review surface: dirty-aware ``update``, ``list_docs``, +``diff`` (regenerate vs on-disk), and ``promote`` (the human gate). """ from __future__ import annotations +import difflib import re +from dataclasses import replace from pathlib import Path from typing import TYPE_CHECKING from .citations import verify_citations +from .errors import DocgenError from .manifest import Manifest, content_sha from .recipes import get_recipe from .runner import AgentDocRunner +from .staleness import DOCS_CONSUMER, is_stale, stale_docs from .templates.base import get_template -from .types import DOC_LANG_VERSION, STATUS_DRAFT, DocArtifact, DocTarget, DocType +from .types import ( + DOC_LANG_VERSION, + STATUS_DRAFT, + DocArtifact, + DocTarget, + DocType, + Footnote, +) if TYPE_CHECKING: from agentforge_core.contracts.llm import LLMClient from agentforge_graph.config import ConfigSource, DocGenConfig from agentforge_graph.ingest import CodeGraph + from agentforge_graph.ingest.incremental import DirtySet def _slug(scope: str) -> str: @@ -30,7 +42,7 @@ def _slug(scope: str) -> str: class DocGenerator: - """Generates grounded docs for one repository/config.""" + """Generates and maintains grounded docs for one repository/config.""" def __init__( self, @@ -44,21 +56,15 @@ def __init__( self._cg = cg self._cfg = cfg self._repo_path = Path(repo_path) + self._config = config self._runner = AgentDocRunner(cfg, repo_path=str(repo_path), config=config, model=model) - async def generate(self, target: DocTarget) -> DocArtifact: - """Generate one doc: seed the pack, run the grounded Agent loop, verify - citations, write the draft under ``output_root``, and record it.""" - recipe = get_recipe(target.type) - pack = await recipe.seed(self._cg, target) - template = get_template(target.type) - - body, prov = await self._runner.compose(pack, template) - verified = verify_citations(body, prov, require_citations=self._cfg.require_citations) + # --- generation ---------------------------------------------------------- - commit = self._git_commit() + async def generate(self, target: DocTarget) -> DocArtifact: + """Generate one doc: seed → Agent compose → verify → write draft → record.""" + full, source_ids, footnotes, commit = await self._compose(target) rel = self._doc_relpath(target) - full = self._stamp(verified.body, target, commit) abs_path = self._repo_path / rel abs_path.parent.mkdir(parents=True, exist_ok=True) abs_path.write_text(full) @@ -69,18 +75,96 @@ async def generate(self, target: DocTarget) -> DocArtifact: status=STATUS_DRAFT, synced_commit=commit, doc_lang_version=DOC_LANG_VERSION, - source_ids=prov.source_ids(), - footnotes=verified.footnotes, + source_ids=source_ids, + scope=target.scope, + footnotes=footnotes, content_sha=content_sha(full), ) self._manifest().upsert(artifact) return artifact - # --- helpers ------------------------------------------------------------- + async def update(self) -> list[DocArtifact]: + """Regenerate only the docs whose source symbols were dirtied (feat-004 + reuse), then mark those symbols clean for the ``docs`` consumer.""" + manifest = self._manifest() + dirty = self._dirtyset() + dirty_ids = await dirty.dirty_for(DOCS_CONSUMER) + todo = stale_docs(manifest.all(), dirty_ids) + + regenerated: list[DocArtifact] = [] + for art in todo: + regenerated.append(await self.generate(DocTarget(type=art.type, scope=art.scope))) + + covered = {sid for art in todo for sid in art.source_ids} + cleaned = [i for i in dirty_ids if i in covered] + if cleaned: + await dirty.mark_clean(DOCS_CONSUMER, cleaned) + return regenerated + + # --- review surface ------------------------------------------------------ + + async def list_docs(self) -> list[DocArtifact]: + """Every generated doc with its ``stale`` flag computed (dirty source, + moved index commit, or a bumped doc_lang_version).""" + manifest = self._manifest() + dirty = set(await self._dirtyset().dirty_for(DOCS_CONSUMER)) + head = self._git_commit() + out: list[DocArtifact] = [] + for a in manifest.all(): + stale = is_stale(a, dirty, head) or a.doc_lang_version != DOC_LANG_VERSION + out.append(replace(a, stale=stale)) + return out + + async def diff(self, path: str) -> str: + """Unified diff of the on-disk doc vs a fresh regeneration — what + ``update`` would change.""" + art = self._manifest().get(path) + if art is None: + raise DocgenError(f"no generated doc recorded at {path!r}") + abs_path = self._repo_path / path + current = abs_path.read_text() if abs_path.exists() else "" + full, *_ = await self._compose(DocTarget(type=art.type, scope=art.scope)) + return "".join( + difflib.unified_diff( + current.splitlines(keepends=True), + full.splitlines(keepends=True), + fromfile=f"{path} (on disk)", + tofile=f"{path} (regenerated)", + ) + ) + + def promote(self, path: str) -> DocArtifact: + """Flip a draft to ``accepted`` — the human review gate.""" + manifest = self._manifest() + if manifest.get(path) is None: + raise DocgenError(f"no generated doc recorded at {path!r}") + return manifest.promote(path) + + # --- internals ----------------------------------------------------------- + + async def _compose( + self, target: DocTarget + ) -> tuple[str, tuple[str, ...], tuple[Footnote, ...], str]: + recipe = get_recipe(target.type) + pack = await recipe.seed(self._cg, target) + template = get_template(target.type) + body, prov = await self._runner.compose(pack, template) + verified = verify_citations(body, prov, require_citations=self._cfg.require_citations) + commit = self._git_commit() + full = self._stamp(verified.body, target, commit) + return full, prov.source_ids(), verified.footnotes, commit def _manifest(self) -> Manifest: return Manifest(self._repo_path / self._cfg.output_root) + def _dirtyset(self) -> DirtySet: + from agentforge_graph.config import StoreConfig + from agentforge_graph.ingest.incremental import DirtySet + from agentforge_graph.store import resolve_root + + root = resolve_root(self._repo_path, StoreConfig.load(self._config)) + return DirtySet(root) + def _doc_relpath(self, target: DocTarget) -> str: root = self._cfg.output_root.rstrip("/") if target.type is DocType.COMPONENT and target.scope: diff --git a/src/agentforge_graph/docgen/manifest.py b/src/agentforge_graph/docgen/manifest.py index 66dd2d4..28a034d 100644 --- a/src/agentforge_graph/docgen/manifest.py +++ b/src/agentforge_graph/docgen/manifest.py @@ -69,6 +69,7 @@ def _artifact_to_record(a: DocArtifact) -> dict[str, Any]: "synced_commit": a.synced_commit, "doc_lang_version": a.doc_lang_version, "source_ids": list(a.source_ids), + "scope": a.scope, "footnotes": [{"marker": f.marker, "ref": _ref_to_dict(f.ref)} for f in a.footnotes], "content_sha": a.content_sha, } @@ -82,6 +83,7 @@ def _artifact_from_record(path: str, r: dict[str, Any]) -> DocArtifact: synced_commit=r.get("synced_commit", ""), doc_lang_version=r.get("doc_lang_version", ""), source_ids=tuple(r.get("source_ids", [])), + scope=r.get("scope"), footnotes=tuple( Footnote(marker=f["marker"], ref=_ref_from_dict(f["ref"])) for f in r.get("footnotes", []) @@ -147,6 +149,7 @@ def promote(self, path: str) -> DocArtifact: synced_commit=current.synced_commit, doc_lang_version=current.doc_lang_version, source_ids=current.source_ids, + scope=current.scope, footnotes=current.footnotes, content_sha=current.content_sha, ) diff --git a/src/agentforge_graph/docgen/types.py b/src/agentforge_graph/docgen/types.py index 661d3f1..f750386 100644 --- a/src/agentforge_graph/docgen/types.py +++ b/src/agentforge_graph/docgen/types.py @@ -133,6 +133,7 @@ class DocArtifact: synced_commit: str # git commit the run was built from doc_lang_version: str source_ids: tuple[str, ...] # ProvenanceSet keys — the staleness key + scope: str | None = None # the DocTarget scope — needed to regenerate the doc footnotes: tuple[Footnote, ...] = () content_sha: str = "" # sha256 of the generated bytes (drift detection) stale: bool = False # computed, not serialized diff --git a/tests/docgen/test_update.py b/tests/docgen/test_update.py new file mode 100644 index 0000000..2f0b925 --- /dev/null +++ b/tests/docgen/test_update.py @@ -0,0 +1,134 @@ +"""feat-016 chunk 5: dirty-aware update + list/diff/promote.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest +from agentforge_core.contracts.llm import LLMClient +from agentforge_core.values.messages import LLMResponse, Message, TokenUsage, ToolSpec + +from agentforge_graph.config import DocGenConfig, StoreConfig +from agentforge_graph.docgen import DocGenerator, get_recipe +from agentforge_graph.docgen.errors import DocgenError +from agentforge_graph.docgen.staleness import DOCS_CONSUMER +from agentforge_graph.docgen.types import STATUS_ACCEPTED, DocTarget, DocType +from agentforge_graph.ingest import CodeGraph +from agentforge_graph.ingest.incremental import DirtySet +from agentforge_graph.store import resolve_root + +_SRC = """\ +class Repo: + def save(self): + return validate() + + +def validate(): + return True +""" + + +class _ConstLLM(LLMClient): + """A repeating LLMClient (unlike MockLLMClient, never exhausts) — returns the + same end_turn document for every call, so multi-compose flows (update/diff) + stay deterministic and creds-free.""" + + def __init__(self, doc: str) -> None: + self._doc = doc + + async def call( + self, system: str, messages: list[Message], tools: list[ToolSpec] | None = None + ) -> LLMResponse: + del system, messages, tools + return LLMResponse( + content=self._doc, + tool_calls=(), + stop_reason="end_turn", + usage=TokenUsage(input_tokens=1, output_tokens=1), + cost_usd=0.0, + model="const", + provider="const", + ) + + async def close(self) -> None: + return + + +@pytest.fixture +async def env(tmp_path: Path) -> AsyncIterator[tuple[CodeGraph, Path]]: + (tmp_path / "app.py").write_text(_SRC) + cg = await CodeGraph.index(repo_path=tmp_path) + yield cg, tmp_path + await cg.close() + + +async def _gen(cg: CodeGraph, repo: Path) -> tuple[DocGenerator, str]: + pack = await get_recipe(DocType.ARCHITECTURE).seed(cg, DocTarget(type=DocType.ARCHITECTURE)) + sid = pack.facts[0].ref.id + doc = f"## Overview\n\nCentral symbol [^f1].\n\n## References\n\n[^f1]: {sid}\n" + return DocGenerator(cg, DocGenConfig(), repo_path=repo, model=_ConstLLM(doc)), sid + + +async def test_update_regenerates_only_dirty_docs(env: tuple[CodeGraph, Path]) -> None: + cg, repo = env + gen, sid = await _gen(cg, repo) + art = await gen.generate(DocTarget(type=DocType.ARCHITECTURE)) + + # nothing dirty yet → update is a no-op + assert await gen.update() == [] + + # dirty a symbol the doc grounds on → update regenerates that doc + dirty = DirtySet(resolve_root(repo, StoreConfig.load(None))) + await dirty.add([sid]) + regenerated = await gen.update() + assert [a.path for a in regenerated] == [art.path] + + # the covered dirty id is now marked clean for the docs consumer + assert sid not in await DirtySet(resolve_root(repo, StoreConfig.load(None))).dirty_for( + DOCS_CONSUMER + ) + + +async def test_list_docs_flags_stale(env: tuple[CodeGraph, Path]) -> None: + cg, repo = env + gen, sid = await _gen(cg, repo) + await gen.generate(DocTarget(type=DocType.ARCHITECTURE)) + + fresh = await gen.list_docs() + assert len(fresh) == 1 and fresh[0].stale is False + + await DirtySet(resolve_root(repo, StoreConfig.load(None))).add([sid]) + listed = await gen.list_docs() + assert listed[0].stale is True + + +async def test_promote_flips_status(env: tuple[CodeGraph, Path]) -> None: + cg, repo = env + gen, _ = await _gen(cg, repo) + art = await gen.generate(DocTarget(type=DocType.ARCHITECTURE)) + promoted = gen.promote(art.path) + assert promoted.status == STATUS_ACCEPTED + assert (await gen.list_docs())[0].status == STATUS_ACCEPTED + + +async def test_promote_unknown_path_raises(env: tuple[CodeGraph, Path]) -> None: + cg, repo = env + gen, _ = await _gen(cg, repo) + with pytest.raises(DocgenError, match="no generated doc"): + gen.promote("docs/_generated/nope.md") + + +async def test_diff_detects_on_disk_edit(env: tuple[CodeGraph, Path]) -> None: + cg, repo = env + gen, _ = await _gen(cg, repo) + art = await gen.generate(DocTarget(type=DocType.ARCHITECTURE)) + + # freshly generated == a deterministic regeneration → empty diff + assert await gen.diff(art.path) == "" + + # a human edit shows up in the diff + (repo / art.path).write_text("## Overview\n\nHand-edited nonsense.\n") + d = await gen.diff(art.path) + assert "Hand-edited nonsense" in d + assert "Central symbol" in d # what regeneration would restore From 3f8edbbc8e1f3b4948abbfd384a3e7952fb65ecf Mon Sep 17 00:00:00 2001 From: Khemchand Joshi Date: Fri, 10 Jul 2026 13:26:11 +0530 Subject: [PATCH 08/11] feat-016 chunk 6: ckg docs CLI + CodeGraph facade - CodeGraph facade (framework layer, ADR-0001): docs_generate / docs_update / docs_list / docs_diff / docs_promote, each building a DocGenerator lazily from DocGenConfig; enabled gate on the write verbs; optional `model` seam (bring-your-own LLMClient) for hermetic tests + advanced callers. DocGenConfig gains `enabled`. - cli.py: `ckg docs` nested subparser (generate/update/list/diff/promote), modeled on `ckg ci`. generate takes --type/--all/--scope/--budget-usd; list --format table|json (reuses cli_format); diff/promote take a positional doc path (repo via --path). DocgenError -> stderr + exit 2. Tests: generate requires --type/--all; refuses when docgen.enabled:false; list surfaces a seeded doc (JSON); promote flips + shows accepted on relist; diff on an unknown doc exits 2. CLI tests are sync (main owns the loop) with async fixtures seeding via a const LLMClient. Full gate: 1150 passed / 69 skipped / 94.20% cov; ruff + mypy strict clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/agentforge_graph/cli.py | 141 +++++++++++++++++++++++ src/agentforge_graph/config.py | 1 + src/agentforge_graph/ingest/codegraph.py | 65 +++++++++++ tests/docgen/test_cli_docs.py | 125 ++++++++++++++++++++ 4 files changed, 332 insertions(+) create mode 100644 tests/docgen/test_cli_docs.py diff --git a/src/agentforge_graph/cli.py b/src/agentforge_graph/cli.py index 0b53771..abc20ea 100644 --- a/src/agentforge_graph/cli.py +++ b/src/agentforge_graph/cli.py @@ -562,6 +562,109 @@ async def _enrich(args: argparse.Namespace) -> int: return 0 +def _resolve_doc_types(args: argparse.Namespace) -> list[str]: + if getattr(args, "all", False): + from agentforge_graph.config import DocGenConfig + + return list(DocGenConfig.load(args.config).types) + return [args.type] if args.type else [] + + +async def _docs_generate(args: argparse.Namespace) -> int: + """feat-016: generate grounded doc draft(s).""" + from agentforge_graph.docgen import DocgenError + + types = _resolve_doc_types(args) + if not types: + print("ckg docs generate: specify --type or --all", file=sys.stderr) + return 2 + cg = await CodeGraph.open(repo_path=args.path, config=args.config) + try: + for t in types: + try: + art = await cg.docs_generate(t, scope=args.scope, budget_usd=args.budget_usd) + except DocgenError as exc: + print(f"ckg docs generate: {exc}", file=sys.stderr) + return 2 + cites = f" · {len(art.footnotes)} citation(s)" if art.footnotes else "" + print(f"generated {art.path} [{art.status}]{cites}") + finally: + await cg.close() + return 0 + + +async def _docs_update(args: argparse.Namespace) -> int: + """feat-016: regenerate only docs whose code changed.""" + from agentforge_graph.docgen import DocgenError + + cg = await CodeGraph.open(repo_path=args.path, config=args.config) + try: + try: + docs = await cg.docs_update(budget_usd=args.budget_usd) + except DocgenError as exc: + print(f"ckg docs update: {exc}", file=sys.stderr) + return 2 + if not docs: + print("ckg docs update: nothing stale") + for art in docs: + print(f"regenerated {art.path}") + finally: + await cg.close() + return 0 + + +async def _docs_list(args: argparse.Namespace) -> int: + """feat-016: list generated docs + staleness.""" + from agentforge_graph.cli_format import render_json, render_table + + cg = await CodeGraph.open(repo_path=args.path, config=args.config) + try: + docs = await cg.docs_list() + finally: + await cg.close() + cols = ["path", "type", "status", "stale", "citations", "synced"] + rows = [ + [d.path, d.type.value, d.status, d.stale, len(d.footnotes), (d.synced_commit or "")[:8]] + for d in docs + ] + print(render_json(cols, rows) if args.format == "json" else render_table(cols, rows)) + return 0 + + +async def _docs_diff(args: argparse.Namespace) -> int: + """feat-016: diff a doc vs a fresh regeneration.""" + from agentforge_graph.docgen import DocgenError + + cg = await CodeGraph.open(repo_path=args.path, config=args.config) + try: + try: + out = await cg.docs_diff(args.doc) + except DocgenError as exc: + print(f"ckg docs diff: {exc}", file=sys.stderr) + return 2 + finally: + await cg.close() + print(out if out else "(no differences)") + return 0 + + +async def _docs_promote(args: argparse.Namespace) -> int: + """feat-016: mark a reviewed draft as accepted.""" + from agentforge_graph.docgen import DocgenError + + cg = await CodeGraph.open(repo_path=args.path, config=args.config) + try: + try: + art = cg.docs_promote(args.doc) + except DocgenError as exc: + print(f"ckg docs promote: {exc}", file=sys.stderr) + return 2 + finally: + await cg.close() + print(f"promoted {art.path} → {art.status}") + return 0 + + async def _build(args: argparse.Namespace) -> int: """ENH-021: the one command — index, then embed (where enabled), then enrich (with --enrich), for a whole workspace (--workspace) or a single repo.""" @@ -1250,6 +1353,44 @@ def build_parser() -> argparse.ArgumentParser: ) ci_init.set_defaults(func=_ci_init) + # feat-016: grounded documentation generation (nested verbs, like `ci`). + docs_p = sub.add_parser("docs", help="grounded documentation generation (feat-016)") + docs_sub = docs_p.add_subparsers(dest="docs_cmd", required=True) + _doc_types = ["ai-context", "architecture", "component", "design"] + + dg = docs_sub.add_parser("generate", help="generate a grounded doc draft") + _add_repo_arg(dg) + dg.add_argument("--type", choices=_doc_types, help="doc type to generate") + dg.add_argument("--scope", default=None, help="package/path or subsystem to scope the doc") + dg.add_argument("--all", action="store_true", help="generate every configured type") + dg.add_argument("--budget-usd", type=float, default=None, help="per-run USD cap override") + dg.add_argument("--config", default=None, help="path to ckg.yaml") + dg.set_defaults(func=_docs_generate) + + du = docs_sub.add_parser("update", help="regenerate only docs whose code changed") + _add_repo_arg(du) + du.add_argument("--budget-usd", type=float, default=None, help="per-run USD cap override") + du.add_argument("--config", default=None, help="path to ckg.yaml") + du.set_defaults(func=_docs_update) + + dl = docs_sub.add_parser("list", help="list generated docs + staleness") + _add_repo_arg(dl) + dl.add_argument("--format", choices=["table", "json"], default="table") + dl.add_argument("--config", default=None, help="path to ckg.yaml") + dl.set_defaults(func=_docs_list) + + dd = docs_sub.add_parser("diff", help="diff a doc vs a fresh regeneration") + _add_repo_arg(dd, positional=False) + dd.add_argument("doc", help="the generated doc path (repo-relative)") + dd.add_argument("--config", default=None, help="path to ckg.yaml") + dd.set_defaults(func=_docs_diff) + + dp = docs_sub.add_parser("promote", help="mark a reviewed draft as accepted") + _add_repo_arg(dp, positional=False) + dp.add_argument("doc", help="the generated doc path (repo-relative)") + dp.add_argument("--config", default=None, help="path to ckg.yaml") + dp.set_defaults(func=_docs_promote) + smap = sub.add_parser( "services-map", help="cross-service call graph over a workspace (ENH-020)" ) diff --git a/src/agentforge_graph/config.py b/src/agentforge_graph/config.py index a0fecb1..1e807f5 100644 --- a/src/agentforge_graph/config.py +++ b/src/agentforge_graph/config.py @@ -344,6 +344,7 @@ class DocGenConfig(_Block): draft until ``ckg docs promote`` (``promote_required``).""" KEY: ClassVar[str] = "docgen" + enabled: bool = True # disable the docgen surface wholesale output_root: str = "docs/_generated" types: list[str] = Field( default_factory=lambda: ["ai-context", "architecture", "component", "design"] diff --git a/src/agentforge_graph/ingest/codegraph.py b/src/agentforge_graph/ingest/codegraph.py index 0a1f48d..ad8bc7b 100644 --- a/src/agentforge_graph/ingest/codegraph.py +++ b/src/agentforge_graph/ingest/codegraph.py @@ -22,6 +22,8 @@ from .source import RepoSource if TYPE_CHECKING: + from agentforge_core.contracts.llm import LLMClient # feat-016 docgen model type + from agentforge_graph.config import ConfigSource # embed/retrieve import ingest, so reference their types under TYPE_CHECKING. @@ -938,6 +940,69 @@ async def summaries(self, level: str | None = None) -> list[SummaryInfo]: out.sort(key=lambda s: (s.level, s.path)) return out + # --- grounded documentation generation (feat-016) -------------------- + + def _docgen( + self, *, budget_usd: float | None = None, model: str | LLMClient | None = None + ) -> Any: + """Build a ``DocGenerator`` for this graph (framework layer, ADR-0001).""" + from agentforge_graph.config import DocGenConfig + from agentforge_graph.docgen import DocGenerator + + cfg = DocGenConfig.load(self._config) + if budget_usd is not None: + cfg = cfg.model_copy(update={"budget_usd": budget_usd}) + return cfg, DocGenerator( + self, cfg, repo_path=self._repo_path, config=self._config, model=model + ) + + async def docs_generate( + self, + doc_type: str, + scope: str | None = None, + *, + budget_usd: float | None = None, + model: str | LLMClient | None = None, + ) -> Any: + """Generate one grounded doc draft (feat-016).""" + from agentforge_graph.docgen import DocDisabled, DocTarget, DocType + + cfg, gen = self._docgen(budget_usd=budget_usd, model=model) + if not cfg.enabled: + raise DocDisabled("docgen is disabled (docgen.enabled: false)") + try: + dt = DocType(doc_type) + except ValueError as exc: + raise DocDisabled(f"unknown doc type {doc_type!r}") from exc + return await gen.generate(DocTarget(type=dt, scope=scope)) + + async def docs_update( + self, *, budget_usd: float | None = None, model: str | LLMClient | None = None + ) -> Any: + """Regenerate only the docs whose source symbols changed (feat-016).""" + from agentforge_graph.docgen import DocDisabled + + cfg, gen = self._docgen(budget_usd=budget_usd, model=model) + if not cfg.enabled: + raise DocDisabled("docgen is disabled (docgen.enabled: false)") + return await gen.update() + + async def docs_list(self) -> Any: + """Every generated doc with its staleness flag (feat-016).""" + _, gen = self._docgen() + return await gen.list_docs() + + async def docs_diff(self, path: str, *, model: str | LLMClient | None = None) -> str: + """Unified diff of a doc vs a fresh regeneration (feat-016).""" + _, gen = self._docgen(model=model) + result: str = await gen.diff(path) + return result + + def docs_promote(self, path: str) -> Any: + """Flip a generated draft to accepted — the human gate (feat-016).""" + _, gen = self._docgen() + return gen.promote(path) + @property def store(self) -> Store: return self._store diff --git a/tests/docgen/test_cli_docs.py b/tests/docgen/test_cli_docs.py new file mode 100644 index 0000000..b77c84c --- /dev/null +++ b/tests/docgen/test_cli_docs.py @@ -0,0 +1,125 @@ +"""feat-016 chunk 6: the `ckg docs` CLI surface. + +CLI tests are sync (``main`` runs its own event loop); async fixtures seed the +index + a doc using a repeating const LLMClient (no creds), mirroring the +feat-015 query CLI tests. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest +from agentforge_core.contracts.llm import LLMClient +from agentforge_core.values.messages import LLMResponse, Message, TokenUsage, ToolSpec + +from agentforge_graph.cli import main +from agentforge_graph.docgen import get_recipe +from agentforge_graph.docgen.types import DocTarget, DocType +from agentforge_graph.ingest import CodeGraph + +_SRC = """\ +class Repo: + def save(self): + return validate() + + +def validate(): + return True +""" + + +class _ConstLLM(LLMClient): + def __init__(self, doc: str) -> None: + self._doc = doc + + async def call( + self, system: str, messages: list[Message], tools: list[ToolSpec] | None = None + ) -> LLMResponse: + del system, messages, tools + return LLMResponse( + content=self._doc, + tool_calls=(), + stop_reason="end_turn", + usage=TokenUsage(input_tokens=1, output_tokens=1), + cost_usd=0.0, + model="const", + provider="const", + ) + + async def close(self) -> None: + return + + +@pytest.fixture +async def indexed(tmp_path: Path) -> AsyncIterator[Path]: + (tmp_path / "app.py").write_text(_SRC) + cg = await CodeGraph.index(repo_path=tmp_path) + await cg.close() + yield tmp_path + + +@pytest.fixture +async def seeded(tmp_path: Path) -> AsyncIterator[tuple[Path, str]]: + (tmp_path / "app.py").write_text(_SRC) + cg = await CodeGraph.index(repo_path=tmp_path) + try: + pack = await get_recipe(DocType.ARCHITECTURE).seed(cg, DocTarget(type=DocType.ARCHITECTURE)) + sid = pack.facts[0].ref.id + doc = f"## Overview\n\nCentral [^f1].\n\n## References\n\n[^f1]: {sid}\n" + art = await cg.docs_generate("architecture", model=_ConstLLM(doc)) + finally: + await cg.close() + yield tmp_path, art.path + + +def test_generate_requires_type_or_all(indexed: Path, capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["docs", "generate", "--path", str(indexed)]) + assert rc == 2 + assert "--type or --all" in capsys.readouterr().err + + +def test_generate_refuses_when_disabled(indexed: Path, capsys: pytest.CaptureFixture[str]) -> None: + (indexed / "ckg.yaml").write_text("docgen:\n enabled: false\n") + rc = main( + [ + "docs", + "generate", + "--type", + "architecture", + "--path", + str(indexed), + "--config", + str(indexed / "ckg.yaml"), + ] + ) + assert rc == 2 + assert "disabled" in capsys.readouterr().err + + +def test_list_shows_generated_doc( + seeded: tuple[Path, str], capsys: pytest.CaptureFixture[str] +) -> None: + repo, docpath = seeded + rc = main(["docs", "list", "--path", str(repo), "--format", "json"]) + assert rc == 0 + out = capsys.readouterr().out + assert docpath in out + assert "architecture" in out + + +def test_promote_via_cli(seeded: tuple[Path, str], capsys: pytest.CaptureFixture[str]) -> None: + repo, docpath = seeded + rc = main(["docs", "promote", docpath, "--path", str(repo)]) + assert rc == 0 + assert "promoted" in capsys.readouterr().out + # reflected in a subsequent list + assert main(["docs", "list", "--path", str(repo)]) == 0 + assert "accepted" in capsys.readouterr().out + + +def test_diff_unknown_doc_errors(indexed: Path, capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["docs", "diff", "docs/_generated/nope.md", "--path", str(indexed)]) + assert rc == 2 + assert "no generated doc" in capsys.readouterr().err From aeeea081fdebe13bc83f726487936ecf61b54f78 Mon Sep 17 00:00:00 2001 From: Khemchand Joshi Date: Fri, 10 Jul 2026 13:33:12 +0530 Subject: [PATCH 09/11] feat-016 chunk 7: opt-in sync flywheel (anti-echo-chamber by provenance) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ckg docs sync` re-ingests ACCEPTED docs into the graph so retrieval gets richer — opt-in (docgen.round_trip) and gated on promotion: - DocGenerator.sync: per accepted doc, build a DocChunk node tagged Provenance.llm with DESCRIBES edges to its footnote symbols (already validated real symbols — the citation graph IS the linkage), upsert, and embed the text when embed is enabled. Refuses when round_trip is off (DocgenError) or docs are unpromoted (PromoteRequired). - Built in docgen (not KnowledgeIngestor.ingest, whose doc pass GCs repo-wide and would wipe real ingested docs) — a targeted, GC-free path. - THE anti-echo-chamber guarantee is provenance: synced docs are Source.LLM, and recipes/tools floor at >= parsed, so a synced doc can inform search but is never a citable ground-truth fact. - CodeGraph.docs_sync facade + `ckg docs sync` CLI (refuses read-only stores via _refuse_write_if_read_only — sync writes the graph). Tests: refuses round_trip off; refuses unpromoted; creates an llm-sourced DocChunk with DESCRIBES to the cited symbol AND a >=parsed floor excludes it (the guarantee, proven); embeds via the fake embedder without creds. Full gate: 1154 passed / 69 skipped / 94.09% cov; ruff + mypy strict clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/agentforge_graph/cli.py | 24 +++++ src/agentforge_graph/docgen/generator.py | 102 ++++++++++++++++++- src/agentforge_graph/ingest/codegraph.py | 7 ++ tests/docgen/test_sync.py | 123 +++++++++++++++++++++++ 4 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 tests/docgen/test_sync.py diff --git a/src/agentforge_graph/cli.py b/src/agentforge_graph/cli.py index abc20ea..93c6e1e 100644 --- a/src/agentforge_graph/cli.py +++ b/src/agentforge_graph/cli.py @@ -665,6 +665,25 @@ async def _docs_promote(args: argparse.Namespace) -> int: return 0 +async def _docs_sync(args: argparse.Namespace) -> int: + """feat-016: re-ingest accepted docs into the graph (opt-in flywheel).""" + from agentforge_graph.docgen import DocgenError + + if _refuse_write_if_read_only(args): # sync writes the graph + return 2 + cg = await CodeGraph.open(repo_path=args.path, config=args.config) + try: + try: + n = await cg.docs_sync() + except DocgenError as exc: + print(f"ckg docs sync: {exc}", file=sys.stderr) + return 2 + finally: + await cg.close() + print(f"synced {n} accepted doc(s) into the graph") + return 0 + + async def _build(args: argparse.Namespace) -> int: """ENH-021: the one command — index, then embed (where enabled), then enrich (with --enrich), for a whole workspace (--workspace) or a single repo.""" @@ -1391,6 +1410,11 @@ def build_parser() -> argparse.ArgumentParser: dp.add_argument("--config", default=None, help="path to ckg.yaml") dp.set_defaults(func=_docs_promote) + ds = docs_sub.add_parser("sync", help="re-ingest accepted docs into the graph (opt-in)") + _add_repo_arg(ds) + ds.add_argument("--config", default=None, help="path to ckg.yaml") + ds.set_defaults(func=_docs_sync) + smap = sub.add_parser( "services-map", help="cross-service call graph over a workspace (ENH-020)" ) diff --git a/src/agentforge_graph/docgen/generator.py b/src/agentforge_graph/docgen/generator.py index 713f99a..ce97ebc 100644 --- a/src/agentforge_graph/docgen/generator.py +++ b/src/agentforge_graph/docgen/generator.py @@ -14,7 +14,7 @@ from typing import TYPE_CHECKING from .citations import verify_citations -from .errors import DocgenError +from .errors import DocgenError, PromoteRequired from .manifest import Manifest, content_sha from .recipes import get_recipe from .runner import AgentDocRunner @@ -140,6 +140,106 @@ def promote(self, path: str) -> DocArtifact: raise DocgenError(f"no generated doc recorded at {path!r}") return manifest.promote(path) + # --- opt-in flywheel ----------------------------------------------------- + + async def sync(self) -> int: + """Re-ingest **accepted** docs into the graph as ``llm``-sourced + ``DocChunk`` nodes that ``DESCRIBES`` their cited symbols (and, when embed + is enabled, are embedded for search). Opt-in (``round_trip``) and gated on + promotion. Tagging them ``Source.LLM`` is the anti-echo-chamber guarantee: + recipes/tools floor at ``>= parsed``, so a synced doc can inform search but + is never a citable ground-truth fact.""" + if not self._cfg.round_trip: + raise DocgenError( + "docgen.round_trip is off; enable it to feed generated docs back into the graph" + ) + all_docs = self._manifest().all() + accepted = [a for a in all_docs if a.accepted] + if not accepted: + if all_docs: + raise PromoteRequired("no accepted docs to sync — promote reviewed drafts first") + return 0 + + repo = self._repo_path.resolve().name + commit = self._git_commit() + store = self._cg.store + embedder = self._embedder() + for art in accepted: + sg = await self._doc_subgraph(art, repo, commit) + await store.graph.upsert(sg) + if embedder is not None: + await self._embed_doc(embedder, art, sg) + return len(accepted) + + async def _doc_subgraph(self, art: DocArtifact, repo: str, commit: str): # type: ignore[no-untyped-def] + from agentforge_graph.core import ( + Edge, + EdgeKind, + FileSubgraph, + Node, + NodeKind, + Provenance, + SymbolID, + ) + + abs_path = self._repo_path / art.path + text = abs_path.read_text() if abs_path.exists() else "" + chunk_id = SymbolID.for_symbol("docgen", repo, art.path, "generated.") + prov = Provenance.llm(f"docgen@{DOC_LANG_VERSION}", 1.0, commit) + node = Node( + id=chunk_id, + kind=NodeKind.DOC_CHUNK, + name=f"generated:{art.path}", + attrs={ + "path": art.path, + "text": text, + "doc_source": "generated", # distinguishes docgen output from ingested docs + "doc_type": art.type.value, + }, + provenance=prov, + ) + edges: list[Edge] = [] + seen: set[str] = set() + for f in art.footnotes: + if f.ref.id in seen or await self._cg.store.graph.get(f.ref.id) is None: + continue + seen.add(f.ref.id) + edges.append(Edge(src=chunk_id, dst=f.ref.id, kind=EdgeKind.DESCRIBES, provenance=prov)) + return FileSubgraph(path=art.path, content_hash=art.content_sha, nodes=[node], edges=edges) + + def _embedder(self) -> object | None: + from agentforge_graph.config import EmbedConfig + from agentforge_graph.embed import embedder_from_config + + cfg = EmbedConfig.load(self._config) + if not cfg.enabled: + return None + return embedder_from_config(cfg) + + async def _embed_doc(self, embedder: object, art: DocArtifact, sg: object) -> None: + from agentforge_graph.core import Embedded, NodeKind + from agentforge_graph.embed import Embedder + + if not isinstance(embedder, Embedder): + return + node = sg.nodes[0] # type: ignore[attr-defined] + vectors = await embedder.embed([str(node.attrs.get("text", ""))], "document") + await self._cg.store.vectors.delete_where({"ref": node.id}) + await self._cg.store.vectors.upsert( + [ + Embedded( + ref=node.id, + vector=vectors[0], + kind=NodeKind.DOC_CHUNK, + attrs={ + "path": art.path, + "source_type": "generated-doc", + "model": embedder.name, + }, + ) + ] + ) + # --- internals ----------------------------------------------------------- async def _compose( diff --git a/src/agentforge_graph/ingest/codegraph.py b/src/agentforge_graph/ingest/codegraph.py index ad8bc7b..197e315 100644 --- a/src/agentforge_graph/ingest/codegraph.py +++ b/src/agentforge_graph/ingest/codegraph.py @@ -1003,6 +1003,13 @@ def docs_promote(self, path: str) -> Any: _, gen = self._docgen() return gen.promote(path) + async def docs_sync(self) -> int: + """Opt-in flywheel: re-ingest accepted docs into the graph, llm-tagged + (feat-016). Refuses when round_trip is off / docs are unpromoted.""" + _, gen = self._docgen() + result: int = await gen.sync() + return result + @property def store(self) -> Store: return self._store diff --git a/tests/docgen/test_sync.py b/tests/docgen/test_sync.py new file mode 100644 index 0000000..97cd02e --- /dev/null +++ b/tests/docgen/test_sync.py @@ -0,0 +1,123 @@ +"""feat-016 chunk 7: the opt-in sync flywheel + anti-echo-chamber guarantee.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest +from agentforge_core.contracts.llm import LLMClient +from agentforge_core.values.messages import LLMResponse, Message, TokenUsage, ToolSpec + +from agentforge_graph.core import EdgeKind, GraphQuery, NodeKind, Source +from agentforge_graph.docgen import get_recipe +from agentforge_graph.docgen.errors import DocgenError, PromoteRequired +from agentforge_graph.docgen.types import DocTarget, DocType +from agentforge_graph.ingest import CodeGraph + +_SRC = """\ +class Repo: + def save(self): + return validate() + + +def validate(): + return True +""" + + +class _ConstLLM(LLMClient): + def __init__(self, doc: str) -> None: + self._doc = doc + + async def call( + self, system: str, messages: list[Message], tools: list[ToolSpec] | None = None + ) -> LLMResponse: + del system, messages, tools + return LLMResponse( + content=self._doc, + tool_calls=(), + stop_reason="end_turn", + usage=TokenUsage(input_tokens=1, output_tokens=1), + cost_usd=0.0, + model="const", + provider="const", + ) + + async def close(self) -> None: + return + + +async def _generate(cg: CodeGraph, *, promote: bool) -> str: + pack = await get_recipe(DocType.ARCHITECTURE).seed(cg, DocTarget(type=DocType.ARCHITECTURE)) + sid = pack.facts[0].ref.id + doc = f"## Overview\n\nCentral [^f1].\n\n## References\n\n[^f1]: {sid}\n" + art = await cg.docs_generate("architecture", model=_ConstLLM(doc)) + if promote: + cg.docs_promote(art.path) + return sid + + +async def _open(tmp_path: Path, cfg: str) -> AsyncIterator[CodeGraph]: + (tmp_path / "app.py").write_text(_SRC) + (tmp_path / "ckg.yaml").write_text(cfg) + cg = await CodeGraph.index(repo_path=tmp_path, config=str(tmp_path / "ckg.yaml")) + return cg + + +async def test_sync_refuses_when_round_trip_off(tmp_path: Path) -> None: + cg = await _open(tmp_path, "docgen:\n enabled: true\n") # round_trip defaults off + try: + await _generate(cg, promote=True) + with pytest.raises(DocgenError, match="round_trip"): + await cg.docs_sync() + finally: + await cg.close() + + +async def test_sync_refuses_unpromoted(tmp_path: Path) -> None: + cg = await _open(tmp_path, "docgen:\n round_trip: true\n") + try: + await _generate(cg, promote=False) # a draft, not accepted + with pytest.raises(PromoteRequired): + await cg.docs_sync() + finally: + await cg.close() + + +async def test_sync_creates_llm_doc_chunk_with_describes(tmp_path: Path) -> None: + cg = await _open(tmp_path, "docgen:\n round_trip: true\nembed:\n enabled: false\n") + try: + sid = await _generate(cg, promote=True) + assert await cg.docs_sync() == 1 + + nodes = ( + await cg.store.graph.query(GraphQuery(kinds=[NodeKind.DOC_CHUNK], limit=1000)) + ).nodes + gen = [n for n in nodes if n.attrs.get("doc_source") == "generated"] + assert len(gen) == 1 + # anti-echo-chamber: the synced doc is llm-sourced ... + assert gen[0].provenance.source is Source.LLM + # ... and DESCRIBES the symbol it cited + desc = await cg.store.graph.adjacent(gen[0].id, [EdgeKind.DESCRIBES], "out") + assert {e.dst for e in desc} == {sid} + + # ... so a >= parsed floor never surfaces it as a citable fact + floored = ( + await cg.store.graph.query( + GraphQuery(kinds=[NodeKind.DOC_CHUNK], min_source=Source.PARSED, limit=1000) + ) + ).nodes + assert all(n.attrs.get("doc_source") != "generated" for n in floored) + finally: + await cg.close() + + +async def test_sync_embeds_with_fake_embedder(tmp_path: Path) -> None: + cg = await _open(tmp_path, "docgen:\n round_trip: true\nembed:\n driver: fake\n") + try: + await _generate(cg, promote=True) + # exercises the embed path (fake embedder, no creds) without error + assert await cg.docs_sync() == 1 + finally: + await cg.close() From 60eb95fd79827c794947ed7135b01572597c71c3 Mon Sep 17 00:00:00 2001 From: Khemchand Joshi Date: Fri, 10 Jul 2026 13:39:32 +0530 Subject: [PATCH 10/11] feat-016 chunk 8: status + docs + polish - ckg_status reports docgen.{enabled, types, doc_lang_version} (parity with the query surface), tested. - Guide 14 (grounded doc generation) + guides index row. - CHANGELOG [Unreleased]: feat-016 entry. - feat-016 spec Implementation-status: the 8-chunk build, approved design decisions, and documented deviations (capture from RunResult.steps not a proxy; Python templates; sync builds DocChunk directly using footnotes for DESCRIBES; provider defaults to anthropic; facade model seam; the [BUG-CARRIED] space-in-symbol-id citation fix). Full gate: 1154 passed / 69 skipped / 94.10% cov; ruff + mypy strict clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 25 ++++++ .../feat-016-grounded-doc-generation.md | 46 +++++++++- docs/guides/14-grounded-doc-generation.md | 87 +++++++++++++++++++ docs/guides/README.md | 1 + src/agentforge_graph/serve/engine.py | 9 ++ tests/serve/test_engine.py | 4 + 6 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 docs/guides/14-grounded-doc-generation.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 190cc21..28c090d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,31 @@ on a schema mismatch is **rebuild** (ADR-0006). ## [Unreleased] +### Added + +- **feat-016 — grounded documentation generation (`ckg docs …`).** Documentation + as a *cited projection of the graph*: a doc-type **recipe** seeds a context + pack, an `agentforge.Agent` composes the doc while expanding that pack through + the read-only ckg tools, and **every claim is attributed to a real symbol** — + verified against the run's provenance set (the tool boundary). Phase 1 ships the + four descriptive types: **ai-context** (CLAUDE.md/AGENTS.md), **architecture**, + **component**, and **design**. + - **CLI:** `ckg docs generate --type [--scope P] [--all]`, + `ckg docs update` (regenerate only docs whose code changed — reuses the + feat-004 `DirtySet`), `ckg docs list`, `ckg docs diff `, + `ckg docs promote `, and the opt-in `ckg docs sync`. + - **Grounded + reviewable:** drafts land under `docgen.output_root` and **never + overwrite human docs**; each section must cite a real fact (`require_citations`) + or generation fails; a doc is a **draft** until `ckg docs promote` (the human + gate). Budgeted + resumable via the framework Agent's `BudgetPolicy`. + - **Opt-in flywheel:** `ckg docs sync` (default off, `docgen.round_trip`) + re-ingests **accepted** docs as `llm`-sourced `DocChunk` nodes that `DESCRIBES` + their cited symbols. Anti-echo-chamber by provenance: recipes/tools ground at + `≥ parsed`, so a synced doc can inform search but is never a citable + ground-truth fact. + - `docgen:` config block; `ckg_status` reports `docgen.{enabled, types, + doc_lang_version}`. Guide 14. + ## [0.6.4] — 2026-07-08 ### Added diff --git a/docs/features/feat-016-grounded-doc-generation.md b/docs/features/feat-016-grounded-doc-generation.md index 70be779..fca2aa5 100644 --- a/docs/features/feat-016-grounded-doc-generation.md +++ b/docs/features/feat-016-grounded-doc-generation.md @@ -290,7 +290,51 @@ per-run `--scope` override. ## Implementation status -_Not started. Design doc: `design-016-grounded-doc-generation.md` (next)._ +**Implemented on `feat/016-grounded-doc-generation` (targeting 0.7.0).** All four +descriptive doc types generate grounded, cited drafts via a real `agentforge.Agent` +loop over the read-only ckg tools, verified hermetically (the framework's +`agentforge.testing.MockLLMClient` drives the loop with no creds). Built in 8 +chunks: (1) types/manifest/config/staleness seam; (2) recipe seam + architecture +recipe + citation verifier; (3) grounded toolset + Agent runner + `generate` +(first e2e slice); (4) remaining recipes + templates; (5) dirty-aware `update` + +list/diff/promote; (6) `ckg docs` CLI + facade; (7) opt-in `sync` flywheel; +(8) status/docs/polish. + +**Approved design decisions (design-016):** writer is a multi-turn +`agentforge.Agent` loop over a read-only, provenance-floored toolset (grounding at +the **tool boundary**, not a fixed pack); citations are a structured footnote +block verified against the captured provenance set; `ai-context` is draft-only to +`output_root`; a `doc_lang_version` stamps the template/citation contract. + +**Deviations from design-016 (all deliberate, documented):** + +- **Provenance capture is from the tool-observation JSON in `RunResult.steps`, not + a wrapping `_CaptureProxy`.** Fact-bearing ckg tools return + `ContextPack.to_dict()` JSON carrying `id` + `provenance` per item, so capture + walks the observations and applies the `≥ parsed` floor directly — tool-agnostic + and with **no second store handle** (avoids a double-open of the embedded store). +- **Templates are Python `Template` objects, not `.md.tmpl` files** — same + "data, not core" property, no packaging of template artifacts. +- **`sync` builds the `DocChunk` in `docgen` directly rather than calling + `KnowledgeIngestor.ingest`** — the latter's doc pass GCs doc chunks repo-wide + (would wipe real ingested docs). The targeted path reuses the citation graph: + `DESCRIBES` edges come from the doc's **footnotes** (already-validated real + symbols). Embedding of synced docs is gated on `embed.enabled`. +- **`docgen.provider` defaults to `anthropic`** (the framework Agent's registered + provider), not `bedrock` — `model_ref()` builds the `Agent` `model=` string. + Bedrock-backed Agent composition is a follow-up (the framework provider registry + ships `anthropic`). +- **The facade `docs_*` methods expose an optional `model` seam** (bring-your-own + `LLMClient`) for hermetic tests and advanced callers. + +**[BUG-CARRIED, same branch]** the citation parser initially took `split()[0]` of a +footnote definition, but symbol ids are **5 space-joined fields** — so it captured +only `"ckg"`. Fixed: the whole remainder of a `[^fN]: ` line is the id (the +contract is one id per line, nothing after). + +**Deferred (Phase 2/3, out of feat-016 scope):** ADR-draft generation + +ratification (Phase 3); the CI-hardened flywheel that opens a docs PR on merge +(Phase 2); bedrock-backed Agent composition. ## 12. References diff --git a/docs/guides/14-grounded-doc-generation.md b/docs/guides/14-grounded-doc-generation.md new file mode 100644 index 0000000..8825f40 --- /dev/null +++ b/docs/guides/14-grounded-doc-generation.md @@ -0,0 +1,87 @@ +# Grounded documentation generation (`ckg docs …`) + +> **TL;DR:** Generate docs as a **cited projection of the graph** — an +> `agentforge.Agent` writes over facts it pulls from the read-only ckg tools, and +> **every claim is attributed to a real symbol**. `ckg docs generate --type +> architecture` writes a *draft* under `docs/_generated/`; review it, `ckg docs +> promote` it, and `ckg docs update` keeps it fresh when the code changes. Nothing +> overwrites your hand-written docs (feat-016). + +The graph already holds what documentation *is* — typed structure, framework +topology, decisions, summaries. `ckg docs` turns that outward into the standard +descriptive docs, **grounded**: not "an LLM writes a README from scratch", but a +model composing prose over cited graph facts, with provenance. + +## The four doc types (Phase 1) + +| `--type` | What it documents | Grounds on | +|---|---|---| +| `ai-context` | `CLAUDE.md` / `AGENTS.md` — orient a coding agent | central symbols + decisions | +| `architecture` | system overview | top-PageRank symbols + routes/models + repo summary | +| `component` | one module/package (`--scope src/pkg`) | symbols under the scope + its routes/models | +| `design` | a subsystem's "how it works + why" (`--scope …`) | scoped symbols + governing decisions | + +## Generate → review → promote + +```bash +# generate a draft (never touches human docs — writes under docgen.output_root) +ckg docs generate --type architecture +ckg docs generate --type component --scope src/agentforge_graph/store +ckg docs generate --all # every configured type + +ckg docs list # drafts + status + staleness +ckg docs diff docs/_generated/architecture.md # what a regeneration would change +ckg docs promote docs/_generated/architecture.md # mark reviewed → accepted +``` + +Every draft carries a "generated by CKG — do not edit by hand" header, the commit +it was synced from, and a `## References` block linking each footnote to the +symbol it cites. A section that can't cite a real fact **fails generation** +(`require_citations`, on by default) rather than inventing prose — so a published +doc is trustworthy by construction. The human `promote` gate is still +load-bearing: the verifier proves a citation points at a *real* fact, not that +it's the *right* one. + +## Keep it fresh + +Generated docs go stale exactly like embeddings and summaries do — they ride the +same feat-004 dirty-tracking: + +```bash +ckg docs update # regenerate ONLY the docs whose source symbols changed +``` + +## The opt-in flywheel + +`ckg docs sync` feeds **accepted** docs back into the graph so retrieval gets +richer. It is **off by default** (`docgen.round_trip`) and refuses un-promoted +docs. Synced docs are tagged `llm`-provenance, so — because recipes and tools +ground at `≥ parsed` — a generated doc can inform semantic search but is **never** +cited as ground truth for a code claim (anti-echo-chamber). + +```bash +# enable in agentforge.yaml: app.docgen.round_trip: true +ckg docs sync +``` + +## Configuration + +```yaml +docgen: + enabled: true + output_root: docs/_generated # drafts land here; human docs untouched + types: [ai-context, architecture, component, design] + require_citations: true # a section with no citable fact fails + promote_required: true # docs are drafts until `ckg docs promote` + round_trip: false # opt-in flywheel for `ckg docs sync` + budget_usd: 5.0 # per-run cap (framework Agent BudgetPolicy) + provider: anthropic # LLM provider for composition + model: claude-haiku-4-5 +``` + +## Why generation is CLI-only (not an MCP tool) + +Generation mutates the working tree, so it is deliberately **not** an +auto-invoked, agent-facing tool — that preserves feat-008's read-only tool +discipline. The Agent that *composes* a doc only ever calls the read-only ckg +tools; writing the file is the CLI's job, behind your review. diff --git a/docs/guides/README.md b/docs/guides/README.md index 2f51e4c..b0ed8dc 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -33,6 +33,7 @@ setup. They build on each other in order. | 11 | [Agent auto-configuration](11-agent-auto-configuration.md) | `ckg setup` writes your agent's MCP config (repo `.mcp.json` / `--scope user`) + `--hooks` nudge; reversible. | | 12 | [Watch mode & CI indexing](12-watch-and-ci-indexing.md) | Keep the graph fresh: `ckg watch` (local, conditional triggers) + `ckg ci init` (central, CI-as-sole-writer). | | 13 | [Ad-hoc structural queries](13-graph-query.md) | Escape hatch for structural questions no verb covers: a read-only Cypher subset via `ckg query --graph` / the `ckg_query` tool. | +| 14 | [Grounded doc generation](14-grounded-doc-generation.md) | Generate docs as a **cited projection of the graph**: `ckg docs generate/update/promote/sync`. Every claim attributed to a real symbol; drafts never overwrite human docs. | > New here? Start with **[Getting started → a single repo](getting-started/1-single-repo.md)**. > Building an agent on top? Jump to **[10 — Using over MCP](10-using-over-mcp.md)**. diff --git a/src/agentforge_graph/serve/engine.py b/src/agentforge_graph/serve/engine.py index 09a9ba4..9706904 100644 --- a/src/agentforge_graph/serve/engine.py +++ b/src/agentforge_graph/serve/engine.py @@ -246,8 +246,12 @@ async def query_graph(self, query: str, limit: int | None = None) -> dict[str, A } async def status(self) -> dict[str, Any]: + from agentforge_graph.config import DocGenConfig + from agentforge_graph.docgen import DOC_LANG_VERSION from agentforge_graph.store.query import QUERY_LANG_VERSION + dcfg = DocGenConfig.load(self.config) + meta = self._meta() cg = await self.code_graph() nodes = (await cg.store.graph.query(GraphQuery(limit=_ALL))).nodes @@ -271,6 +275,11 @@ async def status(self) -> dict[str, Any]: "lang_version": QUERY_LANG_VERSION, "capabilities": sorted(cg.query_capabilities), }, + "docgen": { + "enabled": dcfg.enabled, + "types": list(dcfg.types), + "doc_lang_version": DOC_LANG_VERSION, + }, "tool_api_version": TOOL_API_VERSION, } diff --git a/tests/serve/test_engine.py b/tests/serve/test_engine.py index 9a947d4..e985e61 100644 --- a/tests/serve/test_engine.py +++ b/tests/serve/test_engine.py @@ -28,6 +28,10 @@ async def test_status_reports_counts_and_version(tmp_path: Path) -> None: assert status["by_kind"].get("Class") == 1 assert status["tool_api_version"] == TOOL_API_VERSION assert "dirty" in status + # feat-016: docgen surface reported in status + assert status["docgen"]["enabled"] is True + assert status["docgen"]["doc_lang_version"] == "1.0" + assert "architecture" in status["docgen"]["types"] finally: await engine.close() From 41512f61fee2302ee013dcf1a49b2ce0fd1d6399 Mon Sep 17 00:00:00 2001 From: Khemchand Joshi Date: Fri, 10 Jul 2026 14:07:13 +0530 Subject: [PATCH 11/11] feat-016: live-dogfood calibrations (grounding + economy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated end-to-end on this repo: indexed src/agentforge_graph (192 files, 2210 nodes) and generated its own architecture doc with a real Anthropic model — a genuine, useful, grounded doc with 28 real citations. Three refinements the hermetic tests couldn't surface: - Economy over exhaustion: the framework Agent caps total tokens (~200k, not constructor-overridable), and a long ReAct loop of large tool observations exhausts it on a big repo. max_iterations 24 -> 12 + the system prompt now tells the model to lean on the rich seed and make only a few targeted tool calls. - Citations pruned, not fatal; grounding is doc-level. A citation is valid only if used inline AND resolving into the provenance set; fabricated ids, dangling markers, and unused defs are pruned (marker stripped, def dropped) — never fatal (real models mis-cite occasionally). require_citations becomes a doc-level gate (>=1 valid citation, refuse pure hallucination); per-section gaps are REPORTED (ungrounded_sections) for the reviewer, not failed on. Integrity guarantee preserved (every PUBLISHED citation is real); the promote gate carries per-section adequacy. The strict per-section rule in design-016 proved impractical live. - Preamble stripped: text before the first heading (model meta-commentary like "Let me compile the document:") is dropped. feat-016 spec Implementation-status updated with the calibrations + a follow-up (docgen-specific tool response_token_cap to extend the loop budget on large repos). Full gate: 1156 passed / 69 skipped / 94.09% cov; ruff + mypy strict clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../feat-016-grounded-doc-generation.md | 28 ++++++- src/agentforge_graph/config.py | 6 +- src/agentforge_graph/docgen/citations.py | 84 +++++++++++-------- src/agentforge_graph/docgen/generator.py | 10 +++ src/agentforge_graph/docgen/templates/base.py | 3 + tests/docgen/test_citations.py | 51 ++++++++--- tests/docgen/test_config.py | 2 +- tests/docgen/test_runner.py | 7 +- 8 files changed, 137 insertions(+), 54 deletions(-) diff --git a/docs/features/feat-016-grounded-doc-generation.md b/docs/features/feat-016-grounded-doc-generation.md index fca2aa5..b3d6b3b 100644 --- a/docs/features/feat-016-grounded-doc-generation.md +++ b/docs/features/feat-016-grounded-doc-generation.md @@ -332,9 +332,35 @@ footnote definition, but symbol ids are **5 space-joined fields** — so it capt only `"ckg"`. Fixed: the whole remainder of a `[^fN]: ` line is the id (the contract is one id per line, nothing after). +**Live-dogfood calibrations (validated end-to-end on this repo — a 192-file / +2210-node index, generating its own architecture doc with a real Anthropic model, +28 real citations).** Three refinements the hermetic tests could not surface: + +- **Economy over exhaustion.** The framework `Agent` caps total tokens (~200k, + not constructor-overridable), and a long ReAct loop of large tool observations + exhausts it on a big repo. `max_iterations` lowered to **12** and the system + prompt now instructs the model to lean on the rich seed and make only a few + targeted tool calls. (Follow-up: a smaller per-tool `response_token_cap` for the + docgen toolset would let the loop run longer within budget.) +- **Citations are pruned, not fatal; grounding is doc-level.** A real model + occasionally mis-cites. A citation is *valid* only if used inline **and** + resolving into the provenance set; anything else (fabricated id, dangling + marker, unused def) is **pruned** (marker stripped, def dropped) — never fatal. + `require_citations` is then a **doc-level** gate: the doc must retain ≥1 valid + citation (refuse pure-hallucination), and per-section gaps are *reported* + (`ungrounded_sections`) for the reviewer rather than failing the doc — real docs + have legitimate overview/connective prose. The strict per-section-must-cite rule + in design-016 proved impractical against a live model; the integrity guarantee + (every *published* citation is real) is preserved, and the human promote gate + carries per-section adequacy. (`BadCitationError` is retained but no longer + raised — fabrications are pruned.) +- **Preamble stripped.** Text before the first Markdown heading (a model's + "Let me compile the document:" meta-commentary) is dropped. + **Deferred (Phase 2/3, out of feat-016 scope):** ADR-draft generation + ratification (Phase 3); the CI-hardened flywheel that opens a docs PR on merge -(Phase 2); bedrock-backed Agent composition. +(Phase 2); bedrock-backed Agent composition; a docgen-specific tool +`response_token_cap` to extend the loop budget on large repos. ## 12. References diff --git a/src/agentforge_graph/config.py b/src/agentforge_graph/config.py index 1e807f5..ba7f9e3 100644 --- a/src/agentforge_graph/config.py +++ b/src/agentforge_graph/config.py @@ -356,7 +356,11 @@ class DocGenConfig(_Block): round_trip: bool = False # opt-in flywheel; `ckg docs sync` honors this promote_required: bool = True # docs are drafts until `ckg docs promote` budget_usd: float = 5.0 # per-run cap (agentforge.Agent BudgetPolicy) - max_iterations: int = 24 # Agent tool-call loop bound per doc + # Agent tool-call loop bound per doc. Kept modest: the seed is already rich, so + # a few targeted tool calls suffice — and the framework Agent caps total tokens + # (~200k, not constructor-overridable), which a long loop of large tool + # observations otherwise exhausts on a big repo. Economy is guided in the prompt. + max_iterations: int = 12 regenerate_on_ci: bool = False # feat-014 CI can flip this on (commit the diff in a PR) # Provider selection — resolved to the framework Agent's `model` string, # ":" (agentforge's registered provider is `anthropic`). diff --git a/src/agentforge_graph/docgen/citations.py b/src/agentforge_graph/docgen/citations.py index 5c9377f..ae116ad 100644 --- a/src/agentforge_graph/docgen/citations.py +++ b/src/agentforge_graph/docgen/citations.py @@ -22,7 +22,7 @@ import re from dataclasses import dataclass -from .errors import BadCitationError, UngroundedError +from .errors import UngroundedError from .types import Footnote, ProvenanceSet, SymbolRef _HEADING = re.compile(r"^(#{1,6})\s+(.*)$", re.M) @@ -34,12 +34,21 @@ @dataclass(frozen=True) class VerifiedDoc: """Result of verifying a rendered doc: the citation-rewritten body, the - verified footnotes, and any sections that carried no citation (empty when - ``require_citations`` held).""" + verified footnotes, any sections that carried no valid citation (empty when + ``require_citations`` held), and the markers pruned as fabricated/dangling.""" body: str footnotes: tuple[Footnote, ...] ungrounded_sections: tuple[str, ...] + dropped: tuple[str, ...] = () + + +def _strip_preamble(content: str) -> str: + """Drop any text before the first Markdown heading — a real model sometimes + emits meta-commentary ("Let me compile the document:") above the doc despite + the instruction to output only Markdown.""" + m = _HEADING.search(content) + return content[m.start() :] if m else content def _split_references(body: str) -> tuple[str, str]: @@ -72,49 +81,54 @@ def _render_def(marker: str, ref: SymbolRef) -> str: def verify_citations(body: str, prov: ProvenanceSet, *, require_citations: bool) -> VerifiedDoc: + """Verify a rendered doc's citations against the provenance set. + + Two guarantees, calibrated on real-model behaviour: + + - **Integrity (always):** every *published* citation points at a real, + provenance-floored fact. A citation is *valid* only if it is used inline + **and** its definition resolves into the provenance set; anything else + (fabricated id, dangling marker, unused def) is **pruned** — the inline + marker stripped and the def dropped — never fatal, because a real model + occasionally mis-cites. + - **Grounding (``require_citations``, doc-level):** the doc must retain at + least one valid citation — we refuse to publish prose with *no* grounding. + Per-section gaps (a heading whose section has no valid citation) are + *reported* in ``ungrounded_sections`` for the reviewer, not failed on: + real docs have legitimate overview/connective prose, and the human promote + gate carries per-section adequacy.""" content, refs = _split_references(body) + content = _strip_preamble(content) - # (1) parse + resolve every footnote definition against the provenance set. - footnotes: list[Footnote] = [] - defined: dict[str, SymbolRef] = {} + # Resolvable defs (symbol id in the provenance set). A symbol id is 5 + # space-joined fields, so the whole remainder of a `[^fN]: ` line is the id. + resolvable: dict[str, SymbolRef] = {} for m in _REF_DEF.finditer(refs): - marker = m.group(1) - # A symbol id is 5 space-joined fields, so the whole remainder of the - # line IS the id — the contract is `[^fN]: `, nothing after. - symbol_id = m.group(2).strip() - if not prov.contains(symbol_id): - raise BadCitationError( - f"footnote [^{marker}] cites {symbol_id!r}, which no tool returned " - f"(not in the provenance set of {len(prov.refs)} facts)" - ) - ref = prov.refs[symbol_id] - defined[marker] = ref - footnotes.append(Footnote(marker=marker, ref=ref)) - - # (2) every inline marker in the content must have a definition. + marker, symbol_id = m.group(1), m.group(2).strip() + if prov.contains(symbol_id): + resolvable[marker] = prov.refs[symbol_id] + used = {m.group(1) for m in _INLINE.finditer(content)} - dangling = sorted(used - set(defined)) - if dangling: - raise BadCitationError( - f"citation marker(s) {dangling} used in the body have no References entry" - ) + valid = {m: ref for m, ref in resolvable.items() if m in used} # used AND resolvable + dropped = tuple(dict.fromkeys(m for m in used | set(resolvable) if m not in valid)) - # (3) every content section must carry a valid citation. - ungrounded = tuple(title for title, text in _sections(content) if not _INLINE.search(text)) - if ungrounded and require_citations: + if require_citations and not valid: raise UngroundedError( - "ungrounded section(s) with no citable fact: " + ", ".join(repr(s) for s in ungrounded) + "the generated doc has no valid citation — refusing to publish ungrounded prose" ) - # (4) rewrite the References block to human-facing links. + # Strip inline markers that aren't valid (fabricated / dangling). + stripped = _INLINE.sub(lambda m: m.group(0) if m.group(1) in valid else "", content) + ungrounded = tuple(t for t, text in _sections(stripped) if not _INLINE.search(text)) + + # Rewrite valid defs to human links; drop the rest. rewritten_refs = _REF_DEF.sub( - lambda m: ( - _render_def(m.group(1), defined[m.group(1)]) if m.group(1) in defined else m.group(0) - ), + lambda m: _render_def(m.group(1), valid[m.group(1)]) if m.group(1) in valid else "", refs, ) return VerifiedDoc( - body=content + rewritten_refs, - footnotes=tuple(footnotes), + body=stripped + rewritten_refs, + footnotes=tuple(Footnote(marker=k, ref=v) for k, v in valid.items()), ungrounded_sections=ungrounded, + dropped=dropped, ) diff --git a/src/agentforge_graph/docgen/generator.py b/src/agentforge_graph/docgen/generator.py index ce97ebc..1cbee85 100644 --- a/src/agentforge_graph/docgen/generator.py +++ b/src/agentforge_graph/docgen/generator.py @@ -8,6 +8,7 @@ from __future__ import annotations import difflib +import logging import re from dataclasses import replace from pathlib import Path @@ -37,6 +38,9 @@ from agentforge_graph.ingest.incremental import DirtySet +logger = logging.getLogger(__name__) + + def _slug(scope: str) -> str: return re.sub(r"[^a-zA-Z0-9]+", "-", scope).strip("-") or "root" @@ -250,6 +254,12 @@ async def _compose( template = get_template(target.type) body, prov = await self._runner.compose(pack, template) verified = verify_citations(body, prov, require_citations=self._cfg.require_citations) + if verified.dropped: + logger.info( + "docgen %s: pruned %d fabricated/dangling citation(s)", + target.type.value, + len(verified.dropped), + ) commit = self._git_commit() full = self._stamp(verified.body, target, commit) return full, prov.source_ids(), verified.footnotes, commit diff --git a/src/agentforge_graph/docgen/templates/base.py b/src/agentforge_graph/docgen/templates/base.py index 9d14cb1..9c5358f 100644 --- a/src/agentforge_graph/docgen/templates/base.py +++ b/src/agentforge_graph/docgen/templates/base.py @@ -29,6 +29,9 @@ ckg_impact, ckg_repo_map, ckg_routes, ckg_decisions) to gather the facts you need before writing. The seed facts you are given are a starting point, not the whole picture — expand with the tools. +- Be economical. The seed facts are usually enough to write most sections; make + only a FEW targeted tool calls to fill specific gaps, then write. Do not explore + exhaustively — a focused, well-cited doc beats a sprawling one. Grounding rules (STRICT): - Every substantive claim MUST cite a real fact with a footnote marker like diff --git a/tests/docgen/test_citations.py b/tests/docgen/test_citations.py index 013c6e2..c477631 100644 --- a/tests/docgen/test_citations.py +++ b/tests/docgen/test_citations.py @@ -6,7 +6,6 @@ from agentforge_graph.core import NodeKind from agentforge_graph.docgen import ( - BadCitationError, ProvenanceSet, SymbolRef, UngroundedError, @@ -51,15 +50,38 @@ def test_valid_doc_builds_footnotes_and_rewrites() -> None: assert "[^f1]" in v.body and "[^f2]" in v.body -def test_bad_citation_symbol_not_in_provenance() -> None: +def test_fabricated_citation_is_pruned_not_fatal() -> None: + # [^f1] cites a symbol not in the provenance set → pruned. f2 stays valid, so + # the doc is grounded overall (doc-level) and does NOT fail; the Overview + # section is reported as a gap for the reviewer. body = _GOOD.replace("[^f1]: s1", "[^f1]: s99") - with pytest.raises(BadCitationError, match="s99"): - verify_citations(body, _prov(), require_citations=True) + v = verify_citations(body, _prov(), require_citations=True) + assert {f.marker for f in v.footnotes} == {"f2"} + assert "f1" in v.dropped + assert "[^f1]" not in v.body # fabricated marker stripped from the prose + assert "Overview" in v.ungrounded_sections + + +def test_dangling_inline_marker_is_stripped() -> None: + body = _GOOD.replace("[^f2]", "[^f9]", 1) # inline [^f2] -> [^f9]; f9 has no def + v = verify_citations(body, _prov(), require_citations=True) + assert "[^f9]" not in v.body # dangling marker stripped + assert "f9" in v.dropped + assert {f.marker for f in v.footnotes} == {"f1"} # f2 def now unused -> dropped too + assert "Behaviour" in v.ungrounded_sections + +def test_preamble_before_first_heading_is_stripped() -> None: + body = "Perfect. Let me compile the document:\n\n" + _GOOD + v = verify_citations(body, _prov(), require_citations=True) + assert "Let me compile" not in v.body + assert v.body.lstrip().startswith("## Overview") -def test_dangling_inline_marker_without_definition() -> None: - body = _GOOD.replace("[^f2]", "[^f9]", 1) # used in body, but only [^f2] defined - with pytest.raises(BadCitationError, match=r"f9"): + +def test_no_valid_citation_fails_when_required() -> None: + # every citation fabricated → zero grounding → refuse to publish + body = _GOOD.replace("[^f1]: s1", "[^f1]: s98").replace("[^f2]: s2", "[^f2]: s99") + with pytest.raises(UngroundedError, match="no valid citation"): verify_citations(body, _prov(), require_citations=True) @@ -78,9 +100,12 @@ def test_dangling_inline_marker_without_definition() -> None: """ -def test_ungrounded_section_raises_when_required() -> None: - with pytest.raises(UngroundedError, match="Speculative"): - verify_citations(_UNGROUNDED, _prov(), require_citations=True) +def test_partial_grounding_passes_and_reports_gaps() -> None: + # one grounded section + one uncited section → doc is grounded overall, so it + # passes even under require_citations; the gap is reported for the reviewer. + v = verify_citations(_UNGROUNDED, _prov(), require_citations=True) + assert v.ungrounded_sections == ("Speculative",) + assert {f.marker for f in v.footnotes} == {"f1"} def test_ungrounded_section_reported_when_not_required() -> None: @@ -90,8 +115,8 @@ def test_ungrounded_section_reported_when_not_required() -> None: def test_references_block_markers_do_not_count_as_content_citations() -> None: - # A doc whose only [^..] usage is inside the References block → the single - # content section is ungrounded (the split must exclude the refs block). + # A doc whose only [^..] usage is inside the References block has NO inline + # citation → zero grounding → refused (the split must exclude the refs block). body = "## Lonely\n\nProse with no marker.\n\n## References\n\n[^f1]: s1\n" - with pytest.raises(UngroundedError, match="Lonely"): + with pytest.raises(UngroundedError, match="no valid citation"): verify_citations(body, _prov(), require_citations=True) diff --git a/tests/docgen/test_config.py b/tests/docgen/test_config.py index 24b72f8..0ee23bd 100644 --- a/tests/docgen/test_config.py +++ b/tests/docgen/test_config.py @@ -20,7 +20,7 @@ def test_defaults() -> None: assert c.round_trip is False assert c.promote_required is True assert c.budget_usd == 5.0 - assert c.max_iterations == 24 + assert c.max_iterations == 12 assert c.provider == "anthropic" assert c.model_ref() == "anthropic:claude-haiku-4-5" diff --git a/tests/docgen/test_runner.py b/tests/docgen/test_runner.py index a7df195..52301fd 100644 --- a/tests/docgen/test_runner.py +++ b/tests/docgen/test_runner.py @@ -16,7 +16,7 @@ from agentforge_graph.config import DocGenConfig from agentforge_graph.docgen import DocGenerator, get_recipe -from agentforge_graph.docgen.errors import BadCitationError, DocgenError +from agentforge_graph.docgen.errors import DocgenError, UngroundedError from agentforge_graph.docgen.types import STATUS_DRAFT, DocTarget, DocType from agentforge_graph.ingest import CodeGraph @@ -90,10 +90,11 @@ async def test_generate_writes_grounded_draft(indexed: tuple[CodeGraph, Path]) - async def test_generate_rejects_fabricated_citation(indexed: tuple[CodeGraph, Path]) -> None: cg, repo = indexed - # cite an id the seed/tools never produced → BadCitationError + # cite an id the seed/tools never produced → pruned; the sole section is then + # ungrounded under require_citations → UngroundedError bad = "## Overview\n\nClaim [^f1].\n\n## References\n\n[^f1]: ckg py repo ghost.py Ghost#\n" gen = DocGenerator(cg, DocGenConfig(), repo_path=repo, model=_script(bad)) - with pytest.raises(BadCitationError): + with pytest.raises(UngroundedError): await gen.generate(DocTarget(type=DocType.ARCHITECTURE))