Skip to content

Commit e1a088a

Browse files
cdeustclaude
andauthored
feat(wiki): richer multi-axis classification (ADR-2244 Phase 1) (#27)
Replace the single ``kind`` axis with a 4-tuple ``(kind, lifecycle, audience, provenance) + tags``. Solves the audit finding (2026-05-12) that 92% of the methodology wiki was stuck in the ``notes`` catch-all because the 9-kind taxonomy was too coarse and had no facet dimensions. Schema (ADR-2244 §4): kind exclusive — one of {tutorial, how-to, reference, explanation, adr, runbook, rfc, journal} lifecycle facet — {seedling, draft, active, deprecated, archived} or ADR-only {proposed, accepted, rejected, superseded} audience facet — multi-valued, closed enum {developer, ops, security, internal, external} provenance facet — {human, ai-generated, imported, auto-generated}; ai/auto-generated require a full Generator block {model, version, prompt_template, generated_at} tags free — capped ~50 controlled-vocab terms What changed ------------ * ``mcp_server/shared/wiki_classification.py`` (new) — ``Classification`` + ``Generator`` dataclasses, closed-enum constants, validators that fail fast on invalid tuples, ``to_frontmatter()`` for serialization, ``normalize_legacy_kind`` for read-time backward compat. * ``mcp_server/core/wiki_layout.py`` — split ``PAGE_KINDS`` into ``MODERN_PAGE_KINDS`` (8 ADR-2244 kinds) and ``LEGACY_PAGE_KINDS`` (6 pre-ADR kinds kept readable for the migration window). * ``mcp_server/core/wiki_classifier.py`` — ``classify_memory`` now returns a ``Classification | None`` (single function, no v1/v2 parallel per user direction 2026-05-12). New detection patterns for tutorial, how-to, runbook, rfc, journal. Provenance and audience inference. Legacy kind detection (adr/lesson/convention/ spec/note) is preserved as a private ``_classify_to_legacy_kind`` helper and mapped to modern kinds (lesson/convention/note all collapse to ``explanation``; spec → ``rfc``). * ``mcp_server/core/wiki_sync.py`` — routes via the 4-tuple, writes modern frontmatter. **Fixes Task #8** (file→notes/ misroute, 7820 pages affected): file documentation from ``codebase_analyze`` now lands in ``reference/<domain>/`` with ``provenance=auto-generated`` instead of the silent ``notes/`` fallback caused by the old ``_KIND_TO_DIR`` having no ``file`` mapping. * ``mcp_server/core/wiki_templates.py`` — 5 new templates (tutorial, how-to, runbook, rfc, explanation). Required-frontmatter contracts for every modern kind include the 4-tuple axes. * ``mcp_server/handlers/wiki_purge.py`` — minimal update for the new return shape (uses ``result.kind`` for reporting, ``None`` still signals purge). Backward compatibility ---------------------- Legacy directories (``notes/``, ``specs/``, ``conventions/``, ``lessons/``, ``guides/``, ``files/``) remain accepted by ``page_path`` / ``domain_page_path`` so existing pages stay readable. ``normalize_legacy_kind`` maps legacy frontmatter kinds to modern equivalents for callers that need a uniform view. New writes go through the modern schema exclusively. Tests ----- * ``tests_py/shared/test_wiki_classification.py`` (new) — 24 tests for Classification validation: empty audience rejected, unknown audience rejected, ADR/non-ADR lifecycle disjoint, generator block required for ai/auto-generated, frontmatter serialization, legacy normalization. * ``tests_py/core/test_wiki_classifier.py`` — extended with 7 tests for tutorial/how-to/runbook/rfc/journal pattern detection plus provenance and audience inference. Legacy ``test_valid_lesson_admitted`` updated to assert the new ``kind == "explanation"`` mapping per ADR-2244. * ``tests_py/core/test_wiki_sync_routing.py`` (new) — 6 end-to-end routing tests including the explicit Task #8 regression for file-documentation routing. * ``tests_py/core/test_wiki_layout.py`` — ``test_page_kinds_stable`` rewritten as ``test_page_kinds_modern_plus_legacy`` to assert the new split. Local run: ``pytest tests_py/core/ tests_py/shared/`` → 1964 passed. Targeted ADR-2244 suite (85 tests) → all green. ``ruff format`` / ``ruff check`` → clean. Research basis -------------- docs/research/wiki-classification-survey.md — literature survey (Cochrane-style synthesis) across 14 mature documentation systems (Diátaxis, DITA, Cloudflare, MediaWiki, Confluence, Backstage, Nygard/MADR, digital gardens, SRE conventions, Ranganathan, …). Every schema dimension is grounded in ≥2 prior systems. GRADE certainty: moderate — strong convergence in practice, but no controlled study comparing single-axis vs. multi-axis findability. Recommend a ~100-page pilot migration (Phase 2) before bulk rollout (Phase 4). ADR --- adr/_general/2244-richer-wiki-classification.md (in the methodology wiki). Accepted 2026-05-12. Phase 1 = this PR; Phases 2–6 (pilot migration → stable IDs → bulk migration → cleanup → producer audit) follow. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f9fab48 commit e1a088a

11 files changed

Lines changed: 1426 additions & 49 deletions

docs/research/wiki-classification-survey.md

Lines changed: 123 additions & 0 deletions
Large diffs are not rendered by default.

mcp_server/core/wiki_classifier.py

Lines changed: 237 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -443,23 +443,16 @@ def _apply_user_rules(content: str, tags: list[str] | None):
443443
return match
444444

445445

446-
def classify_memory(content: str, tags: list[str] | None = None) -> str | None:
447-
"""Classify memory content for wiki sync.
446+
def _classify_to_legacy_kind(content: str, tags: list[str] | None = None) -> str | None:
447+
"""Run the admission gates and return a legacy kind name or None.
448448
449-
Inversion (Eco + Ahrens research): default to REJECT. A memory is
450-
promoted to the wiki only if it passes three gates:
449+
This is the internal kind-detection used by ``classify_memory``.
450+
Returns one of {adr, lesson, convention, spec, note} when admitted,
451+
or None on rejection. The string return is mapped to the ADR-2244
452+
modern kind by ``classify_memory`` before reaching any caller.
451453
452-
1. Noise rejection (tool output, JSON, slash-command framing)
453-
2. Hard-negative gate (imperatives, first-person narration, status,
454-
temporal deixis — any hit disqualifies)
455-
3. Positive scoring (≥ 4 of 8 quality signals)
456-
457-
Explicit knowledge-shaped tags (decision/adr/spec/design/lesson/
458-
convention/rule) bypass the positive scorer — those are opt-in by
459-
the caller and presumed wiki-worthy.
460-
461-
Returns page kind ('adr', 'lesson', 'convention', 'spec', 'note')
462-
or None if the content should be rejected.
454+
Internal-only — direct callers should use ``classify_memory`` so they
455+
receive a full ``Classification`` tuple, not just the kind.
463456
"""
464457
if not content or len(content.strip()) < 50:
465458
return None
@@ -508,8 +501,14 @@ def classify_memory(content: str, tags: list[str] | None = None) -> str | None:
508501

509502
# Tag-based fast-path: explicit knowledge tags bypass positive scoring.
510503
# The caller has declared intent; trust the declaration.
504+
# ADR-2244: extended to include the new modern-kind shape tags
505+
# (runbook/tutorial/how-to/rfc/journal) plus the auto-gen producer
506+
# markers (code-reference/codebase) so codebase_analyze output is
507+
# admitted by the gate and the provenance facet downstream marks it
508+
# as auto-generated.
511509
tag_set = tag_set_pre
512510
_EXPLICIT_KNOWLEDGE_TAGS = {
511+
# Legacy knowledge tags.
513512
"decision",
514513
"adr",
515514
"architecture",
@@ -521,6 +520,21 @@ def classify_memory(content: str, tags: list[str] | None = None) -> str | None:
521520
"standard",
522521
"paper",
523522
"research",
523+
# ADR-2244 modern-kind shape tags.
524+
"runbook",
525+
"playbook",
526+
"tutorial",
527+
"getting-started",
528+
"how-to",
529+
"howto",
530+
"rfc",
531+
"proposal",
532+
"journal",
533+
# Auto-gen producer markers (provenance flips to auto-generated
534+
# downstream; bypassing positive score is correct here because
535+
# the producer has already filtered to high-signal content).
536+
"code-reference",
537+
"codebase",
524538
}
525539
has_explicit_tag = bool(tag_set & _EXPLICIT_KNOWLEDGE_TAGS)
526540

@@ -562,6 +576,214 @@ def classify_memory(content: str, tags: list[str] | None = None) -> str | None:
562576
return "note"
563577

564578

579+
# ── ADR-2244 4-tuple classification (Phase 1) ─────────────────────────
580+
581+
582+
# Patterns introduced for the new kinds in ADR-2244 §4.1. They sit alongside
583+
# the legacy _ADR_PATTERNS / _LESSON_PATTERNS / _CONVENTION_PATTERNS so the
584+
# v1 classifier stays unchanged.
585+
586+
_TUTORIAL_PATTERNS = [
587+
re.compile(
588+
r"\b(tutorial:|in this tutorial|we['']?ll (learn|build|create|walk through)|"
589+
r"by the end of this tutorial|getting started:|step 1[:.])\b",
590+
re.IGNORECASE,
591+
),
592+
]
593+
594+
_HOWTO_PATTERNS = [
595+
re.compile(
596+
r"\b(how to |how do (you|i) |here['']?s how to |to do (this|that),)\b",
597+
re.IGNORECASE,
598+
),
599+
]
600+
601+
_RUNBOOK_PATTERNS = [
602+
re.compile(
603+
r"\b(runbook|incident response|on[- ]call|when (the )?alert fires|"
604+
r"if (this|that) happens|recovery procedure|rollback steps?)\b",
605+
re.IGNORECASE,
606+
),
607+
]
608+
609+
_RFC_PATTERNS = [
610+
re.compile(
611+
r"\b(rfc:|proposal:|we propose to|proposed (design|change|approach)|"
612+
r"this rfc|request for comments)\b",
613+
re.IGNORECASE,
614+
),
615+
]
616+
617+
_JOURNAL_PATTERNS = [
618+
# Dated entry header — ## 2026-05-12 or ## 2026-05-12 — title
619+
re.compile(r"^##?\s*\d{4}-\d{2}-\d{2}\b", re.MULTILINE),
620+
]
621+
622+
623+
def _detect_modern_kind(
624+
content: str,
625+
tag_set: set[str],
626+
legacy_kind: str,
627+
) -> str:
628+
"""Map the v1 (legacy) kind to a modern v2 kind, plus pattern overrides.
629+
630+
The v1 classifier picks one of {adr, lesson, convention, spec, note}.
631+
The v2 classifier needs one of the 8 modern kinds. This function:
632+
633+
1. Checks the new pattern catalogs (tutorial, how-to, runbook, rfc,
634+
journal) — those override the legacy mapping when they hit.
635+
2. Falls back to the legacy → modern map for everything else.
636+
637+
The mapping rationale (ADR-2244 §4.1):
638+
adr → adr (kept)
639+
lesson → explanation (root-cause analysis is explanatory)
640+
convention → explanation (the "why" of a rule is explanation)
641+
spec → rfc (default; post-implementation specs
642+
should be retagged to ``reference``)
643+
note → explanation (catch-all becomes explanation, not notes)
644+
"""
645+
# 1. Modern-pattern overrides — strongest signals first.
646+
for pat in _RUNBOOK_PATTERNS:
647+
if pat.search(content):
648+
return "runbook"
649+
if tag_set & {"runbook", "playbook", "incident"}:
650+
return "runbook"
651+
652+
for pat in _TUTORIAL_PATTERNS:
653+
if pat.search(content):
654+
return "tutorial"
655+
if tag_set & {"tutorial", "getting-started"}:
656+
return "tutorial"
657+
658+
for pat in _HOWTO_PATTERNS:
659+
if pat.search(content):
660+
return "how-to"
661+
if tag_set & {"how-to", "howto", "guide"}:
662+
return "how-to"
663+
664+
for pat in _RFC_PATTERNS:
665+
if pat.search(content):
666+
return "rfc"
667+
if tag_set & {"rfc", "proposal"}:
668+
return "rfc"
669+
670+
for pat in _JOURNAL_PATTERNS:
671+
if pat.search(content):
672+
return "journal"
673+
if tag_set & {"journal", "diary", "log"}:
674+
return "journal"
675+
676+
# 2. Legacy → modern fallback.
677+
_LEGACY_KIND_MAP = {
678+
"adr": "adr",
679+
"lesson": "explanation",
680+
"convention": "explanation",
681+
"spec": "rfc",
682+
"note": "explanation",
683+
# Reference can come from explicit tags or downstream callers; the
684+
# v1 classifier never returns it, so this row is documentation only.
685+
"reference": "reference",
686+
}
687+
return _LEGACY_KIND_MAP.get(legacy_kind, "explanation")
688+
689+
690+
def _detect_provenance(tag_set: set[str]) -> str:
691+
"""Infer ``provenance`` from tags.
692+
693+
``human`` is the default. Explicit provenance tags or the codebase
694+
auto-gen signature (``code-reference``, ``codebase``) flip to
695+
``auto-generated``. Memory imports flip to ``imported``.
696+
"""
697+
if tag_set & {"auto-generated", "code-reference", "codebase"}:
698+
return "auto-generated"
699+
if tag_set & {"imported", "import"}:
700+
return "imported"
701+
if tag_set & {"ai-generated", "synthesized", "synth"}:
702+
return "ai-generated"
703+
return "human"
704+
705+
706+
def _default_lifecycle(kind: str) -> str:
707+
"""Default lifecycle for a newly classified page.
708+
709+
ADRs default to ``proposed`` (Nygard convention). Everything else
710+
defaults to ``seedling`` (digital-garden convention).
711+
"""
712+
return "proposed" if kind == "adr" else "seedling"
713+
714+
715+
def classify_memory(
716+
content: str,
717+
tags: list[str] | None = None,
718+
):
719+
"""Classify memory content for the wiki (ADR-2244 single classifier).
720+
721+
Returns a ``Classification`` (kind, lifecycle, audience, provenance,
722+
generator, tags) from ``mcp_server.shared.wiki_classification`` when
723+
the memory should be admitted, or ``None`` to reject.
724+
725+
Admission gates (same as the legacy single-kind classifier):
726+
1. Audit-tag gate — backfill / session-summary / stage-N / tool-output
727+
are rejected before user rules.
728+
2. User-editable rules from ``wiki/_rules/*.md``.
729+
3. Noise rejection — tool/system/slash artefacts.
730+
4. Hard-negative gate — imperatives, first-person, status framing,
731+
temporal deixis, path/URL titles, audit-shaped titles.
732+
5. Positive scoring — ≥ 4 of 8 signals, unless an explicit knowledge
733+
tag (decision/adr/spec/design/lesson/convention/rule) bypasses.
734+
735+
Routing (ADR-2244 §4.1): the 5 legacy kinds (adr/lesson/convention/
736+
spec/note) map to the 8 modern kinds via ``_detect_modern_kind``;
737+
pattern overrides for tutorial/how-to/runbook/rfc/journal take
738+
precedence over the legacy-derived kind.
739+
740+
Local import keeps the module's import graph flat.
741+
"""
742+
from mcp_server.shared.wiki_classification import Classification, Generator
743+
744+
legacy_kind = _classify_to_legacy_kind(content, tags)
745+
if legacy_kind is None:
746+
return None
747+
748+
tag_set = {t.lower() for t in (tags or [])}
749+
modern_kind = _detect_modern_kind(content, tag_set, legacy_kind)
750+
provenance = _detect_provenance(tag_set)
751+
lifecycle = _default_lifecycle(modern_kind)
752+
753+
# Audience inference — closed enum per ADR-2244 §4.3.
754+
# Security-tagged content gets security audience; ops/runbook gets ops;
755+
# everything else defaults to developer.
756+
audiences: list[str] = []
757+
if tag_set & {"security", "auth", "crypto", "vulnerability"}:
758+
audiences.append("security")
759+
if modern_kind == "runbook" or tag_set & {"ops", "sre", "infra", "deploy"}:
760+
audiences.append("ops")
761+
if not audiences:
762+
audiences.append("developer")
763+
764+
# Provenance with full generator block when ai/auto-generated.
765+
generator: Generator | None = None
766+
if provenance in {"ai-generated", "auto-generated"}:
767+
generator = Generator(
768+
model="unknown",
769+
version="",
770+
prompt_template="",
771+
generated_at="",
772+
)
773+
774+
# Tags pass through (capped to keep frontmatter tractable).
775+
out_tags = tuple(sorted(tag_set))[:50]
776+
777+
return Classification(
778+
kind=modern_kind,
779+
lifecycle=lifecycle,
780+
audience=tuple(audiences),
781+
provenance=provenance,
782+
generator=generator,
783+
tags=out_tags,
784+
)
785+
786+
565787
def _line_is_title_candidate(cleaned: str) -> bool:
566788
"""Return True iff ``cleaned`` is acceptable as a wiki page title.
567789

mcp_server/core/wiki_layout.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,38 @@
1818
import re
1919
from pathlib import PurePosixPath
2020

21-
PAGE_KINDS = (
21+
# Modern kinds (ADR-2244 §4.1). Each drives a directory under wiki/ for
22+
# pages classified to that kind. New writes use these names exclusively.
23+
MODERN_PAGE_KINDS = (
24+
"tutorial",
25+
"how-to",
26+
"reference",
27+
"explanation",
2228
"adr",
29+
"runbook",
30+
"rfc",
31+
"journal",
32+
)
33+
34+
# Legacy kinds — kept in PAGE_KINDS so existing pages remain readable and
35+
# wiki_list/wiki_read continue to function during the migration window.
36+
# New code SHOULD NOT route to these. See
37+
# ``mcp_server.shared.wiki_classification.LEGACY_KIND_TO_MODERN`` for the
38+
# read-time normalization map.
39+
LEGACY_PAGE_KINDS = (
2340
"specs",
2441
"guides",
25-
"reference",
2642
"conventions",
2743
"lessons",
2844
"notes",
29-
"journal",
3045
"files",
3146
)
3247

48+
# Full set accepted by ``page_path`` / ``domain_page_path`` — modern + legacy
49+
# for backward-compat. Validation in higher layers (wiki_classification.py)
50+
# enforces modern-only on write.
51+
PAGE_KINDS = MODERN_PAGE_KINDS + LEGACY_PAGE_KINDS
52+
3353
_SAFE = re.compile(r"[^a-zA-Z0-9_.-]+")
3454
_MAX_SLUG_LEN = 80
3555
# Trailing extension tokens to strip before slug consumers append ".md".

0 commit comments

Comments
 (0)