Skip to content

Commit be52855

Browse files
cdeustclaude
andcommitted
fix(wiki): reject audit artefacts (backfill, stage-N, path/URL titles)
The wiki should only contain curated knowledge (specs, ADRs, architecture, security, lessons, conventions). Before this change, backfill imports, session summaries, code-review reports, and file/URL access logs were leaking through the classifier and polluting the wiki view. Three new rejection gates in classify_memory: 1. Audit-tag gate (runs BEFORE user rules, so even "Decision:"-shaped backfills are rejected): _backfill, imported, session-summary, tool-output, code-review, stage-1..11, audit, automated, wip, progress. 2. Path/URL title gate: titles starting with /, ~, drive-letter, https://, ftp://, file://, or bare filenames like "resume.pdf" are audit records, not curated pages. 3. Audit-title gate: titles containing "stage N", "code review", "audit report", "session summary" are work-product reports. Also ran a one-shot purge on the existing wiki: 144 → 63 pages. 81 pages removed were session artefacts; memories remain in the store for recall and audit, only the wiki files were deleted. Added 11 regression tests (tests_py/core/test_wiki_classifier.py) covering each new rejection plus positive controls (valid ADR and lesson still admitted). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6981188 commit be52855

2 files changed

Lines changed: 181 additions & 2 deletions

File tree

mcp_server/core/wiki_classifier.py

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,59 @@
155155
),
156156
]
157157

158+
# Path- or URL-shaped titles — these are file/URL access audit records,
159+
# not curated knowledge. Keep them as memories (for recall), refuse
160+
# promotion to the wiki.
161+
_PATH_OR_URL_TITLE_PATTERNS = [
162+
# Absolute POSIX / Windows path as title
163+
re.compile(r"^\s*#*\s*[/~]"),
164+
re.compile(r"^\s*#*\s*[A-Za-z]:[\\/]"), # Windows drive letter
165+
# URL as title
166+
re.compile(r"^\s*#*\s*(https?|ftp|file|ssh|git)://", re.IGNORECASE),
167+
# Lone filename as the bulk of the title
168+
re.compile(
169+
r"^\s*#*\s*[\w.-]+\.(pdf|png|jpg|jpeg|svg|gif|zip|tar\.gz|docx?|xlsx?|csv|log|yaml|yml)\b",
170+
re.IGNORECASE,
171+
),
172+
]
173+
174+
# Session / audit artefact tags — these are recall-fodder, not wiki-worthy.
175+
# Any hit in tags auto-rejects from the wiki (memory is preserved separately).
176+
_AUDIT_TAGS = frozenset(
177+
{
178+
"_backfill",
179+
"imported",
180+
"session-summary",
181+
"tool-output",
182+
"code-review",
183+
"stage-1",
184+
"stage-2",
185+
"stage-3",
186+
"stage-4",
187+
"stage-5",
188+
"stage-6",
189+
"stage-7",
190+
"stage-8",
191+
"stage-9",
192+
"stage-10",
193+
"stage-11",
194+
"audit",
195+
"automated",
196+
"wip",
197+
"progress",
198+
}
199+
)
200+
201+
# Audit/review-shaped title patterns — "stage N:", "code review", "audit:",
202+
# "session N" — these are work-product reports, not durable knowledge.
203+
_AUDIT_TITLE_PATTERNS = [
204+
re.compile(r"\bstage[ -]?\d+\b", re.IGNORECASE),
205+
re.compile(
206+
r"\b(code[ -]?review|audit[ -]?report|review[ -]?notes?)\b", re.IGNORECASE
207+
),
208+
re.compile(r"\bsession[ -]?(summary|log|report|\d+)\b", re.IGNORECASE),
209+
]
210+
158211

