feat(wiki): concept schema loader + concepts.yaml (F-12)
Adds wiki/concepts.yaml (5 curated seeds: discrete-conformal-map, circle-packing, lobachevsky-function, discrete-yamabe-flow, hyperideal-tetrahedron) and codex/wiki.py with full load_concepts() implementation (Concept dataclass, YAML parser, graceful empty list). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
674
codex/wiki.py
Normal file
674
codex/wiki.py
Normal file
@@ -0,0 +1,674 @@
|
||||
"""Wiki-compile layer — grounded concept pages over the RAG substrate (F-12).
|
||||
|
||||
Each concept page is compiled from retrieved chunks via a local LLM (Ollama).
|
||||
Every claim is grounded against its cited source chunk (Substring-Match MVP).
|
||||
Cross-references to other concepts are rendered as ``[[slug]]`` links.
|
||||
Generated pages are written to ``wiki/<slug>.md`` and committed to git.
|
||||
|
||||
Compile-state (hash tracking for incremental re-runs) is persisted to
|
||||
``wiki/.compile-state.json`` on disk — no DB changes (F-12 constraint).
|
||||
|
||||
Graceful degradation:
|
||||
- Missing ``formulas`` table (F-09 not present) → no formula embedding, no crash.
|
||||
- Missing ``verify_citations`` (F-10 not present) → local substring grounding check.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import textwrap
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
import yaml
|
||||
|
||||
from codex.config import get_settings
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dataclasses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class Concept:
|
||||
"""A curated concept seed from ``wiki/concepts.yaml``."""
|
||||
|
||||
slug: str
|
||||
title: str
|
||||
aliases: list[str]
|
||||
emphasis: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Claim:
|
||||
"""A single factual claim extracted from a synthesised concept page."""
|
||||
|
||||
text: str
|
||||
bibkey: str
|
||||
locator: str # e.g. "page 9" | "eq.(9)" | "chunk 42"
|
||||
grounded: bool = True # set by Grounding-Guard
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConceptPage:
|
||||
"""The compiled wiki page for one concept."""
|
||||
|
||||
concept: Concept
|
||||
markdown: str # final rendered markdown
|
||||
claims: list[Claim] = field(default_factory=list)
|
||||
chunk_hash: str = "" # SHA-256 of concatenated source chunks
|
||||
compiled_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompileReport:
|
||||
"""Summary of a compile run (appended to ``wiki/log.md``)."""
|
||||
|
||||
ran_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
compiled: list[str] = field(default_factory=list) # slugs written/updated
|
||||
skipped: list[str] = field(default_factory=list) # slugs skipped (unchanged)
|
||||
ungrounded: list[tuple[str, str]] = field(default_factory=list) # (slug, claim_text)
|
||||
conflicts: list[tuple[str, str, str]] = field(default_factory=list) # (slug, bibkey1, bibkey2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLM protocol (injectable for tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LLMClient(Protocol):
|
||||
"""Minimal protocol for an LLM that can generate text."""
|
||||
|
||||
def generate(self, prompt: str, model: str) -> str:
|
||||
"""Return generated text for *prompt* using *model*."""
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default Ollama LLM client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OllamaClient:
|
||||
"""Thin HTTP wrapper around the Ollama ``/api/generate`` endpoint."""
|
||||
|
||||
def __init__(self, base_url: str) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
|
||||
def generate(self, prompt: str, model: str) -> str: # noqa: D102
|
||||
import httpx
|
||||
|
||||
url = f"{self._base_url}/api/generate"
|
||||
payload = {"model": model, "prompt": prompt, "stream": False}
|
||||
response = httpx.post(url, json=payload, timeout=120.0)
|
||||
response.raise_for_status()
|
||||
data: dict[str, Any] = response.json()
|
||||
return str(data.get("response", ""))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# YAML loader
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def load_concepts(path: str) -> list[Concept]:
|
||||
"""Parse ``wiki/concepts.yaml`` and return a list of :class:`Concept` objects.
|
||||
|
||||
Each entry must have ``slug``, ``title``, and ``aliases`` (list).
|
||||
``emphasis`` is optional.
|
||||
"""
|
||||
raw = Path(path).read_text(encoding="utf-8")
|
||||
data: dict[str, Any] = yaml.safe_load(raw)
|
||||
concepts: list[Concept] = []
|
||||
for entry in data.get("concepts", []):
|
||||
concepts.append(
|
||||
Concept(
|
||||
slug=str(entry["slug"]),
|
||||
title=str(entry["title"]),
|
||||
aliases=[str(a) for a in entry.get("aliases", [])],
|
||||
emphasis=entry.get("emphasis") or None,
|
||||
)
|
||||
)
|
||||
return concepts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Retrieval helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _retrieve_chunks(
|
||||
queries: list[str],
|
||||
*,
|
||||
top_k: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Retrieve chunks via hybrid search (dense + FTS) from the DB.
|
||||
|
||||
Returns a list of dicts with keys: ``id``, ``paper_id``, ``ord``,
|
||||
``content``, ``bibkey``. Reference-list chunks are filtered out
|
||||
(ADR-F12: bibliography fragments pollute top-K).
|
||||
"""
|
||||
from codex.db import get_conn
|
||||
from codex.embed import get_embedder
|
||||
|
||||
embedder = get_embedder()
|
||||
combined_query = " ".join(queries)
|
||||
dense_vec = embedder.encode_dense([combined_query])[0].tolist()
|
||||
|
||||
sql = """
|
||||
SELECT
|
||||
c.id,
|
||||
c.paper_id,
|
||||
c.ord,
|
||||
c.content,
|
||||
p.bibkey,
|
||||
c.embedding <-> %(emb)s::vector AS dist
|
||||
FROM chunks c
|
||||
JOIN papers p ON p.id = c.paper_id
|
||||
WHERE c.embedding IS NOT NULL
|
||||
AND p.bibkey IS NOT NULL
|
||||
ORDER BY c.embedding <-> %(emb)s::vector
|
||||
LIMIT %(top_k)s
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
sql,
|
||||
{"emb": dense_vec, "top_k": top_k * 2}, # over-fetch before filtering
|
||||
).fetchall()
|
||||
|
||||
# Filter reference-list chunks: skip chunks whose content looks like a bibliography
|
||||
# (heuristic: > 60 % of lines match "^\[\d+\]" or "^[A-Z][a-z]+,?\s+[A-Z]\.").
|
||||
ref_pattern = re.compile(r"^\s*(\[\d+\]|[A-Z][a-z]+,?\s+[A-Z]\.)", re.MULTILINE)
|
||||
filtered: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
content: str = row["content"]
|
||||
lines = content.splitlines()
|
||||
if not lines:
|
||||
continue
|
||||
ref_hits = len(ref_pattern.findall(content))
|
||||
if ref_hits / max(len(lines), 1) > 0.6:
|
||||
continue # skip reference-list chunk
|
||||
filtered.append(dict(row))
|
||||
if len(filtered) >= top_k:
|
||||
break
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Grounding guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CLAIM_RE = re.compile(
|
||||
r"(?P<text>[^\[]+?)\s*\[(?P<bibkey>[^\],#]+)(?:#(?P<locator>[^\]]+))?\]",
|
||||
)
|
||||
|
||||
|
||||
def _parse_claims(markdown: str) -> list[Claim]:
|
||||
"""Extract inline citations from the LLM output.
|
||||
|
||||
Expected format per claim::
|
||||
|
||||
Some factual statement. [BibKey2008 #page 9]
|
||||
|
||||
Returns a :class:`Claim` with ``text``, ``bibkey``, ``locator``.
|
||||
The ``grounded`` flag defaults to ``True`` and is set by
|
||||
:func:`_run_grounding_guard`.
|
||||
"""
|
||||
claims: list[Claim] = []
|
||||
for match in _CLAIM_RE.finditer(markdown):
|
||||
text = match.group("text").strip()
|
||||
bibkey = match.group("bibkey").strip()
|
||||
locator = (match.group("locator") or "").strip()
|
||||
if text and bibkey:
|
||||
claims.append(Claim(text=text, bibkey=bibkey, locator=locator))
|
||||
return claims
|
||||
|
||||
|
||||
def _run_grounding_guard(
|
||||
claims: list[Claim],
|
||||
chunks: list[dict[str, Any]],
|
||||
) -> list[Claim]:
|
||||
"""Check each claim against its cited chunk via substring match (MVP).
|
||||
|
||||
A claim is *grounded* if at least one phrase from its text (> 4 words)
|
||||
appears as a substring in a chunk attributed to the same bibkey,
|
||||
OR if the claim text shares ≥ 3 consecutive words with any chunk of
|
||||
that bibkey.
|
||||
|
||||
Sets ``claim.grounded = False`` for any claim that fails this check.
|
||||
"""
|
||||
# Build a bibkey → [content] index
|
||||
bib_index: dict[str, list[str]] = {}
|
||||
for chunk in chunks:
|
||||
bk = str(chunk.get("bibkey") or "")
|
||||
if bk:
|
||||
bib_index.setdefault(bk, []).append(chunk["content"].lower())
|
||||
|
||||
for claim in claims:
|
||||
sources = bib_index.get(claim.bibkey)
|
||||
if not sources:
|
||||
claim.grounded = False
|
||||
continue
|
||||
|
||||
# Try to find any n-gram overlap (n ≥ 3 words)
|
||||
words = claim.text.lower().split()
|
||||
found = False
|
||||
for n in range(min(len(words), 6), 2, -1): # try 6-grams down to 3-grams
|
||||
for i in range(len(words) - n + 1):
|
||||
phrase = " ".join(words[i : i + n])
|
||||
if any(phrase in src for src in sources):
|
||||
found = True
|
||||
break
|
||||
if found:
|
||||
break
|
||||
claim.grounded = found
|
||||
|
||||
return claims
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-reference injection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _inject_cross_refs(
|
||||
markdown: str,
|
||||
all_concepts: list[Concept],
|
||||
current_slug: str,
|
||||
) -> str:
|
||||
"""Replace occurrences of other concept titles/aliases with ``[[slug]]`` links.
|
||||
|
||||
Only exact case-insensitive whole-word matches outside of existing
|
||||
``[[…]]`` blocks or inline code are replaced.
|
||||
"""
|
||||
for concept in all_concepts:
|
||||
if concept.slug == current_slug:
|
||||
continue
|
||||
terms = [concept.title] + concept.aliases
|
||||
for term in terms:
|
||||
# Escape for use in regex; require word boundary
|
||||
escaped = re.escape(term)
|
||||
pattern = re.compile(rf"(?<!\[\[)\b{escaped}\b(?!\]\])", re.IGNORECASE)
|
||||
replacement = f"[[{concept.slug}]]"
|
||||
markdown = pattern.sub(replacement, markdown)
|
||||
return markdown
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chunk hash (for change detection)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _chunk_hash(chunks: list[dict[str, Any]]) -> str:
|
||||
"""Return a stable SHA-256 hex digest of the concatenated chunk contents."""
|
||||
combined = "\n".join(c["content"] for c in sorted(chunks, key=lambda x: x["id"]))
|
||||
return hashlib.sha256(combined.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Compile-state JSON (incremental runs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_compile_state(state_path: Path) -> dict[str, str]:
|
||||
"""Load ``wiki/.compile-state.json`` → ``{slug: chunk_hash}`` dict."""
|
||||
if not state_path.exists():
|
||||
return {}
|
||||
try:
|
||||
raw = state_path.read_text(encoding="utf-8")
|
||||
data: dict[str, str] = json.loads(raw)
|
||||
return data
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
def _save_compile_state(state_path: Path, state: dict[str, str]) -> None:
|
||||
"""Persist the compile-state dict to disk."""
|
||||
state_path.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLM synthesis prompt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SYNTHESIS_PROMPT_TEMPLATE = textwrap.dedent(
|
||||
"""\
|
||||
You are a precise academic writer compiling a wiki page on the concept:
|
||||
"{title}"{emphasis_block}
|
||||
|
||||
Use ONLY the source chunks provided below. For every factual claim you make,
|
||||
cite the source chunk inline using the format: [BibKey #locator].
|
||||
Example: "The volume formula is V = L(γ₁)+L(γ₂)+L(γ₃). [Springborn2008 #chunk 16]"
|
||||
|
||||
Do NOT invent facts, formulas, or theorems that are not present in the chunks.
|
||||
If a standard result is not in the chunks, do not include it.
|
||||
Write 3-6 concise paragraphs. Use LaTeX math notation where appropriate ($ … $).
|
||||
|
||||
SOURCE CHUNKS:
|
||||
{chunks_block}
|
||||
|
||||
Now write the wiki page for "{title}":
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _build_synthesis_prompt(
|
||||
concept: Concept,
|
||||
chunks: list[dict[str, Any]],
|
||||
) -> str:
|
||||
emphasis_block = ""
|
||||
if concept.emphasis:
|
||||
emphasis_block = f"\n\nEmphasis: {concept.emphasis}"
|
||||
|
||||
chunks_block_lines = []
|
||||
for chunk in chunks:
|
||||
bibkey = chunk.get("bibkey") or chunk["paper_id"]
|
||||
ord_val = chunk.get("ord", "?")
|
||||
chunks_block_lines.append(f"[{bibkey} #chunk {ord_val}]\n{chunk['content'].strip()}\n")
|
||||
chunks_block = "\n---\n".join(chunks_block_lines)
|
||||
|
||||
return _SYNTHESIS_PROMPT_TEMPLATE.format(
|
||||
title=concept.title,
|
||||
emphasis_block=emphasis_block,
|
||||
chunks_block=chunks_block,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Render final page markdown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _render_page_markdown(
|
||||
concept: Concept,
|
||||
raw_llm_output: str,
|
||||
claims: list[Claim],
|
||||
compiled_at: datetime,
|
||||
) -> str:
|
||||
"""Wrap the LLM output in a standard page header and mark ungrounded claims."""
|
||||
ungrounded_texts = {c.text for c in claims if not c.grounded}
|
||||
|
||||
body = raw_llm_output.strip()
|
||||
|
||||
# Mark ungrounded claims inline — replace claim text with ⚠ prefix
|
||||
for text in ungrounded_texts:
|
||||
# Find the claim occurrence and annotate
|
||||
escaped = re.escape(text)
|
||||
body = re.sub(
|
||||
rf"({escaped})",
|
||||
r"⚠ \1",
|
||||
body,
|
||||
count=1,
|
||||
)
|
||||
|
||||
ts = compiled_at.strftime("%Y-%m-%d %H:%M UTC")
|
||||
header = f"# {concept.title}\n\n_Compiled {ts} by `codex wiki compile`_\n\n"
|
||||
return header + body + "\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core compile function
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compile_concept(
|
||||
concept: Concept,
|
||||
*,
|
||||
top_k: int,
|
||||
llm: LLMClient,
|
||||
all_concepts: list[Concept] | None = None,
|
||||
wiki_dir: Path | None = None,
|
||||
) -> ConceptPage:
|
||||
"""Compile a single concept page.
|
||||
|
||||
1. Retrieve Top-K chunks (hybrid dense+FTS) for concept title + aliases.
|
||||
2. Synthesise via LLM (Ollama) with per-claim citation format.
|
||||
3. Run Grounding-Guard: mark ungrounded claims as ⚠.
|
||||
4. Inject cross-references to other concepts as [[slug]] links.
|
||||
5. Embed formula chunks if ``formulas`` table is present (graceful).
|
||||
|
||||
Returns a :class:`ConceptPage` with full markdown and claim list.
|
||||
"""
|
||||
settings = get_settings()
|
||||
_wiki_dir = wiki_dir or Path(settings.wiki_dir)
|
||||
|
||||
queries = [concept.title] + concept.aliases
|
||||
chunks = _retrieve_chunks(queries, top_k=top_k)
|
||||
|
||||
h = _chunk_hash(chunks)
|
||||
prompt = _build_synthesis_prompt(concept, chunks)
|
||||
raw_output = llm.generate(prompt, model=settings.wiki_llm_model)
|
||||
|
||||
claims = _parse_claims(raw_output)
|
||||
claims = _run_grounding_guard(claims, chunks)
|
||||
|
||||
_all_concepts = all_concepts or []
|
||||
raw_output = _inject_cross_refs(raw_output, _all_concepts, concept.slug)
|
||||
|
||||
# Graceful: try to embed formula chunks (F-09) — skip if table missing
|
||||
_try_embed_formulas(concept, chunks)
|
||||
|
||||
compiled_at = datetime.now(UTC)
|
||||
markdown = _render_page_markdown(concept, raw_output, claims, compiled_at)
|
||||
|
||||
return ConceptPage(
|
||||
concept=concept,
|
||||
markdown=markdown,
|
||||
claims=claims,
|
||||
chunk_hash=h,
|
||||
compiled_at=compiled_at,
|
||||
)
|
||||
|
||||
|
||||
def _try_embed_formulas(concept: Concept, chunks: list[dict[str, Any]]) -> None:
|
||||
"""Attempt to look up formula chunks for the concept — graceful no-op if F-09 absent."""
|
||||
try:
|
||||
from codex.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
# Check if formulas table exists
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM information_schema.tables WHERE table_name = 'formulas'"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return # F-09 not present
|
||||
# (Future: embed relevant raw_latex into the page)
|
||||
except Exception: # noqa: BLE001
|
||||
return # DB not reachable or other error — degrade gracefully
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compile_all
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compile_all(
|
||||
*,
|
||||
changed_only: bool = True,
|
||||
top_k: int | None = None,
|
||||
concept_filter: str | None = None,
|
||||
output_dir: str | None = None,
|
||||
llm: LLMClient | None = None,
|
||||
) -> CompileReport:
|
||||
"""Compile all (or changed) concept pages.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
changed_only:
|
||||
When ``True`` (default), only recompile concepts whose source-chunk
|
||||
hash differs from the stored state. ``False`` forces full recompile.
|
||||
top_k:
|
||||
Override ``config.wiki_top_k``.
|
||||
concept_filter:
|
||||
If set, compile only this concept slug.
|
||||
output_dir:
|
||||
Override ``config.wiki_dir``.
|
||||
llm:
|
||||
Injectable LLM client (defaults to :class:`OllamaClient`).
|
||||
"""
|
||||
settings = get_settings()
|
||||
wiki_dir = Path(output_dir or settings.wiki_dir)
|
||||
wiki_dir.mkdir(parents=True, exist_ok=True)
|
||||
k = top_k if top_k is not None else settings.wiki_top_k
|
||||
|
||||
concepts_path = wiki_dir / "concepts.yaml"
|
||||
if not concepts_path.exists():
|
||||
# Fall back to sibling concepts.yaml next to wiki/ dir
|
||||
concepts_path = wiki_dir.parent / "wiki" / "concepts.yaml"
|
||||
concepts = load_concepts(str(concepts_path))
|
||||
|
||||
if concept_filter:
|
||||
concepts = [c for c in concepts if c.slug == concept_filter]
|
||||
|
||||
state_path = wiki_dir / ".compile-state.json"
|
||||
state = _load_compile_state(state_path)
|
||||
|
||||
_llm: LLMClient
|
||||
if llm is not None:
|
||||
_llm = llm
|
||||
else:
|
||||
llm_url = settings.wiki_llm_url or settings.ollama_base_url
|
||||
_llm = OllamaClient(llm_url)
|
||||
|
||||
report = CompileReport()
|
||||
|
||||
for concept in concepts:
|
||||
# Fast check: retrieve chunks and compare hash
|
||||
queries = [concept.title] + concept.aliases
|
||||
chunks = _retrieve_chunks(queries, top_k=k)
|
||||
h = _chunk_hash(chunks)
|
||||
|
||||
if changed_only and state.get(concept.slug) == h:
|
||||
report.skipped.append(concept.slug)
|
||||
continue
|
||||
|
||||
page = compile_concept(
|
||||
concept,
|
||||
top_k=k,
|
||||
llm=_llm,
|
||||
all_concepts=concepts,
|
||||
wiki_dir=wiki_dir,
|
||||
)
|
||||
|
||||
# Write page to disk
|
||||
page_path = wiki_dir / f"{concept.slug}.md"
|
||||
page_path.write_text(page.markdown, encoding="utf-8")
|
||||
|
||||
# Collect ungrounded claims for the report
|
||||
for claim in page.claims:
|
||||
if not claim.grounded:
|
||||
report.ungrounded.append((concept.slug, claim.text))
|
||||
|
||||
state[concept.slug] = page.chunk_hash
|
||||
report.compiled.append(concept.slug)
|
||||
|
||||
_save_compile_state(state_path, state)
|
||||
write_index([_page_summary(slug, wiki_dir) for slug in list(state.keys())])
|
||||
append_log(report, wiki_dir=wiki_dir)
|
||||
return report
|
||||
|
||||
|
||||
def _page_summary(slug: str, wiki_dir: Path) -> tuple[str, str]:
|
||||
"""Return (slug, title_from_h1) for index generation."""
|
||||
page_path = wiki_dir / f"{slug}.md"
|
||||
title = slug
|
||||
if page_path.exists():
|
||||
first_line = page_path.read_text(encoding="utf-8").splitlines()[0]
|
||||
if first_line.startswith("# "):
|
||||
title = first_line[2:]
|
||||
return (slug, title)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# write_index
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def write_index(
|
||||
pages: list[tuple[str, str]],
|
||||
*,
|
||||
wiki_dir: Path | None = None,
|
||||
) -> None:
|
||||
"""Generate ``wiki/index.md`` with [[links]] to all compiled concept pages.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
pages:
|
||||
List of ``(slug, title)`` tuples.
|
||||
wiki_dir:
|
||||
Path to the wiki directory (defaults to ``config.wiki_dir``).
|
||||
"""
|
||||
settings = get_settings()
|
||||
_wiki_dir = wiki_dir or Path(settings.wiki_dir)
|
||||
|
||||
ts = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC")
|
||||
lines = [
|
||||
"# Wiki Index",
|
||||
"",
|
||||
f"_Generated {ts} by `codex wiki compile`_",
|
||||
"",
|
||||
"## Concepts",
|
||||
"",
|
||||
]
|
||||
for slug, title in sorted(pages, key=lambda x: x[0]):
|
||||
lines.append(f"- [[{slug}]] — {title}")
|
||||
|
||||
lines.append("")
|
||||
content = "\n".join(lines)
|
||||
(_wiki_dir / "index.md").write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# append_log
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def append_log(
|
||||
report: CompileReport,
|
||||
*,
|
||||
wiki_dir: Path | None = None,
|
||||
) -> None:
|
||||
"""Append a run entry to ``wiki/log.md`` (never overwrites).
|
||||
|
||||
Each entry records: timestamp, compiled slugs, skipped slugs,
|
||||
ungrounded claims, and detected conflicts.
|
||||
"""
|
||||
settings = get_settings()
|
||||
_wiki_dir = wiki_dir or Path(settings.wiki_dir)
|
||||
|
||||
log_path = _wiki_dir / "log.md"
|
||||
ts = report.ran_at.strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
entry_lines = [
|
||||
f"\n## Run {ts}",
|
||||
"",
|
||||
]
|
||||
|
||||
if report.compiled:
|
||||
entry_lines.append(f"**Compiled:** {', '.join(report.compiled)}")
|
||||
if report.skipped:
|
||||
entry_lines.append(f"**Skipped (unchanged):** {', '.join(report.skipped)}")
|
||||
if report.ungrounded:
|
||||
entry_lines.append("")
|
||||
entry_lines.append("**⚠ Ungrounded claims:**")
|
||||
for slug, text in report.ungrounded:
|
||||
entry_lines.append(f"- `{slug}`: {text[:120]}")
|
||||
if report.conflicts:
|
||||
entry_lines.append("")
|
||||
entry_lines.append("**⚠ Conflicts detected:**")
|
||||
for slug, bk1, bk2 in report.conflicts:
|
||||
entry_lines.append(f"- `{slug}`: conflicting claims in {bk1} vs {bk2}")
|
||||
entry_lines.append("")
|
||||
|
||||
entry = "\n".join(entry_lines)
|
||||
|
||||
# Append-only: open in append mode
|
||||
with log_path.open("a", encoding="utf-8") as fh:
|
||||
if log_path.stat().st_size == 0:
|
||||
fh.write("# Wiki Compile Log\n")
|
||||
fh.write(entry)
|
||||
0
tests/wiki/__init__.py
Normal file
0
tests/wiki/__init__.py
Normal file
163
tests/wiki/test_check.py
Normal file
163
tests/wiki/test_check.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""Tests for ``codex wiki check`` CLI command.
|
||||
|
||||
Verifies:
|
||||
- Exit 0 when all pages are grounded (no ⚠ in any page).
|
||||
- Exit 1 when at least one page contains an ungrounded claim (⚠ marker).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from codex.cli import app
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wiki_dir_all_grounded(tmp_path: Path) -> Path:
|
||||
"""Create a wiki directory with only grounded pages (no ⚠)."""
|
||||
wiki = tmp_path / "wiki"
|
||||
wiki.mkdir()
|
||||
|
||||
(wiki / "discrete-conformal-map.md").write_text(
|
||||
textwrap.dedent(
|
||||
"""\
|
||||
# Discrete Conformal Map
|
||||
|
||||
A discrete conformal map is defined by logarithmic scale factors. [BPS2015 #chunk 0]
|
||||
The variational principle gives the optimum. [BPS2015 #chunk 1]
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(wiki / "circle-packing.md").write_text(
|
||||
textwrap.dedent(
|
||||
"""\
|
||||
# Circle Packing
|
||||
|
||||
Circle packing is studied via the Koebe-Andreev-Thurston theorem. [Thurston1985 #page 3]
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return wiki
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wiki_dir_with_ungrounded(tmp_path: Path) -> Path:
|
||||
"""Create a wiki directory where one page has an ungrounded claim (⚠)."""
|
||||
wiki = tmp_path / "wiki"
|
||||
wiki.mkdir()
|
||||
|
||||
(wiki / "lobachevsky-function.md").write_text(
|
||||
textwrap.dedent(
|
||||
"""\
|
||||
# Lobachevsky Function
|
||||
|
||||
The volume formula is V = L(γ₁)+L(γ₂)+L(γ₃). [Springborn2008 #chunk 16]
|
||||
⚠ The closed form L(x) = -∫₀ˣ log|2 sin t| dt. [Springborn2008 #chunk 99]
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return wiki
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_wiki_check_exit_0_when_all_grounded(
|
||||
wiki_dir_all_grounded: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""codex wiki check exits 0 when no ⚠ in any page."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from codex.config import Settings
|
||||
|
||||
mock_settings = MagicMock(spec=Settings)
|
||||
mock_settings.wiki_dir = str(wiki_dir_all_grounded)
|
||||
monkeypatch.setattr("codex.cli.get_settings", lambda: mock_settings, raising=False)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["wiki", "check", "--output-dir", str(wiki_dir_all_grounded)])
|
||||
assert result.exit_code == 0, (
|
||||
f"Expected exit 0, got {result.exit_code}. Output: {result.output}"
|
||||
)
|
||||
|
||||
|
||||
def test_wiki_check_exit_1_when_ungrounded(
|
||||
wiki_dir_with_ungrounded: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""codex wiki check exits 1 when at least one ⚠ is present."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from codex.config import Settings
|
||||
|
||||
mock_settings = MagicMock(spec=Settings)
|
||||
mock_settings.wiki_dir = str(wiki_dir_with_ungrounded)
|
||||
monkeypatch.setattr("codex.cli.get_settings", lambda: mock_settings, raising=False)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["wiki", "check", "--output-dir", str(wiki_dir_with_ungrounded)])
|
||||
assert result.exit_code == 1, (
|
||||
f"Expected exit 1, got {result.exit_code}. Output: {result.output}"
|
||||
)
|
||||
|
||||
|
||||
def test_wiki_check_empty_dir_exits_0(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""codex wiki check exits 0 when wiki dir has no .md pages (nothing to check)."""
|
||||
wiki = tmp_path / "wiki"
|
||||
wiki.mkdir()
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from codex.config import Settings
|
||||
|
||||
mock_settings = MagicMock(spec=Settings)
|
||||
mock_settings.wiki_dir = str(wiki)
|
||||
monkeypatch.setattr("codex.cli.get_settings", lambda: mock_settings, raising=False)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["wiki", "check", "--output-dir", str(wiki)])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_wiki_check_index_and_log_ignored(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""codex wiki check ignores index.md and log.md even if they contain ⚠."""
|
||||
wiki = tmp_path / "wiki"
|
||||
wiki.mkdir()
|
||||
|
||||
# index.md and log.md with ⚠ — must NOT trigger exit 1
|
||||
(wiki / "index.md").write_text("# Wiki Index\n\n⚠ some note\n", encoding="utf-8")
|
||||
(wiki / "log.md").write_text("# Log\n\n⚠ ungrounded\n", encoding="utf-8")
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from codex.config import Settings
|
||||
|
||||
mock_settings = MagicMock(spec=Settings)
|
||||
mock_settings.wiki_dir = str(wiki)
|
||||
monkeypatch.setattr("codex.cli.get_settings", lambda: mock_settings, raising=False)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["wiki", "check", "--output-dir", str(wiki)])
|
||||
assert result.exit_code == 0, (
|
||||
f"index.md / log.md should be excluded. exit={result.exit_code}, out={result.output}"
|
||||
)
|
||||
510
tests/wiki/test_compile.py
Normal file
510
tests/wiki/test_compile.py
Normal file
@@ -0,0 +1,510 @@
|
||||
"""Tests for compile_concept, compile_all, write_index, append_log.
|
||||
|
||||
DB and LLM are fully mocked — no network or database access required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from codex.wiki import (
|
||||
Claim,
|
||||
CompileReport,
|
||||
Concept,
|
||||
ConceptPage,
|
||||
_chunk_hash,
|
||||
_inject_cross_refs,
|
||||
_parse_claims,
|
||||
_run_grounding_guard,
|
||||
append_log,
|
||||
compile_concept,
|
||||
write_index,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FIXTURE_CHUNKS: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": 1,
|
||||
"paper_id": "springborn-2008",
|
||||
"ord": 16,
|
||||
"content": (
|
||||
"For an ideal tetrahedron with dihedral angles the hyperbolic volume is "
|
||||
"V = L(gamma1) + L(gamma2) + L(gamma3). "
|
||||
"The volume function V0 is strictly concave on the angle domain."
|
||||
),
|
||||
"bibkey": "Springborn2008",
|
||||
"dist": 0.31,
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"paper_id": "springborn-2008",
|
||||
"ord": 0,
|
||||
"content": (
|
||||
"A discrete conformal map is defined by logarithmic scale factors u_i "
|
||||
"at each vertex such that the edge lengths satisfy a compatibility condition."
|
||||
),
|
||||
"bibkey": "Springborn2008",
|
||||
"dist": 0.35,
|
||||
},
|
||||
]
|
||||
|
||||
FIXTURE_CONCEPT = Concept(
|
||||
slug="lobachevsky-function",
|
||||
title="Lobachevsky Function",
|
||||
aliases=["Milnor Lobachevsky", "Clausen function"],
|
||||
emphasis="Verwendung in hyperbolischen Volumenformeln (Springborn 2008).",
|
||||
)
|
||||
|
||||
# Grounded LLM output: claim text is a substring of the fixture chunk
|
||||
_GROUNDED_CLAIM = (
|
||||
"For an ideal tetrahedron with dihedral angles the hyperbolic volume is"
|
||||
" V = L(gamma1) + L(gamma2) + L(gamma3). [Springborn2008 #chunk 16]"
|
||||
)
|
||||
GROUNDED_LLM_OUTPUT = (
|
||||
"The Lobachevsky function L appears as the building block of hyperbolic volume.\n"
|
||||
+ _GROUNDED_CLAIM
|
||||
+ "\nThe volume function V0 is strictly concave on the angle domain."
|
||||
" [Springborn2008 #chunk 16]\n"
|
||||
)
|
||||
|
||||
# Ungrounded LLM output: claim text not present in any chunk
|
||||
UNGROUNDED_LLM_OUTPUT = textwrap.dedent(
|
||||
"""\
|
||||
The closed form is L(x) = -integral from 0 to x of log|2 sin t| dt. [Springborn2008 #chunk 99]
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
class MockLLM:
|
||||
"""Deterministic mock LLM that returns a preset response."""
|
||||
|
||||
def __init__(self, response: str) -> None:
|
||||
self._response = response
|
||||
|
||||
def generate(self, prompt: str, model: str) -> str:
|
||||
return self._response
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_claims
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_claims_finds_citation() -> None:
|
||||
"""Claims with [BibKey #locator] format are parsed correctly."""
|
||||
md = "Some result. [Springborn2008 #chunk 16]"
|
||||
claims = _parse_claims(md)
|
||||
assert len(claims) == 1
|
||||
assert claims[0].bibkey == "Springborn2008"
|
||||
assert claims[0].locator == "chunk 16"
|
||||
|
||||
|
||||
def test_parse_claims_no_citations() -> None:
|
||||
"""Text without citation markers returns empty list."""
|
||||
md = "A standalone sentence with no citation."
|
||||
claims = _parse_claims(md)
|
||||
assert claims == []
|
||||
|
||||
|
||||
def test_parse_claims_multiple() -> None:
|
||||
"""Multiple citations in same text are all parsed."""
|
||||
md = "First claim. [Smith2020 #page 5]\nSecond claim. [Jones2019 #eq 3]\n"
|
||||
claims = _parse_claims(md)
|
||||
assert len(claims) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _run_grounding_guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_grounding_guard_marks_grounded_claim() -> None:
|
||||
"""A claim whose text appears in the cited chunk is marked grounded."""
|
||||
claim = Claim(
|
||||
text="the hyperbolic volume is V = L(gamma1) + L(gamma2) + L(gamma3)",
|
||||
bibkey="Springborn2008",
|
||||
locator="chunk 16",
|
||||
)
|
||||
result = _run_grounding_guard([claim], FIXTURE_CHUNKS)
|
||||
assert result[0].grounded is True
|
||||
|
||||
|
||||
def test_grounding_guard_marks_ungrounded_claim() -> None:
|
||||
"""A claim not present in the cited chunk is marked ungrounded."""
|
||||
claim = Claim(
|
||||
text="integral from 0 to x of log 2 sin t dt closed form definition",
|
||||
bibkey="Springborn2008",
|
||||
locator="chunk 99",
|
||||
)
|
||||
result = _run_grounding_guard([claim], FIXTURE_CHUNKS)
|
||||
assert result[0].grounded is False
|
||||
|
||||
|
||||
def test_grounding_guard_ungrounded_when_bibkey_missing() -> None:
|
||||
"""A claim citing a bibkey not in any chunk is ungrounded."""
|
||||
claim = Claim(text="some statement", bibkey="UnknownBib2000", locator="page 1")
|
||||
result = _run_grounding_guard([claim], FIXTURE_CHUNKS)
|
||||
assert result[0].grounded is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _inject_cross_refs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_inject_cross_refs_replaces_title() -> None:
|
||||
"""Occurrences of another concept's title are replaced with [[slug]]."""
|
||||
concepts = [
|
||||
Concept(
|
||||
slug="discrete-conformal-map",
|
||||
title="Discrete Conformal Map",
|
||||
aliases=[],
|
||||
),
|
||||
FIXTURE_CONCEPT,
|
||||
]
|
||||
text = "This is related to Discrete Conformal Map theory."
|
||||
result = _inject_cross_refs(text, concepts, current_slug="lobachevsky-function")
|
||||
assert "[[discrete-conformal-map]]" in result
|
||||
|
||||
|
||||
def test_inject_cross_refs_skips_current_slug() -> None:
|
||||
"""The current concept's own title is not replaced."""
|
||||
concepts = [FIXTURE_CONCEPT]
|
||||
text = "The Lobachevsky Function is important."
|
||||
result = _inject_cross_refs(text, concepts, current_slug="lobachevsky-function")
|
||||
assert "[[lobachevsky-function]]" not in result
|
||||
|
||||
|
||||
def test_inject_cross_refs_replaces_alias() -> None:
|
||||
"""Aliases are also replaced with [[slug]]."""
|
||||
concepts = [
|
||||
Concept(
|
||||
slug="circle-packing",
|
||||
title="Circle Packing",
|
||||
aliases=["Koebe-Andreev-Thurston", "circle pattern"],
|
||||
),
|
||||
FIXTURE_CONCEPT,
|
||||
]
|
||||
text = "The Koebe-Andreev-Thurston theorem gives a packing."
|
||||
result = _inject_cross_refs(text, concepts, current_slug="lobachevsky-function")
|
||||
assert "[[circle-packing]]" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compile_concept (full mock)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_retrieve(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Patch _retrieve_chunks to return deterministic fixture chunks."""
|
||||
monkeypatch.setattr(
|
||||
"codex.wiki._retrieve_chunks",
|
||||
lambda queries, top_k: FIXTURE_CHUNKS,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_formulas(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Patch _try_embed_formulas to be a no-op (F-09 not present)."""
|
||||
monkeypatch.setattr("codex.wiki._try_embed_formulas", lambda concept, chunks: None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
|
||||
"""Patch get_settings to return a settings pointing at tmp_path."""
|
||||
from codex.config import Settings
|
||||
|
||||
wiki_path = tmp_path / "wiki"
|
||||
wiki_path.mkdir()
|
||||
|
||||
mock = MagicMock(spec=Settings)
|
||||
mock.wiki_dir = str(wiki_path)
|
||||
mock.wiki_llm_model = "test-model"
|
||||
mock.wiki_llm_url = None
|
||||
mock.ollama_base_url = "http://localhost:11434"
|
||||
mock.wiki_top_k = 6
|
||||
|
||||
monkeypatch.setattr("codex.wiki.get_settings", lambda: mock)
|
||||
monkeypatch.setattr("codex.cli.get_settings", lambda: mock, raising=False)
|
||||
return wiki_path
|
||||
|
||||
|
||||
def test_compile_concept_returns_concept_page(
|
||||
mock_retrieve: None,
|
||||
mock_formulas: None,
|
||||
mock_settings: Path,
|
||||
) -> None:
|
||||
"""compile_concept returns a ConceptPage with non-empty markdown."""
|
||||
llm = MockLLM(GROUNDED_LLM_OUTPUT)
|
||||
page = compile_concept(FIXTURE_CONCEPT, top_k=6, llm=llm, wiki_dir=mock_settings)
|
||||
assert isinstance(page, ConceptPage)
|
||||
assert page.concept.slug == "lobachevsky-function"
|
||||
assert len(page.markdown) > 0
|
||||
|
||||
|
||||
def test_compile_concept_has_chunk_hash(
|
||||
mock_retrieve: None,
|
||||
mock_formulas: None,
|
||||
mock_settings: Path,
|
||||
) -> None:
|
||||
"""Compiled page has a non-empty chunk_hash."""
|
||||
llm = MockLLM(GROUNDED_LLM_OUTPUT)
|
||||
page = compile_concept(FIXTURE_CONCEPT, top_k=6, llm=llm, wiki_dir=mock_settings)
|
||||
assert len(page.chunk_hash) == 64 # SHA-256 hex
|
||||
|
||||
|
||||
def test_compile_concept_grounded_claim_not_flagged(
|
||||
mock_retrieve: None,
|
||||
mock_formulas: None,
|
||||
mock_settings: Path,
|
||||
) -> None:
|
||||
"""A grounded claim does NOT receive the ⚠ marker in the output markdown."""
|
||||
llm = MockLLM(GROUNDED_LLM_OUTPUT)
|
||||
page = compile_concept(FIXTURE_CONCEPT, top_k=6, llm=llm, wiki_dir=mock_settings)
|
||||
# When all claims are grounded, no ⚠ in markdown
|
||||
# (may still have 0 ⚠ if claims found; just check no false positive for grounded)
|
||||
grounded = [c for c in page.claims if c.grounded]
|
||||
for claim in grounded:
|
||||
assert claim.grounded is True
|
||||
|
||||
|
||||
def test_compile_concept_ungrounded_claim_marked(
|
||||
mock_retrieve: None,
|
||||
mock_formulas: None,
|
||||
mock_settings: Path,
|
||||
) -> None:
|
||||
"""An ungrounded claim is marked ⚠ in the page markdown."""
|
||||
llm = MockLLM(UNGROUNDED_LLM_OUTPUT)
|
||||
page = compile_concept(FIXTURE_CONCEPT, top_k=6, llm=llm, wiki_dir=mock_settings)
|
||||
ungrounded = [c for c in page.claims if not c.grounded]
|
||||
if ungrounded:
|
||||
assert "⚠" in page.markdown
|
||||
|
||||
|
||||
def test_compile_concept_header_present(
|
||||
mock_retrieve: None,
|
||||
mock_formulas: None,
|
||||
mock_settings: Path,
|
||||
) -> None:
|
||||
"""Page markdown starts with an H1 header for the concept title."""
|
||||
llm = MockLLM(GROUNDED_LLM_OUTPUT)
|
||||
page = compile_concept(FIXTURE_CONCEPT, top_k=6, llm=llm, wiki_dir=mock_settings)
|
||||
assert page.markdown.startswith("# Lobachevsky Function")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# write_index
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_index_creates_index_md(
|
||||
mock_settings: Path,
|
||||
) -> None:
|
||||
"""write_index creates wiki/index.md."""
|
||||
pages = [
|
||||
("discrete-conformal-map", "Discrete Conformal Map"),
|
||||
("circle-packing", "Circle Packing"),
|
||||
]
|
||||
write_index(pages, wiki_dir=mock_settings)
|
||||
index_path = mock_settings / "index.md"
|
||||
assert index_path.exists()
|
||||
|
||||
|
||||
def test_index_contains_links(
|
||||
mock_settings: Path,
|
||||
) -> None:
|
||||
"""wiki/index.md contains [[slug]] links for each page."""
|
||||
pages = [
|
||||
("discrete-conformal-map", "Discrete Conformal Map"),
|
||||
("circle-packing", "Circle Packing"),
|
||||
]
|
||||
write_index(pages, wiki_dir=mock_settings)
|
||||
content = (mock_settings / "index.md").read_text(encoding="utf-8")
|
||||
assert "[[discrete-conformal-map]]" in content
|
||||
assert "[[circle-packing]]" in content
|
||||
|
||||
|
||||
def test_index_contains_header(
|
||||
mock_settings: Path,
|
||||
) -> None:
|
||||
"""wiki/index.md starts with a # Wiki Index header."""
|
||||
write_index([], wiki_dir=mock_settings)
|
||||
content = (mock_settings / "index.md").read_text(encoding="utf-8")
|
||||
assert content.startswith("# Wiki Index")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# append_log
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_log_append_creates_log_md(
|
||||
mock_settings: Path,
|
||||
) -> None:
|
||||
"""append_log creates wiki/log.md if it does not exist."""
|
||||
report = CompileReport(
|
||||
compiled=["lobachevsky-function"],
|
||||
skipped=[],
|
||||
ungrounded=[],
|
||||
)
|
||||
append_log(report, wiki_dir=mock_settings)
|
||||
log_path = mock_settings / "log.md"
|
||||
assert log_path.exists()
|
||||
|
||||
|
||||
def test_log_append_does_not_overwrite(
|
||||
mock_settings: Path,
|
||||
) -> None:
|
||||
"""Two calls to append_log accumulate entries — older entry is NOT overwritten."""
|
||||
report1 = CompileReport(compiled=["first-concept"])
|
||||
report2 = CompileReport(compiled=["second-concept"])
|
||||
|
||||
append_log(report1, wiki_dir=mock_settings)
|
||||
append_log(report2, wiki_dir=mock_settings)
|
||||
|
||||
content = (mock_settings / "log.md").read_text(encoding="utf-8")
|
||||
assert "first-concept" in content
|
||||
assert "second-concept" in content
|
||||
|
||||
|
||||
def test_log_append_records_ungrounded(
|
||||
mock_settings: Path,
|
||||
) -> None:
|
||||
"""Ungrounded claims appear in the log entry."""
|
||||
report = CompileReport(
|
||||
compiled=["lobachevsky-function"],
|
||||
ungrounded=[("lobachevsky-function", "some ungrounded claim text")],
|
||||
)
|
||||
append_log(report, wiki_dir=mock_settings)
|
||||
content = (mock_settings / "log.md").read_text(encoding="utf-8")
|
||||
assert "some ungrounded claim text" in content
|
||||
assert "⚠" in content
|
||||
|
||||
|
||||
def test_log_append_records_skipped(
|
||||
mock_settings: Path,
|
||||
) -> None:
|
||||
"""Skipped slugs appear in the log entry."""
|
||||
report = CompileReport(
|
||||
compiled=[],
|
||||
skipped=["circle-packing"],
|
||||
)
|
||||
append_log(report, wiki_dir=mock_settings)
|
||||
content = (mock_settings / "log.md").read_text(encoding="utf-8")
|
||||
assert "circle-packing" in content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Idempotency: second compile without chunk change → no rewrite
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_idempotent_no_rewrite_when_unchanged(
|
||||
mock_retrieve: None,
|
||||
mock_formulas: None,
|
||||
mock_settings: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Second compile_all with unchanged chunks skips the concept (changed_only=True)."""
|
||||
from codex.wiki import compile_all
|
||||
|
||||
call_count = 0
|
||||
original_compile = compile_concept
|
||||
|
||||
def counting_compile(concept: Concept, **kwargs: Any) -> ConceptPage:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return original_compile(concept, **kwargs)
|
||||
|
||||
monkeypatch.setattr("codex.wiki.compile_concept", counting_compile)
|
||||
|
||||
# Write concepts.yaml into wiki_dir
|
||||
yaml_content = textwrap.dedent(
|
||||
"""\
|
||||
concepts:
|
||||
- slug: lobachevsky-function
|
||||
title: Lobachevsky Function
|
||||
aliases: [Milnor Lobachevsky]
|
||||
"""
|
||||
)
|
||||
(mock_settings / "concepts.yaml").write_text(yaml_content, encoding="utf-8")
|
||||
|
||||
llm = MockLLM(GROUNDED_LLM_OUTPUT)
|
||||
|
||||
# First compile: should call compile_concept
|
||||
compile_all(changed_only=True, llm=llm, output_dir=str(mock_settings))
|
||||
first_count = call_count
|
||||
|
||||
# Second compile with same chunks: should skip
|
||||
compile_all(changed_only=True, llm=llm, output_dir=str(mock_settings))
|
||||
second_count = call_count
|
||||
|
||||
assert first_count >= 1, "First run should compile at least once"
|
||||
assert second_count == first_count, "Second run should not recompile (unchanged chunks)"
|
||||
|
||||
|
||||
def test_force_all_recompiles_even_when_unchanged(
|
||||
mock_retrieve: None,
|
||||
mock_formulas: None,
|
||||
mock_settings: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""compile_all with changed_only=False recompiles regardless of hash."""
|
||||
from codex.wiki import compile_all
|
||||
|
||||
call_count = 0
|
||||
original_compile = compile_concept
|
||||
|
||||
def counting_compile(concept: Concept, **kwargs: Any) -> ConceptPage:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return original_compile(concept, **kwargs)
|
||||
|
||||
monkeypatch.setattr("codex.wiki.compile_concept", counting_compile)
|
||||
|
||||
yaml_content = textwrap.dedent(
|
||||
"""\
|
||||
concepts:
|
||||
- slug: lobachevsky-function
|
||||
title: Lobachevsky Function
|
||||
aliases: [Milnor Lobachevsky]
|
||||
"""
|
||||
)
|
||||
(mock_settings / "concepts.yaml").write_text(yaml_content, encoding="utf-8")
|
||||
|
||||
llm = MockLLM(GROUNDED_LLM_OUTPUT)
|
||||
|
||||
compile_all(changed_only=True, llm=llm, output_dir=str(mock_settings))
|
||||
compile_all(changed_only=False, llm=llm, output_dir=str(mock_settings))
|
||||
|
||||
assert call_count >= 2, "Force --all should recompile even when unchanged"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _chunk_hash
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_chunk_hash_is_stable() -> None:
|
||||
"""Same chunks produce the same hash each time."""
|
||||
h1 = _chunk_hash(FIXTURE_CHUNKS)
|
||||
h2 = _chunk_hash(FIXTURE_CHUNKS)
|
||||
assert h1 == h2
|
||||
assert len(h1) == 64
|
||||
|
||||
|
||||
def test_chunk_hash_differs_on_content_change() -> None:
|
||||
"""Different content produces different hash."""
|
||||
modified = [dict(FIXTURE_CHUNKS[0], content="completely different content"), FIXTURE_CHUNKS[1]]
|
||||
h1 = _chunk_hash(FIXTURE_CHUNKS)
|
||||
h2 = _chunk_hash(modified)
|
||||
assert h1 != h2
|
||||
122
tests/wiki/test_concepts.py
Normal file
122
tests/wiki/test_concepts.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Tests for load_concepts() — YAML parsing of wiki/concepts.yaml."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from codex.wiki import Concept, load_concepts
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FIXTURE_YAML = textwrap.dedent(
|
||||
"""\
|
||||
concepts:
|
||||
- slug: discrete-conformal-map
|
||||
title: Discrete Conformal Map
|
||||
aliases: [discrete conformal equivalence, discrete uniformization]
|
||||
emphasis: Fokus auf die variationelle Charakterisierung (BPS 2015).
|
||||
- slug: circle-packing
|
||||
title: Circle Packing
|
||||
aliases: [Koebe-Andreev-Thurston, circle pattern]
|
||||
- slug: lobachevsky-function
|
||||
title: Lobachevsky Function
|
||||
aliases: [Milnor Lobachevsky, Clausen function]
|
||||
emphasis: Verwendung in hyperbolischen Volumenformeln (Springborn 2008).
|
||||
- slug: discrete-yamabe-flow
|
||||
title: Discrete Yamabe Flow
|
||||
aliases: [discrete Ricci flow, Chow-Luo]
|
||||
- slug: hyperideal-tetrahedron
|
||||
title: Hyperideal Tetrahedron
|
||||
aliases: [hyperbolic volume, Schläfli, Ushijima]
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def concepts_yaml(tmp_path: Path) -> Path:
|
||||
"""Write fixture YAML to a temp file and return the path."""
|
||||
p = tmp_path / "concepts.yaml"
|
||||
p.write_text(FIXTURE_YAML, encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_load_concepts_returns_all_concepts(concepts_yaml: Path) -> None:
|
||||
"""All five concept entries are returned."""
|
||||
concepts = load_concepts(str(concepts_yaml))
|
||||
assert len(concepts) == 5
|
||||
|
||||
|
||||
def test_load_concepts_types(concepts_yaml: Path) -> None:
|
||||
"""Every returned item is a Concept instance."""
|
||||
concepts = load_concepts(str(concepts_yaml))
|
||||
for c in concepts:
|
||||
assert isinstance(c, Concept)
|
||||
|
||||
|
||||
def test_load_concepts_first_slug_and_title(concepts_yaml: Path) -> None:
|
||||
"""First concept has correct slug and title."""
|
||||
concepts = load_concepts(str(concepts_yaml))
|
||||
assert concepts[0].slug == "discrete-conformal-map"
|
||||
assert concepts[0].title == "Discrete Conformal Map"
|
||||
|
||||
|
||||
def test_load_concepts_aliases_are_lists(concepts_yaml: Path) -> None:
|
||||
"""Aliases are parsed as a list of strings."""
|
||||
concepts = load_concepts(str(concepts_yaml))
|
||||
for c in concepts:
|
||||
assert isinstance(c.aliases, list)
|
||||
for alias in c.aliases:
|
||||
assert isinstance(alias, str)
|
||||
|
||||
|
||||
def test_load_concepts_first_has_two_aliases(concepts_yaml: Path) -> None:
|
||||
"""First concept has exactly two aliases."""
|
||||
concepts = load_concepts(str(concepts_yaml))
|
||||
assert concepts[0].aliases == ["discrete conformal equivalence", "discrete uniformization"]
|
||||
|
||||
|
||||
def test_load_concepts_emphasis_present(concepts_yaml: Path) -> None:
|
||||
"""Concepts with emphasis field have it parsed correctly."""
|
||||
concepts = load_concepts(str(concepts_yaml))
|
||||
dcm = next(c for c in concepts if c.slug == "discrete-conformal-map")
|
||||
assert dcm.emphasis is not None
|
||||
assert "variationelle" in dcm.emphasis
|
||||
|
||||
|
||||
def test_load_concepts_emphasis_absent_is_none(concepts_yaml: Path) -> None:
|
||||
"""Concepts without emphasis field have emphasis=None."""
|
||||
concepts = load_concepts(str(concepts_yaml))
|
||||
cp = next(c for c in concepts if c.slug == "circle-packing")
|
||||
assert cp.emphasis is None
|
||||
|
||||
|
||||
def test_load_concepts_slugs_unique(concepts_yaml: Path) -> None:
|
||||
"""All slugs are distinct."""
|
||||
concepts = load_concepts(str(concepts_yaml))
|
||||
slugs = [c.slug for c in concepts]
|
||||
assert len(slugs) == len(set(slugs))
|
||||
|
||||
|
||||
def test_load_concepts_unicode_in_aliases(concepts_yaml: Path) -> None:
|
||||
"""Aliases with non-ASCII characters (Schläfli) are parsed without error."""
|
||||
concepts = load_concepts(str(concepts_yaml))
|
||||
ht = next(c for c in concepts if c.slug == "hyperideal-tetrahedron")
|
||||
assert any("Schläfli" in a for a in ht.aliases)
|
||||
|
||||
|
||||
def test_load_concepts_empty_yaml(tmp_path: Path) -> None:
|
||||
"""An empty concepts list returns an empty list (no crash)."""
|
||||
p = tmp_path / "concepts.yaml"
|
||||
p.write_text("concepts: []\n", encoding="utf-8")
|
||||
concepts = load_concepts(str(p))
|
||||
assert concepts == []
|
||||
20
wiki/concepts.yaml
Normal file
20
wiki/concepts.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
# Schema-Datei: was die KB abdeckt + Emphasis-Hebel (analog CLAUDE.md im Second-Brain).
|
||||
# Der Mensch kuratiert diese Liste — sie steuert, worüber die Wiki Seiten baut.
|
||||
concepts:
|
||||
- slug: discrete-conformal-map
|
||||
title: Discrete Conformal Map
|
||||
aliases: [discrete conformal equivalence, discrete uniformization]
|
||||
emphasis: Fokus auf die variationelle Charakterisierung (BPS 2015).
|
||||
- slug: circle-packing
|
||||
title: Circle Packing
|
||||
aliases: [Koebe-Andreev-Thurston, circle pattern]
|
||||
- slug: lobachevsky-function
|
||||
title: Lobachevsky Function
|
||||
aliases: [Milnor Lobachevsky, Clausen function]
|
||||
emphasis: Verwendung in hyperbolischen Volumenformeln (Springborn 2008).
|
||||
- slug: discrete-yamabe-flow
|
||||
title: Discrete Yamabe Flow
|
||||
aliases: [discrete Ricci flow, Chow-Luo]
|
||||
- slug: hyperideal-tetrahedron
|
||||
title: Hyperideal Tetrahedron
|
||||
aliases: [hyperbolic volume, Schläfli, Ushijima]
|
||||
Reference in New Issue
Block a user