159212
def _fails_hard_negatives(content: str, first_line: str) -> bool:
160213
"""Return True if content hits any hard-negative pattern.
@@ -177,9 +230,25 @@ def _fails_hard_negatives(content: str, first_line: str) -> bool:
177230
for pat in _DEIXIS_PATTERNS:
178231
if pat.search(first_line):
179232
return True
233+
for pat in _PATH_OR_URL_TITLE_PATTERNS:
234+
if pat.search(first_line):
235+
return True
236+
for pat in _AUDIT_TITLE_PATTERNS:
237+
if pat.search(first_line):
238+
return True
180239
return False
181240

182241

242+
def _fails_audit_tag_gate(tags: set[str]) -> bool:
243+
"""Return True if any audit/session tag is present.
244+
245+
Audit-tagged memories are valuable for recall but should never be
246+
promoted to the wiki — the wiki is for curated specs, ADRs,
247+
architecture, security, and lessons.
248+
"""
249+
return bool(tags & _AUDIT_TAGS)
250+
251+
183252
# ── Positive quality signals (admit only if ≥ threshold) ──────────────
184253

185254
_STRUCTURE_HEADING = re.compile(r"^#{1,4}\s+\S", re.MULTILINE)
@@ -374,6 +443,16 @@ def classify_memory(content: str, tags: list[str] | None = None) -> str | None:
374443
stripped = content.strip()
375444
first_line = stripped.split("\n", 1)[0].strip()
376445

446+
# Gate -1 — Audit-tag gate (runs BEFORE user rules).
447+
# Session artefacts (backfill, imports, tool output, code reviews,
448+
# stage reports) are memory-only. They are valuable for recall but
449+
# noise in the wiki. This runs first because even a user rule that
450+
# matches "Decision:" should not override the audit-tag disqualifier:
451+
# backfilled decisions are still backfill, not curated knowledge.
452+
tag_set_pre = {t.lower() for t in (tags or [])}
453+
if _fails_audit_tag_gate(tag_set_pre):
454+
return None
455+
377456
# Gate 0 — User-editable rules (Phase 5.1).
378457
# If the wiki has rules in `_rules/*.md`, they fire BEFORE the
379458
# hardcoded defaults so the user can override any built-in
@@ -398,13 +477,14 @@ def classify_memory(content: str, tags: list[str] | None = None) -> str | None:
398477
if slug in _REJECT_TITLES:
399478
return None
400479

401-
# Gate 2 — Hard-negative gate (task-shape, narration, status, deixis)
480+
# Gate 2 — Hard-negative gate (task-shape, narration, status, deixis,
481+
# path/URL titles, audit-shaped titles)
402482
if _fails_hard_negatives(content, first_line):
403483
return None
404484

405485
# Tag-based fast-path: explicit knowledge tags bypass positive scoring.
406486
# The caller has declared intent; trust the declaration.
407-
tag_set = {t.lower() for t in (tags or [])}
487+
tag_set = tag_set_pre
408488
_EXPLICIT_KNOWLEDGE_TAGS = {
409489
"decision",
410490
"adr",
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""Regression tests for wiki_classifier audit-gate and path/URL rejection."""
2+
3+
from __future__ import annotations
4+
5+
from mcp_server.core.wiki_classifier import classify_memory
6+
7+
8+
# ── Audit-tag gate ────────────────────────────────────────────────────
9+
10+
11+
def test_backfill_tag_rejects_even_with_rich_content() -> None:
12+
content = (
13+
"Decision: adopt pgvector with HNSW (m=16, ef_construction=64) "
14+
"because benchmarks show 3x improvement over IVFFlat at this scale. "
15+
"Consequences: Postgres becomes a hard dependency."
16+
)
17+
assert classify_memory(content, tags=["imported", "_backfill"]) is None
18+
19+
20+
def test_session_summary_tag_rejects() -> None:
21+
content = (
22+
"Session abc-123 in domain 'cortex' | category: bug-fix | "
23+
"topics: recall, regression, pgvector"
24+
)
25+
assert classify_memory(content, tags=["session-summary"]) is None
26+
27+
28+
def test_stage_tag_rejects_audit_artefact() -> None:
29+
content = (
30+
"ai-architect-mcp stage 1 code review (src/main.rs, 1042 LOC): "
31+
"APPROVED-WITH-CHANGES. Five engineer-flagged concerns: "
32+
"MergeMode::PreserveRefined CORRECT, validate_safe_id CORRECT..."
33+
)
34+
assert classify_memory(content, tags=["stage-1", "code-review"]) is None
35+
36+
37+
# ── Path/URL title gate ───────────────────────────────────────────────
38+
39+
40+
def test_posix_path_title_rejects() -> None:
41+
content = (
42+
"/Users/alice/Downloads/resume.pdf\nhttps://linkedin.com/in/alice/\n\n"
43+
"Context note about the file."
44+
)
45+
assert classify_memory(content, tags=["paper"]) is None
46+
47+
48+
def test_home_path_title_rejects() -> None:
49+
content = "~/code/cortex/mcp_server/core/pg_recall.py has a bug."
50+
assert classify_memory(content, tags=["bug-fix"]) is None
51+
52+
53+
def test_url_title_rejects() -> None:
54+
content = (
55+
"https://arxiv.org/abs/2310.12345\n\n"
56+
"This paper proposes WRRF fusion. Results show R@10 = 97.8%."
57+
)
58+
assert classify_memory(content, tags=["paper", "research"]) is None
59+
60+
61+
def test_lone_filename_title_rejects() -> None:
62+
content = "resume-v3.pdf contains my latest CV as of April 2026."
63+
assert classify_memory(content, tags=[]) is None
64+
65+
66+
# ── Audit-shaped titles ───────────────────────────────────────────────
67+
68+
69+
def test_stage_n_in_title_rejects() -> None:
70+
content = "stage 3 research verdict: GitNexus is MIT licensed and usable."
71+
assert classify_memory(content, tags=["research"]) is None
72+
73+
74+
def test_code_review_title_rejects() -> None:
75+
content = "Code review notes for PR #42: three concerns raised around SRP."
76+
assert classify_memory(content, tags=["review"]) is None
77+
78+
79+
# ── Positive control: real ADR/lesson still admitted ─────────────────
80+
81+
82+
def test_valid_adr_admitted() -> None:
83+
content = (
84+
"Decision: use pgvector over IVFFlat for ANN search. Context: "
85+
"100k memories need sub-100ms cosine retrieval. Decided to adopt "
86+
"HNSW (m=16, ef_construction=64) because benchmarks show 3x improvement. "
87+
"Consequences: Postgres becomes mandatory."
88+
)
89+
assert classify_memory(content, tags=["decision", "architecture"]) == "adr"
90+
91+
92+
def test_valid_lesson_admitted() -> None:
93+
content = (
94+
"The bug was that FlashRank ONNX cache persisted stale weights across "
95+
"container restarts. Root cause: cache key did not include model hash. "
96+
"Fix: include model SHA in the cache key. Never ship a cache keyed only "
97+
"on path again."
98+
)
99+
assert classify_memory(content, tags=["lesson", "bug-fix"]) == "lesson"

0 commit comments

Comments
 (0)