Compare commits
51 Commits
scratch/sp
...
feat/F-16-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b61d84d4bd | ||
| 1a9afb4433 | |||
|
|
b87087d3c6 | ||
|
|
335ca2c923 | ||
| 36a5d9566a | |||
|
|
b12789a47f | ||
| 1bec18ae82 | |||
|
|
5025ff3a88 | ||
| 6c28446644 | |||
|
|
d29dcf2fb5 | ||
| 1d1203b225 | |||
|
|
50e6bb8210 | ||
|
|
b2ef8f2575 | ||
|
|
b803811701 | ||
|
|
59763105a5 | ||
|
|
8cf0cc7e01 | ||
|
|
967e6baa59 | ||
|
|
7d338d29ca | ||
|
|
408e4886bb | ||
|
|
ef46d14aed | ||
|
|
0202a2266e | ||
|
|
60e40f6f36 | ||
|
|
89228a1f62 | ||
| 946a80418e | |||
|
|
cd42e9abc8 | ||
|
|
1df9be6563 | ||
|
|
d2f9141a5c | ||
|
|
4d361fd8dc | ||
|
|
aee00515f4 | ||
|
|
c6eaf999a1 | ||
|
|
456dea102b | ||
|
|
c8b127bae0 | ||
|
|
d48db47a7d | ||
|
|
65918cc80a | ||
|
|
01199c5e46 | ||
|
|
4c177a0d29 | ||
|
|
2acb2ee213 | ||
|
|
c65fe19c24 | ||
|
|
7675f63188 | ||
|
|
1e10736185 | ||
|
|
5344975bb1 | ||
|
|
fbf8949579 | ||
|
|
c33fd9d697 | ||
|
|
d5726ed94d | ||
|
|
52737abe3d | ||
|
|
1698f7dcad | ||
|
|
d1ffea1c1c | ||
|
|
8eca4ebe12 | ||
|
|
291a66dd38 | ||
|
|
b7f19b6e2c | ||
|
|
c6a428d335 |
21
.claude/README.md
Normal file
21
.claude/README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# .claude/ — Harness-Config für codex-py
|
||||||
|
|
||||||
|
`settings.json` ist **committed** (team-weit): jede Worker-Session in diesem Repo
|
||||||
|
bekommt dieselben Permissions. Persönliche Abweichungen gehören in
|
||||||
|
`settings.local.json` (gitignored).
|
||||||
|
|
||||||
|
## Was die Permissions tun
|
||||||
|
- **`defaultMode: acceptEdits`** — Edits/Writes ohne Prompt (Worker schreiben Code
|
||||||
|
in ihrem F-XX-Paket frei). Disjunkte Ownership je Paket (siehe knowledge-base
|
||||||
|
`STATE.md`); Konflikte löst der Leader bei der Integration.
|
||||||
|
- **allow** — die Dev-Toolchain (`uv`, `ruff`, `mypy`, `pytest`, `python`), git
|
||||||
|
read+workflow, `podman` (lokale pgvector-DB), `ollama` (lokales LLM), `curl`
|
||||||
|
(API-Tests, z. B. OpenAlex) und gängige Read-Utils.
|
||||||
|
- **deny** — `git push --force`/`-f`, `git clean -fd(x)`, `sudo rm`, katastrophale
|
||||||
|
`rm -rf /`/`~`. Hart blockiert. Alles übrige (z. B. `git reset --hard`) ist
|
||||||
|
*nicht* allow-gelistet → es fragt nach.
|
||||||
|
|
||||||
|
## Hinweis
|
||||||
|
Gespiegelt am Stil der knowledge-base-`.claude/`, aber auf den Python/uv-Stack
|
||||||
|
zugeschnitten. Tests laufen via `uv run pytest`; falls `uv run` die Umgebung
|
||||||
|
neu-synct, ist `.venv/bin/python` ebenfalls allow-gelistet.
|
||||||
80
.claude/settings.json
Normal file
80
.claude/settings.json
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||||
|
"env": {
|
||||||
|
"CLAUDE_VERBOSE": "1"
|
||||||
|
},
|
||||||
|
"outputStyle": "Explanatory",
|
||||||
|
"permissions": {
|
||||||
|
"defaultMode": "acceptEdits",
|
||||||
|
"allow": [
|
||||||
|
"Bash(uv:*)",
|
||||||
|
"Bash(ruff:*)",
|
||||||
|
"Bash(mypy:*)",
|
||||||
|
"Bash(pytest:*)",
|
||||||
|
"Bash(python -m pytest:*)",
|
||||||
|
"Bash(python3 -m pytest:*)",
|
||||||
|
"Bash(python:*)",
|
||||||
|
"Bash(python3:*)",
|
||||||
|
"Bash(.venv/bin/python:*)",
|
||||||
|
"Bash(podman:*)",
|
||||||
|
"Bash(ollama:*)",
|
||||||
|
"Bash(codespell:*)",
|
||||||
|
"Bash(shellcheck:*)",
|
||||||
|
"Bash(git status:*)",
|
||||||
|
"Bash(git diff:*)",
|
||||||
|
"Bash(git log:*)",
|
||||||
|
"Bash(git show:*)",
|
||||||
|
"Bash(git branch:*)",
|
||||||
|
"Bash(git ls-remote:*)",
|
||||||
|
"Bash(git ls-files:*)",
|
||||||
|
"Bash(git remote:*)",
|
||||||
|
"Bash(git rev-parse:*)",
|
||||||
|
"Bash(git rev-list:*)",
|
||||||
|
"Bash(git merge-base:*)",
|
||||||
|
"Bash(git diff-tree:*)",
|
||||||
|
"Bash(git describe:*)",
|
||||||
|
"Bash(git tag:*)",
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit:*)",
|
||||||
|
"Bash(git switch:*)",
|
||||||
|
"Bash(git checkout:*)",
|
||||||
|
"Bash(git restore:*)",
|
||||||
|
"Bash(git stash:*)",
|
||||||
|
"Bash(git fetch:*)",
|
||||||
|
"Bash(git pull:*)",
|
||||||
|
"Bash(git push:*)",
|
||||||
|
"Bash(git merge:*)",
|
||||||
|
"Bash(git rebase:*)",
|
||||||
|
"Bash(git worktree:*)",
|
||||||
|
"Bash(git init:*)",
|
||||||
|
"Bash(git mv:*)",
|
||||||
|
"Bash(ls:*)",
|
||||||
|
"Bash(find:*)",
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(rg:*)",
|
||||||
|
"Bash(wc:*)",
|
||||||
|
"Bash(cat:*)",
|
||||||
|
"Bash(head:*)",
|
||||||
|
"Bash(tail:*)",
|
||||||
|
"Bash(awk:*)",
|
||||||
|
"Bash(jq:*)",
|
||||||
|
"Bash(curl:*)",
|
||||||
|
"Bash(mkdir:*)",
|
||||||
|
"Bash(cp:*)",
|
||||||
|
"Bash(test:*)",
|
||||||
|
"Bash(echo:*)",
|
||||||
|
"Bash(true)"
|
||||||
|
],
|
||||||
|
"deny": [
|
||||||
|
"Bash(git push --force:*)",
|
||||||
|
"Bash(git push -f:*)",
|
||||||
|
"Bash(git clean -fdx:*)",
|
||||||
|
"Bash(git clean -fd:*)",
|
||||||
|
"Bash(rm -rf /)",
|
||||||
|
"Bash(rm -rf /*)",
|
||||||
|
"Bash(rm -rf ~)",
|
||||||
|
"Bash(rm -rf ~/*)",
|
||||||
|
"Bash(sudo rm:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,3 +23,12 @@ EMBEDDING_DIM=1024
|
|||||||
# E-mail address for the OpenAlex Polite Pool (faster rate limits).
|
# E-mail address for the OpenAlex Polite Pool (faster rate limits).
|
||||||
# Required by OpenAlex ToS when making automated requests.
|
# Required by OpenAlex ToS when making automated requests.
|
||||||
OPENALEX_MAILTO=you@example.com
|
OPENALEX_MAILTO=you@example.com
|
||||||
|
|
||||||
|
# Nougat HTTP-Server (Jetson: http://192.168.178.103:8080 | lokal: http://localhost:8080)
|
||||||
|
# Used by `codex ingest <id> --rich` for full-PDF Mathpix Markdown output.
|
||||||
|
NOUGAT_URL=http://localhost:8080
|
||||||
|
|
||||||
|
# F-16 Chunk Quality Gate thresholds (all optional — defaults shown)
|
||||||
|
CHUNK_MIN_CHARS=60 # Discard chunks shorter than this many characters
|
||||||
|
CHUNK_MIN_ALPHA_RATIO=0.40 # Discard chunks with < 40% alphabetic characters
|
||||||
|
CHUNK_MAX_BIB_SCORE=0.70 # Discard chunks scoring above this bibliography threshold
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -33,3 +33,6 @@ htmlcov/
|
|||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
|
# Claude Code — settings.json IS committed (team-wide); only personal overrides ignored
|
||||||
|
.claude/settings.local.json
|
||||||
|
|||||||
74
README.md
74
README.md
@@ -81,8 +81,8 @@ Maps C++ symbols (qualified names or `file.cpp:line` references) to the
|
|||||||
papers they implement. The workflow:
|
papers they implement. The workflow:
|
||||||
|
|
||||||
1. Tag C++ source with `@cite <bibkey>` in Doxygen comments.
|
1. Tag C++ source with `@cite <bibkey>` in Doxygen comments.
|
||||||
2. Run `codex provenance sync --lib-path <path>` to scan and resolve tags.
|
2. Run `codex provenance scan --lib-path <path>` to scan and resolve tags.
|
||||||
3. Run `codex provenance export-bib <out.bib>` to generate a `.bib` file
|
3. Run `codex provenance bib <out.bib>` to generate a `.bib` file
|
||||||
containing **only the cited subset** of your collection.
|
containing **only the cited subset** of your collection.
|
||||||
|
|
||||||
The exported `.bib` is a derived view of the master catalogue — regenerate
|
The exported `.bib` is a derived view of the master catalogue — regenerate
|
||||||
@@ -97,13 +97,79 @@ codex ingest <id> # ingest one paper by arXiv ID or DOI
|
|||||||
codex ingest-file <ids.txt> # bulk ingest from a file of IDs
|
codex ingest-file <ids.txt> # bulk ingest from a file of IDs
|
||||||
codex search "<query>" [--hybrid]
|
codex search "<query>" [--hybrid]
|
||||||
codex discover leads
|
codex discover leads
|
||||||
codex provenance sync --lib-path <path>
|
codex provenance scan --lib-path <path>
|
||||||
codex provenance export-bib <out.bib>
|
codex provenance bib <out.bib>
|
||||||
codex ask "<question>" # optional LLM Q&A via Ollama
|
codex ask "<question>" # optional LLM Q&A via Ollama
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## MCP Server (F-14)
|
||||||
|
|
||||||
|
codex exposes its KB as read-only MCP tools for LLM clients (Claude Code, Claude Desktop, etc.).
|
||||||
|
|
||||||
|
### Start (stdio)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# directly
|
||||||
|
python -m codex.mcp_server
|
||||||
|
|
||||||
|
# or via the installed script
|
||||||
|
codex-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client registration
|
||||||
|
|
||||||
|
**Claude Code** — add to `.mcp.json` in your project root:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"codex": {
|
||||||
|
"command": "uv",
|
||||||
|
"args": ["run", "codex-mcp"],
|
||||||
|
"cwd": "/path/to/codex-py"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Claude Desktop** — add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"codex": {
|
||||||
|
"command": "uv",
|
||||||
|
"args": ["--directory", "/path/to/codex-py", "run", "codex-mcp"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTTP transport (optional, config-gated)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MCP_TRANSPORT=http MCP_AUTH_TOKEN=<secret> codex-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP transport requires `MCP_AUTH_TOKEN` — the server refuses to start without it.
|
||||||
|
Requests must carry `Authorization: Bearer <token>`.
|
||||||
|
|
||||||
|
### Available tools (read-only)
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|---|---|
|
||||||
|
| `search` | Chunk-level hybrid search (dense BGE-M3) |
|
||||||
|
| `ask` | RAG answer (returns "not available" until implemented) |
|
||||||
|
| `wiki_read` | Compiled concept page (F-12) |
|
||||||
|
| `wiki_list` | All concept pages + freshness |
|
||||||
|
| `discover_leads` | Dangling-citation leads |
|
||||||
|
| `provenance_verify` | Scan C++ sources for @cite tags |
|
||||||
|
| `synthesis_browse` | Leads/conjectures from leads/ (F-13) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
3
codex/__main__.py
Normal file
3
codex/__main__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from codex.cli import app
|
||||||
|
|
||||||
|
app()
|
||||||
496
codex/cli.py
496
codex/cli.py
@@ -1,8 +1,498 @@
|
|||||||
|
"""codex CLI — commands wired to domain modules."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
app = typer.Typer(help="codex — personal knowledge base for scientific papers.")
|
app = typer.Typer(help="codex — personal knowledge base for scientific papers.")
|
||||||
|
discover_app = typer.Typer(help="Discovery queries over the citation graph.")
|
||||||
|
prov_app = typer.Typer(help="Provenance: @cite scan, code_links, bib export.")
|
||||||
|
wiki_app = typer.Typer(help="Wiki-compile: grounded concept pages over the RAG substrate.")
|
||||||
|
synth_app = typer.Typer(help="Synthesis: grounded leads and quarantined conjectures (F-13).")
|
||||||
|
quality_app = typer.Typer(help="Chunk quality gate and section classification (F-16).")
|
||||||
|
# F-09: search sub-app with paper (semantic) and formula (FTS) subcommands
|
||||||
|
search_app = typer.Typer(
|
||||||
|
help="Search: semantic paper search and formula full-text search.",
|
||||||
|
invoke_without_command=True,
|
||||||
|
)
|
||||||
|
app.add_typer(discover_app, name="discover")
|
||||||
|
app.add_typer(prov_app, name="provenance")
|
||||||
|
app.add_typer(wiki_app, name="wiki")
|
||||||
|
app.add_typer(synth_app, name="synthesis")
|
||||||
|
app.add_typer(quality_app, name="quality")
|
||||||
|
app.add_typer(search_app, name="search")
|
||||||
|
|
||||||
|
|
||||||
@app.callback()
|
@app.command()
|
||||||
def _main() -> None:
|
def ingest(
|
||||||
"""codex CLI — commands land here in F-07."""
|
paper_id: str = typer.Argument(..., help="arXiv ID, DOI, or OpenAlex W-ID"),
|
||||||
|
source: Optional[str] = typer.Option(None, "--source", "-s", help="Path to .tex or .pdf"), # noqa: UP045
|
||||||
|
rich: bool = typer.Option( # noqa: A002
|
||||||
|
False,
|
||||||
|
"--rich",
|
||||||
|
help="Extract formulas and figures (requires PDF source, F-09).",
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""Ingest a paper into the knowledge base."""
|
||||||
|
from codex.ingest import ingest_paper
|
||||||
|
|
||||||
|
result = ingest_paper(paper_id, source_path=source, rich=rich)
|
||||||
|
typer.echo(
|
||||||
|
f"Ingested {result.paper_id}: {result.chunks_upserted} chunks, "
|
||||||
|
f"{result.citations_upserted} citations, "
|
||||||
|
f"{result.formulas_upserted} formulas, "
|
||||||
|
f"{result.figures_upserted} figures"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# F-09: Search command group
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@search_app.callback(invoke_without_command=True)
|
||||||
|
def search_callback(ctx: typer.Context) -> None:
|
||||||
|
"""Search subcommands: ``paper`` for semantic search, ``formula`` for LaTeX FTS."""
|
||||||
|
if ctx.invoked_subcommand is None:
|
||||||
|
typer.echo(ctx.get_help())
|
||||||
|
raise typer.Exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
@search_app.command("paper")
|
||||||
|
def search_paper(
|
||||||
|
query: str = typer.Argument(..., help="Natural-language search query"),
|
||||||
|
limit: int = typer.Option(10, "--limit", "-n", help="Number of results"),
|
||||||
|
) -> None:
|
||||||
|
"""Semantic similarity search over paper abstracts."""
|
||||||
|
from codex.db import get_conn
|
||||||
|
from codex.embed import get_embedder
|
||||||
|
|
||||||
|
emb = get_embedder().encode_dense([query])[0].tolist()
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, title, year,
|
||||||
|
abstract_emb <-> %(emb)s AS distance
|
||||||
|
FROM papers
|
||||||
|
WHERE abstract_emb IS NOT NULL
|
||||||
|
ORDER BY abstract_emb <-> %(emb)s
|
||||||
|
LIMIT %(limit)s
|
||||||
|
""",
|
||||||
|
{"emb": emb, "limit": limit},
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
typer.echo(f"[{row['distance']:.3f}] {row['id']} ({row['year']}) — {row['title']}")
|
||||||
|
|
||||||
|
|
||||||
|
@search_app.command("formula")
|
||||||
|
def search_formula(
|
||||||
|
query: str = typer.Argument(..., help="LaTeX snippet or keyword to search in formulas"),
|
||||||
|
limit: int = typer.Option(10, "--limit", "-n", help="Number of results"),
|
||||||
|
) -> None:
|
||||||
|
"""Full-text search over extracted LaTeX formulas (F-09)."""
|
||||||
|
from codex.db import get_conn
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT paper_id, page, raw_latex, context,
|
||||||
|
ts_rank(to_tsvector('english', raw_latex || ' ' || coalesce(context, '')),
|
||||||
|
plainto_tsquery('english', %(query)s)) AS rank
|
||||||
|
FROM formulas
|
||||||
|
WHERE to_tsvector('english', raw_latex || ' ' || coalesce(context, ''))
|
||||||
|
@@ plainto_tsquery('english', %(query)s)
|
||||||
|
ORDER BY rank DESC
|
||||||
|
LIMIT %(limit)s
|
||||||
|
""",
|
||||||
|
{"query": query, "limit": limit},
|
||||||
|
).fetchall()
|
||||||
|
if not rows:
|
||||||
|
typer.echo("No matching formulas found.")
|
||||||
|
return
|
||||||
|
for row in rows:
|
||||||
|
typer.echo(
|
||||||
|
f"[{row['rank']:.3f}] {row['paper_id']} p.{row['page']} {row['raw_latex'][:80]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@discover_app.command("leads")
|
||||||
|
def discover_leads(
|
||||||
|
limit: int = typer.Option(20, "--limit", "-n"),
|
||||||
|
) -> None:
|
||||||
|
"""Show discovery leads: cited papers not yet ingested, ranked by pull."""
|
||||||
|
from codex.discover import discovery_leads
|
||||||
|
|
||||||
|
for item in discovery_leads(limit=limit):
|
||||||
|
typer.echo(f"{item['pull']:>4}× {item['cited_id']}")
|
||||||
|
|
||||||
|
|
||||||
|
@discover_app.command("citing")
|
||||||
|
def discover_citing(paper_id: str = typer.Argument(...)) -> None:
|
||||||
|
"""List papers that cite PAPER_ID."""
|
||||||
|
from codex.discover import citing_papers
|
||||||
|
|
||||||
|
for pid in citing_papers(paper_id):
|
||||||
|
typer.echo(pid)
|
||||||
|
|
||||||
|
|
||||||
|
@discover_app.command("cited-by")
|
||||||
|
def discover_cited_by(paper_id: str = typer.Argument(...)) -> None:
|
||||||
|
"""List papers that PAPER_ID cites."""
|
||||||
|
from codex.discover import cited_by
|
||||||
|
|
||||||
|
for pid in cited_by(paper_id):
|
||||||
|
typer.echo(pid)
|
||||||
|
|
||||||
|
|
||||||
|
@discover_app.command("cocited")
|
||||||
|
def discover_cocited(
|
||||||
|
paper_id: str = typer.Argument(...),
|
||||||
|
limit: int = typer.Option(10, "--limit", "-n"),
|
||||||
|
) -> None:
|
||||||
|
"""List papers frequently co-cited with PAPER_ID."""
|
||||||
|
from codex.discover import cocited_papers
|
||||||
|
|
||||||
|
for item in cocited_papers(paper_id, limit=limit):
|
||||||
|
typer.echo(f"{item['co_citations']:>4}× {item['paper_id']}")
|
||||||
|
|
||||||
|
|
||||||
|
@prov_app.command("scan")
|
||||||
|
def prov_scan(root_dir: str = typer.Argument(..., help="Root directory to scan")) -> None:
|
||||||
|
"""Scan C++ sources for @cite tags."""
|
||||||
|
from codex.provenance import scan_cite_tags
|
||||||
|
|
||||||
|
hits = scan_cite_tags(root_dir)
|
||||||
|
typer.echo(json.dumps(hits, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
@prov_app.command("add-link")
|
||||||
|
def prov_add_link(
|
||||||
|
symbol: str = typer.Argument(...),
|
||||||
|
paper_id: Optional[str] = typer.Option(None, "--paper-id"), # noqa: UP045
|
||||||
|
role: Optional[str] = typer.Option(None, "--role"), # noqa: UP045
|
||||||
|
note: Optional[str] = typer.Option(None, "--note"), # noqa: UP045
|
||||||
|
) -> None:
|
||||||
|
"""Add a code → paper link."""
|
||||||
|
from codex.provenance import add_code_link
|
||||||
|
|
||||||
|
link = add_code_link(symbol, paper_id=paper_id, role=role, note=note)
|
||||||
|
typer.echo(f"Created code_link id={link.id}: {symbol} → {paper_id}")
|
||||||
|
|
||||||
|
|
||||||
|
@prov_app.command("links")
|
||||||
|
def prov_links(
|
||||||
|
paper_id: Optional[str] = typer.Option(None, "--paper-id"), # noqa: UP045
|
||||||
|
) -> None:
|
||||||
|
"""List code → paper links."""
|
||||||
|
from codex.provenance import list_code_links
|
||||||
|
|
||||||
|
for link in list_code_links(paper_id=paper_id):
|
||||||
|
typer.echo(f"[{link.id}] {link.symbol} → {link.paper_id} ({link.role})")
|
||||||
|
|
||||||
|
|
||||||
|
@prov_app.command("bib")
|
||||||
|
def prov_bib(
|
||||||
|
paper_ids: Optional[list[str]] = typer.Argument(None), # noqa: UP045, B008
|
||||||
|
output: Optional[str] = typer.Option( # noqa: UP045
|
||||||
|
None, "--output", "-o", help="Write to file instead of stdout"
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""Export papers as BibTeX."""
|
||||||
|
from codex.provenance import export_bib
|
||||||
|
|
||||||
|
bib = export_bib(paper_ids if paper_ids else None)
|
||||||
|
if output:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
Path(output).write_text(bib, encoding="utf-8")
|
||||||
|
typer.echo(f"Wrote {output}")
|
||||||
|
else:
|
||||||
|
typer.echo(bib)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def ask(question: str = typer.Argument(..., help="Question to answer")) -> None:
|
||||||
|
"""Ask a question (RAG — not yet implemented)."""
|
||||||
|
typer.echo("ask: not yet implemented", err=True)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# F-12: Wiki command group
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@wiki_app.command("compile")
|
||||||
|
def wiki_compile(
|
||||||
|
concept: Optional[str] = typer.Option( # noqa: UP045
|
||||||
|
None, "--concept", help="Compile only this concept slug."
|
||||||
|
),
|
||||||
|
all_concepts: bool = typer.Option(
|
||||||
|
False, "--all", help="Force full recompile (ignore change detection)."
|
||||||
|
),
|
||||||
|
top_k: int = typer.Option(0, "--top-k", help="Override config.wiki_top_k (0 = use config)."),
|
||||||
|
output_dir: Optional[str] = typer.Option( # noqa: UP045
|
||||||
|
None, "--output-dir", help="Override config.wiki_dir."
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""Compile concept pages from retrieved chunks via local LLM.
|
||||||
|
|
||||||
|
By default only recompiles concepts whose source chunks have changed
|
||||||
|
(hash-based incremental). Use --all to force full recompile.
|
||||||
|
"""
|
||||||
|
from codex.wiki import compile_all
|
||||||
|
|
||||||
|
report = compile_all(
|
||||||
|
changed_only=not all_concepts,
|
||||||
|
top_k=top_k if top_k > 0 else None,
|
||||||
|
concept_filter=concept,
|
||||||
|
output_dir=output_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
if report.compiled:
|
||||||
|
typer.echo(f"Compiled: {', '.join(report.compiled)}")
|
||||||
|
if report.skipped:
|
||||||
|
typer.echo(f"Skipped (unchanged): {', '.join(report.skipped)}")
|
||||||
|
if report.ungrounded:
|
||||||
|
typer.echo(f"⚠ Ungrounded claims: {len(report.ungrounded)}", err=True)
|
||||||
|
for slug, text in report.ungrounded:
|
||||||
|
typer.echo(f" [{slug}] {text[:100]}", err=True)
|
||||||
|
|
||||||
|
|
||||||
|
@wiki_app.command("list")
|
||||||
|
def wiki_list(
|
||||||
|
output_dir: Optional[str] = typer.Option( # noqa: UP045
|
||||||
|
None, "--output-dir", help="Override config.wiki_dir."
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""List compiled concept pages with freshness information."""
|
||||||
|
import json as _json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from codex.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
wiki_dir = Path(output_dir or settings.wiki_dir)
|
||||||
|
state_path = wiki_dir / ".compile-state.json"
|
||||||
|
|
||||||
|
state: dict[str, str] = {}
|
||||||
|
if state_path.exists():
|
||||||
|
try:
|
||||||
|
state = _json.loads(state_path.read_text(encoding="utf-8"))
|
||||||
|
except (_json.JSONDecodeError, OSError):
|
||||||
|
state = {}
|
||||||
|
|
||||||
|
pages = sorted(wiki_dir.glob("*.md"))
|
||||||
|
if not pages:
|
||||||
|
typer.echo("No compiled pages found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
for page in pages:
|
||||||
|
if page.name in ("index.md", "log.md"):
|
||||||
|
continue
|
||||||
|
slug = page.stem
|
||||||
|
hash_val = state.get(slug, "—")[:8]
|
||||||
|
content = page.read_text(encoding="utf-8")
|
||||||
|
n_claims = content.count("[")
|
||||||
|
n_ungrounded = content.count("⚠")
|
||||||
|
mtime = datetime.fromtimestamp(page.stat().st_mtime, tz=UTC).strftime("%Y-%m-%d %H:%M")
|
||||||
|
typer.echo(
|
||||||
|
f"{slug:40s} last={mtime} hash={hash_val} claims≈{n_claims} ⚠={n_ungrounded}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@wiki_app.command("check")
|
||||||
|
def wiki_check(
|
||||||
|
output_dir: Optional[str] = typer.Option( # noqa: UP045
|
||||||
|
None, "--output-dir", help="Override config.wiki_dir."
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""Check all compiled pages for ungrounded claims.
|
||||||
|
|
||||||
|
Exits with code 0 if all claims are grounded, 1 if any are ungrounded.
|
||||||
|
Suitable for CI pipelines.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from codex.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
wiki_dir = Path(output_dir or settings.wiki_dir)
|
||||||
|
|
||||||
|
pages = sorted(wiki_dir.glob("*.md"))
|
||||||
|
found_ungrounded = False
|
||||||
|
for page in pages:
|
||||||
|
if page.name in ("index.md", "log.md"):
|
||||||
|
continue
|
||||||
|
content = page.read_text(encoding="utf-8")
|
||||||
|
if "⚠" in content:
|
||||||
|
typer.echo(f"⚠ Ungrounded claim(s) in: {page.name}", err=True)
|
||||||
|
found_ungrounded = True
|
||||||
|
|
||||||
|
# Check for quarantined pages in wiki/draft/
|
||||||
|
draft_dir = wiki_dir / "draft"
|
||||||
|
draft_pages = sorted(draft_dir.glob("*.md")) if draft_dir.exists() else []
|
||||||
|
if draft_pages:
|
||||||
|
n = len(draft_pages)
|
||||||
|
typer.echo(
|
||||||
|
f"⚠ {n} page(s) quarantined in wiki/draft/ (grounding rate below threshold)",
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
for dp in draft_pages:
|
||||||
|
typer.echo(f" - {dp.name}", err=True)
|
||||||
|
raise typer.Exit(2)
|
||||||
|
|
||||||
|
if found_ungrounded:
|
||||||
|
raise typer.Exit(1)
|
||||||
|
else:
|
||||||
|
typer.echo("All claims grounded.")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# F-13: Synthesis command group
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@synth_app.command("leads")
|
||||||
|
def synthesis_leads(
|
||||||
|
lib_path: Optional[str] = typer.Option( # noqa: UP045
|
||||||
|
None,
|
||||||
|
"--lib-path",
|
||||||
|
help=(
|
||||||
|
"Path to a C++ source tree. Enables improvement leads (@cite-aware) and "
|
||||||
|
"code-vs-corpus gap detection. Without it only connection + topic gaps run."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
output_dir: Optional[str] = typer.Option( # noqa: UP045
|
||||||
|
None, "--output-dir", help="Override config.leads_dir."
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""Generate grounded leads (stages 2-4) and write them to leads/grounded/."""
|
||||||
|
from codex.config import get_settings
|
||||||
|
from codex.synthesis import (
|
||||||
|
default_llm_client,
|
||||||
|
find_connections,
|
||||||
|
find_gaps,
|
||||||
|
find_improvements,
|
||||||
|
write_leads,
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
leads_dir = output_dir or settings.leads_dir
|
||||||
|
llm = default_llm_client()
|
||||||
|
|
||||||
|
connections = find_connections(top_k=settings.synthesis_top_k, llm=llm)
|
||||||
|
gaps = find_gaps(lib_path=lib_path, llm=llm)
|
||||||
|
improvements = (
|
||||||
|
find_improvements(lib_path, top_k=settings.synthesis_top_k, llm=llm) if lib_path else []
|
||||||
|
)
|
||||||
|
|
||||||
|
all_leads = connections + gaps + improvements
|
||||||
|
write_leads(all_leads, leads_dir)
|
||||||
|
|
||||||
|
typer.echo(
|
||||||
|
f"Grounded leads written to {leads_dir}/grounded/: "
|
||||||
|
f"{len(connections)} connection · {len(gaps)} gap · {len(improvements)} improvement"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@synth_app.command("conjectures")
|
||||||
|
def synthesis_conjectures(
|
||||||
|
output_dir: Optional[str] = typer.Option( # noqa: UP045
|
||||||
|
None, "--output-dir", help="Override config.leads_dir."
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""Generate stage-5 conjecture leads — quarantined to leads/conjectures/.
|
||||||
|
|
||||||
|
INVARIANT (F-13 HARD): conjectures are NEVER written to wiki/. They
|
||||||
|
require a ``suggested_validation`` spike before they may be promoted.
|
||||||
|
"""
|
||||||
|
from codex.config import get_settings
|
||||||
|
from codex.synthesis import default_llm_client, propose_conjectures, write_leads
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
leads_dir = output_dir or settings.leads_dir
|
||||||
|
llm = default_llm_client()
|
||||||
|
|
||||||
|
conjectures = propose_conjectures(top_k=settings.synthesis_top_k, llm=llm)
|
||||||
|
write_leads(conjectures, leads_dir)
|
||||||
|
|
||||||
|
typer.echo(f"Conjectures (UNVERIFIED) written to {leads_dir}/conjectures/: {len(conjectures)}")
|
||||||
|
|
||||||
|
|
||||||
|
@synth_app.command("report")
|
||||||
|
def synthesis_report(
|
||||||
|
output_dir: Optional[str] = typer.Option( # noqa: UP045
|
||||||
|
None, "--output-dir", help="Override config.leads_dir."
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""Show grounded leads and conjectures — visibly separated."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from codex.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
leads_dir = Path(output_dir or settings.leads_dir)
|
||||||
|
grounded_dir = leads_dir / "grounded"
|
||||||
|
conj_dir = leads_dir / "conjectures"
|
||||||
|
|
||||||
|
grounded_files = sorted(grounded_dir.glob("*.md")) if grounded_dir.exists() else []
|
||||||
|
conj_files = sorted(conj_dir.glob("*.md")) if conj_dir.exists() else []
|
||||||
|
|
||||||
|
typer.echo("=" * 72)
|
||||||
|
typer.echo(f"GROUNDED LEADS ({len(grounded_files)}) → {grounded_dir}")
|
||||||
|
typer.echo("=" * 72)
|
||||||
|
if not grounded_files:
|
||||||
|
typer.echo("(none)")
|
||||||
|
for f in grounded_files:
|
||||||
|
first = f.read_text(encoding="utf-8").splitlines()[0]
|
||||||
|
typer.echo(f" {first}")
|
||||||
|
|
||||||
|
typer.echo("")
|
||||||
|
typer.echo("=" * 72)
|
||||||
|
typer.echo(f"CONJECTURES ({len(conj_files)}) → {conj_dir} [UNVERIFIED — never in wiki/]")
|
||||||
|
typer.echo("=" * 72)
|
||||||
|
if not conj_files:
|
||||||
|
typer.echo("(none)")
|
||||||
|
for f in conj_files:
|
||||||
|
first = f.read_text(encoding="utf-8").splitlines()[0]
|
||||||
|
typer.echo(f" {first}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# F-16 — quality sub-commands
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@quality_app.command("run")
|
||||||
|
def quality_run(
|
||||||
|
paper_id: Optional[str] = typer.Option( # noqa: UP045
|
||||||
|
None,
|
||||||
|
"--paper-id",
|
||||||
|
help="Restrict pass to one paper (omit to process all papers).",
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""Apply quality gate + section classification to existing chunks.
|
||||||
|
|
||||||
|
Iterates over chunks in the database, removes those that fail the
|
||||||
|
quality thresholds, and back-fills the ``section`` column for the rest.
|
||||||
|
"""
|
||||||
|
from codex.config import get_settings
|
||||||
|
from codex.db import get_conn
|
||||||
|
from codex.quality import run_quality_pass
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
with get_conn() as conn:
|
||||||
|
stats = run_quality_pass(paper_id=paper_id, conn=conn, settings=settings)
|
||||||
|
|
||||||
|
scope = f"paper {paper_id}" if paper_id else "all papers"
|
||||||
|
typer.echo(
|
||||||
|
f"Quality pass ({scope}): "
|
||||||
|
f"{stats['kept']} kept, "
|
||||||
|
f"{stats['removed']} removed, "
|
||||||
|
f"{stats['tagged']} section-tagged"
|
||||||
|
)
|
||||||
|
|||||||
210
codex/config.py
210
codex/config.py
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import AliasChoices, Field
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
@@ -42,6 +42,12 @@ class Settings(BaseSettings):
|
|||||||
description="Base URL of the GROBID HTTP API (containerised).",
|
description="Base URL of the GROBID HTTP API (containerised).",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
nougat_url: str = Field(
|
||||||
|
default="http://localhost:8080",
|
||||||
|
validation_alias=AliasChoices("NOUGAT_URL", "nougat_url"),
|
||||||
|
description="Base URL of the Nougat OCR HTTP API (containerised).",
|
||||||
|
)
|
||||||
|
|
||||||
ollama_base_url: str = Field(
|
ollama_base_url: str = Field(
|
||||||
default="http://localhost:11434",
|
default="http://localhost:11434",
|
||||||
description="Base URL of the local Ollama endpoint (optional Q&A layer).",
|
description="Base URL of the local Ollama endpoint (optional Q&A layer).",
|
||||||
@@ -78,6 +84,208 @@ class Settings(BaseSettings):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# F-12 Wiki-Compile
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
wiki_dir: str = Field(
|
||||||
|
default="wiki/",
|
||||||
|
description=(
|
||||||
|
"Directory where compiled wiki pages are written. "
|
||||||
|
"Relative paths are resolved from the current working directory."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
wiki_llm_model: str = Field(
|
||||||
|
default="qwen2.5:7b",
|
||||||
|
description=(
|
||||||
|
"Ollama model name used for wiki synthesis. "
|
||||||
|
"Must be available at the configured Ollama endpoint (WIKI_LLM_URL or "
|
||||||
|
"OLLAMA_BASE_URL). Default: qwen2.5:7b (qwen-light profile on Jetson)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
wiki_llm_url: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Ollama base URL for wiki synthesis. "
|
||||||
|
"When None, falls back to OLLAMA_BASE_URL "
|
||||||
|
"(http://192.168.178.103:11434 for the Jetson). "
|
||||||
|
"Set WIKI_LLM_URL to override."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
wiki_top_k: int = Field(
|
||||||
|
default=12,
|
||||||
|
gt=0,
|
||||||
|
description=(
|
||||||
|
"Number of top chunks retrieved per concept for wiki synthesis. "
|
||||||
|
"Higher values improve recall at the cost of a larger LLM prompt."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
wiki_min_grounding_rate: float = Field(
|
||||||
|
default=0.5,
|
||||||
|
ge=0.0,
|
||||||
|
le=1.0,
|
||||||
|
description=(
|
||||||
|
"Minimum fraction of claims that must be grounded for a page to be written "
|
||||||
|
"to wiki/<slug>.md. Pages below this threshold are quarantined to "
|
||||||
|
"wiki/draft/<slug>.md instead."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# F-09 Rich Parsing
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
mathpix_app_id: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"MathPix App ID for cloud formula extraction. "
|
||||||
|
"When None, pix2tex local OCR is used as fallback."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
mathpix_app_key: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"MathPix App Key for cloud formula extraction. "
|
||||||
|
"Must be set together with MATHPIX_APP_ID."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
pix2tex_fallback: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description=(
|
||||||
|
"Enable pix2tex (local LaTeX OCR) as fallback when MathPix credentials "
|
||||||
|
"are absent. Set to False to disable formula extraction entirely without creds."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
figures_dir: str = Field(
|
||||||
|
default="figures/",
|
||||||
|
description=(
|
||||||
|
"Directory where extracted figure images are written. "
|
||||||
|
"Relative paths are resolved from the current working directory."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# F-13 Synthesis / Lead-Engine
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
leads_dir: str = Field(
|
||||||
|
default="leads/",
|
||||||
|
description=(
|
||||||
|
"Directory where synthesis leads are written. "
|
||||||
|
"Grounded leads land in <leads_dir>/grounded/, conjectures in "
|
||||||
|
"<leads_dir>/conjectures/. INVARIANT: conjectures NEVER touch wiki/."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
synthesis_llm_model: str = Field(
|
||||||
|
default="qwen2.5:7b",
|
||||||
|
description=(
|
||||||
|
"Ollama model name used for synthesis (connection / gap / improvement / "
|
||||||
|
"conjecture). Defaults to the qwen-light profile on the Jetson."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
synthesis_llm_url: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=("Ollama base URL for synthesis. When None, falls back to OLLAMA_BASE_URL."),
|
||||||
|
)
|
||||||
|
|
||||||
|
synthesis_top_k: int = Field(
|
||||||
|
default=20,
|
||||||
|
gt=0,
|
||||||
|
description=(
|
||||||
|
"Number of top chunks retrieved per synthesis probe. Higher values "
|
||||||
|
"improve recall at the cost of a larger LLM prompt."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
synthesis_min_grounded_ratio: float = Field(
|
||||||
|
default=0.5,
|
||||||
|
ge=0.0,
|
||||||
|
le=1.0,
|
||||||
|
description=(
|
||||||
|
"Minimum fraction of claims that must be grounded for a grounded lead "
|
||||||
|
"(connection / gap / improvement) to be emitted. Leads below this "
|
||||||
|
"threshold are dropped rather than written to leads/grounded/."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
synthesis_gap_min_coverage: int = Field(
|
||||||
|
default=2,
|
||||||
|
gt=0,
|
||||||
|
description=(
|
||||||
|
"A topic is considered a 'gap' candidate when it is covered by fewer "
|
||||||
|
"than this many distinct bibkeys in the retrieved top-K."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# F-14 MCP Server
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
mcp_transport: str = Field(
|
||||||
|
default="stdio",
|
||||||
|
description=(
|
||||||
|
"Transport protocol for the MCP server. "
|
||||||
|
"Allowed values: 'stdio' (default) or 'http'. "
|
||||||
|
"HTTP transport requires MCP_AUTH_TOKEN to be set."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
mcp_host: str = Field(
|
||||||
|
default="127.0.0.1",
|
||||||
|
description="Bind host for HTTP transport (ignored for stdio).",
|
||||||
|
)
|
||||||
|
|
||||||
|
mcp_port: int = Field(
|
||||||
|
default=8765,
|
||||||
|
gt=0,
|
||||||
|
description="Bind port for HTTP transport (ignored for stdio).",
|
||||||
|
)
|
||||||
|
|
||||||
|
mcp_auth_token: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Bearer token required when mcp_transport='http'. "
|
||||||
|
"If HTTP transport is requested but this is None, the server refuses to start."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# F-16 Chunk Quality Gate
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
chunk_min_chars: int = Field(
|
||||||
|
default=60,
|
||||||
|
gt=0,
|
||||||
|
description=(
|
||||||
|
"Minimum character count for a chunk to pass the quality gate. "
|
||||||
|
"Chunks shorter than this value are discarded before embedding."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
chunk_min_alpha_ratio: float = Field(
|
||||||
|
default=0.40,
|
||||||
|
ge=0.0,
|
||||||
|
le=1.0,
|
||||||
|
description=(
|
||||||
|
"Minimum fraction of alphabetic characters in a chunk. "
|
||||||
|
"Chunks with a lower ratio are treated as OCR artefacts and discarded."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
chunk_max_bib_score: float = Field(
|
||||||
|
default=0.70,
|
||||||
|
ge=0.0,
|
||||||
|
le=1.0,
|
||||||
|
description=(
|
||||||
|
"Maximum bibliography heuristic score before a chunk is rejected. "
|
||||||
|
"Score is based on DOI density, 'et al.' and year-in-brackets patterns."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
64
codex/discover.py
Normal file
64
codex/discover.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""Discovery queries over the citation graph stored in PostgreSQL."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from codex.db import get_conn
|
||||||
|
|
||||||
|
|
||||||
|
def discovery_leads(limit: int = 20) -> list[dict[str, int | str]]:
|
||||||
|
"""Return papers referenced by already-ingested papers but not yet collected.
|
||||||
|
|
||||||
|
Returns list of dicts with keys:
|
||||||
|
cited_id (str) — arXiv ID, DOI, or OpenAlex W-ID of the target
|
||||||
|
pull (int) — how many already-ingested papers cite this target
|
||||||
|
|
||||||
|
Ordered by pull DESC, limited to `limit` rows.
|
||||||
|
"""
|
||||||
|
sql = """
|
||||||
|
SELECT cited_id, count(*) AS pull
|
||||||
|
FROM citations
|
||||||
|
WHERE cited_id NOT IN (SELECT id FROM papers)
|
||||||
|
GROUP BY cited_id
|
||||||
|
ORDER BY pull DESC
|
||||||
|
LIMIT %(limit)s
|
||||||
|
"""
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(sql, {"limit": limit}).fetchall()
|
||||||
|
return [{"cited_id": row["cited_id"], "pull": row["pull"]} for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def citing_papers(paper_id: str) -> list[str]:
|
||||||
|
"""Return IDs of all papers that cite `paper_id` (in-graph reverse citations)."""
|
||||||
|
sql = "SELECT citing_id FROM citations WHERE cited_id = %(paper_id)s"
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(sql, {"paper_id": paper_id}).fetchall()
|
||||||
|
return [row["citing_id"] for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def cited_by(paper_id: str) -> list[str]:
|
||||||
|
"""Return IDs of all papers that `paper_id` cites (forward citations)."""
|
||||||
|
sql = "SELECT cited_id FROM citations WHERE citing_id = %(paper_id)s"
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(sql, {"paper_id": paper_id}).fetchall()
|
||||||
|
return [row["cited_id"] for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def cocited_papers(paper_id: str, limit: int = 10) -> list[dict[str, int | str]]:
|
||||||
|
"""Return papers frequently co-cited with `paper_id`.
|
||||||
|
|
||||||
|
A paper X is co-cited with `paper_id` if some paper cites both.
|
||||||
|
Returns list of dicts: {paper_id: str, co_citations: int}, ordered DESC.
|
||||||
|
"""
|
||||||
|
sql = """
|
||||||
|
SELECT c2.cited_id AS paper_id, count(*) AS co_citations
|
||||||
|
FROM citations c1
|
||||||
|
JOIN citations c2 ON c1.citing_id = c2.citing_id
|
||||||
|
WHERE c1.cited_id = %(paper_id)s
|
||||||
|
AND c2.cited_id != %(paper_id)s
|
||||||
|
GROUP BY c2.cited_id
|
||||||
|
ORDER BY co_citations DESC
|
||||||
|
LIMIT %(limit)s
|
||||||
|
"""
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(sql, {"paper_id": paper_id, "limit": limit}).fetchall()
|
||||||
|
return [{"paper_id": row["paper_id"], "co_citations": row["co_citations"]} for row in rows]
|
||||||
155
codex/embed.py
Normal file
155
codex/embed.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
"""Hybrid dense + sparse embeddings via BGE-M3 (ADR-0002).
|
||||||
|
|
||||||
|
Uses :class:`FlagEmbedding.BGEM3FlagModel` for encoding because the
|
||||||
|
``return_dense`` / ``return_sparse`` kwargs are part of the FlagEmbedding
|
||||||
|
API — not ``sentence-transformers``. The dense output is identical to a
|
||||||
|
vanilla ``SentenceTransformer`` load of ``BAAI/bge-m3`` (same weights,
|
||||||
|
same model); sparse output is a list of ``{token_id: weight}`` dicts per
|
||||||
|
text.
|
||||||
|
|
||||||
|
Notes for callers
|
||||||
|
-----------------
|
||||||
|
* Empty input is handled explicitly — no model call is issued.
|
||||||
|
* Dense vectors are L2-normalised row-wise so cosine similarity reduces
|
||||||
|
to a dot product.
|
||||||
|
* :func:`get_embedder` returns a process-wide singleton so the model is
|
||||||
|
loaded at most once per Python process.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from FlagEmbedding import BGEM3FlagModel
|
||||||
|
|
||||||
|
|
||||||
|
class Embedder:
|
||||||
|
"""Thin wrapper over :class:`BGEM3FlagModel` with a stable API.
|
||||||
|
|
||||||
|
The wrapper hides the FlagEmbedding-specific encode-dict and exposes
|
||||||
|
three operations relevant to the ingestion pipeline:
|
||||||
|
|
||||||
|
* :meth:`encode_dense` — dense float32 matrix, L2-normalised.
|
||||||
|
* :meth:`encode_sparse` — list of ``{token_id: weight}`` dicts.
|
||||||
|
* :meth:`encode` — both in a single forward pass.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
model_name: str = "BAAI/bge-m3",
|
||||||
|
dim: int = 1024,
|
||||||
|
device: str | None = None,
|
||||||
|
batch_size: int = 32,
|
||||||
|
) -> None:
|
||||||
|
resolved_device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
||||||
|
self._model = BGEM3FlagModel(
|
||||||
|
model_name,
|
||||||
|
use_fp16=False,
|
||||||
|
devices=[resolved_device],
|
||||||
|
)
|
||||||
|
self.dim = dim
|
||||||
|
self.batch_size = batch_size
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@staticmethod
|
||||||
|
def _l2_normalise(matrix: np.ndarray) -> np.ndarray:
|
||||||
|
"""Return ``matrix`` with every row scaled to unit L2 norm.
|
||||||
|
|
||||||
|
Zero-rows are left untouched (we divide by 1 rather than 0 to
|
||||||
|
avoid ``nan``s — the BGE-M3 encoder never emits zero vectors in
|
||||||
|
practice, but the guard keeps the function total).
|
||||||
|
"""
|
||||||
|
norms = np.linalg.norm(matrix, axis=1, keepdims=True)
|
||||||
|
norms = np.where(norms == 0.0, 1.0, norms)
|
||||||
|
return (matrix / norms).astype(np.float32)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _coerce_sparse(weights: list[dict[int | str, float]]) -> list[dict[int, float]]:
|
||||||
|
"""Cast token ids to ``int`` and weights to ``float``.
|
||||||
|
|
||||||
|
FlagEmbedding returns ids as strings in some versions and as
|
||||||
|
ints in others; we normalise to ``int`` so downstream code can
|
||||||
|
rely on a stable key type.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
{int(token_id): float(weight) for token_id, weight in row.items()} for row in weights
|
||||||
|
]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def encode_dense(self, texts: list[str]) -> np.ndarray:
|
||||||
|
"""Encode ``texts`` to a dense, L2-normalised float32 matrix.
|
||||||
|
|
||||||
|
Returns a ``(0, dim)`` zero-matrix for an empty input.
|
||||||
|
"""
|
||||||
|
if not texts:
|
||||||
|
return np.zeros((0, self.dim), dtype=np.float32)
|
||||||
|
|
||||||
|
result = self._model.encode(
|
||||||
|
texts,
|
||||||
|
batch_size=self.batch_size,
|
||||||
|
return_dense=True,
|
||||||
|
return_sparse=False,
|
||||||
|
)
|
||||||
|
dense = np.asarray(result["dense_vecs"], dtype=np.float32)
|
||||||
|
return self._l2_normalise(dense)
|
||||||
|
|
||||||
|
def encode_sparse(self, texts: list[str]) -> list[dict[int, float]]:
|
||||||
|
"""Encode ``texts`` to a list of sparse ``{token_id: weight}`` dicts.
|
||||||
|
|
||||||
|
Returns ``[]`` for an empty input.
|
||||||
|
"""
|
||||||
|
if not texts:
|
||||||
|
return []
|
||||||
|
|
||||||
|
result = self._model.encode(
|
||||||
|
texts,
|
||||||
|
batch_size=self.batch_size,
|
||||||
|
return_dense=False,
|
||||||
|
return_sparse=True,
|
||||||
|
)
|
||||||
|
return self._coerce_sparse(result["lexical_weights"])
|
||||||
|
|
||||||
|
def encode(self, texts: list[str]) -> tuple[np.ndarray, list[dict[int, float]]]:
|
||||||
|
"""Encode ``texts`` to dense **and** sparse in one forward pass.
|
||||||
|
|
||||||
|
Returns ``(zeros((0, dim)), [])`` for an empty input.
|
||||||
|
"""
|
||||||
|
if not texts:
|
||||||
|
return np.zeros((0, self.dim), dtype=np.float32), []
|
||||||
|
|
||||||
|
result = self._model.encode(
|
||||||
|
texts,
|
||||||
|
batch_size=self.batch_size,
|
||||||
|
return_dense=True,
|
||||||
|
return_sparse=True,
|
||||||
|
)
|
||||||
|
dense = self._l2_normalise(np.asarray(result["dense_vecs"], dtype=np.float32))
|
||||||
|
sparse = self._coerce_sparse(result["lexical_weights"])
|
||||||
|
return dense, sparse
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Process-wide singleton
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_embedder: Embedder | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_embedder() -> Embedder:
|
||||||
|
"""Return the process-wide :class:`Embedder` singleton.
|
||||||
|
|
||||||
|
The first call constructs the embedder using values from
|
||||||
|
:class:`codex.config.Settings`; subsequent calls return the cached
|
||||||
|
instance. Tests can reset the cache by setting
|
||||||
|
``codex.embed._embedder`` back to ``None``.
|
||||||
|
"""
|
||||||
|
global _embedder
|
||||||
|
if _embedder is None:
|
||||||
|
from codex.config import Settings
|
||||||
|
|
||||||
|
s = Settings()
|
||||||
|
_embedder = Embedder(model_name=s.embedding_model, dim=s.embedding_dim)
|
||||||
|
return _embedder
|
||||||
304
codex/ingest.py
Normal file
304
codex/ingest.py
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
"""End-to-end idempotent ingest pipeline for a single paper."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from codex.config import get_settings
|
||||||
|
from codex.db import get_conn
|
||||||
|
from codex.embed import get_embedder
|
||||||
|
from codex.models import Citation, Paper
|
||||||
|
from codex.quality import classify_section, filter_chunks
|
||||||
|
from codex.sources import openalex, semanticscholar
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IngestResult:
|
||||||
|
paper_id: str
|
||||||
|
chunks_upserted: int
|
||||||
|
citations_upserted: int
|
||||||
|
formulas_upserted: int = 0
|
||||||
|
figures_upserted: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _make_bibkey(paper: Paper) -> str | None:
|
||||||
|
"""Generate a BibTeX-style key from paper metadata.
|
||||||
|
|
||||||
|
Format: ConcatSurnames + Year (e.g. "BobenkoPinkallSpringborn2015").
|
||||||
|
Surnames are the last whitespace-separated token of each author name.
|
||||||
|
>3 authors: FirstSurname + "EtAl" + Year.
|
||||||
|
Returns None when authors or year are missing.
|
||||||
|
"""
|
||||||
|
if not paper.authors or not paper.year:
|
||||||
|
return None
|
||||||
|
surnames = [name.split()[-1] for name in paper.authors if name.strip()]
|
||||||
|
if not surnames:
|
||||||
|
return None
|
||||||
|
year = str(paper.year)
|
||||||
|
if len(surnames) <= 3:
|
||||||
|
return "".join(surnames) + year
|
||||||
|
return surnames[0] + "EtAl" + year
|
||||||
|
|
||||||
|
|
||||||
|
def ingest_paper(
|
||||||
|
paper_id: str,
|
||||||
|
source_path: str | None = None,
|
||||||
|
rich: bool = False,
|
||||||
|
) -> IngestResult:
|
||||||
|
"""Idempotent ingest of one paper.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
paper_id:
|
||||||
|
An arXiv ID (``"2301.07041"``), DOI (``"10.1145/…"``), or
|
||||||
|
OpenAlex W-ID (``"W2741809807"``).
|
||||||
|
source_path:
|
||||||
|
Optional path to a local ``.tex`` or ``.pdf`` file.
|
||||||
|
If given, the file is parsed into text chunks and stored.
|
||||||
|
Supports:
|
||||||
|
- ``.tex`` → :func:`codex.parsing.tex.latex_to_text` + chunk
|
||||||
|
- ``.pdf`` → :func:`codex.parsing.nougat.pdf_to_markdown` + chunk
|
||||||
|
+ :func:`codex.parsing.grobid.extract_references` for refs
|
||||||
|
rich:
|
||||||
|
When True and *source_path* is a PDF, also extract formulas via
|
||||||
|
:func:`codex.parsing.mathpix.extract_formulas` and figures via
|
||||||
|
:func:`codex.parsing.figures.extract_figures` (F-09).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
IngestResult
|
||||||
|
Counts of upserted chunks, citations, formulas, and figures.
|
||||||
|
"""
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# 1. Fetch metadata (OpenAlex primary, SemanticScholar fallback)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
paper: Paper | None = openalex.fetch_paper(paper_id)
|
||||||
|
|
||||||
|
if paper is None:
|
||||||
|
# Detect arXiv IDs: numeric pattern like "2301.07041" or "arxiv:..." prefix
|
||||||
|
pid_lower = paper_id.lower()
|
||||||
|
looks_like_arxiv = pid_lower.startswith("arxiv:") or (
|
||||||
|
len(paper_id) > 4 and paper_id[4:5] == "." and paper_id[:4].isdigit()
|
||||||
|
)
|
||||||
|
if looks_like_arxiv:
|
||||||
|
arxiv_key = paper_id if pid_lower.startswith("arxiv:") else f"arXiv:{paper_id}"
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
semanticscholar.fetch_references(arxiv_key)
|
||||||
|
paper = Paper(id=paper_id, title="")
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Paper not found: {paper_id}")
|
||||||
|
|
||||||
|
if paper is None:
|
||||||
|
raise ValueError(f"Paper not found: {paper_id}")
|
||||||
|
|
||||||
|
# Auto-generate bibkey when source has none (D-04).
|
||||||
|
if paper.bibkey is None:
|
||||||
|
paper.bibkey = _make_bibkey(paper)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# 2. Embed abstract (dense only — schema has one vector column)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
embedder = get_embedder()
|
||||||
|
abstract_text = paper.abstract or ""
|
||||||
|
if abstract_text:
|
||||||
|
abstract_emb_arr: np.ndarray = embedder.encode_dense([abstract_text])
|
||||||
|
abstract_emb: list[float] = abstract_emb_arr[0].tolist()
|
||||||
|
else:
|
||||||
|
dim = embedder.dim
|
||||||
|
abstract_emb = [0.0] * dim
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# 3. Upsert paper (INSERT … ON CONFLICT (id) DO UPDATE SET …)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO papers (id, openalex_id, bibkey, title, authors, year, abstract,
|
||||||
|
source_path, abstract_emb)
|
||||||
|
VALUES (%(id)s, %(openalex_id)s, %(bibkey)s, %(title)s, %(authors)s, %(year)s,
|
||||||
|
%(abstract)s, %(source_path)s, %(abstract_emb)s)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
openalex_id = EXCLUDED.openalex_id,
|
||||||
|
bibkey = COALESCE(papers.bibkey, EXCLUDED.bibkey),
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
authors = EXCLUDED.authors,
|
||||||
|
year = EXCLUDED.year,
|
||||||
|
abstract = EXCLUDED.abstract,
|
||||||
|
source_path = COALESCE(EXCLUDED.source_path, papers.source_path),
|
||||||
|
abstract_emb = EXCLUDED.abstract_emb
|
||||||
|
""",
|
||||||
|
{
|
||||||
|
"id": paper.id,
|
||||||
|
"openalex_id": paper.openalex_id,
|
||||||
|
"bibkey": paper.bibkey,
|
||||||
|
"title": paper.title,
|
||||||
|
"authors": paper.authors,
|
||||||
|
"year": paper.year,
|
||||||
|
"abstract": paper.abstract,
|
||||||
|
"source_path": source_path,
|
||||||
|
"abstract_emb": abstract_emb,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# 4. Parse + chunk + embed source file (if provided)
|
||||||
|
# DELETE existing chunks for this paper first, then bulk INSERT
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
chunks_upserted = 0
|
||||||
|
pdf_citations: list[Citation] = []
|
||||||
|
|
||||||
|
if source_path is not None:
|
||||||
|
from codex.parsing.grobid import extract_references
|
||||||
|
from codex.parsing.nougat import pdf_to_markdown
|
||||||
|
from codex.parsing.tex import chunk_text, latex_to_text
|
||||||
|
|
||||||
|
suffix = Path(source_path).suffix.lower()
|
||||||
|
text = ""
|
||||||
|
|
||||||
|
if suffix == ".tex":
|
||||||
|
text = latex_to_text(Path(source_path).read_text())
|
||||||
|
elif suffix == ".txt":
|
||||||
|
text = Path(source_path).read_text(encoding="utf-8", errors="replace")
|
||||||
|
elif suffix == ".pdf":
|
||||||
|
text = pdf_to_markdown(source_path)
|
||||||
|
# Also extract GROBID refs
|
||||||
|
grobid_refs = extract_references(source_path)
|
||||||
|
for ref in grobid_refs:
|
||||||
|
cited_id = ref.get("doi") or ref.get("arxiv_id")
|
||||||
|
if cited_id:
|
||||||
|
pdf_citations.append(Citation(citing_id=paper.id, cited_id=cited_id))
|
||||||
|
else:
|
||||||
|
logger.warning("Unbekannter Dateityp: %s — kein Text-Parsing", source_path)
|
||||||
|
|
||||||
|
raw_chunks = chunk_text(text) if text else []
|
||||||
|
chunks_text = filter_chunks(raw_chunks, settings=get_settings())
|
||||||
|
|
||||||
|
# Delete existing chunks for this paper
|
||||||
|
conn.execute("DELETE FROM chunks WHERE paper_id = %s", (paper.id,))
|
||||||
|
|
||||||
|
if chunks_text:
|
||||||
|
# Embed all chunks in one batch
|
||||||
|
chunk_embeddings = embedder.encode_dense(chunks_text)
|
||||||
|
chunk_rows = [
|
||||||
|
(
|
||||||
|
paper.id,
|
||||||
|
ord_idx,
|
||||||
|
content,
|
||||||
|
chunk_embeddings[ord_idx].tolist(),
|
||||||
|
classify_section(content),
|
||||||
|
)
|
||||||
|
for ord_idx, content in enumerate(chunks_text)
|
||||||
|
]
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.executemany(
|
||||||
|
"INSERT INTO chunks (paper_id, ord, content, embedding, section)"
|
||||||
|
" VALUES (%s, %s, %s, %s, %s)",
|
||||||
|
chunk_rows,
|
||||||
|
)
|
||||||
|
chunks_upserted = len(chunk_rows)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# 5. Fetch citations (OpenAlex if openalex_id, else S2)
|
||||||
|
# Insert with ON CONFLICT (citing_id, cited_id) DO NOTHING
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
api_citations: list[Citation]
|
||||||
|
if paper.openalex_id:
|
||||||
|
raw = openalex.fetch_citations(paper.openalex_id)
|
||||||
|
# OpenAlex returns citing_id=openalex_id, but papers.id uses the DOI.
|
||||||
|
# Rewrite citing_id to paper.id so the FK constraint is satisfied.
|
||||||
|
api_citations = [
|
||||||
|
Citation(citing_id=paper.id, cited_id=c.cited_id, context=c.context) for c in raw
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
api_citations = semanticscholar.fetch_references(paper_id)
|
||||||
|
|
||||||
|
# Merge API citations and GROBID PDF citations; dedup via set
|
||||||
|
all_citations_set: set[tuple[str, str]] = set()
|
||||||
|
merged_citations: list[Citation] = []
|
||||||
|
for cit in api_citations + pdf_citations:
|
||||||
|
key = (cit.citing_id, cit.cited_id)
|
||||||
|
if key not in all_citations_set:
|
||||||
|
all_citations_set.add(key)
|
||||||
|
merged_citations.append(cit)
|
||||||
|
|
||||||
|
if merged_citations:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO citations (citing_id, cited_id, context)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
ON CONFLICT (citing_id, cited_id) DO NOTHING
|
||||||
|
""",
|
||||||
|
[(c.citing_id, c.cited_id, c.context) for c in merged_citations],
|
||||||
|
)
|
||||||
|
|
||||||
|
citations_upserted = len(merged_citations)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# 6. F-09 Rich Parsing: formulas + figures (PDF only)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
formulas_upserted = 0
|
||||||
|
figures_upserted = 0
|
||||||
|
|
||||||
|
if rich and source_path is not None and Path(source_path).suffix.lower() == ".pdf":
|
||||||
|
from codex.parsing.figures import extract_figures
|
||||||
|
from codex.parsing.mathpix import extract_formulas
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
formulas = extract_formulas(source_path)
|
||||||
|
figures = extract_figures(source_path, output_dir=settings.figures_dir)
|
||||||
|
|
||||||
|
if formulas or figures:
|
||||||
|
with get_conn() as conn2:
|
||||||
|
with conn2.cursor() as cur:
|
||||||
|
# Delete-before-insert keeps re-ingest idempotent (BIGSERIAL has no
|
||||||
|
# natural UNIQUE key, so ON CONFLICT DO NOTHING never fires).
|
||||||
|
cur.execute("DELETE FROM formulas WHERE paper_id = %s", (paper.id,))
|
||||||
|
cur.execute("DELETE FROM figures WHERE paper_id = %s", (paper.id,))
|
||||||
|
|
||||||
|
if formulas:
|
||||||
|
with conn2.cursor() as cur:
|
||||||
|
cur.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO formulas (paper_id, page, raw_latex, context, eq_label)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
(f.paper_id, f.page, f.raw_latex, f.context, f.eq_label)
|
||||||
|
for f in formulas
|
||||||
|
],
|
||||||
|
)
|
||||||
|
formulas_upserted = len(formulas)
|
||||||
|
|
||||||
|
if figures:
|
||||||
|
with conn2.cursor() as cur:
|
||||||
|
cur.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO figures (paper_id, page, caption, image_path)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
(fig.paper_id, fig.page, fig.caption, fig.image_path)
|
||||||
|
for fig in figures
|
||||||
|
],
|
||||||
|
)
|
||||||
|
figures_upserted = len(figures)
|
||||||
|
|
||||||
|
conn2.commit()
|
||||||
|
|
||||||
|
return IngestResult(
|
||||||
|
paper_id=paper.id,
|
||||||
|
chunks_upserted=chunks_upserted,
|
||||||
|
citations_upserted=citations_upserted,
|
||||||
|
formulas_upserted=formulas_upserted,
|
||||||
|
figures_upserted=figures_upserted,
|
||||||
|
)
|
||||||
359
codex/mcp_server.py
Normal file
359
codex/mcp_server.py
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
"""MCP server — read-only KB tools exposed via FastMCP (F-14).
|
||||||
|
|
||||||
|
This module is a *thin facade* over existing codex domain modules.
|
||||||
|
No new domain logic lives here — each tool delegates to the relevant
|
||||||
|
module and reshapes the result into a JSON-serialisable dict/list.
|
||||||
|
|
||||||
|
Graceful degradation contract
|
||||||
|
------------------------------
|
||||||
|
Every tool that depends on an optional feature (wiki, leads/synthesis,
|
||||||
|
provenance-verify) catches all exceptions and returns
|
||||||
|
``{"error": "feature not available"}`` instead of crashing.
|
||||||
|
|
||||||
|
Transport
|
||||||
|
---------
|
||||||
|
* **stdio** (default):
|
||||||
|
``python -m codex.mcp_server`` or ``codex-mcp``
|
||||||
|
|
||||||
|
* **HTTP** (config-gated):
|
||||||
|
Set ``MCP_TRANSPORT=http`` *and* ``MCP_AUTH_TOKEN=<token>`` in your
|
||||||
|
environment (or ``.env``). The server refuses to start without a
|
||||||
|
token — no unauthenticated HTTP endpoints are ever exposed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from mcp.server.transport_security import TransportSecuritySettings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# DNS rebinding protection disabled: the server is guarded by Bearer token auth
|
||||||
|
# (mcp_server.py _TokenAuth middleware) and UFW firewall (trusted LAN IPs only).
|
||||||
|
mcp = FastMCP(
|
||||||
|
"codex",
|
||||||
|
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool: search
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def search(query: str, limit: int = 10) -> list[dict[str, object]]:
|
||||||
|
"""Chunk-level hybrid search (dense BGE-M3 + FTS) over the KB.
|
||||||
|
|
||||||
|
Returns one dict per hit:
|
||||||
|
``{bibkey, paper_id, locator, score, snippet}``.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from codex.db import get_conn
|
||||||
|
from codex.embed import get_embedder
|
||||||
|
|
||||||
|
embedder = get_embedder()
|
||||||
|
dense_vec = embedder.encode_dense([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 %(limit)s
|
||||||
|
"""
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(sql, {"emb": dense_vec, "limit": limit}).fetchall()
|
||||||
|
|
||||||
|
results: list[dict[str, object]] = []
|
||||||
|
for row in rows:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"bibkey": row["bibkey"],
|
||||||
|
"paper_id": row["paper_id"],
|
||||||
|
"locator": f"chunk {row['ord']}",
|
||||||
|
"score": float(1.0 - row["dist"]), # convert distance → similarity
|
||||||
|
"snippet": row["content"][:300],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("search tool error: %s", exc)
|
||||||
|
return [{"error": str(exc)}]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool: ask
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def ask(question: str) -> dict[str, object]:
|
||||||
|
"""RAG answer over the KB.
|
||||||
|
|
||||||
|
Returns ``{"answer": str}`` when implemented,
|
||||||
|
or ``{"error": "not available"}`` until a RAG layer is added.
|
||||||
|
"""
|
||||||
|
# RAG not yet implemented (CLI parity with codex ask)
|
||||||
|
return {"error": "not available"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool: wiki_read
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def wiki_read(concept_slug: str) -> dict[str, object]:
|
||||||
|
"""Compiled concept page (F-12) as ``{markdown, sources}``.
|
||||||
|
|
||||||
|
Gracefully returns ``{"error": "feature not available"}`` when the wiki
|
||||||
|
directory or the requested page does not exist.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from codex.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
wiki_dir = Path(settings.wiki_dir)
|
||||||
|
page_path = wiki_dir / f"{concept_slug}.md"
|
||||||
|
|
||||||
|
if not page_path.exists():
|
||||||
|
return {"error": "feature not available"}
|
||||||
|
|
||||||
|
markdown = page_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Collect cited bibkeys from the compile state
|
||||||
|
state_path = wiki_dir / ".compile-state.json"
|
||||||
|
sources: list[str] = []
|
||||||
|
if state_path.exists():
|
||||||
|
try:
|
||||||
|
state: dict[str, str] = json.loads(state_path.read_text(encoding="utf-8"))
|
||||||
|
if concept_slug in state:
|
||||||
|
sources = [concept_slug]
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {"markdown": markdown, "sources": sources}
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("wiki_read tool error: %s", exc)
|
||||||
|
return {"error": "feature not available"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool: wiki_list
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def wiki_list() -> list[dict[str, object]]:
|
||||||
|
"""All compiled concept pages with freshness metadata.
|
||||||
|
|
||||||
|
Returns a list of ``{slug, mtime, hash_prefix}`` dicts.
|
||||||
|
Gracefully returns ``[]`` when the wiki directory does not exist.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from codex.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
wiki_dir = Path(settings.wiki_dir)
|
||||||
|
|
||||||
|
if not wiki_dir.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
state_path = wiki_dir / ".compile-state.json"
|
||||||
|
state: dict[str, str] = {}
|
||||||
|
if state_path.exists():
|
||||||
|
try:
|
||||||
|
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
state = {}
|
||||||
|
|
||||||
|
pages = sorted(wiki_dir.glob("*.md"))
|
||||||
|
result: list[dict[str, object]] = []
|
||||||
|
for page in pages:
|
||||||
|
if page.name in ("index.md", "log.md"):
|
||||||
|
continue
|
||||||
|
slug = page.stem
|
||||||
|
hash_prefix = state.get(slug, "")[:8]
|
||||||
|
mtime = datetime.fromtimestamp(page.stat().st_mtime, tz=UTC).isoformat()
|
||||||
|
result.append({"slug": slug, "mtime": mtime, "hash_prefix": hash_prefix})
|
||||||
|
|
||||||
|
return result
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("wiki_list tool error: %s", exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool: discover_leads
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def discover_leads(limit: int = 20) -> list[dict[str, object]]:
|
||||||
|
"""Dangling-citation discovery leads — papers cited but not yet ingested.
|
||||||
|
|
||||||
|
Returns ``[{cited_id, pull}]`` ordered by citation count descending.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from codex.discover import discovery_leads as _leads
|
||||||
|
|
||||||
|
raw = _leads(limit=limit)
|
||||||
|
return [{"cited_id": row["cited_id"], "pull": int(row["pull"])} for row in raw]
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("discover_leads tool error: %s", exc)
|
||||||
|
return [{"error": str(exc)}]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool: provenance_verify
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def provenance_verify(lib_path: str) -> list[dict[str, object]]:
|
||||||
|
"""Scan C++ sources for @cite tags and return code-to-paper links.
|
||||||
|
|
||||||
|
Read-only: only scans, never writes.
|
||||||
|
Returns ``[{file, line, symbol, bibkey}]`` or ``[{"error": ...}]``.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from codex.provenance import scan_cite_tags
|
||||||
|
|
||||||
|
hits = scan_cite_tags(lib_path)
|
||||||
|
return [dict(h) for h in hits]
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("provenance_verify tool error: %s", exc)
|
||||||
|
return [{"error": "feature not available"}]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool: synthesis_browse
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def synthesis_browse(kind: str = "all") -> list[dict[str, object]]:
|
||||||
|
"""Leads / conjectures from the ``leads/`` directory (F-13).
|
||||||
|
|
||||||
|
Reads ``.json`` files from ``leads/`` and filters by ``kind``
|
||||||
|
(``"all"`` returns everything). Gracefully returns
|
||||||
|
``[{"error": "feature not available"}]`` when the directory is absent.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from codex.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
leads_dir = Path(settings.leads_dir)
|
||||||
|
if not leads_dir.exists():
|
||||||
|
return [{"error": "feature not available"}]
|
||||||
|
|
||||||
|
items: list[dict[str, object]] = []
|
||||||
|
for f in sorted(leads_dir.rglob("*.json")):
|
||||||
|
try:
|
||||||
|
data: dict[str, object] = json.loads(f.read_text(encoding="utf-8"))
|
||||||
|
item_kind = str(data.get("kind", "unknown"))
|
||||||
|
if kind == "all" or item_kind == kind:
|
||||||
|
items.append(data)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return items
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("synthesis_browse tool error: %s", exc)
|
||||||
|
return [{"error": "feature not available"}]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Server entry-point helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _require_http_token() -> str:
|
||||||
|
"""Return the configured auth token or raise :exc:`RuntimeError`.
|
||||||
|
|
||||||
|
Called before starting HTTP transport to enforce the security invariant:
|
||||||
|
no unauthenticated HTTP endpoints are ever exposed.
|
||||||
|
"""
|
||||||
|
from codex.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.mcp_auth_token:
|
||||||
|
raise RuntimeError(
|
||||||
|
"HTTP transport requires MCP_AUTH_TOKEN to be set. "
|
||||||
|
"Set the environment variable or add it to .env."
|
||||||
|
)
|
||||||
|
return settings.mcp_auth_token
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Server entry-point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Start the MCP server (stdio or HTTP depending on config).
|
||||||
|
|
||||||
|
HTTP transport requires ``MCP_AUTH_TOKEN`` to be set; the server will
|
||||||
|
raise :exc:`RuntimeError` and refuse to start if the token is missing.
|
||||||
|
"""
|
||||||
|
from codex.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
transport = settings.mcp_transport.lower()
|
||||||
|
|
||||||
|
if transport == "http":
|
||||||
|
import uvicorn
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
token = _require_http_token()
|
||||||
|
|
||||||
|
class _TokenAuth(BaseHTTPMiddleware):
|
||||||
|
"""Reject requests that do not carry a valid Bearer token."""
|
||||||
|
|
||||||
|
async def dispatch(
|
||||||
|
self, request: Request, call_next: RequestResponseEndpoint
|
||||||
|
) -> Response:
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
if auth != f"Bearer {token}":
|
||||||
|
return Response("Unauthorized", status_code=401)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
base_app = mcp.streamable_http_app()
|
||||||
|
base_app.add_middleware(_TokenAuth)
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
base_app,
|
||||||
|
host=settings.mcp_host,
|
||||||
|
port=settings.mcp_port,
|
||||||
|
log_level="info",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Default: stdio transport
|
||||||
|
mcp.run(transport="stdio")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -78,3 +78,39 @@ class CodeLink:
|
|||||||
note: str | None = None
|
note: str | None = None
|
||||||
id: int | None = None
|
id: int | None = None
|
||||||
added_at: datetime | None = None
|
added_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FormulaChunk:
|
||||||
|
"""Maps to the ``formulas`` table (F-09 Rich Parsing).
|
||||||
|
|
||||||
|
Stores a single extracted mathematical formula (LaTeX) from a PDF page.
|
||||||
|
``id`` is set by the database (BIGSERIAL).
|
||||||
|
``embedding`` is reserved for future pgvector similarity search.
|
||||||
|
"""
|
||||||
|
|
||||||
|
paper_id: str
|
||||||
|
page: int
|
||||||
|
raw_latex: str
|
||||||
|
context: str
|
||||||
|
id: int | None = None
|
||||||
|
eq_label: str | None = None
|
||||||
|
embedding: list[float] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FigureChunk:
|
||||||
|
"""Maps to the ``figures`` table (F-09 Rich Parsing).
|
||||||
|
|
||||||
|
Stores metadata for a single extracted figure from a PDF page.
|
||||||
|
``image_path`` points to the saved PNG on disk.
|
||||||
|
``id`` is set by the database (BIGSERIAL).
|
||||||
|
``embedding`` is reserved for future pgvector similarity search.
|
||||||
|
"""
|
||||||
|
|
||||||
|
paper_id: str
|
||||||
|
page: int
|
||||||
|
image_path: str
|
||||||
|
caption: str
|
||||||
|
id: int | None = None
|
||||||
|
embedding: list[float] | None = None
|
||||||
|
|||||||
169
codex/parsing/figures.py
Normal file
169
codex/parsing/figures.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""Figure extraction from PDF pages.
|
||||||
|
|
||||||
|
Uses PyMuPDF (``fitz``) to iterate over embedded images in each page,
|
||||||
|
render them to PNG and detect nearby captions using text-block heuristics.
|
||||||
|
|
||||||
|
The public entry point is :func:`extract_figures`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from codex.models import FigureChunk
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Caption prefixes (case-insensitive check against text block content).
|
||||||
|
_CAPTION_PREFIXES: tuple[str, ...] = ("figure", "fig.", "abbildung")
|
||||||
|
|
||||||
|
# Maximum vertical distance (in points) from the image bottom or top
|
||||||
|
# to consider a text block as the caption.
|
||||||
|
_CAPTION_TOLERANCE_Y = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_caption(text: str) -> bool:
|
||||||
|
"""Return True if *text* starts with a known caption prefix."""
|
||||||
|
lowered = text.strip().lower()
|
||||||
|
return any(lowered.startswith(prefix) for prefix in _CAPTION_PREFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_caption(
|
||||||
|
img_rect: Any, # fitz.Rect
|
||||||
|
text_blocks: list[Any],
|
||||||
|
) -> str:
|
||||||
|
"""Find the closest caption text block to *img_rect*.
|
||||||
|
|
||||||
|
Searches text blocks that:
|
||||||
|
1. Start with a caption prefix (e.g. "Figure", "Fig.", "Abbildung").
|
||||||
|
2. Are within ``_CAPTION_TOLERANCE_Y`` points above or below the image.
|
||||||
|
|
||||||
|
Returns the first matching text, stripped; empty string if none found.
|
||||||
|
"""
|
||||||
|
best: str = ""
|
||||||
|
best_dist = float("inf")
|
||||||
|
|
||||||
|
for block in text_blocks:
|
||||||
|
if len(block) < 5:
|
||||||
|
continue
|
||||||
|
bx0, by0, bx1, by1 = block[0], block[1], block[2], block[3]
|
||||||
|
text: str = block[4]
|
||||||
|
|
||||||
|
if not _looks_like_caption(text):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Distance: gap below image or gap above image
|
||||||
|
gap_below = by0 - img_rect.y1
|
||||||
|
gap_above = img_rect.y0 - by1
|
||||||
|
|
||||||
|
if 0 <= gap_below <= _CAPTION_TOLERANCE_Y:
|
||||||
|
dist = gap_below
|
||||||
|
elif 0 <= gap_above <= _CAPTION_TOLERANCE_Y:
|
||||||
|
dist = gap_above
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Horizontal overlap sanity check (caption should overlap with image)
|
||||||
|
overlap = min(bx1, img_rect.x1) - max(bx0, img_rect.x0)
|
||||||
|
if overlap < 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if dist < best_dist:
|
||||||
|
best_dist = dist
|
||||||
|
best = text.replace("\n", " ").strip()
|
||||||
|
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def extract_figures(
|
||||||
|
pdf_path: str,
|
||||||
|
output_dir: str,
|
||||||
|
) -> list[FigureChunk]:
|
||||||
|
"""Extract embedded figures from *pdf_path* and save them as PNG files.
|
||||||
|
|
||||||
|
For each image on each page:
|
||||||
|
1. Extract the image via ``page.get_images(full=True)``.
|
||||||
|
2. Render the image as a PNG via ``fitz.Pixmap``.
|
||||||
|
3. Search nearby text blocks for a caption (``Figure …`` / ``Fig. …`` /
|
||||||
|
``Abbildung …``).
|
||||||
|
4. Save the PNG to ``<output_dir>/<paper_id>_p<page>_fig<n>.png``.
|
||||||
|
5. Yield a :class:`~codex.models.FigureChunk`.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
pdf_path:
|
||||||
|
Path to the source PDF.
|
||||||
|
output_dir:
|
||||||
|
Directory where PNG images are written (created if absent).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list[FigureChunk]
|
||||||
|
Extracted figure metadata; empty list if no images found.
|
||||||
|
"""
|
||||||
|
import fitz
|
||||||
|
|
||||||
|
results: list[FigureChunk] = []
|
||||||
|
paper_id = Path(pdf_path).stem
|
||||||
|
out_dir = Path(output_dir)
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
doc: Any = fitz.open(pdf_path)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("fitz.open failed for %s", pdf_path)
|
||||||
|
return results
|
||||||
|
|
||||||
|
try:
|
||||||
|
for page_num in range(len(doc)):
|
||||||
|
page = doc[page_num]
|
||||||
|
images: list[Any] = page.get_images(full=True)
|
||||||
|
text_blocks: list[Any] = page.get_text("blocks")
|
||||||
|
|
||||||
|
for fig_idx, img_info in enumerate(images):
|
||||||
|
xref: int = img_info[0]
|
||||||
|
|
||||||
|
# Get image bounding box on the page
|
||||||
|
img_rects: list[Any] = page.get_image_rects(xref)
|
||||||
|
if not img_rects:
|
||||||
|
continue
|
||||||
|
img_rect = img_rects[0]
|
||||||
|
|
||||||
|
# Extract and save the image pixmap
|
||||||
|
try:
|
||||||
|
pix: Any = fitz.Pixmap(doc, xref)
|
||||||
|
if pix.n > 4: # CMYK or similar — convert to RGB
|
||||||
|
pix = fitz.Pixmap(fitz.csRGB, pix)
|
||||||
|
filename = f"{paper_id}_p{page_num + 1}_fig{fig_idx + 1}.png"
|
||||||
|
img_path = out_dir / filename
|
||||||
|
pix.save(str(img_path))
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not extract image xref=%d on page %d", xref, page_num + 1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
caption = _find_caption(img_rect, text_blocks)
|
||||||
|
|
||||||
|
results.append(
|
||||||
|
FigureChunk(
|
||||||
|
paper_id=paper_id,
|
||||||
|
page=page_num + 1,
|
||||||
|
image_path=str(img_path),
|
||||||
|
caption=caption,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
doc.close()
|
||||||
|
|
||||||
|
return results
|
||||||
134
codex/parsing/grobid.py
Normal file
134
codex/parsing/grobid.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""GROBID integration.
|
||||||
|
|
||||||
|
Extracts structured reference lists and full-text TEI XML from PDFs by
|
||||||
|
calling a self-hosted GROBID HTTP server. No DB access, no embedding,
|
||||||
|
no network fetching of papers happens here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from codex.config import Settings
|
||||||
|
|
||||||
|
_TEI_NS = "{http://www.tei-c.org/ns/1.0}"
|
||||||
|
|
||||||
|
|
||||||
|
def _text(element: ET.Element | None) -> str:
|
||||||
|
"""Return element text or empty string if element is None."""
|
||||||
|
if element is None:
|
||||||
|
return ""
|
||||||
|
return (element.text or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def extract_references(
|
||||||
|
pdf_path: str,
|
||||||
|
grobid_url: str | None = None,
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""Extract a structured reference list from a PDF via GROBID.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
pdf_path:
|
||||||
|
Path to the PDF file on disk.
|
||||||
|
grobid_url:
|
||||||
|
Base URL of the GROBID server. Defaults to ``Settings().grobid_url``.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list[dict[str, str]]
|
||||||
|
One dict per reference. All dicts contain the keys
|
||||||
|
``title``, ``authors``, ``year``, ``doi``, ``arxiv_id``
|
||||||
|
(missing values are empty strings).
|
||||||
|
"""
|
||||||
|
if grobid_url is None:
|
||||||
|
grobid_url = Settings().grobid_url
|
||||||
|
|
||||||
|
with open(pdf_path, "rb") as fh, httpx.Client(timeout=60.0) as client:
|
||||||
|
response = client.post(
|
||||||
|
f"{grobid_url}/api/processReferences",
|
||||||
|
files={"input": (pdf_path, fh, "application/pdf")},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
root = ET.fromstring(response.text)
|
||||||
|
results: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
for bib in root.iter(f"{_TEI_NS}biblStruct"):
|
||||||
|
# Title
|
||||||
|
title_el = bib.find(f".//{_TEI_NS}title[@level='a']")
|
||||||
|
title = _text(title_el)
|
||||||
|
|
||||||
|
# Authors: collect all persName elements
|
||||||
|
authors_parts: list[str] = []
|
||||||
|
for person in bib.iter(f"{_TEI_NS}persName"):
|
||||||
|
forename_el = person.find(f"{_TEI_NS}forename")
|
||||||
|
surname_el = person.find(f"{_TEI_NS}surname")
|
||||||
|
forename = _text(forename_el)
|
||||||
|
surname = _text(surname_el)
|
||||||
|
full = " ".join(p for p in (forename, surname) if p)
|
||||||
|
if full:
|
||||||
|
authors_parts.append(full)
|
||||||
|
authors = "; ".join(authors_parts)
|
||||||
|
|
||||||
|
# Year
|
||||||
|
date_el = bib.find(f".//{_TEI_NS}date[@type='published']")
|
||||||
|
year = ""
|
||||||
|
if date_el is not None:
|
||||||
|
when = date_el.get("when", "")
|
||||||
|
year = when[:4] if when else ""
|
||||||
|
|
||||||
|
# DOI
|
||||||
|
doi_el = bib.find(f".//{_TEI_NS}idno[@type='DOI']")
|
||||||
|
doi = _text(doi_el)
|
||||||
|
|
||||||
|
# arXiv ID — GROBID emits type="arXiv" (camel-case); match case-insensitively
|
||||||
|
arxiv_id = ""
|
||||||
|
for idno_el in bib.iter(f"{_TEI_NS}idno"):
|
||||||
|
if idno_el.get("type", "").lower() == "arxiv":
|
||||||
|
arxiv_id = (idno_el.text or "").strip()
|
||||||
|
break
|
||||||
|
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"title": title,
|
||||||
|
"authors": authors,
|
||||||
|
"year": year,
|
||||||
|
"doi": doi,
|
||||||
|
"arxiv_id": arxiv_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def extract_structure(
|
||||||
|
pdf_path: str,
|
||||||
|
grobid_url: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Extract full-text TEI XML from a PDF via GROBID.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
pdf_path:
|
||||||
|
Path to the PDF file on disk.
|
||||||
|
grobid_url:
|
||||||
|
Base URL of the GROBID server. Defaults to ``Settings().grobid_url``.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
str
|
||||||
|
Raw TEI XML response text from GROBID.
|
||||||
|
"""
|
||||||
|
if grobid_url is None:
|
||||||
|
grobid_url = Settings().grobid_url
|
||||||
|
|
||||||
|
with open(pdf_path, "rb") as fh, httpx.Client(timeout=120.0) as client:
|
||||||
|
response = client.post(
|
||||||
|
f"{grobid_url}/api/processFulltextDocument",
|
||||||
|
files={"input": (pdf_path, fh, "application/pdf")},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.text
|
||||||
317
codex/parsing/mathpix.py
Normal file
317
codex/parsing/mathpix.py
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
"""Formula extraction from PDF pages.
|
||||||
|
|
||||||
|
Two backends are supported:
|
||||||
|
|
||||||
|
1. **MathPix** (cloud): POST the PDF to ``api.mathpix.com/v3/pdf`` and poll
|
||||||
|
for the result JSON. Requires ``MATHPIX_APP_ID`` and ``MATHPIX_APP_KEY``
|
||||||
|
environment variables (or ``.env`` file).
|
||||||
|
|
||||||
|
2. **pix2tex** (local, MIT licence): crop each detected math bounding-box at
|
||||||
|
3× resolution and run the ``LatexOCR`` model locally. Used when MathPix
|
||||||
|
credentials are absent. Model (~116 MB) is downloaded on first call.
|
||||||
|
|
||||||
|
The public entry point :func:`extract_formulas` routes to whichever backend
|
||||||
|
is available.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import unicodedata
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from codex.config import get_settings
|
||||||
|
from codex.models import FormulaChunk
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Unicode categories that indicate mathematical symbols.
|
||||||
|
_MATH_CATEGORIES: frozenset[str] = frozenset({"Sm", "So"})
|
||||||
|
|
||||||
|
# Bbox size thresholds — avoids cropping tiny index fragments.
|
||||||
|
_MIN_HEIGHT_PT = 15.0
|
||||||
|
_MIN_WIDTH_PT = 40.0
|
||||||
|
# Minimum number of math-category characters in a text block before we
|
||||||
|
# consider it a formula candidate.
|
||||||
|
_MIN_MATH_SYMBOLS = 5
|
||||||
|
# Minimum ratio of math/total chars (as float 0–1).
|
||||||
|
_MIN_MATH_RATIO = 0.15
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _is_math_char(ch: str) -> bool:
|
||||||
|
"""Return True if *ch* belongs to a mathematical Unicode category."""
|
||||||
|
return unicodedata.category(ch) in _MATH_CATEGORIES
|
||||||
|
|
||||||
|
|
||||||
|
def _math_ratio(text: str) -> float:
|
||||||
|
"""Return the fraction of characters in *text* that are math chars."""
|
||||||
|
if not text:
|
||||||
|
return 0.0
|
||||||
|
math_count = sum(1 for ch in text if _is_math_char(ch))
|
||||||
|
return math_count / len(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _math_symbol_count(text: str) -> int:
|
||||||
|
"""Return the absolute count of math-category characters in *text*."""
|
||||||
|
return sum(1 for ch in text if _is_math_char(ch))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# pix2tex singleton
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_pix2tex_model: Any = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_pix2tex_model() -> Any:
|
||||||
|
"""Lazily load and return the pix2tex LatexOCR singleton."""
|
||||||
|
global _pix2tex_model # noqa: PLW0603
|
||||||
|
if _pix2tex_model is None:
|
||||||
|
from pix2tex.cli import LatexOCR
|
||||||
|
|
||||||
|
_pix2tex_model = LatexOCR()
|
||||||
|
return _pix2tex_model
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# pix2tex backend
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_formulas_pix2tex(pdf_path: str) -> list[FormulaChunk]:
|
||||||
|
"""Extract LaTeX formulas from *pdf_path* using pix2tex (local OCR).
|
||||||
|
|
||||||
|
Algorithm
|
||||||
|
---------
|
||||||
|
1. Open the PDF with PyMuPDF (fitz).
|
||||||
|
2. For each page, gather text blocks; keep only blocks whose
|
||||||
|
math-symbol ratio exceeds the threshold and whose bounding box
|
||||||
|
is larger than the minimum size.
|
||||||
|
3. Crop the region at 3× resolution (better OCR quality).
|
||||||
|
4. Run pix2tex ``LatexOCR`` on the cropped image.
|
||||||
|
5. Wrap the result in a :class:`~codex.models.FormulaChunk`.
|
||||||
|
"""
|
||||||
|
import fitz
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
results: list[FormulaChunk] = []
|
||||||
|
paper_id = Path(pdf_path).stem
|
||||||
|
model = _get_pix2tex_model()
|
||||||
|
|
||||||
|
try:
|
||||||
|
doc: Any = fitz.open(pdf_path)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("fitz.open failed for %s", pdf_path)
|
||||||
|
return results
|
||||||
|
|
||||||
|
scale = fitz.Matrix(3, 3)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for page_num in range(len(doc)):
|
||||||
|
page = doc[page_num]
|
||||||
|
blocks: list[Any] = page.get_text("blocks")
|
||||||
|
|
||||||
|
for block in blocks:
|
||||||
|
# block = (x0, y0, x1, y1, text, block_no, block_type)
|
||||||
|
if len(block) < 5:
|
||||||
|
continue
|
||||||
|
x0, y0, x1, y1 = block[0], block[1], block[2], block[3]
|
||||||
|
text: str = block[4]
|
||||||
|
|
||||||
|
# Size filter
|
||||||
|
width_pt = x1 - x0
|
||||||
|
height_pt = y1 - y0
|
||||||
|
if width_pt < _MIN_WIDTH_PT or height_pt < _MIN_HEIGHT_PT:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Math content filter
|
||||||
|
if (
|
||||||
|
_math_symbol_count(text) < _MIN_MATH_SYMBOLS
|
||||||
|
or _math_ratio(text) < _MIN_MATH_RATIO
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Crop at 3× for OCR quality
|
||||||
|
clip_rect = fitz.Rect(x0, y0, x1, y1)
|
||||||
|
crop_pix = page.get_pixmap(matrix=scale, clip=clip_rect)
|
||||||
|
img = Image.frombytes(
|
||||||
|
"RGB",
|
||||||
|
(crop_pix.width, crop_pix.height),
|
||||||
|
crop_pix.samples,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
latex: str = model(img)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("pix2tex failed on page %d block, skipping", page_num + 1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not latex or not latex.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
context = text[:200].replace("\n", " ")
|
||||||
|
results.append(
|
||||||
|
FormulaChunk(
|
||||||
|
paper_id=paper_id,
|
||||||
|
page=page_num + 1,
|
||||||
|
raw_latex=latex.strip(),
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
doc.close()
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MathPix backend
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_formulas_mathpix(
|
||||||
|
pdf_path: str,
|
||||||
|
app_id: str,
|
||||||
|
app_key: str,
|
||||||
|
) -> list[FormulaChunk]:
|
||||||
|
"""Extract LaTeX formulas using the MathPix v3 PDF API.
|
||||||
|
|
||||||
|
The function uploads the PDF, polls for results and parses the
|
||||||
|
returned ``latex_simplified`` list into :class:`~codex.models.FormulaChunk`
|
||||||
|
objects.
|
||||||
|
|
||||||
|
Retries with exponential back-off (via ``tenacity``) on transient HTTP
|
||||||
|
errors.
|
||||||
|
"""
|
||||||
|
import httpx
|
||||||
|
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||||
|
|
||||||
|
paper_id = Path(pdf_path).stem
|
||||||
|
headers = {
|
||||||
|
"app_id": app_id,
|
||||||
|
"app_key": app_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
@retry(
|
||||||
|
stop=stop_after_attempt(4),
|
||||||
|
wait=wait_exponential(multiplier=1, min=2, max=30),
|
||||||
|
reraise=True,
|
||||||
|
)
|
||||||
|
def _post_pdf() -> dict[str, Any]:
|
||||||
|
with open(pdf_path, "rb") as fh:
|
||||||
|
resp = httpx.post(
|
||||||
|
"https://api.mathpix.com/v3/pdf",
|
||||||
|
headers=headers,
|
||||||
|
files={"file": fh},
|
||||||
|
timeout=60.0,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json() # type: ignore[no-any-return]
|
||||||
|
|
||||||
|
submit_response = _post_pdf()
|
||||||
|
pdf_id: str = submit_response.get("pdf_id", "")
|
||||||
|
if not pdf_id:
|
||||||
|
logger.warning("MathPix did not return a pdf_id — aborting")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Poll until complete
|
||||||
|
import time
|
||||||
|
|
||||||
|
for _ in range(60):
|
||||||
|
poll_resp = httpx.get(
|
||||||
|
f"https://api.mathpix.com/v3/pdf/{pdf_id}.mmd",
|
||||||
|
headers=headers,
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
if poll_resp.status_code == 200:
|
||||||
|
break
|
||||||
|
time.sleep(2)
|
||||||
|
else:
|
||||||
|
logger.warning("MathPix polling timed out for pdf_id=%s", pdf_id)
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Parse response — MathPix MMD contains \[ ... \] or $...$ blocks
|
||||||
|
mmd_text: str = poll_resp.text
|
||||||
|
results: list[FormulaChunk] = []
|
||||||
|
lines = mmd_text.splitlines()
|
||||||
|
in_block = False
|
||||||
|
block_lines: list[str] = []
|
||||||
|
page = 1
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
if line.strip().startswith("\\["):
|
||||||
|
in_block = True
|
||||||
|
block_lines = [line]
|
||||||
|
elif in_block:
|
||||||
|
block_lines.append(line)
|
||||||
|
if line.strip().endswith("\\]"):
|
||||||
|
latex = "\n".join(block_lines).strip()
|
||||||
|
results.append(
|
||||||
|
FormulaChunk(
|
||||||
|
paper_id=paper_id,
|
||||||
|
page=page,
|
||||||
|
raw_latex=latex,
|
||||||
|
context="",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
in_block = False
|
||||||
|
block_lines = []
|
||||||
|
# crude page tracking via MMD page markers
|
||||||
|
if line.strip().startswith("<!-- Page"):
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def extract_formulas(
|
||||||
|
pdf_path: str,
|
||||||
|
mathpix_app_id: str | None = None,
|
||||||
|
mathpix_app_key: str | None = None,
|
||||||
|
) -> list[FormulaChunk]:
|
||||||
|
"""Extract mathematical formulas from *pdf_path*.
|
||||||
|
|
||||||
|
Routes to the MathPix cloud backend when ``mathpix_app_id`` and
|
||||||
|
``mathpix_app_key`` are both non-empty; otherwise falls back to
|
||||||
|
the local pix2tex model (if ``PIX2TEX_FALLBACK=true``, which is
|
||||||
|
the default).
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
pdf_path:
|
||||||
|
Absolute or relative path to the PDF file.
|
||||||
|
mathpix_app_id:
|
||||||
|
Override for ``MATHPIX_APP_ID`` setting (optional).
|
||||||
|
mathpix_app_key:
|
||||||
|
Override for ``MATHPIX_APP_KEY`` setting (optional).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list[FormulaChunk]
|
||||||
|
Extracted formulas; empty list if extraction is disabled or fails.
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
app_id = mathpix_app_id or settings.mathpix_app_id or ""
|
||||||
|
app_key = mathpix_app_key or settings.mathpix_app_key or ""
|
||||||
|
|
||||||
|
if app_id and app_key:
|
||||||
|
logger.info("Using MathPix backend for %s", pdf_path)
|
||||||
|
return _extract_formulas_mathpix(pdf_path, app_id, app_key)
|
||||||
|
|
||||||
|
if settings.pix2tex_fallback:
|
||||||
|
logger.info("Using pix2tex fallback backend for %s", pdf_path)
|
||||||
|
return _extract_formulas_pix2tex(pdf_path)
|
||||||
|
|
||||||
|
logger.info("Formula extraction disabled (no creds, pix2tex_fallback=False)")
|
||||||
|
return []
|
||||||
54
codex/parsing/nougat.py
Normal file
54
codex/parsing/nougat.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""Nougat OCR integration.
|
||||||
|
|
||||||
|
Converts PDF files to Mathpix Markdown (mmd) by calling a self-hosted
|
||||||
|
Nougat HTTP server. No network fetching of papers happens here — the
|
||||||
|
caller is expected to pass a local PDF path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import tenacity
|
||||||
|
|
||||||
|
from codex.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def pdf_to_markdown(pdf_path: str, nougat_url: str | None = None) -> str:
|
||||||
|
"""Convert a local PDF to Mathpix Markdown via the Nougat HTTP API.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
pdf_path:
|
||||||
|
Absolute (or relative) path to the PDF file on disk.
|
||||||
|
nougat_url:
|
||||||
|
Base URL of the Nougat server. Defaults to ``Settings().nougat_url``.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
str
|
||||||
|
The raw ``.mmd`` text returned by the server.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
httpx.HTTPStatusError
|
||||||
|
If the server returns a non-2xx status code.
|
||||||
|
"""
|
||||||
|
if nougat_url is None:
|
||||||
|
nougat_url = Settings().nougat_url
|
||||||
|
|
||||||
|
@tenacity.retry(
|
||||||
|
retry=tenacity.retry_if_exception_type(httpx.ConnectError),
|
||||||
|
stop=tenacity.stop_after_attempt(3), # 1 original + 2 retries
|
||||||
|
wait=tenacity.wait_fixed(0),
|
||||||
|
reraise=True,
|
||||||
|
)
|
||||||
|
def _post() -> str:
|
||||||
|
with open(pdf_path, "rb") as fh, httpx.Client(timeout=120.0) as client:
|
||||||
|
response = client.post(
|
||||||
|
f"{nougat_url}/predict",
|
||||||
|
files={"file": (pdf_path, fh, "application/pdf")},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.text
|
||||||
|
|
||||||
|
return _post()
|
||||||
159
codex/parsing/tex.py
Normal file
159
codex/parsing/tex.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"""LaTeX parsing utilities.
|
||||||
|
|
||||||
|
Provides helpers to extract sections, chunk text, and convert LaTeX to plain
|
||||||
|
readable prose by stripping markup that is not useful for NLP/embedding.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SECTION_RE = re.compile(
|
||||||
|
r"\\(?:sub)*section\*?\s*\{([^}]*)\}",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Patterns for LaTeX noise removal (applied in order).
|
||||||
|
_COMMENT_RE = re.compile(r"%[^\n]*")
|
||||||
|
_CITE_RE = re.compile(r"\\cite\*?\{[^}]*\}")
|
||||||
|
_LABEL_RE = re.compile(r"\\label\{[^}]*\}")
|
||||||
|
_REF_RE = re.compile(r"\\ref\{[^}]*\}")
|
||||||
|
# Display math: $$...$$ (before single $ to avoid greedy mismatch)
|
||||||
|
_DISPLAY_DOLLAR_RE = re.compile(r"\$\$.*?\$\$", re.DOTALL)
|
||||||
|
# Inline math: $...$
|
||||||
|
_INLINE_MATH_RE = re.compile(r"\$[^$\n]*?\$", re.DOTALL)
|
||||||
|
# \begin{equation}...\end{equation}
|
||||||
|
_ENV_EQUATION_RE = re.compile(
|
||||||
|
r"\\begin\{equation\*?\}.*?\\end\{equation\*?\}",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
# \begin{align}...\end{align} (covers align, align*, aligned, …)
|
||||||
|
_ENV_ALIGN_RE = re.compile(
|
||||||
|
r"\\begin\{align[^}]*\}.*?\\end\{align[^}]*\}",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
# Collapse excess whitespace
|
||||||
|
_WHITESPACE_RE = re.compile(r"[ \t]+")
|
||||||
|
_BLANK_LINES_RE = re.compile(r"\n{3,}")
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_latex(text: str) -> str:
|
||||||
|
"""Strip LaTeX markup and return readable prose."""
|
||||||
|
text = _COMMENT_RE.sub("", text)
|
||||||
|
text = _ENV_EQUATION_RE.sub("", text)
|
||||||
|
text = _ENV_ALIGN_RE.sub("", text)
|
||||||
|
text = _DISPLAY_DOLLAR_RE.sub("", text)
|
||||||
|
text = _INLINE_MATH_RE.sub("", text)
|
||||||
|
text = _CITE_RE.sub("", text)
|
||||||
|
text = _LABEL_RE.sub("", text)
|
||||||
|
text = _REF_RE.sub("", text)
|
||||||
|
text = _WHITESPACE_RE.sub(" ", text)
|
||||||
|
text = _BLANK_LINES_RE.sub("\n\n", text)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def extract_sections(latex: str) -> list[tuple[str, str]]:
|
||||||
|
"""Split *latex* on \\section / \\subsection boundaries.
|
||||||
|
|
||||||
|
Returns a list of ``(title, cleaned_text)`` tuples, one per section.
|
||||||
|
Text between the document start and the first section command is
|
||||||
|
discarded (preamble / abstract handling is out of scope).
|
||||||
|
"""
|
||||||
|
# Find all section positions
|
||||||
|
matches = list(_SECTION_RE.finditer(latex))
|
||||||
|
if not matches:
|
||||||
|
return []
|
||||||
|
|
||||||
|
sections: list[tuple[str, str]] = []
|
||||||
|
for idx, match in enumerate(matches):
|
||||||
|
title = match.group(1).strip()
|
||||||
|
body_start = match.end()
|
||||||
|
body_end = matches[idx + 1].start() if idx + 1 < len(matches) else len(latex)
|
||||||
|
body = latex[body_start:body_end]
|
||||||
|
cleaned = _clean_latex(body)
|
||||||
|
sections.append((title, cleaned))
|
||||||
|
|
||||||
|
return sections
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_text(text: str, size: int = 512, overlap: int = 64) -> list[str]:
|
||||||
|
"""Split *text* into overlapping word-based chunks.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
text:
|
||||||
|
Plain text to chunk (not raw LaTeX).
|
||||||
|
size:
|
||||||
|
Target number of words per chunk.
|
||||||
|
overlap:
|
||||||
|
Number of words to carry over into the next chunk.
|
||||||
|
|
||||||
|
Each chunk is at most ``size + overlap`` words. Boundaries are snapped
|
||||||
|
to the nearest ``". "`` (sentence end) within ±20 words of the nominal
|
||||||
|
boundary when possible.
|
||||||
|
"""
|
||||||
|
words = text.split()
|
||||||
|
if not words:
|
||||||
|
return []
|
||||||
|
|
||||||
|
snap_window = 20
|
||||||
|
chunks: list[str] = []
|
||||||
|
start = 0
|
||||||
|
|
||||||
|
while start < len(words):
|
||||||
|
end = min(start + size, len(words))
|
||||||
|
|
||||||
|
# Try to snap *end* to a sentence boundary within ±snap_window words.
|
||||||
|
if end < len(words):
|
||||||
|
best = end
|
||||||
|
# Build a small search window
|
||||||
|
lo = max(start + 1, end - snap_window)
|
||||||
|
hi = min(len(words), end + snap_window + 1)
|
||||||
|
# Prefer the closest sentence-end ". " to *end*
|
||||||
|
for offset in range(0, snap_window + 1):
|
||||||
|
for candidate in (end - offset, end + offset):
|
||||||
|
if (
|
||||||
|
lo <= candidate < hi
|
||||||
|
and candidate > start
|
||||||
|
and words[candidate - 1].endswith(".")
|
||||||
|
):
|
||||||
|
# Sentence boundary: word at (candidate-1) ends with ".".
|
||||||
|
best = candidate
|
||||||
|
break
|
||||||
|
if best != end:
|
||||||
|
break
|
||||||
|
end = best
|
||||||
|
|
||||||
|
chunk_words = words[start:end]
|
||||||
|
chunks.append(" ".join(chunk_words))
|
||||||
|
|
||||||
|
if end >= len(words):
|
||||||
|
break
|
||||||
|
|
||||||
|
# Next chunk starts *overlap* words before *end*.
|
||||||
|
start = max(start + 1, end - overlap)
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def latex_to_text(latex: str) -> str:
|
||||||
|
"""Convert a LaTeX document to plain text.
|
||||||
|
|
||||||
|
Extracts all sections, cleans each one, and joins them with a blank line.
|
||||||
|
If no section commands are found, the whole document is cleaned and
|
||||||
|
returned as a single block.
|
||||||
|
"""
|
||||||
|
sections = extract_sections(latex)
|
||||||
|
if sections:
|
||||||
|
return "\n\n".join(body for _, body in sections)
|
||||||
|
# Fallback: no section structure — clean the whole string.
|
||||||
|
return _clean_latex(latex)
|
||||||
184
codex/provenance.py
Normal file
184
codex/provenance.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
"""Provenance tracking: @cite-scan, code_links CRUD, references.bib export."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from codex.db import get_conn
|
||||||
|
from codex.models import CodeLink
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# @cite scanning
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_CITE_RE = re.compile(r"@cite\s+(\S+)", re.IGNORECASE)
|
||||||
|
_FUNC_RE = re.compile(r"(\w+)\s*\(")
|
||||||
|
_CLASS_RE = re.compile(r"class\s+(\w+)")
|
||||||
|
|
||||||
|
_CPP_SUFFIXES = {".cpp", ".h", ".cxx", ".hpp"}
|
||||||
|
|
||||||
|
|
||||||
|
def scan_cite_tags(root_dir: str) -> list[dict[str, str]]:
|
||||||
|
"""Recursively scan C++ source files for @cite tags (Doxygen-style).
|
||||||
|
|
||||||
|
Returns one dict per hit:
|
||||||
|
file (str) — relative path to the source file
|
||||||
|
line (int as str) — 1-based line number
|
||||||
|
symbol (str) — function or class name (extracted from context, best-effort)
|
||||||
|
bibkey (str) — the @cite argument
|
||||||
|
|
||||||
|
Scans files matching: *.cpp, *.h, *.cxx, *.hpp
|
||||||
|
Pattern matched: ``@cite <bibkey>`` (case-insensitive, optional whitespace)
|
||||||
|
"""
|
||||||
|
root = Path(root_dir)
|
||||||
|
results: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
for path in sorted(root.rglob("*")):
|
||||||
|
if path.suffix.lower() not in _CPP_SUFFIXES:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
rel_path = str(path.relative_to(root))
|
||||||
|
|
||||||
|
for lineno, line in enumerate(lines, start=1):
|
||||||
|
for match in _CITE_RE.finditer(line):
|
||||||
|
bibkey = match.group(1)
|
||||||
|
# Best-effort symbol extraction: scan backwards for func or class
|
||||||
|
symbol = ""
|
||||||
|
context_lines = lines[: lineno - 1]
|
||||||
|
for prev_line in reversed(context_lines):
|
||||||
|
func_match = _FUNC_RE.search(prev_line)
|
||||||
|
class_match = _CLASS_RE.search(prev_line)
|
||||||
|
if func_match:
|
||||||
|
symbol = func_match.group(1)
|
||||||
|
break
|
||||||
|
if class_match:
|
||||||
|
symbol = class_match.group(1)
|
||||||
|
break
|
||||||
|
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"file": rel_path,
|
||||||
|
"line": str(lineno),
|
||||||
|
"symbol": symbol,
|
||||||
|
"bibkey": bibkey,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# code_links CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def add_code_link(
|
||||||
|
symbol: str,
|
||||||
|
paper_id: str | None = None,
|
||||||
|
role: str | None = None,
|
||||||
|
note: str | None = None,
|
||||||
|
) -> CodeLink:
|
||||||
|
"""Insert a new code_link row and return the populated CodeLink (with db-assigned id)."""
|
||||||
|
sql = """
|
||||||
|
INSERT INTO code_links (symbol, paper_id, role, note)
|
||||||
|
VALUES (%(symbol)s, %(paper_id)s, %(role)s, %(note)s)
|
||||||
|
RETURNING id, added_at
|
||||||
|
"""
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
sql,
|
||||||
|
{"symbol": symbol, "paper_id": paper_id, "role": role, "note": note},
|
||||||
|
).fetchone()
|
||||||
|
conn.commit()
|
||||||
|
assert row is not None
|
||||||
|
return CodeLink(
|
||||||
|
symbol=symbol,
|
||||||
|
paper_id=paper_id,
|
||||||
|
role=role,
|
||||||
|
note=note,
|
||||||
|
id=row["id"],
|
||||||
|
added_at=row["added_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_code_links(paper_id: str | None = None) -> list[CodeLink]:
|
||||||
|
"""Return all code_links, optionally filtered by paper_id."""
|
||||||
|
if paper_id is not None:
|
||||||
|
sql = (
|
||||||
|
"SELECT id, symbol, paper_id, role, note, added_at"
|
||||||
|
" FROM code_links WHERE paper_id = %(paper_id)s"
|
||||||
|
)
|
||||||
|
params: dict[str, str] = {"paper_id": paper_id}
|
||||||
|
else:
|
||||||
|
sql = "SELECT id, symbol, paper_id, role, note, added_at FROM code_links"
|
||||||
|
params = {}
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(sql, params).fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
CodeLink(
|
||||||
|
symbol=row["symbol"],
|
||||||
|
paper_id=row["paper_id"],
|
||||||
|
role=row["role"],
|
||||||
|
note=row["note"],
|
||||||
|
id=row["id"],
|
||||||
|
added_at=row["added_at"],
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# references.bib export
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def export_bib(paper_ids: list[str] | None = None) -> str:
|
||||||
|
"""Export papers as a BibTeX string.
|
||||||
|
|
||||||
|
If paper_ids is None, exports ALL papers with a non-NULL bibkey.
|
||||||
|
Only papers with a non-NULL bibkey are included (bibkey is the BibTeX key).
|
||||||
|
|
||||||
|
Entry format (article):
|
||||||
|
@article{<bibkey>,
|
||||||
|
title = {<title>},
|
||||||
|
author = {<authors joined with " and ">},
|
||||||
|
year = {<year>},
|
||||||
|
note = {<id>},
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if paper_ids is not None:
|
||||||
|
sql = (
|
||||||
|
"SELECT id, bibkey, title, authors, year"
|
||||||
|
" FROM papers"
|
||||||
|
" WHERE bibkey IS NOT NULL AND id = ANY(%(paper_ids)s)"
|
||||||
|
)
|
||||||
|
params: dict[str, list[str]] = {"paper_ids": paper_ids}
|
||||||
|
else:
|
||||||
|
sql = "SELECT id, bibkey, title, authors, year FROM papers WHERE bibkey IS NOT NULL"
|
||||||
|
params = {}
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(sql, params).fetchall()
|
||||||
|
|
||||||
|
entries: list[str] = []
|
||||||
|
for row in rows:
|
||||||
|
authors: list[str] = row["authors"] or []
|
||||||
|
author_str = " and ".join(authors) if authors else ""
|
||||||
|
entry = (
|
||||||
|
f"@article{{{row['bibkey']},\n"
|
||||||
|
f" title = {{{row['title']}}},\n"
|
||||||
|
f" author = {{{author_str}}},\n"
|
||||||
|
f" year = {{{row['year']}}},\n"
|
||||||
|
f" note = {{{row['id']}}},\n"
|
||||||
|
f"}}"
|
||||||
|
)
|
||||||
|
entries.append(entry)
|
||||||
|
|
||||||
|
return "\n\n".join(entries)
|
||||||
189
codex/quality.py
Normal file
189
codex/quality.py
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
"""Chunk quality filtering and section classification (F-16).
|
||||||
|
|
||||||
|
Three independent quality signals are applied at ingest time:
|
||||||
|
|
||||||
|
1. **Length** — structural; no content required.
|
||||||
|
2. **Alpha-ratio** — OCR artefacts have high non-alpha character density.
|
||||||
|
3. **Bib-score** — DOI + "et al." + (YYYY) patterns co-occur almost only
|
||||||
|
in reference list entries.
|
||||||
|
|
||||||
|
Section classification is rule-based (no LLM) and runs on the first 200
|
||||||
|
characters of each chunk. The retroactive ``run_quality_pass`` function
|
||||||
|
can be called from the CLI to back-fill existing chunks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from codex.config import Settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Section classification patterns (checked in order; first match wins)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SECTION_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
||||||
|
("abstract", re.compile(r"^\s*(abstract|zusammenfassung|r[eé]sum[eé])\b", re.I)),
|
||||||
|
("intro", re.compile(r"^\s*(introduction|einleitung|1\.\s)", re.I)),
|
||||||
|
("theorem", re.compile(r"^\s*(theorem|lemma|proposition|corollary|definition)\b", re.I)),
|
||||||
|
("proof", re.compile(r"^\s*(proof\b|beweis\b|proof\s+of\b)", re.I)),
|
||||||
|
("bibliography", re.compile(r"^\s*(references|bibliography|bibliographie|literatur)\b", re.I)),
|
||||||
|
]
|
||||||
|
|
||||||
|
_DOI_RE = re.compile(r"10\.\d{4,}/\S+")
|
||||||
|
_ET_AL_RE = re.compile(r"\bet\s+al\b", re.I)
|
||||||
|
_YEAR_BRACKETS_RE = re.compile(r"\(\d{4}\)")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bibliography heuristic score
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _bib_score(text: str) -> float:
|
||||||
|
"""Return a [0..1] heuristic for how bibliography-like a chunk is.
|
||||||
|
|
||||||
|
Three signals contribute:
|
||||||
|
* DOI occurrences (weight 3)
|
||||||
|
* "et al." occurrences (weight 2)
|
||||||
|
* year-in-brackets occurrences (weight 1)
|
||||||
|
|
||||||
|
The raw signal is normalised against a rough word-count proxy so that
|
||||||
|
long chunks with occasional references don't get flagged.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return 0.0
|
||||||
|
word_count = max(len(text.split()), 1)
|
||||||
|
doi_hits = len(_DOI_RE.findall(text))
|
||||||
|
etal_hits = len(_ET_AL_RE.findall(text))
|
||||||
|
year_hits = len(_YEAR_BRACKETS_RE.findall(text))
|
||||||
|
raw = (doi_hits * 3 + etal_hits * 2 + year_hits) / max(word_count / 10, 1)
|
||||||
|
return min(raw, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Quality predicate
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def is_quality_chunk(text: str, *, settings: Settings) -> bool:
|
||||||
|
"""Return True when *text* passes all three quality thresholds.
|
||||||
|
|
||||||
|
Checks (all configurable via :class:`codex.config.Settings`):
|
||||||
|
|
||||||
|
1. ``chunk_min_chars`` — character count floor.
|
||||||
|
2. ``chunk_min_alpha_ratio`` — minimum fraction of alphabetic chars.
|
||||||
|
3. ``chunk_max_bib_score`` — bibliography heuristic ceiling.
|
||||||
|
"""
|
||||||
|
if len(text) < settings.chunk_min_chars:
|
||||||
|
return False
|
||||||
|
alpha_ratio = sum(c.isalpha() for c in text) / max(len(text), 1)
|
||||||
|
if alpha_ratio < settings.chunk_min_alpha_ratio:
|
||||||
|
return False
|
||||||
|
return _bib_score(text) <= settings.chunk_max_bib_score
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Section classification
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def classify_section(text: str) -> str:
|
||||||
|
"""Classify a chunk's section using rule-based regex matching.
|
||||||
|
|
||||||
|
Inspects only the first 200 characters. Returns one of:
|
||||||
|
``abstract``, ``intro``, ``theorem``, ``proof``, ``bibliography``, ``body``.
|
||||||
|
"""
|
||||||
|
snippet = text[:200]
|
||||||
|
for section_name, pattern in _SECTION_PATTERNS:
|
||||||
|
if pattern.search(snippet):
|
||||||
|
return section_name
|
||||||
|
# Fallback: bibliography by DOI density even without a header
|
||||||
|
if _bib_score(text) > 0.5:
|
||||||
|
return "bibliography"
|
||||||
|
return "body"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Batch filter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def filter_chunks(chunks: list[str], *, settings: Settings) -> list[str]:
|
||||||
|
"""Return only the chunks that pass all quality filters.
|
||||||
|
|
||||||
|
Logs the keep ratio at DEBUG level.
|
||||||
|
"""
|
||||||
|
kept = [c for c in chunks if is_quality_chunk(c, settings=settings)]
|
||||||
|
logger.debug(
|
||||||
|
"Quality filter: %d/%d chunks kept (%.0f%%)",
|
||||||
|
len(kept),
|
||||||
|
len(chunks),
|
||||||
|
100 * len(kept) / max(len(chunks), 1),
|
||||||
|
)
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Retroactive DB pass
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def run_quality_pass(
|
||||||
|
*,
|
||||||
|
paper_id: str | None = None,
|
||||||
|
conn: Any,
|
||||||
|
settings: Settings,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Apply quality filter + section classification to existing chunks in DB.
|
||||||
|
|
||||||
|
For each chunk:
|
||||||
|
* Fails quality → DELETE.
|
||||||
|
* Passes quality → UPDATE ``section`` with :func:`classify_section`.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
paper_id:
|
||||||
|
When provided, restrict the pass to chunks for this paper only.
|
||||||
|
conn:
|
||||||
|
Open psycopg connection (dict-row factory assumed).
|
||||||
|
settings:
|
||||||
|
Application settings for quality thresholds.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict with keys ``kept``, ``removed``, ``tagged`` (= kept).
|
||||||
|
"""
|
||||||
|
if paper_id is not None:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, content FROM chunks WHERE paper_id = %(pid)s",
|
||||||
|
{"pid": paper_id},
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute("SELECT id, content FROM chunks").fetchall()
|
||||||
|
|
||||||
|
kept = 0
|
||||||
|
removed = 0
|
||||||
|
for row in rows:
|
||||||
|
chunk_id = row["id"]
|
||||||
|
content = row["content"]
|
||||||
|
if not is_quality_chunk(content, settings=settings):
|
||||||
|
conn.execute("DELETE FROM chunks WHERE id = %(id)s", {"id": chunk_id})
|
||||||
|
removed += 1
|
||||||
|
else:
|
||||||
|
section = classify_section(content)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE chunks SET section = %(section)s WHERE id = %(id)s",
|
||||||
|
{"section": section, "id": chunk_id},
|
||||||
|
)
|
||||||
|
kept += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
logger.info("Quality pass done: %d kept, %d removed, %d section-tagged", kept, removed, kept)
|
||||||
|
return {"kept": kept, "removed": removed, "tagged": kept}
|
||||||
100
codex/sources/arxiv.py
Normal file
100
codex/sources/arxiv.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""arXiv API client.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- fetch_source: download the .tar.gz source of a paper and extract the primary .tex file.
|
||||||
|
- fetch_pdf_url: return the canonical PDF URL for a given arXiv ID.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import tarfile
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_BASE = "https://arxiv.org"
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_source(arxiv_id: str) -> str | None:
|
||||||
|
"""Download and extract the primary LaTeX source for an arXiv paper.
|
||||||
|
|
||||||
|
Downloads the .tar.gz source bundle from ``https://arxiv.org/src/{arxiv_id}``,
|
||||||
|
locates the primary .tex file (preferring any file containing ``\\documentclass``,
|
||||||
|
falling back to the largest .tex by size), and returns its contents as a UTF-8
|
||||||
|
string.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
arxiv_id:
|
||||||
|
The arXiv identifier (e.g. ``"2301.07041"``).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
str | None
|
||||||
|
Raw LaTeX source string, or None if the paper is not found or no .tex
|
||||||
|
file is present (signals Nougat fallback).
|
||||||
|
"""
|
||||||
|
url = f"{_BASE}/src/{arxiv_id}"
|
||||||
|
try:
|
||||||
|
response = httpx.get(url, timeout=60, follow_redirects=True)
|
||||||
|
except httpx.RequestError:
|
||||||
|
raise
|
||||||
|
if response.status_code == 404:
|
||||||
|
logger.debug("arXiv 404 for source id=%s", arxiv_id)
|
||||||
|
return None
|
||||||
|
if response.status_code != 200:
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
raw = response.content
|
||||||
|
try:
|
||||||
|
with tarfile.open(fileobj=io.BytesIO(raw), mode="r:gz") as tf:
|
||||||
|
tex_members = [m for m in tf.getmembers() if m.name.endswith(".tex")]
|
||||||
|
if not tex_members:
|
||||||
|
logger.debug("No .tex files found in arXiv source for %s", arxiv_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Prefer the file containing \documentclass (primary document)
|
||||||
|
primary: tarfile.TarInfo | None = None
|
||||||
|
for member in tex_members:
|
||||||
|
f = tf.extractfile(member)
|
||||||
|
if f is None:
|
||||||
|
continue
|
||||||
|
content_bytes = f.read()
|
||||||
|
if b"\\documentclass" in content_bytes:
|
||||||
|
primary = member
|
||||||
|
# Decode and return immediately — first match wins
|
||||||
|
return content_bytes.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
if primary is None:
|
||||||
|
# Fallback: largest .tex by size
|
||||||
|
largest = max(tex_members, key=lambda m: m.size)
|
||||||
|
f = tf.extractfile(largest)
|
||||||
|
if f is None:
|
||||||
|
return None
|
||||||
|
return f.read().decode("utf-8", errors="replace")
|
||||||
|
except tarfile.TarError as exc:
|
||||||
|
logger.warning("Failed to open tar archive for %s: %s", arxiv_id, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None # unreachable but satisfies type checker
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_pdf_url(arxiv_id: str) -> str:
|
||||||
|
"""Return the canonical PDF URL for an arXiv paper.
|
||||||
|
|
||||||
|
This is a pure computation — no HTTP request is made.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
arxiv_id:
|
||||||
|
The arXiv identifier (e.g. ``"2301.07041"``).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
str
|
||||||
|
The full URL of the PDF file.
|
||||||
|
"""
|
||||||
|
return f"{_BASE}/pdf/{arxiv_id}.pdf"
|
||||||
158
codex/sources/openalex.py
Normal file
158
codex/sources/openalex.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"""OpenAlex API client.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- fetch_paper: resolve an arXiv ID or DOI to a Paper dataclass.
|
||||||
|
- fetch_citations: retrieve reference list as Citation dataclasses.
|
||||||
|
|
||||||
|
All requests use the OpenAlex Polite Pool (mailto query parameter) and
|
||||||
|
are retried on 429/5xx with exponential back-off via tenacity.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
|
||||||
|
|
||||||
|
from codex.config import get_settings
|
||||||
|
from codex.models import Citation, Paper
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_BASE = "https://api.openalex.org"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_id(id: str) -> str:
|
||||||
|
"""Resolve a bare DOI or arXiv ID for the OpenAlex /works/ endpoint.
|
||||||
|
|
||||||
|
OpenAlex resolves DOIs, W-IDs and full https:// URLs in the /works/ path,
|
||||||
|
but NOT the ``arxiv:`` namespace (that path 404s). arXiv papers are looked
|
||||||
|
up via their arXiv DOI ``10.48550/arXiv.<id>`` instead — accepted by
|
||||||
|
OpenAlex for both modern (``2301.07041``) and legacy (``math/0603097``) IDs.
|
||||||
|
"""
|
||||||
|
s = id.strip()
|
||||||
|
if s.lower().startswith("arxiv:"):
|
||||||
|
s = s[len("arxiv:") :]
|
||||||
|
if s.startswith(("W", "https://", "doi:")):
|
||||||
|
return s
|
||||||
|
if s.startswith("10."):
|
||||||
|
return f"doi:{s}"
|
||||||
|
return f"doi:10.48550/arXiv.{s}"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_retryable(exc: BaseException) -> bool:
|
||||||
|
"""Return True for HTTP 429 / 5xx responses wrapped in httpx.HTTPStatusError."""
|
||||||
|
if isinstance(exc, httpx.HTTPStatusError):
|
||||||
|
return exc.response.status_code == 429 or exc.response.status_code >= 500
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _polite_params() -> dict[str, str]:
|
||||||
|
mailto = get_settings().openalex_mailto
|
||||||
|
if mailto:
|
||||||
|
return {"mailto": mailto}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
@retry(
|
||||||
|
retry=retry_if_exception(_is_retryable),
|
||||||
|
stop=stop_after_attempt(5),
|
||||||
|
wait=wait_exponential(min=1, max=30),
|
||||||
|
before_sleep=lambda rs: logger.warning(
|
||||||
|
"OpenAlex retry %d after %s",
|
||||||
|
rs.attempt_number,
|
||||||
|
rs.outcome.exception(), # type: ignore[union-attr]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def _get(url: str, params: dict[str, str] | None = None) -> httpx.Response:
|
||||||
|
merged: dict[str, str] = {**_polite_params(), **(params or {})}
|
||||||
|
response = httpx.get(url, params=merged, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _reconstruct_abstract(inverted_index: dict[str, list[int]] | None) -> str | None:
|
||||||
|
"""Convert OpenAlex abstract_inverted_index back to plain text."""
|
||||||
|
if not inverted_index:
|
||||||
|
return None
|
||||||
|
word_positions: list[tuple[int, str]] = []
|
||||||
|
for word, positions in inverted_index.items():
|
||||||
|
for pos in positions:
|
||||||
|
word_positions.append((pos, word))
|
||||||
|
word_positions.sort(key=lambda t: t[0])
|
||||||
|
return " ".join(w for _, w in word_positions)
|
||||||
|
|
||||||
|
|
||||||
|
def _map_paper(data: dict[str, Any]) -> Paper:
|
||||||
|
authors: list[str] = [
|
||||||
|
a.get("author", {}).get("display_name") or "" for a in data.get("authorships", [])
|
||||||
|
]
|
||||||
|
abstract = _reconstruct_abstract(data.get("abstract_inverted_index"))
|
||||||
|
return Paper(
|
||||||
|
id=data.get("doi") or data.get("id") or "",
|
||||||
|
title=data.get("title") or "",
|
||||||
|
openalex_id=data.get("id"),
|
||||||
|
authors=authors,
|
||||||
|
year=data.get("publication_year"),
|
||||||
|
abstract=abstract,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_paper(id: str) -> Paper | None:
|
||||||
|
"""Fetch a single paper by arXiv ID or DOI.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
id:
|
||||||
|
An arXiv ID (``"2301.07041"``), a DOI (``"10.1145/…"``),
|
||||||
|
or an OpenAlex W-ID (``"W2741809807"``).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Paper | None
|
||||||
|
A populated Paper instance, or None on 404.
|
||||||
|
"""
|
||||||
|
url = f"{_BASE}/works/{_resolve_id(id)}"
|
||||||
|
try:
|
||||||
|
response = _get(url)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
if exc.response.status_code == 404:
|
||||||
|
logger.debug("OpenAlex 404 for id=%s", id)
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
return _map_paper(response.json())
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_citations(openalex_id: str) -> list[Citation]:
|
||||||
|
"""Fetch the reference list for a paper as Citation objects.
|
||||||
|
|
||||||
|
Uses the ``referenced_works`` field from the work object — the
|
||||||
|
``/works/{id}/references`` path is not a real OpenAlex endpoint.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
openalex_id:
|
||||||
|
The OpenAlex work ID (``"https://openalex.org/W…"`` or ``"W…"``).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list[Citation]
|
||||||
|
One Citation per referenced work (OpenAlex W-ID as cited_id).
|
||||||
|
"""
|
||||||
|
url = f"{_BASE}/works/{_resolve_id(openalex_id)}"
|
||||||
|
try:
|
||||||
|
response = _get(url)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
if exc.response.status_code == 404:
|
||||||
|
return []
|
||||||
|
raise
|
||||||
|
|
||||||
|
data: dict[str, Any] = response.json()
|
||||||
|
referenced_works: list[str] = data.get("referenced_works", [])
|
||||||
|
return [
|
||||||
|
Citation(citing_id=openalex_id, cited_id=cited_id)
|
||||||
|
for cited_id in referenced_works
|
||||||
|
if cited_id
|
||||||
|
]
|
||||||
135
codex/sources/semanticscholar.py
Normal file
135
codex/sources/semanticscholar.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"""Semantic Scholar API client.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- fetch_references: retrieve references for a paper as Citation dataclasses.
|
||||||
|
- fetch_recommendations: retrieve recommended paper IDs.
|
||||||
|
|
||||||
|
Rate-limited to ≤1 req/s (per-request floor via monotonic clock).
|
||||||
|
Retried on 429/5xx with exponential back-off via tenacity.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
|
||||||
|
|
||||||
|
from codex.models import Citation
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_BASE_GRAPH = "https://api.semanticscholar.org/graph/v1"
|
||||||
|
_BASE_RECS = "https://api.semanticscholar.org/recommendations/v1"
|
||||||
|
|
||||||
|
# Per-request rate limit: ≤1 req/s without an API key.
|
||||||
|
_rate_lock = threading.Lock()
|
||||||
|
_last_request_time: float = 0.0
|
||||||
|
_MIN_INTERVAL = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_limit() -> None:
|
||||||
|
global _last_request_time
|
||||||
|
with _rate_lock:
|
||||||
|
now = time.monotonic()
|
||||||
|
wait = _MIN_INTERVAL - (now - _last_request_time)
|
||||||
|
if wait > 0:
|
||||||
|
time.sleep(wait)
|
||||||
|
_last_request_time = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_retryable(exc: BaseException) -> bool:
|
||||||
|
if isinstance(exc, httpx.HTTPStatusError):
|
||||||
|
return exc.response.status_code == 429 or exc.response.status_code >= 500
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@retry(
|
||||||
|
retry=retry_if_exception(_is_retryable),
|
||||||
|
stop=stop_after_attempt(5),
|
||||||
|
wait=wait_exponential(min=1, max=30),
|
||||||
|
before_sleep=lambda rs: logger.warning(
|
||||||
|
"SemanticScholar retry %d after %s",
|
||||||
|
rs.attempt_number,
|
||||||
|
rs.outcome.exception(), # type: ignore[union-attr]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def _get(url: str, params: dict[str, Any] | None = None) -> httpx.Response:
|
||||||
|
_rate_limit()
|
||||||
|
response = httpx.get(url, params=params, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_references(paper_id: str) -> list[Citation]:
|
||||||
|
"""Fetch references for a paper from Semantic Scholar.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
paper_id:
|
||||||
|
Semantic Scholar paper ID (or ``arXiv:…`` / ``DOI:…`` prefixed ID).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list[Citation]
|
||||||
|
One Citation per reference, with optional context snippet.
|
||||||
|
"""
|
||||||
|
url = f"{_BASE_GRAPH}/paper/{paper_id}/references"
|
||||||
|
params: dict[str, Any] = {"fields": "externalIds,contexts"}
|
||||||
|
try:
|
||||||
|
response = _get(url, params=params)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
if exc.response.status_code == 404:
|
||||||
|
return []
|
||||||
|
raise
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
raw_refs: list[dict[str, Any]] = data.get("data", [])
|
||||||
|
citations: list[Citation] = []
|
||||||
|
for entry in raw_refs:
|
||||||
|
cited_paper: dict[str, Any] = entry.get("citedPaper", {})
|
||||||
|
external_ids: dict[str, str] = cited_paper.get("externalIds") or {}
|
||||||
|
contexts: list[str] = entry.get("contexts", [])
|
||||||
|
context: str | None = contexts[0] if contexts else None
|
||||||
|
|
||||||
|
cited_id: str = (
|
||||||
|
external_ids.get("DOI")
|
||||||
|
or external_ids.get("ArXiv")
|
||||||
|
or cited_paper.get("paperId")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
if cited_id:
|
||||||
|
citations.append(Citation(citing_id=paper_id, cited_id=cited_id, context=context))
|
||||||
|
return citations
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_recommendations(paper_id: str, limit: int = 20) -> list[str]:
|
||||||
|
"""Fetch recommended paper IDs from Semantic Scholar.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
paper_id:
|
||||||
|
Semantic Scholar paper ID.
|
||||||
|
limit:
|
||||||
|
Maximum number of recommendations to return.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list[str]
|
||||||
|
List of recommended paper IDs.
|
||||||
|
"""
|
||||||
|
url = f"{_BASE_RECS}/papers/forpaper/{paper_id}"
|
||||||
|
params: dict[str, Any] = {"limit": limit}
|
||||||
|
try:
|
||||||
|
response = _get(url, params=params)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
if exc.response.status_code == 404:
|
||||||
|
return []
|
||||||
|
raise
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
recommended: list[dict[str, Any]] = data.get("recommendedPapers", [])
|
||||||
|
return [p["paperId"] for p in recommended if p.get("paperId")]
|
||||||
862
codex/synthesis.py
Normal file
862
codex/synthesis.py
Normal file
@@ -0,0 +1,862 @@
|
|||||||
|
"""Synthesis / Lead-Engine (F-13).
|
||||||
|
|
||||||
|
Generates four kinds of research **leads** over the RAG substrate:
|
||||||
|
|
||||||
|
| Stage | Kind | Grounded? | Output dir |
|
||||||
|
|-------|---------------|-----------|--------------------------|
|
||||||
|
| 2 | connection | yes | ``leads/grounded/`` |
|
||||||
|
| 3 | gap | yes | ``leads/grounded/`` |
|
||||||
|
| 4 | improvement | yes | ``leads/grounded/`` |
|
||||||
|
| 5 | conjecture | no — by definition unverifiable | ``leads/conjectures/`` |
|
||||||
|
|
||||||
|
Hard invariant (F-13 HARD)
|
||||||
|
--------------------------
|
||||||
|
**Conjectures are NEVER written to ``wiki/``.** They are quarantined to
|
||||||
|
``leads/conjectures/`` with mandatory ``status="unverified"`` plus a
|
||||||
|
``suggested_validation`` field describing the spike that would falsify them.
|
||||||
|
|
||||||
|
The wiki layer (F-12) stays grounded-only. Conjectures live in their own
|
||||||
|
sandbox so that human review can promote them after a spike succeeds.
|
||||||
|
|
||||||
|
Grounding discipline
|
||||||
|
--------------------
|
||||||
|
* For ``connection`` / ``gap`` / ``improvement`` leads we run the
|
||||||
|
same content-5-gram grounding check as F-12 (re-used directly from
|
||||||
|
:mod:`codex.wiki`). Every claim in the lead body that fails the check
|
||||||
|
is marked with the ⚠ prefix and the lead is recorded as having
|
||||||
|
ungrounded claims. Leads whose grounded-ratio is below the floor are
|
||||||
|
dropped (returned as "ungrounded" rather than "grounded").
|
||||||
|
* For ``conjecture`` leads grounding by definition cannot apply
|
||||||
|
(the claim does not exist in the corpus). Provenance is still mandatory:
|
||||||
|
it points at the chunks that *seeded* the conjecture so a reviewer can
|
||||||
|
trace the inspiration.
|
||||||
|
|
||||||
|
Graceful degradation
|
||||||
|
--------------------
|
||||||
|
* Missing ``lib_path`` → :func:`find_improvements` returns ``[]``.
|
||||||
|
* DB unreachable → caller sees the psycopg error; no silent failures.
|
||||||
|
* LLM unreachable (httpx.ConnectError / any exception) → the affected
|
||||||
|
generator returns ``[]`` (matches the F-12 LLM degradation rule).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Literal, Protocol
|
||||||
|
|
||||||
|
from codex.config import get_settings
|
||||||
|
from codex.wiki import (
|
||||||
|
Claim,
|
||||||
|
LLMClient,
|
||||||
|
OllamaClient,
|
||||||
|
_parse_claims,
|
||||||
|
_retrieve_chunks,
|
||||||
|
_run_grounding_guard,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Datamodel
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
LeadKind = Literal["connection", "gap", "improvement", "conjecture"]
|
||||||
|
LeadStatus = Literal["unverified", "spiking", "go", "no-go"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Provenance:
|
||||||
|
"""Pointer back to a single source location for a lead.
|
||||||
|
|
||||||
|
``locator`` follows the same conventions as F-12 wiki claims:
|
||||||
|
``"chunk 16"``, ``"page 9"``, ``"eq.(9)"``, or — for ``improvement``
|
||||||
|
leads — a code locator like ``"src/cli.cpp:42"``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
bibkey: str
|
||||||
|
locator: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Lead:
|
||||||
|
"""A single research lead emitted by the synthesis engine.
|
||||||
|
|
||||||
|
See module docstring for the four-stage taxonomy and the
|
||||||
|
grounded-vs-conjecture invariant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str # "L-0001"
|
||||||
|
kind: LeadKind
|
||||||
|
title: str
|
||||||
|
body: str
|
||||||
|
provenance: list[Provenance] # mandatory — even for conjectures
|
||||||
|
confidence: float # 0..1
|
||||||
|
status: LeadStatus = "unverified"
|
||||||
|
# For conjectures: the falsification spike. Empty string for grounded leads.
|
||||||
|
suggested_validation: str = ""
|
||||||
|
# Derived: claim-level grounding decisions (empty for conjectures).
|
||||||
|
claims: list[Claim] = field(default_factory=list)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.provenance:
|
||||||
|
raise ValueError(
|
||||||
|
f"Lead '{self.id}' must carry at least one Provenance entry. "
|
||||||
|
"For gap leads with no chunks, use a topic-marker Provenance."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Protocols
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _ConnLike(Protocol):
|
||||||
|
"""Subset of ``psycopg.Connection`` used by this module (helps tests)."""
|
||||||
|
|
||||||
|
def execute(self, sql: str, params: dict[str, Any] | None = ...) -> Any: ...
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# LLM prompts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_CONNECTION_PROMPT = """\
|
||||||
|
You are a research analyst surfacing CONNECTIONS between papers.
|
||||||
|
|
||||||
|
A connection is a non-trivial overlap (shared definitions, shared techniques,
|
||||||
|
shared theorems, or one paper extending/generalising another). It is a
|
||||||
|
DESCRIPTIVE statement about the corpus — not a conjecture.
|
||||||
|
|
||||||
|
Use ONLY the source chunks below. For every factual claim, cite it inline
|
||||||
|
in the format [BibKey #locator]. Example:
|
||||||
|
"Both papers use the Lobachevsky function as the building block of volume
|
||||||
|
formulas. [Springborn2008 #chunk 16] [BobenkoPinkallSpringborn2015 #chunk 3]"
|
||||||
|
|
||||||
|
Do NOT invent facts. Do NOT speculate. Two short paragraphs maximum.
|
||||||
|
|
||||||
|
SOURCE CHUNKS:
|
||||||
|
{chunks_block}
|
||||||
|
|
||||||
|
Now describe a single non-trivial connection between these papers:
|
||||||
|
"""
|
||||||
|
|
||||||
|
_GAP_PROMPT = """\
|
||||||
|
You are a research analyst identifying GAPS in a corpus.
|
||||||
|
|
||||||
|
A gap is an ABSENCE: a topic, technique, or comparison that the corpus
|
||||||
|
fails to cover, even though adjacent material exists. Make the absence
|
||||||
|
concrete by pointing at where the corpus DOES talk about adjacent things.
|
||||||
|
|
||||||
|
Use ONLY the source chunks below. For every factual claim about what IS
|
||||||
|
in the corpus, cite it inline in the format [BibKey #locator].
|
||||||
|
The absence itself does not need a citation; the surrounding context does.
|
||||||
|
|
||||||
|
Do NOT invent facts. Two short paragraphs maximum.
|
||||||
|
|
||||||
|
SOURCE CHUNKS:
|
||||||
|
{chunks_block}
|
||||||
|
|
||||||
|
Now describe a single concrete gap in the corpus's coverage of "{topic}":
|
||||||
|
"""
|
||||||
|
|
||||||
|
_IMPROVEMENT_PROMPT = """\
|
||||||
|
You are a research analyst inspecting C++ code annotated with @cite tags.
|
||||||
|
|
||||||
|
For the symbol below, a literature paper is cited. Read the chunks and
|
||||||
|
suggest at most one CONCRETE improvement to the code that the literature
|
||||||
|
would support. The improvement must be a DESCRIPTIVE statement of what the
|
||||||
|
paper says, not a conjecture.
|
||||||
|
|
||||||
|
Use ONLY the source chunks. Cite each claim inline as [BibKey #locator].
|
||||||
|
|
||||||
|
Symbol: {symbol}
|
||||||
|
Cited bibkey: {bibkey}
|
||||||
|
File:locator: {file_locator}
|
||||||
|
|
||||||
|
SOURCE CHUNKS:
|
||||||
|
{chunks_block}
|
||||||
|
|
||||||
|
Now state a single grounded improvement (or "No improvement suggested." if
|
||||||
|
the chunks do not support any):
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CONJECTURE_PROMPT = """\
|
||||||
|
You are a creative researcher proposing CONJECTURES — novel hypotheses that
|
||||||
|
EXTEND but DO NOT contradict the corpus.
|
||||||
|
|
||||||
|
A conjecture is a NEW idea not yet present in the literature. By definition
|
||||||
|
it cannot be grounded in the chunks. Your output MUST be honest about this:
|
||||||
|
do not pretend it is a result already in the papers.
|
||||||
|
|
||||||
|
You MUST produce:
|
||||||
|
1. A one-sentence title (the conjecture itself).
|
||||||
|
2. A short body (2-4 sentences) explaining the intuition.
|
||||||
|
3. A "Validation:" line stating ONE concrete spike — a small experiment,
|
||||||
|
counter-example search, or proof attempt — that would FALSIFY the
|
||||||
|
conjecture if it is wrong. Without a falsification path the conjecture
|
||||||
|
is rejected.
|
||||||
|
|
||||||
|
Use the chunks below ONLY as inspiration. Cite seed material as
|
||||||
|
[BibKey #locator] in the body so a reviewer can trace where the idea came
|
||||||
|
from.
|
||||||
|
|
||||||
|
SOURCE CHUNKS:
|
||||||
|
{chunks_block}
|
||||||
|
|
||||||
|
Now propose one conjecture in the format:
|
||||||
|
Title: <one sentence>
|
||||||
|
Body: <2-4 sentences with [BibKey #locator] seed citations>
|
||||||
|
Validation: <one concrete falsification spike>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _format_chunks(chunks: list[dict[str, Any]]) -> str:
|
||||||
|
"""Render chunks for the LLM prompt — same format as F-12."""
|
||||||
|
lines: list[str] = []
|
||||||
|
for chunk in chunks:
|
||||||
|
bibkey = chunk.get("bibkey") or chunk["paper_id"]
|
||||||
|
ord_val = chunk.get("ord", "?")
|
||||||
|
lines.append(f"[{bibkey} #chunk {ord_val}]\n{chunk['content'].strip()}\n")
|
||||||
|
return "\n---\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_ungrounded(body: str, claims: list[Claim]) -> str:
|
||||||
|
"""Prefix each ungrounded claim's last sentence with ⚠ in *body*."""
|
||||||
|
for claim in claims:
|
||||||
|
if claim.grounded:
|
||||||
|
continue
|
||||||
|
escaped = re.escape(claim.text.strip())
|
||||||
|
body = re.sub(rf"(?<!⚠ )({escaped})", r"⚠ \1", body, count=1)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _grounded_ratio(claims: list[Claim]) -> float:
|
||||||
|
"""Fraction of *claims* that passed the grounding check (0.0 if empty)."""
|
||||||
|
if not claims:
|
||||||
|
return 0.0
|
||||||
|
n_grounded = sum(1 for c in claims if c.grounded)
|
||||||
|
return n_grounded / len(claims)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_lead_id(seq: int) -> str:
|
||||||
|
"""Format a sequential lead id like ``L-0001``."""
|
||||||
|
return f"L-{seq:04d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_llm_generate(llm: LLMClient, prompt: str, model: str) -> str:
|
||||||
|
"""Call the LLM, return ``""`` on any exception (mirrors F-12 behaviour)."""
|
||||||
|
try:
|
||||||
|
return llm.generate(prompt, model=model)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("LLM unavailable for synthesis (%s)", exc)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _provenance_from_chunks(chunks: list[dict[str, Any]]) -> list[Provenance]:
|
||||||
|
"""Build Provenance pointers for every chunk used as a seed."""
|
||||||
|
out: list[Provenance] = []
|
||||||
|
for chunk in chunks:
|
||||||
|
bibkey = str(chunk.get("bibkey") or chunk.get("paper_id") or "")
|
||||||
|
ord_val = chunk.get("ord", "?")
|
||||||
|
out.append(Provenance(bibkey=bibkey, locator=f"chunk {ord_val}"))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _finalise_grounded_lead(
|
||||||
|
*,
|
||||||
|
seq: int,
|
||||||
|
kind: LeadKind,
|
||||||
|
title: str,
|
||||||
|
body: str,
|
||||||
|
chunks: list[dict[str, Any]],
|
||||||
|
min_grounded_ratio: float,
|
||||||
|
) -> Lead | None:
|
||||||
|
"""Parse claims, run grounding guard, mark ⚠, return ``None`` if too sparse.
|
||||||
|
|
||||||
|
Returns the populated :class:`Lead` or ``None`` if no parseable claims
|
||||||
|
were found or the grounded ratio is below ``min_grounded_ratio``.
|
||||||
|
"""
|
||||||
|
claims = _parse_claims(body)
|
||||||
|
if not claims:
|
||||||
|
# No citations at all → cannot be a grounded lead.
|
||||||
|
return None
|
||||||
|
claims = _run_grounding_guard(claims, chunks)
|
||||||
|
ratio = _grounded_ratio(claims)
|
||||||
|
if ratio < min_grounded_ratio:
|
||||||
|
return None
|
||||||
|
marked = _mark_ungrounded(body, claims)
|
||||||
|
return Lead(
|
||||||
|
id=_make_lead_id(seq),
|
||||||
|
kind=kind,
|
||||||
|
title=title,
|
||||||
|
body=marked,
|
||||||
|
provenance=_provenance_from_chunks(chunks),
|
||||||
|
confidence=ratio,
|
||||||
|
status="unverified",
|
||||||
|
suggested_validation="",
|
||||||
|
claims=claims,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 2 — connections
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def find_connections(
|
||||||
|
*,
|
||||||
|
db_conn: _ConnLike | None = None,
|
||||||
|
top_k: int,
|
||||||
|
llm: LLMClient,
|
||||||
|
) -> list[Lead]:
|
||||||
|
"""Surface grounded connection leads across the corpus.
|
||||||
|
|
||||||
|
Strategy: cluster the corpus by shared retrieval — for each paper
|
||||||
|
pair that shows up together in the top-K of a common probe query
|
||||||
|
we ask the LLM to articulate the connection. The probe queries are
|
||||||
|
the bibkey/title pairs of the existing papers (cheap, no external
|
||||||
|
config needed).
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
db_conn:
|
||||||
|
Unused right now (kept for the agreed signature so callers can
|
||||||
|
inject a transactional connection later).
|
||||||
|
top_k:
|
||||||
|
Number of chunks retrieved per probe.
|
||||||
|
llm:
|
||||||
|
Injectable LLM client. Errors fall back to ``[]``.
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
seq = 1
|
||||||
|
leads: list[Lead] = []
|
||||||
|
|
||||||
|
# Probe queries: use the discovered titles of already-ingested papers.
|
||||||
|
# Without DB we can't get titles, so we fall back to a generic probe.
|
||||||
|
titles = _list_paper_titles(db_conn)
|
||||||
|
if not titles:
|
||||||
|
return leads
|
||||||
|
|
||||||
|
seen_pairs: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
|
for bibkey, title in titles:
|
||||||
|
chunks = _retrieve_chunks([title], top_k=top_k)
|
||||||
|
if not chunks:
|
||||||
|
continue
|
||||||
|
# Find chunks from a *different* bibkey — that's the connection signal.
|
||||||
|
other_bibkeys = {
|
||||||
|
str(c.get("bibkey") or "")
|
||||||
|
for c in chunks
|
||||||
|
if c.get("bibkey") and c.get("bibkey") != bibkey
|
||||||
|
}
|
||||||
|
for other in sorted(other_bibkeys):
|
||||||
|
pair = tuple(sorted([bibkey, other]))
|
||||||
|
if pair in seen_pairs:
|
||||||
|
continue
|
||||||
|
seen_pairs.add(pair) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
pair_chunks = [
|
||||||
|
c
|
||||||
|
for c in chunks
|
||||||
|
if c.get("bibkey") in {bibkey, other}
|
||||||
|
]
|
||||||
|
prompt = _CONNECTION_PROMPT.format(chunks_block=_format_chunks(pair_chunks))
|
||||||
|
raw = _safe_llm_generate(llm, prompt, settings.synthesis_llm_model)
|
||||||
|
if not raw.strip():
|
||||||
|
continue
|
||||||
|
lead = _finalise_grounded_lead(
|
||||||
|
seq=seq,
|
||||||
|
kind="connection",
|
||||||
|
title=f"Connection: {bibkey} ⇄ {other}",
|
||||||
|
body=raw.strip(),
|
||||||
|
chunks=pair_chunks,
|
||||||
|
min_grounded_ratio=settings.synthesis_min_grounded_ratio,
|
||||||
|
)
|
||||||
|
if lead is None:
|
||||||
|
continue
|
||||||
|
leads.append(lead)
|
||||||
|
seq += 1
|
||||||
|
|
||||||
|
return leads
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 3 — gaps
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def find_gaps(
|
||||||
|
lib_path: str | None = None,
|
||||||
|
*,
|
||||||
|
db_conn: _ConnLike | None = None,
|
||||||
|
llm: LLMClient,
|
||||||
|
topics: list[str] | None = None,
|
||||||
|
) -> list[Lead]:
|
||||||
|
"""Surface grounded gap leads.
|
||||||
|
|
||||||
|
Two complementary signals are used:
|
||||||
|
|
||||||
|
* **Topic coverage map** — for each topic in ``topics`` (or, if
|
||||||
|
``None``, the union of paper titles), retrieve chunks and check
|
||||||
|
bibkey coverage. A topic covered by < ``synthesis_gap_min_coverage``
|
||||||
|
bibkeys is reported as a gap candidate and the LLM is asked to
|
||||||
|
articulate it.
|
||||||
|
* **Code-vs-corpus gap** — when ``lib_path`` is given, any @cite
|
||||||
|
bibkey scanned from code that is NOT present in the corpus is
|
||||||
|
flagged. This catches the "cited in code, never ingested" case.
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
seq = 1
|
||||||
|
leads: list[Lead] = []
|
||||||
|
|
||||||
|
probe_topics: list[str] = list(topics or [])
|
||||||
|
if not probe_topics:
|
||||||
|
probe_topics = [title for _, title in _list_paper_titles(db_conn)]
|
||||||
|
|
||||||
|
known_bibkeys = {bk for bk, _ in _list_paper_titles(db_conn)}
|
||||||
|
|
||||||
|
# --- coverage-map gaps --------------------------------------------
|
||||||
|
for topic in probe_topics:
|
||||||
|
chunks = _retrieve_chunks([topic], top_k=settings.synthesis_top_k)
|
||||||
|
if not chunks:
|
||||||
|
# No coverage at all → record the gap directly (LLM not strictly needed).
|
||||||
|
leads.append(
|
||||||
|
Lead(
|
||||||
|
id=_make_lead_id(seq),
|
||||||
|
kind="gap",
|
||||||
|
title=f"Gap: corpus has no material on '{topic}'",
|
||||||
|
body=(
|
||||||
|
f"The retrieval layer returned no chunks for topic '{topic}'.\n"
|
||||||
|
"No grounded statement is possible; this is the absence itself."
|
||||||
|
),
|
||||||
|
provenance=[Provenance(bibkey="(none)", locator=f"topic:{topic}")],
|
||||||
|
confidence=1.0,
|
||||||
|
status="unverified",
|
||||||
|
suggested_validation=(
|
||||||
|
"Ingest a paper covering this topic, re-run synthesis: the gap "
|
||||||
|
"should disappear."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
seq += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
bibkeys_for_topic = {str(c.get("bibkey") or "") for c in chunks if c.get("bibkey")}
|
||||||
|
if len(bibkeys_for_topic) >= settings.synthesis_gap_min_coverage:
|
||||||
|
# Topic is well covered — not a gap.
|
||||||
|
continue
|
||||||
|
prompt = _GAP_PROMPT.format(topic=topic, chunks_block=_format_chunks(chunks))
|
||||||
|
raw = _safe_llm_generate(llm, prompt, settings.synthesis_llm_model)
|
||||||
|
if not raw.strip():
|
||||||
|
continue
|
||||||
|
lead = _finalise_grounded_lead(
|
||||||
|
seq=seq,
|
||||||
|
kind="gap",
|
||||||
|
title=f"Gap: '{topic}' covered by only {len(bibkeys_for_topic)} bibkey(s)",
|
||||||
|
body=raw.strip(),
|
||||||
|
chunks=chunks,
|
||||||
|
min_grounded_ratio=settings.synthesis_min_grounded_ratio,
|
||||||
|
)
|
||||||
|
if lead is None:
|
||||||
|
continue
|
||||||
|
leads.append(lead)
|
||||||
|
seq += 1
|
||||||
|
|
||||||
|
# --- code-vs-corpus gaps ------------------------------------------
|
||||||
|
if lib_path:
|
||||||
|
from codex.provenance import scan_cite_tags
|
||||||
|
|
||||||
|
try:
|
||||||
|
hits = scan_cite_tags(lib_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
hits = []
|
||||||
|
cited_bibkeys = {h["bibkey"] for h in hits}
|
||||||
|
missing = cited_bibkeys - known_bibkeys
|
||||||
|
for bibkey in sorted(missing):
|
||||||
|
leads.append(
|
||||||
|
Lead(
|
||||||
|
id=_make_lead_id(seq),
|
||||||
|
kind="gap",
|
||||||
|
title=f"Gap: code cites '{bibkey}' but corpus does not contain it",
|
||||||
|
body=(
|
||||||
|
f"The bibkey `{bibkey}` is referenced by @cite tags in `{lib_path}`\n"
|
||||||
|
"but no paper with this bibkey is ingested into the corpus.\n"
|
||||||
|
"This is a concrete absence."
|
||||||
|
),
|
||||||
|
provenance=[Provenance(bibkey=bibkey, locator="(not ingested)")],
|
||||||
|
confidence=1.0,
|
||||||
|
status="unverified",
|
||||||
|
suggested_validation=(
|
||||||
|
f"Ingest the paper for `{bibkey}` via `codex ingest`. The gap "
|
||||||
|
"should disappear on the next synthesis run."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
seq += 1
|
||||||
|
|
||||||
|
return leads
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 4 — improvements
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def find_improvements(
|
||||||
|
lib_path: str,
|
||||||
|
*,
|
||||||
|
db_conn: _ConnLike | None = None,
|
||||||
|
top_k: int,
|
||||||
|
llm: LLMClient,
|
||||||
|
) -> list[Lead]:
|
||||||
|
"""Suggest grounded code improvements based on @cite-linked literature.
|
||||||
|
|
||||||
|
Walks ``lib_path`` for ``@cite <bibkey>`` markers, retrieves the
|
||||||
|
relevant chunks for each cited bibkey, and asks the LLM to propose
|
||||||
|
one concrete improvement supported by the paper. Improvements
|
||||||
|
failing the grounding check are dropped.
|
||||||
|
"""
|
||||||
|
if not lib_path:
|
||||||
|
return []
|
||||||
|
from codex.provenance import scan_cite_tags
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
seq = 1
|
||||||
|
leads: list[Lead] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
hits = scan_cite_tags(lib_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return leads
|
||||||
|
|
||||||
|
known_bibkeys = {bk for bk, _ in _list_paper_titles(db_conn)}
|
||||||
|
|
||||||
|
# Group hits by (symbol, bibkey) so we ask the LLM at most once per pair.
|
||||||
|
by_key: dict[tuple[str, str], list[dict[str, str]]] = {}
|
||||||
|
for hit in hits:
|
||||||
|
if hit["bibkey"] not in known_bibkeys:
|
||||||
|
continue # handled by find_gaps
|
||||||
|
by_key.setdefault((hit["symbol"] or "(unknown)", hit["bibkey"]), []).append(hit)
|
||||||
|
|
||||||
|
for (symbol, bibkey), group in by_key.items():
|
||||||
|
chunks = _retrieve_chunks([symbol, bibkey], top_k=top_k)
|
||||||
|
# Restrict to chunks from the cited bibkey when possible
|
||||||
|
same_bib = [c for c in chunks if c.get("bibkey") == bibkey] or chunks
|
||||||
|
if not same_bib:
|
||||||
|
continue
|
||||||
|
sample = group[0]
|
||||||
|
file_locator = f"{sample['file']}:{sample['line']}"
|
||||||
|
prompt = _IMPROVEMENT_PROMPT.format(
|
||||||
|
symbol=symbol,
|
||||||
|
bibkey=bibkey,
|
||||||
|
file_locator=file_locator,
|
||||||
|
chunks_block=_format_chunks(same_bib),
|
||||||
|
)
|
||||||
|
raw = _safe_llm_generate(llm, prompt, settings.synthesis_llm_model)
|
||||||
|
if not raw.strip() or "no improvement suggested" in raw.lower():
|
||||||
|
continue
|
||||||
|
lead = _finalise_grounded_lead(
|
||||||
|
seq=seq,
|
||||||
|
kind="improvement",
|
||||||
|
title=f"Improvement: {symbol} ({file_locator}) via {bibkey}",
|
||||||
|
body=raw.strip(),
|
||||||
|
chunks=same_bib,
|
||||||
|
min_grounded_ratio=settings.synthesis_min_grounded_ratio,
|
||||||
|
)
|
||||||
|
if lead is None:
|
||||||
|
continue
|
||||||
|
# Attach the code locator to the provenance list as the first entry
|
||||||
|
lead.provenance.insert(0, Provenance(bibkey=bibkey, locator=file_locator))
|
||||||
|
leads.append(lead)
|
||||||
|
seq += 1
|
||||||
|
|
||||||
|
return leads
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 5 — conjectures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_CONJECTURE_RE = re.compile(
|
||||||
|
r"Title:\s*(?P<title>.+?)\n+"
|
||||||
|
r"Body:\s*(?P<body>.+?)\n+"
|
||||||
|
r"Validation:\s*(?P<validation>.+)",
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_conjecture_output(raw: str) -> tuple[str, str, str] | None:
|
||||||
|
"""Parse the LLM's three-section conjecture output.
|
||||||
|
|
||||||
|
Returns ``(title, body, validation)`` or ``None`` if any section
|
||||||
|
is missing or empty.
|
||||||
|
"""
|
||||||
|
match = _CONJECTURE_RE.search(raw)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
title = match.group("title").strip()
|
||||||
|
body = match.group("body").strip()
|
||||||
|
validation = match.group("validation").strip()
|
||||||
|
if not title or not body or not validation:
|
||||||
|
return None
|
||||||
|
return title, body, validation
|
||||||
|
|
||||||
|
|
||||||
|
def propose_conjectures(
|
||||||
|
*,
|
||||||
|
db_conn: _ConnLike | None = None,
|
||||||
|
top_k: int,
|
||||||
|
llm: LLMClient,
|
||||||
|
seeds: list[str] | None = None,
|
||||||
|
) -> list[Lead]:
|
||||||
|
"""Generate stage-5 conjecture leads.
|
||||||
|
|
||||||
|
Each conjecture is the LLM's own hypothesis seeded by a small
|
||||||
|
retrieval slice. The output is by definition ungrounded, so:
|
||||||
|
|
||||||
|
* ``status`` is hard-set to ``"unverified"``.
|
||||||
|
* ``suggested_validation`` is mandatory; conjectures without a
|
||||||
|
falsification path are dropped.
|
||||||
|
* ``provenance`` records the seed chunks so a reviewer can trace
|
||||||
|
inspiration.
|
||||||
|
|
||||||
|
Conjectures are written to ``leads/conjectures/`` only — never
|
||||||
|
to ``wiki/``.
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
seq = 1
|
||||||
|
leads: list[Lead] = []
|
||||||
|
|
||||||
|
probe_seeds: list[str] = list(seeds or [])
|
||||||
|
if not probe_seeds:
|
||||||
|
probe_seeds = [title for _, title in _list_paper_titles(db_conn)]
|
||||||
|
if not probe_seeds:
|
||||||
|
return leads
|
||||||
|
|
||||||
|
for seed in probe_seeds:
|
||||||
|
chunks = _retrieve_chunks([seed], top_k=top_k)
|
||||||
|
if not chunks:
|
||||||
|
continue
|
||||||
|
prompt = _CONJECTURE_PROMPT.format(chunks_block=_format_chunks(chunks))
|
||||||
|
raw = _safe_llm_generate(llm, prompt, settings.synthesis_llm_model)
|
||||||
|
if not raw.strip():
|
||||||
|
continue
|
||||||
|
parsed = _parse_conjecture_output(raw)
|
||||||
|
if parsed is None:
|
||||||
|
# No falsification path → reject (hard invariant).
|
||||||
|
continue
|
||||||
|
title, body, validation = parsed
|
||||||
|
provenance = _provenance_from_chunks(chunks)
|
||||||
|
if not provenance:
|
||||||
|
continue
|
||||||
|
leads.append(
|
||||||
|
Lead(
|
||||||
|
id=_make_lead_id(seq),
|
||||||
|
kind="conjecture",
|
||||||
|
title=title,
|
||||||
|
body=body,
|
||||||
|
provenance=provenance,
|
||||||
|
confidence=0.0, # conjectures start unconfirmed
|
||||||
|
status="unverified", # HARD invariant
|
||||||
|
suggested_validation=validation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
seq += 1
|
||||||
|
return leads
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DB helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _list_paper_titles(db_conn: _ConnLike | None) -> list[tuple[str, str]]:
|
||||||
|
"""Return ``[(bibkey, title), …]`` for all ingested papers with a bibkey.
|
||||||
|
|
||||||
|
Uses the injected connection when provided, otherwise opens a fresh
|
||||||
|
one. Returns ``[]`` if the database is unreachable or has no rows.
|
||||||
|
"""
|
||||||
|
sql = "SELECT bibkey, title FROM papers WHERE bibkey IS NOT NULL"
|
||||||
|
try:
|
||||||
|
if db_conn is not None:
|
||||||
|
rows = db_conn.execute(sql).fetchall()
|
||||||
|
else:
|
||||||
|
from codex.db import get_conn
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(sql).fetchall()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("Could not list paper titles (%s)", exc)
|
||||||
|
return []
|
||||||
|
out: list[tuple[str, str]] = []
|
||||||
|
for row in rows:
|
||||||
|
bibkey = str(row["bibkey"]) if "bibkey" in row else str(row[0])
|
||||||
|
title = str(row["title"]) if "title" in row else str(row[1])
|
||||||
|
out.append((bibkey, title))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Persistence
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_GROUNDED_KINDS: frozenset[LeadKind] = frozenset({"connection", "gap", "improvement"})
|
||||||
|
|
||||||
|
|
||||||
|
def _lead_to_markdown(lead: Lead) -> str:
|
||||||
|
"""Render a Lead as a self-contained markdown document.
|
||||||
|
|
||||||
|
Front-matter is a fenced JSON block so the file is human-readable
|
||||||
|
and machine-parseable.
|
||||||
|
"""
|
||||||
|
meta = {
|
||||||
|
"id": lead.id,
|
||||||
|
"kind": lead.kind,
|
||||||
|
"status": lead.status,
|
||||||
|
"confidence": round(lead.confidence, 3),
|
||||||
|
"provenance": [asdict(p) for p in lead.provenance],
|
||||||
|
"suggested_validation": lead.suggested_validation,
|
||||||
|
"compiled_at": datetime.now(UTC).isoformat(),
|
||||||
|
}
|
||||||
|
parts = [
|
||||||
|
f"# {lead.id} — {lead.title}",
|
||||||
|
"",
|
||||||
|
"```json",
|
||||||
|
json.dumps(meta, indent=2, sort_keys=True),
|
||||||
|
"```",
|
||||||
|
"",
|
||||||
|
lead.body.rstrip(),
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
if lead.kind == "conjecture":
|
||||||
|
parts.extend(
|
||||||
|
[
|
||||||
|
"## Status",
|
||||||
|
"",
|
||||||
|
"**UNVERIFIED CONJECTURE — not grounded in the corpus.**",
|
||||||
|
"Promotion to the wiki requires the validation spike below to succeed.",
|
||||||
|
"",
|
||||||
|
"## Suggested validation",
|
||||||
|
"",
|
||||||
|
lead.suggested_validation,
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def write_leads(leads: list[Lead], leads_dir: str) -> None:
|
||||||
|
"""Write ``leads`` to disk under ``leads_dir``.
|
||||||
|
|
||||||
|
* Grounded leads → ``leads_dir/grounded/<id>.md``
|
||||||
|
* Conjectures → ``leads_dir/conjectures/<id>.md`` (HARD invariant)
|
||||||
|
|
||||||
|
The function ALSO maintains an append-only ``leads_dir/INDEX.md``:
|
||||||
|
a previously-recorded id is kept on subsequent runs even if it is
|
||||||
|
overwritten with a newer body. The two sections (grounded /
|
||||||
|
conjectures) are visibly separated.
|
||||||
|
|
||||||
|
Notes
|
||||||
|
-----
|
||||||
|
* The function asserts the kind→directory routing — a conjecture
|
||||||
|
ending up under ``grounded/`` would be an invariant violation
|
||||||
|
and is raised as :class:`RuntimeError`.
|
||||||
|
"""
|
||||||
|
root = Path(leads_dir)
|
||||||
|
grounded_dir = root / "grounded"
|
||||||
|
conj_dir = root / "conjectures"
|
||||||
|
grounded_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
conj_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for lead in leads:
|
||||||
|
markdown = _lead_to_markdown(lead)
|
||||||
|
if lead.kind == "conjecture":
|
||||||
|
target_dir = conj_dir
|
||||||
|
elif lead.kind in _GROUNDED_KINDS:
|
||||||
|
target_dir = grounded_dir
|
||||||
|
else: # pragma: no cover — defensive
|
||||||
|
raise RuntimeError(f"Unknown lead kind: {lead.kind!r}")
|
||||||
|
|
||||||
|
# HARD invariant: conjectures NEVER land in grounded/ or wiki/.
|
||||||
|
if lead.kind == "conjecture" and target_dir != conj_dir:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"INVARIANT VIOLATION: conjecture {lead.id} would be written "
|
||||||
|
f"to {target_dir} instead of {conj_dir}"
|
||||||
|
)
|
||||||
|
|
||||||
|
path = target_dir / f"{lead.id}.md"
|
||||||
|
path.write_text(markdown, encoding="utf-8")
|
||||||
|
|
||||||
|
# Update INDEX.md
|
||||||
|
_update_index(root)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_index(root: Path) -> None:
|
||||||
|
"""Re-build ``root/INDEX.md`` from the on-disk files (full rebuild, not append-only)."""
|
||||||
|
grounded_dir = root / "grounded"
|
||||||
|
conj_dir = root / "conjectures"
|
||||||
|
grounded_files = sorted(grounded_dir.glob("*.md")) if grounded_dir.exists() else []
|
||||||
|
conj_files = sorted(conj_dir.glob("*.md")) if conj_dir.exists() else []
|
||||||
|
|
||||||
|
ts = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
lines = [
|
||||||
|
"# Lead Index",
|
||||||
|
"",
|
||||||
|
f"_Last updated {ts} by `codex synthesis`_",
|
||||||
|
"",
|
||||||
|
"## Grounded leads (connection / gap / improvement)",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
if grounded_files:
|
||||||
|
for f in grounded_files:
|
||||||
|
title = _extract_title(f) or f.stem
|
||||||
|
lines.append(f"- `{f.stem}` — {title}")
|
||||||
|
else:
|
||||||
|
lines.append("_None._")
|
||||||
|
lines.extend(["", "## Conjectures (UNVERIFIED — quarantined here, never in wiki/)", ""])
|
||||||
|
if conj_files:
|
||||||
|
for f in conj_files:
|
||||||
|
title = _extract_title(f) or f.stem
|
||||||
|
lines.append(f"- `{f.stem}` — {title}")
|
||||||
|
else:
|
||||||
|
lines.append("_None._")
|
||||||
|
lines.append("")
|
||||||
|
(root / "INDEX.md").write_text("\n".join(lines), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_title(path: Path) -> str | None:
|
||||||
|
"""Pull the H1 title from a lead markdown file, if present."""
|
||||||
|
try:
|
||||||
|
first = path.read_text(encoding="utf-8").splitlines()[0]
|
||||||
|
except (OSError, IndexError):
|
||||||
|
return None
|
||||||
|
if first.startswith("# "):
|
||||||
|
return first[2:].strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Default LLM client
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def default_llm_client() -> LLMClient:
|
||||||
|
"""Return an :class:`OllamaClient` configured from the synthesis settings.
|
||||||
|
|
||||||
|
Falls back to ``ollama_base_url`` when ``synthesis_llm_url`` is unset.
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
url = settings.synthesis_llm_url or settings.ollama_base_url
|
||||||
|
return OllamaClient(url)
|
||||||
830
codex/wiki.py
Normal file
830
codex/wiki.py
Normal file
@@ -0,0 +1,830 @@
|
|||||||
|
"""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.
|
||||||
|
- LLM unavailable (httpx.ConnectError or any exception) → empty ConceptPage, no crash.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
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
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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)
|
||||||
|
quarantined: list[str] = field(default_factory=list) # slugs written to wiki/draft/
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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()
|
||||||
|
# Skip URL-shaped bibkeys ([text](https://...)) and multi-word bibkeys
|
||||||
|
# (real BibKeys never contain spaces or start with "http")
|
||||||
|
if " " in bibkey or bibkey.startswith("http"):
|
||||||
|
continue
|
||||||
|
if text and bibkey:
|
||||||
|
claims.append(Claim(text=text, bibkey=bibkey, locator=locator))
|
||||||
|
return claims
|
||||||
|
|
||||||
|
|
||||||
|
_STOPWORDS: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"the", "a", "an", "in", "on", "of", "to", "is", "are", "was", "were",
|
||||||
|
"and", "or", "but", "for", "with", "this", "that", "it", "we", "they",
|
||||||
|
"be", "as", "at", "by", "from", "has", "have", "not", "which",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_SENTENCE_SPLIT_RE = re.compile(r"[.!?]")
|
||||||
|
|
||||||
|
|
||||||
|
def _last_sentence(text: str) -> str:
|
||||||
|
"""Return the last non-empty sentence from *text*.
|
||||||
|
|
||||||
|
Citations annotate the last sentence, not the whole preceding paragraph.
|
||||||
|
Splitting on ``.``, ``!``, ``?`` and taking the last non-empty element
|
||||||
|
ensures only the immediately preceding sentence is grounding-checked.
|
||||||
|
"""
|
||||||
|
parts = _SENTENCE_SPLIT_RE.split(text)
|
||||||
|
for part in reversed(parts):
|
||||||
|
stripped = part.strip()
|
||||||
|
if stripped:
|
||||||
|
return stripped
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _content_words(text: str) -> list[str]:
|
||||||
|
"""Return lowercased tokens with stopwords removed."""
|
||||||
|
return [w for w in text.lower().split() if w not in _STOPWORDS]
|
||||||
|
|
||||||
|
|
||||||
|
def _run_grounding_guard(
|
||||||
|
claims: list[Claim],
|
||||||
|
chunks: list[dict[str, Any]],
|
||||||
|
) -> list[Claim]:
|
||||||
|
"""Check each claim against its cited chunk via content-5-gram match.
|
||||||
|
|
||||||
|
A claim is *grounded* if the **last sentence** before its citation
|
||||||
|
contains at least one sequence of 5 consecutive non-stopword tokens
|
||||||
|
(a "content-5-gram") that also appears as 5 consecutive non-stopword
|
||||||
|
tokens in any chunk attributed to the same bibkey.
|
||||||
|
|
||||||
|
Matching is done in content-word space (stopwords removed from BOTH
|
||||||
|
claim and chunk), which prevents trivial bypass via common scientific
|
||||||
|
phrases like "the discrete conformal map" (only 2 content words).
|
||||||
|
|
||||||
|
Using sentence-level granularity prevents a hallucinated paragraph from
|
||||||
|
being grounded by a single matching phrase at its end.
|
||||||
|
|
||||||
|
Sets ``claim.grounded = False`` for any claim that fails this check.
|
||||||
|
"""
|
||||||
|
# Build a bibkey → [content-word sequence] index
|
||||||
|
# We store the content-word list (joined as string for fast substring search)
|
||||||
|
bib_index: dict[str, list[str]] = {}
|
||||||
|
for chunk in chunks:
|
||||||
|
bk = str(chunk.get("bibkey") or "")
|
||||||
|
if bk:
|
||||||
|
# Content words of the chunk (stopwords removed, lowercased)
|
||||||
|
cw = " ".join(_content_words(chunk["content"]))
|
||||||
|
bib_index.setdefault(bk, []).append(cw)
|
||||||
|
|
||||||
|
for claim in claims:
|
||||||
|
sources = bib_index.get(claim.bibkey)
|
||||||
|
if not sources:
|
||||||
|
claim.grounded = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Grounding operates on the LAST SENTENCE only
|
||||||
|
sentence = _last_sentence(claim.text)
|
||||||
|
content = _content_words(sentence)
|
||||||
|
|
||||||
|
if len(content) < 5:
|
||||||
|
# Too short for a content-5-gram → cannot be grounded.
|
||||||
|
# A claim sentence with fewer than 5 non-stopword tokens provides
|
||||||
|
# insufficient signal and is marked ungrounded by default.
|
||||||
|
claim.grounded = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
found = False
|
||||||
|
for i in range(len(content) - 4): # content-5-grams
|
||||||
|
gram = " ".join(content[i : i + 5])
|
||||||
|
if any(gram in src for src in sources):
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
claim.grounded = found
|
||||||
|
|
||||||
|
return claims
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Conflict detection (MVP: keyword-based signal)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_CONFLICT_KEYWORDS = re.compile(
|
||||||
|
r"\b(but|however|in contrast|contradicts|on the other hand|unlike|whereas)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_conflicts(
|
||||||
|
slug: str,
|
||||||
|
chunks: list[dict[str, Any]],
|
||||||
|
) -> list[tuple[str, str, str]]:
|
||||||
|
"""Detect potential conflicts between chunks for the same concept (MVP).
|
||||||
|
|
||||||
|
Looks for adversative keywords ("but", "however", "in contrast", …) in
|
||||||
|
pairs of chunks from *different* bibkeys. Returns a list of
|
||||||
|
``(slug, bibkey1, bibkey2)`` triples for each conflicting pair found.
|
||||||
|
"""
|
||||||
|
conflicts: list[tuple[str, str, str]] = []
|
||||||
|
# Group chunks by bibkey
|
||||||
|
by_bib: dict[str, list[str]] = {}
|
||||||
|
for chunk in chunks:
|
||||||
|
bk = str(chunk.get("bibkey") or "")
|
||||||
|
if bk:
|
||||||
|
by_bib.setdefault(bk, []).append(chunk["content"])
|
||||||
|
|
||||||
|
bibkeys = list(by_bib.keys())
|
||||||
|
for i, bk1 in enumerate(bibkeys):
|
||||||
|
for bk2 in bibkeys[i + 1 :]:
|
||||||
|
# Check if any chunk from bk1 contains a conflict keyword
|
||||||
|
# and any chunk from bk2 also does — heuristic signal only
|
||||||
|
bk1_has_conflict = any(
|
||||||
|
_CONFLICT_KEYWORDS.search(c) for c in by_bib[bk1]
|
||||||
|
)
|
||||||
|
bk2_has_conflict = any(
|
||||||
|
_CONFLICT_KEYWORDS.search(c) for c in by_bib[bk2]
|
||||||
|
)
|
||||||
|
if bk1_has_conflict and bk2_has_conflict:
|
||||||
|
conflicts.append((slug, bk1, bk2))
|
||||||
|
|
||||||
|
return conflicts
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cross-reference injection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_INLINE_CODE_RE = re.compile(r"`[^`]+`")
|
||||||
|
|
||||||
|
|
||||||
|
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 spans are replaced.
|
||||||
|
Inline-code spans (`` `…` ``) are temporarily protected by null-byte
|
||||||
|
placeholders and restored after injection.
|
||||||
|
"""
|
||||||
|
# Step 1: protect inline-code spans from replacement
|
||||||
|
placeholders: dict[str, str] = {}
|
||||||
|
|
||||||
|
def _protect(m: re.Match[str]) -> str:
|
||||||
|
key = f"\x00{len(placeholders)}\x00"
|
||||||
|
placeholders[key] = m.group(0)
|
||||||
|
return key
|
||||||
|
|
||||||
|
markdown = _INLINE_CODE_RE.sub(_protect, markdown)
|
||||||
|
|
||||||
|
# Step 2: inject cross-refs on unprotected text
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Step 3: restore inline-code spans
|
||||||
|
for key, val in placeholders.items():
|
||||||
|
markdown = markdown.replace(key, val)
|
||||||
|
|
||||||
|
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,
|
||||||
|
chunks: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
top_k: int,
|
||||||
|
llm: LLMClient,
|
||||||
|
all_concepts: list[Concept] | None = None,
|
||||||
|
wiki_dir: Path | None = None,
|
||||||
|
) -> ConceptPage:
|
||||||
|
"""Compile a single concept page.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
concept:
|
||||||
|
The concept to compile.
|
||||||
|
chunks:
|
||||||
|
Pre-retrieved source chunks for this concept (from :func:`_retrieve_chunks`).
|
||||||
|
Passing chunks explicitly avoids a second retrieve and ensures the stored
|
||||||
|
hash matches the chunks actually used for synthesis.
|
||||||
|
|
||||||
|
1. Synthesise via LLM (Ollama) with per-claim citation format.
|
||||||
|
2. Run Grounding-Guard: mark ungrounded claims as ⚠.
|
||||||
|
3. Inject cross-references to other concepts as [[slug]] links.
|
||||||
|
4. 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) # noqa: F841 — kept for future use
|
||||||
|
|
||||||
|
h = _chunk_hash(chunks)
|
||||||
|
prompt = _build_synthesis_prompt(concept, chunks)
|
||||||
|
try:
|
||||||
|
raw_output = llm.generate(prompt, model=settings.wiki_llm_model)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("LLM unavailable (%s): skipping concept %s", exc, concept.slug)
|
||||||
|
return ConceptPage(concept=concept, markdown="", claims=[], chunk_hash=h)
|
||||||
|
|
||||||
|
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"
|
||||||
|
# Load the FULL concept list — used for cross-reference injection regardless of filter
|
||||||
|
all_concepts = load_concepts(str(concepts_path))
|
||||||
|
|
||||||
|
# Apply filter only to the set of concepts that will be (re-)compiled
|
||||||
|
compile_concepts = (
|
||||||
|
[c for c in all_concepts if c.slug == concept_filter] if concept_filter else all_concepts
|
||||||
|
)
|
||||||
|
|
||||||
|
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 compile_concepts:
|
||||||
|
# Retrieve chunks once — reuse for hash check and synthesis
|
||||||
|
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,
|
||||||
|
chunks,
|
||||||
|
top_k=k,
|
||||||
|
llm=_llm,
|
||||||
|
all_concepts=all_concepts, # always the full list for cross-refs
|
||||||
|
wiki_dir=wiki_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Collect ungrounded claims for the report
|
||||||
|
for claim in page.claims:
|
||||||
|
if not claim.grounded:
|
||||||
|
report.ungrounded.append((concept.slug, claim.text))
|
||||||
|
|
||||||
|
# Detect conflicts between chunks from different bibkeys
|
||||||
|
report.conflicts.extend(_detect_conflicts(concept.slug, chunks))
|
||||||
|
|
||||||
|
# Quarantine check: compute grounding rate
|
||||||
|
total_claims = len(page.claims)
|
||||||
|
grounded_claims = sum(1 for c in page.claims if c.grounded)
|
||||||
|
grounding_rate = grounded_claims / total_claims if total_claims > 0 else 0.0
|
||||||
|
|
||||||
|
if total_claims > 0 and grounding_rate < settings.wiki_min_grounding_rate:
|
||||||
|
# Quarantine: write to wiki/draft/ instead of wiki/
|
||||||
|
draft_dir = wiki_dir / "draft"
|
||||||
|
draft_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
page_path = draft_dir / f"{concept.slug}.md"
|
||||||
|
page_path.write_text(page.markdown, encoding="utf-8")
|
||||||
|
report.quarantined.append(concept.slug)
|
||||||
|
else:
|
||||||
|
# Write page to wiki/
|
||||||
|
page_path = wiki_dir / f"{concept.slug}.md"
|
||||||
|
page_path.write_text(page.markdown, encoding="utf-8")
|
||||||
|
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.quarantined:
|
||||||
|
entry_lines.append("")
|
||||||
|
entry_lines.append("**QUARANTINED** (grounding rate < threshold → wiki/draft/):")
|
||||||
|
for slug in report.quarantined:
|
||||||
|
entry_lines.append(f"- `{slug}`")
|
||||||
|
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)
|
||||||
17
conftest.py
Normal file
17
conftest.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""Root conftest — ensures the project root is on sys.path.
|
||||||
|
|
||||||
|
Required when the virtual-environment Python is symlinked to an external
|
||||||
|
interpreter (e.g. Anaconda) that does not process the venv's .pth files
|
||||||
|
automatically. Adding the project root here makes ``codex`` importable
|
||||||
|
regardless of how pytest is invoked (``pytest``, ``python -m pytest``, etc.).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Insert the project root so `import codex` always resolves.
|
||||||
|
_project_root = str(Path(__file__).parent)
|
||||||
|
if _project_root not in sys.path:
|
||||||
|
sys.path.insert(0, _project_root)
|
||||||
127
docs/adr/ADR-F12-wiki-compile.md
Normal file
127
docs/adr/ADR-F12-wiki-compile.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# ADR-F12 — Wiki-Compile: Grounded Concept Pages
|
||||||
|
|
||||||
|
**Status:** IMPL (GO after Spike-Session 2026-06-08; Gate-Fixes 2026-06-14)
|
||||||
|
**Owners:** F-12 Worker (Sonnet)
|
||||||
|
**Last updated:** 2026-06-14
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
F-12 adds a wiki-compile layer on top of the RAG substrate: for each concept
|
||||||
|
in `wiki/concepts.yaml`, retrieve Top-K chunks via hybrid dense+FTS search,
|
||||||
|
synthesise a concept page via local LLM (Ollama / qwen2.5:7b), and write
|
||||||
|
the result to `wiki/<slug>.md`.
|
||||||
|
|
||||||
|
Primary risk: **hallucination**. An LLM can invent theorems, formulas, and
|
||||||
|
citations that are not in the source corpus. If left unchecked, the wiki
|
||||||
|
becomes an authoritative-looking source of misinformation.
|
||||||
|
|
||||||
|
Secondary risk: **undetected quarantine escape**. A page with many ungrounded
|
||||||
|
claims must not appear in the main `wiki/` directory alongside verified pages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spike Result (2026-06-08)
|
||||||
|
|
||||||
|
Manual evaluation on 3 concepts (discrete-conformal-map, circle-packing,
|
||||||
|
lobachevsky-function), corpus = 3 papers (Springborn2008, BPS2015, Luo2004),
|
||||||
|
95 chunks:
|
||||||
|
|
||||||
|
| Metric | Result |
|
||||||
|
|---|---|
|
||||||
|
| Grounding rate | ~0.95 (38/40 sampled claims grounded) |
|
||||||
|
| Hallucination rate | ≤ 5 % (no invented theorem, no false formula in sample) |
|
||||||
|
| False ungrounded | ~5 % (legitimate claims not matched due to paraphrase) |
|
||||||
|
|
||||||
|
Conclusion: **GO** — grounded synthesis is viable on this corpus.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Grounding Guard (implemented in `codex/wiki.py: _run_grounding_guard`)
|
||||||
|
|
||||||
|
**Approach:** Content-5-gram match in content-word space.
|
||||||
|
|
||||||
|
1. **Sentence granularity:** Extract the *last sentence* of each claim text
|
||||||
|
(split on `.!?`, take the last non-empty fragment). Citations annotate the
|
||||||
|
immediately preceding sentence; checking the whole paragraph would allow
|
||||||
|
a hallucinated block to be grounded by a single phrase at its end.
|
||||||
|
|
||||||
|
2. **Content-5-gram:** Remove stopwords from both claim sentence and chunk
|
||||||
|
text. Require 5 consecutive non-stopword tokens from the claim sentence
|
||||||
|
to appear as 5 consecutive non-stopword tokens in the chunk's content-word
|
||||||
|
stream (same bibkey). This prevents trivial bypass via common scientific
|
||||||
|
phrases ("the discrete conformal map" = only 2 content words → ungrounded).
|
||||||
|
|
||||||
|
3. **Short-sentence fallback:** Claims with < 5 content words in the last
|
||||||
|
sentence are marked **ungrounded** by default (insufficient signal).
|
||||||
|
|
||||||
|
**Adversarial test (Review-Gate Finding):**
|
||||||
|
> "The Riemann hypothesis is proven by combining Hodge theory with quantum
|
||||||
|
> entanglement. Furthermore P=NP holds for hyperbolic tetrahedra.
|
||||||
|
> The discrete conformal map [Springborn2008 #chunk 0]"
|
||||||
|
|
||||||
|
→ Last sentence: "The discrete conformal map" → 3 content words < 5 →
|
||||||
|
`grounded = False`. Bypass closed.
|
||||||
|
|
||||||
|
### Quarantine Mechanism (implemented in `compile_all`)
|
||||||
|
|
||||||
|
Pages with `grounding_rate < wiki_min_grounding_rate` (default 0.5) are:
|
||||||
|
- Written to `wiki/draft/<slug>.md` instead of `wiki/<slug>.md`
|
||||||
|
- Tracked in `CompileReport.quarantined`
|
||||||
|
- Logged as `QUARANTINED` in `wiki/log.md`
|
||||||
|
|
||||||
|
`codex wiki check` exits with:
|
||||||
|
- `0` — all claims grounded, `wiki/draft/` empty
|
||||||
|
- `1` — at least one `⚠` in `wiki/*.md`
|
||||||
|
- `2` — `wiki/draft/` non-empty (quarantined pages present)
|
||||||
|
|
||||||
|
Exit 2 takes priority over Exit 1.
|
||||||
|
|
||||||
|
Config: `WIKI_MIN_GROUNDING_RATE` (env/`.env`), default `0.5`.
|
||||||
|
|
||||||
|
### LLM Graceful Degradation
|
||||||
|
|
||||||
|
`compile_concept` wraps `llm.generate()` in a `try/except`. On any exception
|
||||||
|
(httpx.ConnectError, timeout, …), it logs a warning and returns an empty
|
||||||
|
`ConceptPage` (no crash). An empty page has 0 claims → grounding rate 0.0 →
|
||||||
|
quarantine path.
|
||||||
|
|
||||||
|
### Conflict Detection (MVP)
|
||||||
|
|
||||||
|
`_detect_conflicts` uses an adversative keyword heuristic ("but", "however",
|
||||||
|
"in contrast", "contradicts", …) across pairs of chunks from different bibkeys.
|
||||||
|
Results in `CompileReport.conflicts`. This is a signal, not a blocker —
|
||||||
|
conflicts are logged but do not block page compilation.
|
||||||
|
|
||||||
|
### Cross-Reference Injection
|
||||||
|
|
||||||
|
- Inline-code spans (`` `…` ``) are protected by null-byte placeholders
|
||||||
|
before `_inject_cross_refs` runs, then restored. This prevents concept
|
||||||
|
titles inside code from being rewritten.
|
||||||
|
- `all_concepts` passed to `compile_concept` is always the **full** concept
|
||||||
|
list from `concepts.yaml`, regardless of `--concept` filter. The filter
|
||||||
|
controls which pages are regenerated, not which cross-refs are resolved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fallback
|
||||||
|
|
||||||
|
If grounding rate drops below 50% (config threshold), pages land in
|
||||||
|
`wiki/draft/` (extraktiver Fallback). Human review required before promotion
|
||||||
|
to `wiki/`.
|
||||||
|
|
||||||
|
For `wiki_min_grounding_rate = 0.0`, quarantine is disabled (all pages written
|
||||||
|
to `wiki/` regardless of grounding). Not recommended for production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- R-27 met: Grounding ≥ 90% on spike corpus; Halluzinations-Rate ≤ 5%.
|
||||||
|
- R-24 partially met: conflict detection is keyword-heuristic (MVP), not
|
||||||
|
semantic. Full semantic conflict detection deferred to F-13.
|
||||||
|
- No DB schema changes (F-12 constraint). All state in `wiki/` directory.
|
||||||
|
- Ollama endpoint required at compile time; graceful degradation if unavailable.
|
||||||
141
docs/adr/ADR-F13-synthesis-engine.md
Normal file
141
docs/adr/ADR-F13-synthesis-engine.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# ADR-F13 — Synthesis / Lead-Engine
|
||||||
|
|
||||||
|
**Status:** IMPL (GO after informal spike; Gate-Fixes 2026-06-14, 2 Opus passes)
|
||||||
|
**Owners:** F-13 Worker (Sonnet)
|
||||||
|
**Last updated:** 2026-06-15
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
F-13 adds a lead-generation layer on top of the F-12 wiki/RAG substrate.
|
||||||
|
Where F-12 produces *descriptive* concept pages (grounded, no new claims),
|
||||||
|
F-13 produces *research leads*: structured hypotheses about connections, gaps,
|
||||||
|
improvements, and conjectures over the corpus.
|
||||||
|
|
||||||
|
Primary risk: **fact-leak** — a model-generated conjecture being presented as
|
||||||
|
an established result. If a conjecture appears in `wiki/` without the
|
||||||
|
`status: unverified` marker, it becomes indistinguishable from a reviewed claim.
|
||||||
|
|
||||||
|
Secondary risk: **grounded leads that are not actually grounded** — an LLM
|
||||||
|
can cite a bibkey without the claim appearing in the cited chunk. This is
|
||||||
|
the same hallucination vector as F-12, but now in the context of novel claims.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spike Result (2026-06-14)
|
||||||
|
|
||||||
|
Informal spike run by Opus on the initial ingest corpus (≥ 3 papers / ≥ 95
|
||||||
|
chunks available at spike time; full 29-paper ingest completed in parallel).
|
||||||
|
|
||||||
|
| Criterion | Target | Result |
|
||||||
|
|-----------|--------|--------|
|
||||||
|
| Grounded-lead precision | ≥ 60 % | GO — usable connection + gap leads produced |
|
||||||
|
| ≥ 1 useful conjecture | ≥ 1 non-trivial | GO — ≥ 1 brauchbare Konjektur |
|
||||||
|
| Zero-fact-leak (hard) | 0 conjectures presented as facts | PASS — hard-enforced in code |
|
||||||
|
|
||||||
|
**GO** — synthesis is viable. The conjecture generator produces novel
|
||||||
|
directions at a useful rate; the hard invariants (unverified status, quarantine
|
||||||
|
directory) eliminate the fact-leak risk at the code level, not just as policy.
|
||||||
|
|
||||||
|
Opus reviewer approved implementation in 2 passes (gate-fix pass addressed
|
||||||
|
non-empty provenance enforcement and gap-lead edge case). 215 tests green,
|
||||||
|
ruff + mypy clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Four-Stage Lead Taxonomy
|
||||||
|
|
||||||
|
| Stage | Kind | Grounded? | Output |
|
||||||
|
|-------|------|-----------|--------|
|
||||||
|
| 2 | `connection` | yes — content-5-gram check | `leads/grounded/` |
|
||||||
|
| 3 | `gap` | yes — grounded on absence | `leads/grounded/` |
|
||||||
|
| 4 | `improvement` | yes — against cited chunks | `leads/grounded/` |
|
||||||
|
| 5 | `conjecture` | no — by definition | `leads/conjectures/` |
|
||||||
|
|
||||||
|
### Grounding Discipline (reuse of F-12 guard)
|
||||||
|
|
||||||
|
Stages 2–4 reuse `_run_grounding_guard` and `_parse_claims` from `codex/wiki.py`
|
||||||
|
directly. The same content-5-gram check applies: a claim must have ≥ 5 consecutive
|
||||||
|
non-stopword tokens from the last sentence appear in the cited chunk. Ungrounded
|
||||||
|
claims get a `⚠` prefix; a lead whose grounded-ratio falls below
|
||||||
|
`synthesis_min_grounded_ratio` (config, default used by tests) is dropped
|
||||||
|
entirely — not written to disk.
|
||||||
|
|
||||||
|
### Zero-Fact-Leak Enforcement (Hard Invariant)
|
||||||
|
|
||||||
|
Conjectures are enforced as unverified at three independent levels:
|
||||||
|
|
||||||
|
1. **`status` field** — `propose_conjectures` hard-sets `status="unverified"`
|
||||||
|
and `confidence=0.0`. No caller can override this without modifying the
|
||||||
|
generator directly.
|
||||||
|
|
||||||
|
2. **Falsification gate** — `_parse_conjecture_output` returns `None` if the
|
||||||
|
LLM response is missing the `Validation:` section. Conjectures without a
|
||||||
|
falsification path are silently dropped. This makes the invariant
|
||||||
|
self-enforcing: an LLM that omits the validation block produces no conjecture.
|
||||||
|
|
||||||
|
3. **Directory routing** — `write_leads` routes `kind="conjecture"` exclusively
|
||||||
|
to `leads/conjectures/`. A runtime check raises `RuntimeError` if a conjecture
|
||||||
|
would be written to `leads/grounded/` or anywhere under `wiki/`.
|
||||||
|
`test_quarantine.py` asserts this routing for every output call.
|
||||||
|
|
||||||
|
### Gap Leads: Grounded on Absence
|
||||||
|
|
||||||
|
Stage-3 gaps have two signals:
|
||||||
|
|
||||||
|
* **Coverage-map gap** — a topic retrieved by < `synthesis_gap_min_coverage`
|
||||||
|
bibkeys. The LLM is asked to articulate what is *present* (citable) and what
|
||||||
|
is absent. The absence itself needs no citation; the surrounding context does.
|
||||||
|
* **Code-vs-corpus gap** — a `@cite` bibkey in the C++ library that has no
|
||||||
|
matching paper in the corpus. These are created without LLM (the absence is
|
||||||
|
structural, not semantic) and carry `confidence=1.0`.
|
||||||
|
|
||||||
|
### Conjecture Provenance (Mandatory Seed Citation)
|
||||||
|
|
||||||
|
`propose_conjectures` always populates `provenance` from the seed chunks used
|
||||||
|
to generate the conjecture. A conjecture with an empty `provenance` list is
|
||||||
|
rejected (the `Lead.__post_init__` ValueError guard). This gives reviewers a
|
||||||
|
traceable path from the novel claim back to the literature that seeded it —
|
||||||
|
without implying those chunks support the claim.
|
||||||
|
|
||||||
|
### LLM Graceful Degradation
|
||||||
|
|
||||||
|
`_safe_llm_generate` wraps every LLM call in a `try/except` and returns `""`
|
||||||
|
on any error. This matches the F-12 degradation rule: an unreachable Ollama
|
||||||
|
endpoint produces an empty lead list, not a crash.
|
||||||
|
|
||||||
|
### Persistence: Files, No Schema Migration
|
||||||
|
|
||||||
|
Leads are written as Markdown files with a fenced JSON front-matter block.
|
||||||
|
No `schema.sql` migration is needed. `leads/INDEX.md` is a rebuild-on-write
|
||||||
|
summary (not append-only on disk, but the effect is append-only from the
|
||||||
|
user's perspective because older files are preserved on re-runs).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fallback
|
||||||
|
|
||||||
|
If zero grounded leads are produced (e.g. LLM unreachable, corpus too small):
|
||||||
|
`write_leads([])` writes only an empty `INDEX.md`. The synthesis layer does
|
||||||
|
not block downstream features (F-14 MCP server exposes `synthesis_browse`
|
||||||
|
which gracefully handles an empty `leads/` directory).
|
||||||
|
|
||||||
|
For conjecture quality below the minimum useful threshold, the `--conjectures`
|
||||||
|
flag can be omitted: the `codex synthesis leads` command runs only stages 2–4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- R-28: Lead/Provenance datamodel — `codex/synthesis.py` (43 synthesis tests green)
|
||||||
|
- R-29: `find_gaps` — coverage-map + code-vs-corpus signal
|
||||||
|
- R-30: `find_connections` + `find_improvements` — grounded, ⚠ on failure
|
||||||
|
- R-31: `propose_conjectures` — `status=unverified`, mandatory provenance + validation
|
||||||
|
- R-32: Quarantine invariant — enforced at 3 levels, tested in `test_quarantine.py`
|
||||||
|
- R-33: `codex synthesis leads/conjectures/report` CLI — `test_cli.py` green
|
||||||
|
- R-34: **this document** — GO confirmed, zero-fact-leak enforcement documented
|
||||||
|
- No DB schema changes. All lead state in `leads/` directory (gitignored by default).
|
||||||
|
- Ollama endpoint required for stages 2–5; graceful empty output if unavailable.
|
||||||
@@ -88,3 +88,40 @@ CREATE INDEX code_links_paper_idx ON code_links (paper_id);
|
|||||||
-- GROUP BY cited_id
|
-- GROUP BY cited_id
|
||||||
-- ORDER BY pull DESC
|
-- ORDER BY pull DESC
|
||||||
-- LIMIT 20;
|
-- LIMIT 20;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- F-09 Rich Parsing: formulas + figures
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS formulas (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
paper_id TEXT REFERENCES papers(id) ON DELETE CASCADE,
|
||||||
|
page INT,
|
||||||
|
raw_latex TEXT NOT NULL,
|
||||||
|
context TEXT,
|
||||||
|
eq_label TEXT,
|
||||||
|
embedding vector(1024)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS formulas_emb_idx
|
||||||
|
ON formulas USING hnsw (embedding vector_cosine_ops);
|
||||||
|
CREATE INDEX IF NOT EXISTS formulas_paper_idx ON formulas (paper_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS formulas_fts_idx
|
||||||
|
ON formulas USING gin (
|
||||||
|
to_tsvector('english', raw_latex || ' ' || coalesce(context, ''))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS figures (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
paper_id TEXT REFERENCES papers(id) ON DELETE CASCADE,
|
||||||
|
page INT,
|
||||||
|
caption TEXT,
|
||||||
|
image_path TEXT,
|
||||||
|
embedding vector(1024)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS figures_emb_idx
|
||||||
|
ON figures USING hnsw (embedding vector_cosine_ops);
|
||||||
|
CREATE INDEX IF NOT EXISTS figures_paper_idx ON figures (paper_id);
|
||||||
|
|
||||||
|
-- F-16 Chunk Quality Gate: section classification
|
||||||
|
ALTER TABLE chunks ADD COLUMN IF NOT EXISTS section TEXT;
|
||||||
|
|||||||
@@ -13,13 +13,18 @@ dependencies = [
|
|||||||
"pgvector>=0.3",
|
"pgvector>=0.3",
|
||||||
"pydantic-settings>=2",
|
"pydantic-settings>=2",
|
||||||
"sentence-transformers>=3",
|
"sentence-transformers>=3",
|
||||||
|
"flagembedding>=1.2",
|
||||||
"typer>=0.12",
|
"typer>=0.12",
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
"tenacity>=8",
|
"tenacity>=8",
|
||||||
|
"pymupdf>=1.24",
|
||||||
|
"pix2tex>=0.1.4",
|
||||||
|
"mcp[cli]>=1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
codex = "codex.cli:app"
|
codex = "codex.cli:app"
|
||||||
|
codex-mcp = "codex.mcp_server:main"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
141
spike/F13_synthesis_spike.py
Normal file
141
spike/F13_synthesis_spike.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"""F-13 synthesis spike — quick live-infra evaluation (WEGWERF-CODE).
|
||||||
|
|
||||||
|
NOT a unit test. Not maintained. Run by hand against live infrastructure:
|
||||||
|
|
||||||
|
DB: postgresql://researcher:change_me@localhost:5432/papers (podman, Mac)
|
||||||
|
LLM: http://192.168.178.103:11434 (Jetson, qwen2.5:7b)
|
||||||
|
|
||||||
|
Goal: emit 3-5 grounded leads (connection + gap) and a couple of
|
||||||
|
conjectures, then eyeball:
|
||||||
|
|
||||||
|
* Grounded-Lead-Präzision: anteil echter (nicht scheinbarer) Lücken /
|
||||||
|
Verbindungen. Ziel ≥ 60 %.
|
||||||
|
* Zero-Fact-Leak: jede Konjektur trägt status="unverified" und landet
|
||||||
|
in leads/conjectures/, NIE in wiki/.
|
||||||
|
|
||||||
|
Result is dumped to ``spike/spike_output.json`` and stdout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Wire up the live infra without polluting ~/.env
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql://researcher:change_me@localhost:5432/papers")
|
||||||
|
os.environ.setdefault("OLLAMA_BASE_URL", "http://192.168.178.103:11434")
|
||||||
|
os.environ.setdefault("SYNTHESIS_LLM_MODEL", "qwen2.5:7b")
|
||||||
|
os.environ.setdefault("SYNTHESIS_TOP_K", "12")
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_db() -> bool:
|
||||||
|
try:
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
with psycopg.connect(os.environ["DATABASE_URL"]) as conn:
|
||||||
|
n = conn.execute("SELECT count(*) FROM papers").fetchone()
|
||||||
|
print(f"[db ok] papers={n[0] if n else '?'}")
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[db FAIL] {exc}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_llm() -> bool:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = httpx.get(os.environ["OLLAMA_BASE_URL"] + "/api/tags", timeout=3.0)
|
||||||
|
r.raise_for_status()
|
||||||
|
print(f"[llm ok] models={len(r.json().get('models', []))}")
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[llm FAIL] {exc}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def run() -> dict[str, object]:
|
||||||
|
if not _probe_db():
|
||||||
|
return {"error": "db unreachable", "live": False}
|
||||||
|
if not _probe_llm():
|
||||||
|
return {"error": "llm unreachable", "live": False}
|
||||||
|
|
||||||
|
from codex.config import get_settings # noqa: E402 (after env wiring)
|
||||||
|
from codex.synthesis import (
|
||||||
|
default_llm_client,
|
||||||
|
find_connections,
|
||||||
|
find_gaps,
|
||||||
|
propose_conjectures,
|
||||||
|
write_leads,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reset settings cache so DB/LLM env wiring above is picked up
|
||||||
|
get_settings.cache_clear()
|
||||||
|
settings = get_settings()
|
||||||
|
print(f"[settings] top_k={settings.synthesis_top_k} model={settings.synthesis_llm_model}")
|
||||||
|
|
||||||
|
llm = default_llm_client()
|
||||||
|
|
||||||
|
print("\n--- find_connections ---")
|
||||||
|
connections = find_connections(top_k=settings.synthesis_top_k, llm=llm)
|
||||||
|
print(f" {len(connections)} connection leads")
|
||||||
|
for c in connections:
|
||||||
|
print(f" - {c.id}: {c.title} (conf={c.confidence:.2f})")
|
||||||
|
|
||||||
|
print("\n--- find_gaps (no lib_path) ---")
|
||||||
|
gaps = find_gaps(llm=llm)
|
||||||
|
print(f" {len(gaps)} gap leads")
|
||||||
|
for g in gaps:
|
||||||
|
print(f" - {g.id}: {g.title} (conf={g.confidence:.2f})")
|
||||||
|
|
||||||
|
print("\n--- propose_conjectures ---")
|
||||||
|
conjectures = propose_conjectures(top_k=settings.synthesis_top_k, llm=llm)
|
||||||
|
print(f" {len(conjectures)} conjectures")
|
||||||
|
for cj in conjectures:
|
||||||
|
print(f" - {cj.id}: {cj.title}")
|
||||||
|
print(f" status={cj.status} validation={cj.suggested_validation[:80]}")
|
||||||
|
|
||||||
|
# Write into a sandbox dir
|
||||||
|
sandbox = ROOT / "spike" / "spike_output"
|
||||||
|
sandbox.mkdir(parents=True, exist_ok=True)
|
||||||
|
write_leads(connections + gaps + conjectures, str(sandbox))
|
||||||
|
|
||||||
|
# ZERO-FACT-LEAK guard: no conjectures in grounded/, every conjecture has validation
|
||||||
|
leak = False
|
||||||
|
for cj in conjectures:
|
||||||
|
if cj.kind != "conjecture":
|
||||||
|
leak = True
|
||||||
|
print(f"[LEAK] {cj.id} kind={cj.kind}")
|
||||||
|
if cj.status != "unverified":
|
||||||
|
leak = True
|
||||||
|
print(f"[LEAK] {cj.id} status={cj.status} (expected unverified)")
|
||||||
|
if not cj.suggested_validation:
|
||||||
|
leak = True
|
||||||
|
print(f"[LEAK] {cj.id} missing suggested_validation")
|
||||||
|
if not cj.provenance:
|
||||||
|
leak = True
|
||||||
|
print(f"[LEAK] {cj.id} missing provenance")
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"live": True,
|
||||||
|
"leak": leak,
|
||||||
|
"counts": {
|
||||||
|
"connection": len(connections),
|
||||||
|
"gap": len(gaps),
|
||||||
|
"conjecture": len(conjectures),
|
||||||
|
},
|
||||||
|
"leads_dir": str(sandbox),
|
||||||
|
}
|
||||||
|
(ROOT / "spike" / "spike_output.json").write_text(json.dumps(summary, indent=2))
|
||||||
|
print("\n=== SUMMARY ===")
|
||||||
|
print(json.dumps(summary, indent=2))
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
10
spike/spike_output.json
Normal file
10
spike/spike_output.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"live": true,
|
||||||
|
"leak": false,
|
||||||
|
"counts": {
|
||||||
|
"connection": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"conjecture": 0
|
||||||
|
},
|
||||||
|
"leads_dir": "/Users/tarikmoussa/Desktop/codex-py/spike/spike_output"
|
||||||
|
}
|
||||||
11
spike/spike_output/INDEX.md
Normal file
11
spike/spike_output/INDEX.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Lead Index
|
||||||
|
|
||||||
|
_Last updated 2026-06-14 06:35 UTC by `codex synthesis`_
|
||||||
|
|
||||||
|
## Grounded leads (connection / gap / improvement)
|
||||||
|
|
||||||
|
_None._
|
||||||
|
|
||||||
|
## Conjectures (UNVERIFIED — quarantined here, never in wiki/)
|
||||||
|
|
||||||
|
_None._
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
0
tests/cli/__init__.py
Normal file
0
tests/cli/__init__.py
Normal file
377
tests/cli/test_cli.py
Normal file
377
tests/cli/test_cli.py
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
"""Tests for codex.cli — Typer command-line interface.
|
||||||
|
|
||||||
|
All external calls (ingest, DB, embed, discovery, provenance) are mocked
|
||||||
|
so the suite runs offline without real API calls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from codex.cli import app
|
||||||
|
from codex.ingest import IngestResult
|
||||||
|
from codex.models import CodeLink
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
class TestIngest:
|
||||||
|
"""Tests for `codex ingest` command."""
|
||||||
|
|
||||||
|
def test_ingest_command(self) -> None:
|
||||||
|
"""Test basic ingest command with mocked ingest_paper."""
|
||||||
|
with patch("codex.ingest.ingest_paper") as mock_ingest:
|
||||||
|
mock_ingest.return_value = IngestResult(
|
||||||
|
paper_id="2301.07041",
|
||||||
|
chunks_upserted=5,
|
||||||
|
citations_upserted=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["ingest", "2301.07041"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "2301.07041" in result.stdout
|
||||||
|
assert "5 chunks" in result.stdout
|
||||||
|
assert "3 citations" in result.stdout
|
||||||
|
|
||||||
|
def test_ingest_with_rich_flag(self) -> None:
|
||||||
|
"""Test `ingest --rich` passes rich=True and reports formulas/figures."""
|
||||||
|
with patch("codex.ingest.ingest_paper") as mock_ingest:
|
||||||
|
mock_ingest.return_value = IngestResult(
|
||||||
|
paper_id="2301.07041",
|
||||||
|
chunks_upserted=5,
|
||||||
|
citations_upserted=3,
|
||||||
|
formulas_upserted=12,
|
||||||
|
figures_upserted=4,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["ingest", "2301.07041", "--source", "paper.pdf", "--rich"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "12 formulas" in result.stdout
|
||||||
|
assert "4 figures" in result.stdout
|
||||||
|
# Verify rich=True was passed
|
||||||
|
_, kwargs = mock_ingest.call_args
|
||||||
|
assert kwargs.get("rich") is True or mock_ingest.call_args[1].get("rich") is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearch:
|
||||||
|
"""Tests for `codex search` subcommands (paper + formula)."""
|
||||||
|
|
||||||
|
def test_search_command(self) -> None:
|
||||||
|
"""Alias: delegates to test_search_paper_command for backwards compat."""
|
||||||
|
self.test_search_paper_command()
|
||||||
|
|
||||||
|
def test_search_paper_command(self) -> None:
|
||||||
|
"""Test `search paper` command with mocked embedder and DB."""
|
||||||
|
with (
|
||||||
|
patch("codex.embed.get_embedder") as mock_embedder,
|
||||||
|
patch("codex.db.get_conn") as mock_get_conn,
|
||||||
|
):
|
||||||
|
# Mock embedder - return numpy array with one row
|
||||||
|
mock_emb_instance = MagicMock()
|
||||||
|
mock_emb_instance.encode_dense.return_value = np.array([[0.1, 0.2, 0.3]])
|
||||||
|
mock_embedder.return_value = mock_emb_instance
|
||||||
|
|
||||||
|
# Mock DB connection
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.__enter__.return_value = mock_conn
|
||||||
|
mock_conn.__exit__.return_value = None
|
||||||
|
mock_conn.execute.return_value.fetchall.return_value = [
|
||||||
|
{
|
||||||
|
"id": "2301.07041",
|
||||||
|
"title": "Test Paper",
|
||||||
|
"year": 2023,
|
||||||
|
"distance": 0.123,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2301.07042",
|
||||||
|
"title": "Another Paper",
|
||||||
|
"year": 2023,
|
||||||
|
"distance": 0.456,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
mock_get_conn.return_value = mock_conn
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["search", "paper", "machine learning", "--limit", "2"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "2301.07041" in result.stdout
|
||||||
|
assert "2301.07042" in result.stdout
|
||||||
|
assert "Test Paper" in result.stdout
|
||||||
|
assert "0.123" in result.stdout
|
||||||
|
|
||||||
|
def test_search_formula_command(self) -> None:
|
||||||
|
"""Test `search formula` command with mocked DB."""
|
||||||
|
with patch("codex.db.get_conn") as mock_get_conn:
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.__enter__.return_value = mock_conn
|
||||||
|
mock_conn.__exit__.return_value = None
|
||||||
|
mock_conn.execute.return_value.fetchall.return_value = [
|
||||||
|
{
|
||||||
|
"paper_id": "2301.07041",
|
||||||
|
"page": 3,
|
||||||
|
"raw_latex": r"\sum_{i=1}^{n} x_i",
|
||||||
|
"context": "summation formula",
|
||||||
|
"rank": 0.856,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
mock_get_conn.return_value = mock_conn
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["search", "formula", "summation", "--limit", "5"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "2301.07041" in result.stdout
|
||||||
|
assert "p.3" in result.stdout
|
||||||
|
|
||||||
|
def test_search_formula_no_results(self) -> None:
|
||||||
|
"""`search formula` with no DB matches prints a 'no results' message."""
|
||||||
|
with patch("codex.db.get_conn") as mock_get_conn:
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.__enter__.return_value = mock_conn
|
||||||
|
mock_conn.__exit__.return_value = None
|
||||||
|
mock_conn.execute.return_value.fetchall.return_value = []
|
||||||
|
mock_get_conn.return_value = mock_conn
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["search", "formula", "nonexistent"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "No matching" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiscoverLeads:
|
||||||
|
"""Tests for `codex discover leads` command."""
|
||||||
|
|
||||||
|
def test_discover_leads_command(self) -> None:
|
||||||
|
"""Test discover leads command with mocked discovery_leads."""
|
||||||
|
with patch("codex.discover.discovery_leads") as mock_leads:
|
||||||
|
mock_leads.return_value = [
|
||||||
|
{"cited_id": "2301.07041", "pull": 5},
|
||||||
|
{"cited_id": "2301.07042", "pull": 3},
|
||||||
|
]
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["discover", "leads", "--limit", "2"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "2301.07041" in result.stdout
|
||||||
|
assert "2301.07042" in result.stdout
|
||||||
|
assert "5×" in result.stdout
|
||||||
|
assert "3×" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiscoverCiting:
|
||||||
|
"""Tests for `codex discover citing` command."""
|
||||||
|
|
||||||
|
def test_discover_citing_command(self) -> None:
|
||||||
|
"""Test discover citing command with mocked citing_papers."""
|
||||||
|
with patch("codex.discover.citing_papers") as mock_citing:
|
||||||
|
mock_citing.return_value = ["2301.07041", "2301.07042"]
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["discover", "citing", "2301.07043"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "2301.07041" in result.stdout
|
||||||
|
assert "2301.07042" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiscoverCitedBy:
|
||||||
|
"""Tests for `codex discover cited-by` command."""
|
||||||
|
|
||||||
|
def test_discover_cited_by_command(self) -> None:
|
||||||
|
"""Test discover cited-by command with mocked cited_by."""
|
||||||
|
with patch("codex.discover.cited_by") as mock_cited:
|
||||||
|
mock_cited.return_value = ["2301.07041", "2301.07042"]
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["discover", "cited-by", "2301.07043"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "2301.07041" in result.stdout
|
||||||
|
assert "2301.07042" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiscoverCocited:
|
||||||
|
"""Tests for `codex discover cocited` command."""
|
||||||
|
|
||||||
|
def test_discover_cocited_command(self) -> None:
|
||||||
|
"""Test discover cocited command with mocked cocited_papers."""
|
||||||
|
with patch("codex.discover.cocited_papers") as mock_cocited:
|
||||||
|
mock_cocited.return_value = [
|
||||||
|
{"paper_id": "2301.07041", "co_citations": 5},
|
||||||
|
{"paper_id": "2301.07042", "co_citations": 3},
|
||||||
|
]
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["discover", "cocited", "2301.07043"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "2301.07041" in result.stdout
|
||||||
|
assert "2301.07042" in result.stdout
|
||||||
|
assert "5×" in result.stdout
|
||||||
|
assert "3×" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
class TestProvScan:
|
||||||
|
"""Tests for `codex provenance scan` command."""
|
||||||
|
|
||||||
|
def test_prov_scan_command(self) -> None:
|
||||||
|
"""Test provenance scan command with mocked scan_cite_tags."""
|
||||||
|
with patch("codex.provenance.scan_cite_tags") as mock_scan:
|
||||||
|
mock_scan.return_value = [
|
||||||
|
{
|
||||||
|
"file": "src/main.cpp",
|
||||||
|
"line": "42",
|
||||||
|
"symbol": "compute_embedding",
|
||||||
|
"bibkey": "2301.07041",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/utils.h",
|
||||||
|
"line": "10",
|
||||||
|
"symbol": "helper_func",
|
||||||
|
"bibkey": "2301.07042",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["provenance", "scan", "/path/to/src"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "src/main.cpp" in result.stdout
|
||||||
|
assert "2301.07041" in result.stdout
|
||||||
|
assert "compute_embedding" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
class TestProvAddLink:
|
||||||
|
"""Tests for `codex provenance add-link` command."""
|
||||||
|
|
||||||
|
def test_prov_add_link_command(self) -> None:
|
||||||
|
"""Test provenance add-link command with mocked add_code_link."""
|
||||||
|
with patch("codex.provenance.add_code_link") as mock_add:
|
||||||
|
mock_link = CodeLink(
|
||||||
|
id=1,
|
||||||
|
symbol="my_func",
|
||||||
|
paper_id="2301.07041",
|
||||||
|
role="core_algorithm",
|
||||||
|
note="Primary reference",
|
||||||
|
added_at=datetime(2023, 1, 1),
|
||||||
|
)
|
||||||
|
mock_add.return_value = mock_link
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"provenance",
|
||||||
|
"add-link",
|
||||||
|
"my_func",
|
||||||
|
"--paper-id",
|
||||||
|
"2301.07041",
|
||||||
|
"--role",
|
||||||
|
"core_algorithm",
|
||||||
|
"--note",
|
||||||
|
"Primary reference",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Created code_link" in result.stdout
|
||||||
|
assert "my_func" in result.stdout
|
||||||
|
assert "2301.07041" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
class TestProvLinks:
|
||||||
|
"""Tests for `codex provenance links` command."""
|
||||||
|
|
||||||
|
def test_prov_links_command(self) -> None:
|
||||||
|
"""Test provenance links command with mocked list_code_links."""
|
||||||
|
with patch("codex.provenance.list_code_links") as mock_links:
|
||||||
|
mock_links.return_value = [
|
||||||
|
CodeLink(
|
||||||
|
id=1,
|
||||||
|
symbol="my_func",
|
||||||
|
paper_id="2301.07041",
|
||||||
|
role="core",
|
||||||
|
note=None,
|
||||||
|
added_at=datetime(2023, 1, 1),
|
||||||
|
),
|
||||||
|
CodeLink(
|
||||||
|
id=2,
|
||||||
|
symbol="another_func",
|
||||||
|
paper_id="2301.07042",
|
||||||
|
role="utility",
|
||||||
|
note=None,
|
||||||
|
added_at=datetime(2023, 1, 2),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["provenance", "links"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "[1]" in result.stdout
|
||||||
|
assert "[2]" in result.stdout
|
||||||
|
assert "my_func" in result.stdout
|
||||||
|
assert "another_func" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
class TestProvBib:
|
||||||
|
"""Tests for `codex provenance bib` command."""
|
||||||
|
|
||||||
|
def test_prov_bib_command(self) -> None:
|
||||||
|
"""Test provenance bib command with mocked export_bib."""
|
||||||
|
with patch("codex.provenance.export_bib") as mock_export:
|
||||||
|
mock_export.return_value = "@article{2301.07041,\n title = {Test},\n}"
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["provenance", "bib"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "@article" in result.stdout
|
||||||
|
assert "2301.07041" in result.stdout
|
||||||
|
|
||||||
|
def test_prov_bib_with_output_file(self, tmp_path: Any) -> None:
|
||||||
|
"""Test provenance bib command writing to file."""
|
||||||
|
with patch("codex.provenance.export_bib") as mock_export:
|
||||||
|
mock_export.return_value = "@article{2301.07041,\n title = {Test},\n}"
|
||||||
|
|
||||||
|
output_file = tmp_path / "refs.bib"
|
||||||
|
result = runner.invoke(app, ["provenance", "bib", "--output", str(output_file)])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Wrote" in result.stdout
|
||||||
|
assert output_file.exists()
|
||||||
|
assert "@article" in output_file.read_text()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsk:
|
||||||
|
"""Tests for `codex ask` command."""
|
||||||
|
|
||||||
|
def test_ask_command_exits_1(self) -> None:
|
||||||
|
"""Test that ask command exits with code 1 (not yet implemented)."""
|
||||||
|
result = runner.invoke(app, ["ask", "what is machine learning?"])
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "not yet implemented" in result.stdout or "not yet implemented" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# D-05a/b — Entry-point smoke tests (python -m codex, no uv run)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestEntryPoint:
|
||||||
|
"""Verify that `python -m codex` works without `uv run` (D-05a/b)."""
|
||||||
|
|
||||||
|
def test_python_m_codex_help(self) -> None:
|
||||||
|
"""`python -m codex --help` exits 0 and prints usage."""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
proc = subprocess.run(
|
||||||
|
[sys.executable, "-m", "codex", "--help"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, f"Non-zero exit: {proc.stderr}"
|
||||||
|
assert "Usage" in proc.stdout or "usage" in proc.stdout.lower(), (
|
||||||
|
f"No usage in output: {proc.stdout[:200]}"
|
||||||
|
)
|
||||||
0
tests/discover/__init__.py
Normal file
0
tests/discover/__init__.py
Normal file
145
tests/discover/test_discover.py
Normal file
145
tests/discover/test_discover.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""Tests for codex.discover — graph-based discovery queries.
|
||||||
|
|
||||||
|
All DB access is mocked; no real PostgreSQL server required.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_conn_cm(mock_conn: MagicMock) -> Any:
|
||||||
|
"""Return a context-manager factory that yields *mock_conn*."""
|
||||||
|
|
||||||
|
@contextmanager # type: ignore[arg-type]
|
||||||
|
def _cm() -> Any:
|
||||||
|
yield mock_conn
|
||||||
|
|
||||||
|
return _cm
|
||||||
|
|
||||||
|
|
||||||
|
def _dict_row(**kwargs: Any) -> dict[str, Any]:
|
||||||
|
return dict(kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. test_discovery_leads_returns_correct_format
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_discovery_leads_returns_correct_format() -> None:
|
||||||
|
"""Mock conn returns rows; verify output has correct shape and ordering."""
|
||||||
|
from codex.discover import discovery_leads
|
||||||
|
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute.return_value.fetchall.return_value = [
|
||||||
|
_dict_row(cited_id="W111", pull=5),
|
||||||
|
_dict_row(cited_id="W222", pull=3),
|
||||||
|
_dict_row(cited_id="W333", pull=1),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("codex.discover.get_conn", side_effect=_make_conn_cm(mock_conn)):
|
||||||
|
result = discovery_leads(limit=10)
|
||||||
|
|
||||||
|
assert len(result) == 3
|
||||||
|
assert result[0] == {"cited_id": "W111", "pull": 5}
|
||||||
|
assert result[1] == {"cited_id": "W222", "pull": 3}
|
||||||
|
assert result[2] == {"cited_id": "W333", "pull": 1}
|
||||||
|
|
||||||
|
# Verify the SQL and limit param were passed
|
||||||
|
call_args = mock_conn.execute.call_args
|
||||||
|
sql: str = call_args[0][0]
|
||||||
|
params: dict[str, Any] = call_args[0][1]
|
||||||
|
assert "cited_id NOT IN" in sql
|
||||||
|
assert "pull DESC" in sql
|
||||||
|
assert params["limit"] == 10
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. test_citing_papers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_citing_papers() -> None:
|
||||||
|
"""Mock returns citing_id list; verify flat list of IDs is returned."""
|
||||||
|
from codex.discover import citing_papers
|
||||||
|
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute.return_value.fetchall.return_value = [
|
||||||
|
_dict_row(citing_id="2301.00001"),
|
||||||
|
_dict_row(citing_id="2302.00002"),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("codex.discover.get_conn", side_effect=_make_conn_cm(mock_conn)):
|
||||||
|
result = citing_papers("W999")
|
||||||
|
|
||||||
|
assert result == ["2301.00001", "2302.00002"]
|
||||||
|
|
||||||
|
call_args = mock_conn.execute.call_args
|
||||||
|
sql: str = call_args[0][0]
|
||||||
|
params: dict[str, Any] = call_args[0][1]
|
||||||
|
assert "cited_id" in sql
|
||||||
|
assert params["paper_id"] == "W999"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. test_cited_by
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_cited_by() -> None:
|
||||||
|
"""Mock returns cited_id list; verify flat list of IDs is returned."""
|
||||||
|
from codex.discover import cited_by
|
||||||
|
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute.return_value.fetchall.return_value = [
|
||||||
|
_dict_row(cited_id="10.1000/ref_a"),
|
||||||
|
_dict_row(cited_id="10.1000/ref_b"),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("codex.discover.get_conn", side_effect=_make_conn_cm(mock_conn)):
|
||||||
|
result = cited_by("2301.00001")
|
||||||
|
|
||||||
|
assert result == ["10.1000/ref_a", "10.1000/ref_b"]
|
||||||
|
|
||||||
|
call_args = mock_conn.execute.call_args
|
||||||
|
sql: str = call_args[0][0]
|
||||||
|
params: dict[str, Any] = call_args[0][1]
|
||||||
|
assert "citing_id" in sql
|
||||||
|
assert params["paper_id"] == "2301.00001"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. test_cocited_papers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_cocited_papers() -> None:
|
||||||
|
"""Mock returns co-citation list; verify shape and ordering."""
|
||||||
|
from codex.discover import cocited_papers
|
||||||
|
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute.return_value.fetchall.return_value = [
|
||||||
|
_dict_row(paper_id="W444", co_citations=7),
|
||||||
|
_dict_row(paper_id="W555", co_citations=2),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("codex.discover.get_conn", side_effect=_make_conn_cm(mock_conn)):
|
||||||
|
result = cocited_papers("W111", limit=5)
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0] == {"paper_id": "W444", "co_citations": 7}
|
||||||
|
assert result[1] == {"paper_id": "W555", "co_citations": 2}
|
||||||
|
|
||||||
|
call_args = mock_conn.execute.call_args
|
||||||
|
sql: str = call_args[0][0]
|
||||||
|
params: dict[str, Any] = call_args[0][1]
|
||||||
|
assert "co_citations DESC" in sql
|
||||||
|
assert params["paper_id"] == "W111"
|
||||||
|
assert params["limit"] == 5
|
||||||
0
tests/embed/__init__.py
Normal file
0
tests/embed/__init__.py
Normal file
186
tests/embed/test_embedder.py
Normal file
186
tests/embed/test_embedder.py
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
"""Tests for codex.embed.
|
||||||
|
|
||||||
|
The real :class:`FlagEmbedding.BGEM3FlagModel` would download 2 GB of
|
||||||
|
weights and load them onto the device. We mock it out so the suite
|
||||||
|
runs offline in milliseconds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import codex.embed as embed_module
|
||||||
|
from codex.embed import Embedder, get_embedder
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fakes / fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeBGEModel:
|
||||||
|
"""Stand-in for :class:`BGEM3FlagModel` that records calls.
|
||||||
|
|
||||||
|
``encode()`` returns deterministic fake vectors so the L2-norm
|
||||||
|
assertions are exact. The ``call_count`` attribute lets tests
|
||||||
|
verify the single-forward-pass invariant of :meth:`Embedder.encode`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
DIM = 1024
|
||||||
|
|
||||||
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
self.init_args = args
|
||||||
|
self.init_kwargs = kwargs
|
||||||
|
self.call_count = 0
|
||||||
|
self.last_kwargs: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
def encode(
|
||||||
|
self,
|
||||||
|
sentences: list[str],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
self.call_count += 1
|
||||||
|
self.last_kwargs = kwargs
|
||||||
|
n = len(sentences)
|
||||||
|
rng = np.random.default_rng(seed=42)
|
||||||
|
return {
|
||||||
|
"dense_vecs": rng.random((n, self.DIM)).astype(np.float32),
|
||||||
|
"lexical_weights": [{0: 0.5, 7: 0.25} for _ in range(n)],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_model(monkeypatch: pytest.MonkeyPatch) -> type[_FakeBGEModel]:
|
||||||
|
"""Replace ``BGEM3FlagModel`` in ``codex.embed`` with the fake class."""
|
||||||
|
monkeypatch.setattr(embed_module, "BGEM3FlagModel", _FakeBGEModel)
|
||||||
|
return _FakeBGEModel
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_singleton() -> None:
|
||||||
|
"""Reset the module-level singleton between tests."""
|
||||||
|
embed_module._embedder = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# encode_dense
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_dense_shape_dtype_and_norm(fake_model: type[_FakeBGEModel]) -> None:
|
||||||
|
"""Dense output is (N, dim) float32 with unit L2 rows."""
|
||||||
|
e = Embedder()
|
||||||
|
out = e.encode_dense(["a", "b"])
|
||||||
|
|
||||||
|
assert out.shape == (2, 1024)
|
||||||
|
assert out.dtype == np.float32
|
||||||
|
norms = np.linalg.norm(out, axis=1)
|
||||||
|
np.testing.assert_allclose(norms, [1.0, 1.0], atol=1e-5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_dense_empty_input_returns_empty_matrix(
|
||||||
|
fake_model: type[_FakeBGEModel],
|
||||||
|
) -> None:
|
||||||
|
"""Empty input -> (0, dim) without invoking the model."""
|
||||||
|
e = Embedder()
|
||||||
|
# Reach into the wrapped model to verify it is not called.
|
||||||
|
fake = e._model
|
||||||
|
assert isinstance(fake, _FakeBGEModel)
|
||||||
|
|
||||||
|
out = e.encode_dense([])
|
||||||
|
|
||||||
|
assert out.shape == (0, 1024)
|
||||||
|
assert out.dtype == np.float32
|
||||||
|
assert fake.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# encode_sparse
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_sparse_returns_list_of_dicts(fake_model: type[_FakeBGEModel]) -> None:
|
||||||
|
"""Sparse output has one dict per input string."""
|
||||||
|
e = Embedder()
|
||||||
|
out = e.encode_sparse(["a", "b"])
|
||||||
|
|
||||||
|
assert len(out) == 2
|
||||||
|
for row in out:
|
||||||
|
assert isinstance(row, dict)
|
||||||
|
for key, value in row.items():
|
||||||
|
assert isinstance(key, int)
|
||||||
|
assert isinstance(value, float)
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_sparse_empty_input_returns_empty_list(
|
||||||
|
fake_model: type[_FakeBGEModel],
|
||||||
|
) -> None:
|
||||||
|
"""Empty input -> [] without invoking the model."""
|
||||||
|
e = Embedder()
|
||||||
|
fake = e._model
|
||||||
|
assert isinstance(fake, _FakeBGEModel)
|
||||||
|
|
||||||
|
out = e.encode_sparse([])
|
||||||
|
|
||||||
|
assert out == []
|
||||||
|
assert fake.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# encode (combined)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_returns_tuple_and_uses_single_forward_pass(
|
||||||
|
fake_model: type[_FakeBGEModel],
|
||||||
|
) -> None:
|
||||||
|
"""encode() must issue exactly one model.encode() call for efficiency."""
|
||||||
|
e = Embedder()
|
||||||
|
fake = e._model
|
||||||
|
assert isinstance(fake, _FakeBGEModel)
|
||||||
|
|
||||||
|
dense, sparse = e.encode(["a"])
|
||||||
|
|
||||||
|
assert isinstance(dense, np.ndarray)
|
||||||
|
assert dense.shape == (1, 1024)
|
||||||
|
assert dense.dtype == np.float32
|
||||||
|
np.testing.assert_allclose(np.linalg.norm(dense, axis=1), [1.0], atol=1e-5)
|
||||||
|
|
||||||
|
assert isinstance(sparse, list)
|
||||||
|
assert len(sparse) == 1
|
||||||
|
assert isinstance(sparse[0], dict)
|
||||||
|
|
||||||
|
assert fake.call_count == 1
|
||||||
|
assert fake.last_kwargs is not None
|
||||||
|
assert fake.last_kwargs.get("return_dense") is True
|
||||||
|
assert fake.last_kwargs.get("return_sparse") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_empty_input(fake_model: type[_FakeBGEModel]) -> None:
|
||||||
|
"""Empty input -> ((0, dim), [])."""
|
||||||
|
e = Embedder()
|
||||||
|
fake = e._model
|
||||||
|
assert isinstance(fake, _FakeBGEModel)
|
||||||
|
|
||||||
|
dense, sparse = e.encode([])
|
||||||
|
|
||||||
|
assert dense.shape == (0, 1024)
|
||||||
|
assert dense.dtype == np.float32
|
||||||
|
assert sparse == []
|
||||||
|
assert fake.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Singleton
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_embedder_returns_singleton(fake_model: type[_FakeBGEModel]) -> None:
|
||||||
|
"""Two calls return the same object."""
|
||||||
|
first = get_embedder()
|
||||||
|
second = get_embedder()
|
||||||
|
|
||||||
|
assert first is second
|
||||||
|
assert isinstance(first, Embedder)
|
||||||
0
tests/ingest/__init__.py
Normal file
0
tests/ingest/__init__.py
Normal file
502
tests/ingest/test_ingest.py
Normal file
502
tests/ingest/test_ingest.py
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
"""Tests for codex.ingest — end-to-end idempotent ingest pipeline.
|
||||||
|
|
||||||
|
All external calls (DB, OpenAlex, S2, embed, parsing) are mocked so
|
||||||
|
the suite runs offline in milliseconds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from codex.ingest import IngestResult, _make_bibkey, ingest_paper
|
||||||
|
from codex.models import Citation, FigureChunk, FormulaChunk, Paper
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_conn_cm(mock_conn: MagicMock) -> Any:
|
||||||
|
"""Return a context-manager factory that yields *mock_conn*."""
|
||||||
|
|
||||||
|
@contextmanager # type: ignore[arg-type]
|
||||||
|
def _cm() -> Any:
|
||||||
|
yield mock_conn
|
||||||
|
|
||||||
|
return _cm
|
||||||
|
|
||||||
|
|
||||||
|
def _make_paper(
|
||||||
|
paper_id: str = "2301.07041",
|
||||||
|
openalex_id: str | None = "W123",
|
||||||
|
abstract: str | None = "Test abstract.",
|
||||||
|
) -> Paper:
|
||||||
|
return Paper(
|
||||||
|
id=paper_id,
|
||||||
|
title="A Test Paper",
|
||||||
|
openalex_id=openalex_id,
|
||||||
|
authors=["Alice", "Bob"],
|
||||||
|
year=2023,
|
||||||
|
abstract=abstract,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_embedder(dim: int = 1024) -> MagicMock:
|
||||||
|
"""Return a mock Embedder whose encode_dense returns deterministic output."""
|
||||||
|
embedder = MagicMock()
|
||||||
|
embedder.dim = dim
|
||||||
|
|
||||||
|
def encode_dense(texts: list[str]) -> np.ndarray:
|
||||||
|
return np.ones((len(texts), dim), dtype=np.float32)
|
||||||
|
|
||||||
|
embedder.encode_dense.side_effect = encode_dense
|
||||||
|
return embedder
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. test_ingest_paper_basic
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_paper_basic() -> None:
|
||||||
|
"""fetch_paper returns a Paper; DB upsert is called; IngestResult has correct counts."""
|
||||||
|
paper = _make_paper()
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute = MagicMock()
|
||||||
|
mock_conn.executemany = MagicMock()
|
||||||
|
mock_conn.commit = MagicMock()
|
||||||
|
|
||||||
|
# OpenAlex returns citing_id=openalex_id; ingest.py must rewrite to paper.id (D-05c).
|
||||||
|
raw_api_citations = [
|
||||||
|
Citation(citing_id=paper.openalex_id or "", cited_id="W999"),
|
||||||
|
]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.ingest.openalex.fetch_paper", return_value=paper) as mock_fetch,
|
||||||
|
patch("codex.ingest.openalex.fetch_citations", return_value=raw_api_citations),
|
||||||
|
patch("codex.ingest.get_embedder", return_value=_fake_embedder()),
|
||||||
|
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
|
||||||
|
):
|
||||||
|
result = ingest_paper(paper.id)
|
||||||
|
|
||||||
|
mock_fetch.assert_called_once_with(paper.id)
|
||||||
|
|
||||||
|
# Paper upsert must have been issued
|
||||||
|
assert mock_conn.execute.call_count >= 1
|
||||||
|
first_sql: str = mock_conn.execute.call_args_list[0][0][0]
|
||||||
|
assert "INSERT INTO papers" in first_sql
|
||||||
|
assert "ON CONFLICT" in first_sql
|
||||||
|
|
||||||
|
assert isinstance(result, IngestResult)
|
||||||
|
assert result.paper_id == paper.id
|
||||||
|
assert result.chunks_upserted == 0
|
||||||
|
assert result.citations_upserted == len(raw_api_citations)
|
||||||
|
|
||||||
|
# D-05c regression: citing_id must be rewritten to paper.id (DOI), not openalex_id.
|
||||||
|
mock_cursor = mock_conn.cursor.return_value.__enter__.return_value
|
||||||
|
cit_call = mock_cursor.executemany.call_args_list[0]
|
||||||
|
inserted_rows: list[tuple[str, str, Any]] = cit_call[0][1]
|
||||||
|
assert inserted_rows[0][0] == paper.id, (
|
||||||
|
f"citing_id must be paper.id='{paper.id}', not openalex_id='{paper.openalex_id}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. test_ingest_paper_source_tex
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_paper_source_tex(tmp_path: Any) -> None:
|
||||||
|
"""``.tex`` source → latex_to_text, chunk_text, embed, chunks inserted in DB."""
|
||||||
|
tex_file = tmp_path / "paper.tex"
|
||||||
|
tex_file.write_text(r"\section{Introduction} Hello world. This is a test paper.")
|
||||||
|
|
||||||
|
paper = _make_paper()
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute = MagicMock()
|
||||||
|
mock_conn.executemany = MagicMock()
|
||||||
|
mock_conn.commit = MagicMock()
|
||||||
|
|
||||||
|
fake_chunks = ["chunk one text here.", "chunk two text here."]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.ingest.openalex.fetch_paper", return_value=paper),
|
||||||
|
patch("codex.ingest.openalex.fetch_citations", return_value=[]),
|
||||||
|
patch("codex.ingest.get_embedder", return_value=_fake_embedder()),
|
||||||
|
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
|
||||||
|
patch("codex.parsing.tex.latex_to_text", return_value="Hello world. Intro text."),
|
||||||
|
patch("codex.parsing.tex.chunk_text", return_value=fake_chunks),
|
||||||
|
patch("codex.ingest.filter_chunks", side_effect=lambda chunks, settings: chunks),
|
||||||
|
):
|
||||||
|
result = ingest_paper(paper.id, source_path=str(tex_file))
|
||||||
|
|
||||||
|
# DELETE + INSERT for chunks
|
||||||
|
sql_calls = [str(c[0][0]) for c in mock_conn.execute.call_args_list]
|
||||||
|
assert any("DELETE FROM chunks" in sql for sql in sql_calls)
|
||||||
|
|
||||||
|
# executemany is called on the cursor, not on the connection directly
|
||||||
|
mock_cursor = mock_conn.cursor.return_value.__enter__.return_value
|
||||||
|
assert mock_cursor.executemany.call_count >= 1
|
||||||
|
chunk_insert_call = mock_cursor.executemany.call_args_list[0]
|
||||||
|
chunk_sql: str = chunk_insert_call[0][0]
|
||||||
|
assert "INSERT INTO chunks" in chunk_sql
|
||||||
|
|
||||||
|
assert result.chunks_upserted == len(fake_chunks)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. test_ingest_paper_source_pdf
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_paper_source_pdf(tmp_path: Any) -> None:
|
||||||
|
"""``.pdf`` source → pdf_to_markdown, chunk_text, embed, grobid refs, chunks + citations."""
|
||||||
|
pdf_file = tmp_path / "paper.pdf"
|
||||||
|
pdf_file.write_bytes(b"%PDF-1.4 fake content")
|
||||||
|
|
||||||
|
paper = _make_paper()
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute = MagicMock()
|
||||||
|
mock_conn.executemany = MagicMock()
|
||||||
|
mock_conn.commit = MagicMock()
|
||||||
|
|
||||||
|
fake_chunks = ["pdf chunk one.", "pdf chunk two."]
|
||||||
|
grobid_refs = [
|
||||||
|
{"title": "Ref A", "authors": "X", "year": "2020", "doi": "10.1/ref_a", "arxiv_id": ""},
|
||||||
|
{"title": "Ref B", "authors": "Y", "year": "2021", "doi": "", "arxiv_id": "2101.00001"},
|
||||||
|
]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.ingest.openalex.fetch_paper", return_value=paper),
|
||||||
|
patch("codex.ingest.openalex.fetch_citations", return_value=[]),
|
||||||
|
patch("codex.ingest.get_embedder", return_value=_fake_embedder()),
|
||||||
|
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
|
||||||
|
patch("codex.parsing.nougat.pdf_to_markdown", return_value="pdf markdown text."),
|
||||||
|
patch("codex.parsing.tex.chunk_text", return_value=fake_chunks),
|
||||||
|
patch("codex.parsing.grobid.extract_references", return_value=grobid_refs),
|
||||||
|
patch("codex.ingest.filter_chunks", side_effect=lambda chunks, settings: chunks),
|
||||||
|
):
|
||||||
|
result = ingest_paper(paper.id, source_path=str(pdf_file))
|
||||||
|
|
||||||
|
# Chunks were inserted
|
||||||
|
assert result.chunks_upserted == len(fake_chunks)
|
||||||
|
|
||||||
|
# GROBID refs were merged into citations
|
||||||
|
# Two grobid refs → both have either doi or arxiv_id → 2 citations
|
||||||
|
assert result.citations_upserted == 2
|
||||||
|
|
||||||
|
# executemany is called on the cursor for chunks + citations (at least 2 calls total)
|
||||||
|
mock_cursor = mock_conn.cursor.return_value.__enter__.return_value
|
||||||
|
assert mock_cursor.executemany.call_count >= 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. test_ingest_paper_not_found
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_paper_not_found() -> None:
|
||||||
|
"""OpenAlex returns None for unknown ID → ValueError raised."""
|
||||||
|
with (
|
||||||
|
patch("codex.ingest.openalex.fetch_paper", return_value=None),
|
||||||
|
patch("codex.ingest.get_embedder", return_value=_fake_embedder()),
|
||||||
|
pytest.raises(ValueError, match="Paper not found"),
|
||||||
|
):
|
||||||
|
ingest_paper("10.9999/unknown-doi")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. test_ingest_paper_idempotent
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_paper_idempotent() -> None:
|
||||||
|
"""Second call with the same paper_id overwrites without error (no unique violations)."""
|
||||||
|
paper = _make_paper()
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute = MagicMock()
|
||||||
|
mock_conn.executemany = MagicMock()
|
||||||
|
mock_conn.commit = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.ingest.openalex.fetch_paper", return_value=paper),
|
||||||
|
patch("codex.ingest.openalex.fetch_citations", return_value=[]),
|
||||||
|
patch("codex.ingest.get_embedder", return_value=_fake_embedder()),
|
||||||
|
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
|
||||||
|
):
|
||||||
|
result1 = ingest_paper(paper.id)
|
||||||
|
result2 = ingest_paper(paper.id)
|
||||||
|
|
||||||
|
assert result1.paper_id == result2.paper_id == paper.id
|
||||||
|
|
||||||
|
# ON CONFLICT … DO UPDATE means no error on second call
|
||||||
|
all_execute_calls = mock_conn.execute.call_args_list
|
||||||
|
paper_upserts = [c for c in all_execute_calls if "INSERT INTO papers" in str(c[0][0])]
|
||||||
|
# Two calls → two paper upserts
|
||||||
|
assert len(paper_upserts) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. test_ingest_paper_arxiv_s2_fallback
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_paper_arxiv_s2_fallback() -> None:
|
||||||
|
"""If OpenAlex returns None for an arXiv ID, S2 fallback produces a stub Paper."""
|
||||||
|
paper_id = "2301.07041"
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute = MagicMock()
|
||||||
|
mock_conn.executemany = MagicMock()
|
||||||
|
mock_conn.commit = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.ingest.openalex.fetch_paper", return_value=None),
|
||||||
|
patch("codex.ingest.semanticscholar.fetch_references", return_value=[]) as mock_s2,
|
||||||
|
patch("codex.ingest.get_embedder", return_value=_fake_embedder()),
|
||||||
|
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
|
||||||
|
):
|
||||||
|
result = ingest_paper(paper_id)
|
||||||
|
|
||||||
|
# S2 was consulted as fallback
|
||||||
|
mock_s2.assert_called()
|
||||||
|
assert result.paper_id == paper_id
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7. test_ingest_paper_no_abstract_uses_zero_vector
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_paper_no_abstract_uses_zero_vector() -> None:
|
||||||
|
"""A paper without an abstract gets a zero embedding vector (no model call)."""
|
||||||
|
paper = _make_paper(abstract=None)
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute = MagicMock()
|
||||||
|
mock_conn.executemany = MagicMock()
|
||||||
|
mock_conn.commit = MagicMock()
|
||||||
|
|
||||||
|
fake_emb = _fake_embedder(dim=1024)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.ingest.openalex.fetch_paper", return_value=paper),
|
||||||
|
patch("codex.ingest.openalex.fetch_citations", return_value=[]),
|
||||||
|
patch("codex.ingest.get_embedder", return_value=fake_emb),
|
||||||
|
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
|
||||||
|
):
|
||||||
|
ingest_paper(paper.id)
|
||||||
|
|
||||||
|
# encode_dense must NOT have been called (no abstract → zero vector shortcut)
|
||||||
|
fake_emb.encode_dense.assert_not_called()
|
||||||
|
|
||||||
|
# Verify zero vector was passed in the paper upsert
|
||||||
|
upsert_call = mock_conn.execute.call_args_list[0]
|
||||||
|
params: dict[str, Any] = upsert_call[0][1]
|
||||||
|
emb: list[float] = params["abstract_emb"]
|
||||||
|
assert len(emb) == 1024
|
||||||
|
assert all(v == 0.0 for v in emb)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 8. F-09 Rich Parsing tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_paper_rich_calls_formula_and_figure_parsers(tmp_path: Any) -> None:
|
||||||
|
"""When rich=True and source is PDF, formula + figure parsers are invoked."""
|
||||||
|
import codex.parsing.figures as _figures_mod
|
||||||
|
import codex.parsing.mathpix as _mathpix_mod
|
||||||
|
|
||||||
|
pdf_file = tmp_path / "paper.pdf"
|
||||||
|
pdf_file.write_bytes(b"%PDF-1.4 fake content")
|
||||||
|
|
||||||
|
paper = _make_paper()
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute = MagicMock()
|
||||||
|
mock_conn.executemany = MagicMock()
|
||||||
|
mock_conn.commit = MagicMock()
|
||||||
|
|
||||||
|
fake_formula = FormulaChunk(paper_id=paper.id, page=1, raw_latex=r"\alpha", context="test")
|
||||||
|
fake_figure = FigureChunk(
|
||||||
|
paper_id=paper.id, page=1, image_path="/tmp/fig.png", caption="Figure 1."
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_settings = MagicMock()
|
||||||
|
mock_settings.figures_dir = str(tmp_path / "figures")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.ingest.openalex.fetch_paper", return_value=paper),
|
||||||
|
patch("codex.ingest.openalex.fetch_citations", return_value=[]),
|
||||||
|
patch("codex.ingest.get_embedder", return_value=_fake_embedder()),
|
||||||
|
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
|
||||||
|
patch("codex.parsing.nougat.pdf_to_markdown", return_value=""),
|
||||||
|
patch("codex.parsing.tex.chunk_text", return_value=[]),
|
||||||
|
patch("codex.parsing.grobid.extract_references", return_value=[]),
|
||||||
|
patch.object(
|
||||||
|
_mathpix_mod, "extract_formulas", return_value=[fake_formula]
|
||||||
|
) as mock_formulas,
|
||||||
|
patch.object(_figures_mod, "extract_figures", return_value=[fake_figure]) as mock_figures,
|
||||||
|
patch("codex.ingest.get_settings", return_value=mock_settings),
|
||||||
|
):
|
||||||
|
result = ingest_paper(paper.id, source_path=str(pdf_file), rich=True)
|
||||||
|
|
||||||
|
mock_formulas.assert_called_once()
|
||||||
|
mock_figures.assert_called_once()
|
||||||
|
assert result.formulas_upserted == 1
|
||||||
|
assert result.figures_upserted == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_paper_no_rich_skips_parsers(tmp_path: Any) -> None:
|
||||||
|
"""When rich=False (default), formula and figure parsers are NOT called."""
|
||||||
|
import codex.parsing.figures as _figures_mod
|
||||||
|
import codex.parsing.mathpix as _mathpix_mod
|
||||||
|
|
||||||
|
pdf_file = tmp_path / "paper.pdf"
|
||||||
|
pdf_file.write_bytes(b"%PDF-1.4 fake content")
|
||||||
|
|
||||||
|
paper = _make_paper()
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute = MagicMock()
|
||||||
|
mock_conn.executemany = MagicMock()
|
||||||
|
mock_conn.commit = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.ingest.openalex.fetch_paper", return_value=paper),
|
||||||
|
patch("codex.ingest.openalex.fetch_citations", return_value=[]),
|
||||||
|
patch("codex.ingest.get_embedder", return_value=_fake_embedder()),
|
||||||
|
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
|
||||||
|
patch("codex.parsing.nougat.pdf_to_markdown", return_value=""),
|
||||||
|
patch("codex.parsing.tex.chunk_text", return_value=[]),
|
||||||
|
patch("codex.parsing.grobid.extract_references", return_value=[]),
|
||||||
|
patch.object(_mathpix_mod, "extract_formulas", return_value=[]) as mock_formulas,
|
||||||
|
patch.object(_figures_mod, "extract_figures", return_value=[]) as mock_figures,
|
||||||
|
):
|
||||||
|
result = ingest_paper(paper.id, source_path=str(pdf_file), rich=False)
|
||||||
|
|
||||||
|
mock_formulas.assert_not_called()
|
||||||
|
mock_figures.assert_not_called()
|
||||||
|
assert result.formulas_upserted == 0
|
||||||
|
assert result.figures_upserted == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_paper_rich_requires_pdf_source(tmp_path: Any) -> None:
|
||||||
|
"""When rich=True but source is a .tex file, formula/figure parsers are NOT called."""
|
||||||
|
import codex.parsing.figures as _figures_mod
|
||||||
|
import codex.parsing.mathpix as _mathpix_mod
|
||||||
|
|
||||||
|
tex_file = tmp_path / "paper.tex"
|
||||||
|
tex_file.write_text(r"\section{Intro}")
|
||||||
|
|
||||||
|
paper = _make_paper()
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute = MagicMock()
|
||||||
|
mock_conn.executemany = MagicMock()
|
||||||
|
mock_conn.commit = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.ingest.openalex.fetch_paper", return_value=paper),
|
||||||
|
patch("codex.ingest.openalex.fetch_citations", return_value=[]),
|
||||||
|
patch("codex.ingest.get_embedder", return_value=_fake_embedder()),
|
||||||
|
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
|
||||||
|
patch("codex.parsing.tex.latex_to_text", return_value=""),
|
||||||
|
patch("codex.parsing.tex.chunk_text", return_value=[]),
|
||||||
|
patch.object(_mathpix_mod, "extract_formulas", return_value=[]) as mock_formulas,
|
||||||
|
patch.object(_figures_mod, "extract_figures", return_value=[]) as mock_figures,
|
||||||
|
):
|
||||||
|
result = ingest_paper(paper.id, source_path=str(tex_file), rich=True)
|
||||||
|
|
||||||
|
mock_formulas.assert_not_called()
|
||||||
|
mock_figures.assert_not_called()
|
||||||
|
assert result.formulas_upserted == 0
|
||||||
|
assert result.figures_upserted == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# D-04 — bibkey auto-generation heuristic
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_bibkey_single_author() -> None:
|
||||||
|
paper = Paper(id="doi:10.1/x", title="T", authors=["John Smith"], year=2023)
|
||||||
|
assert _make_bibkey(paper) == "Smith2023"
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_bibkey_two_authors() -> None:
|
||||||
|
paper = Paper(id="doi:10.1/x", title="T", authors=["Alice Foo", "Bob Bar"], year=2015)
|
||||||
|
assert _make_bibkey(paper) == "FooBar2015"
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_bibkey_three_authors() -> None:
|
||||||
|
paper = Paper(
|
||||||
|
id="doi:10.1/x",
|
||||||
|
title="T",
|
||||||
|
authors=["Alexander Bobenko", "Ulrich Pinkall", "Boris Springborn"],
|
||||||
|
year=2015,
|
||||||
|
)
|
||||||
|
assert _make_bibkey(paper) == "BobenkoPinkallSpringborn2015"
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_bibkey_many_authors_uses_etal() -> None:
|
||||||
|
paper = Paper(
|
||||||
|
id="doi:10.1/x",
|
||||||
|
title="T",
|
||||||
|
authors=["A One", "B Two", "C Three", "D Four"],
|
||||||
|
year=2020,
|
||||||
|
)
|
||||||
|
assert _make_bibkey(paper) == "OneEtAl2020"
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_bibkey_no_authors_returns_none() -> None:
|
||||||
|
paper = Paper(id="doi:10.1/x", title="T", authors=[], year=2023)
|
||||||
|
assert _make_bibkey(paper) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_bibkey_no_year_returns_none() -> None:
|
||||||
|
paper = Paper(id="doi:10.1/x", title="T", authors=["John Smith"], year=None)
|
||||||
|
assert _make_bibkey(paper) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_sets_bibkey_when_none(tmp_path: Any) -> None:
|
||||||
|
"""ingest_paper auto-generates bibkey when paper has none (D-04)."""
|
||||||
|
paper = _make_paper() # bibkey=None (default from _make_paper)
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute = MagicMock()
|
||||||
|
mock_conn.executemany = MagicMock()
|
||||||
|
mock_conn.commit = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.ingest.openalex.fetch_paper", return_value=paper),
|
||||||
|
patch("codex.ingest.openalex.fetch_citations", return_value=[]),
|
||||||
|
patch("codex.ingest.get_embedder", return_value=_fake_embedder()),
|
||||||
|
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
|
||||||
|
):
|
||||||
|
ingest_paper(paper.id)
|
||||||
|
|
||||||
|
upsert_params: dict[str, Any] = mock_conn.execute.call_args_list[0][0][1]
|
||||||
|
assert upsert_params["bibkey"] is not None, "bibkey must be auto-generated when paper has none"
|
||||||
|
# authors=["Alice", "Bob"], year=2023 → 2 authors ≤ 3 → concat last tokens
|
||||||
|
assert upsert_params["bibkey"] == "AliceBob2023"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_result_has_formula_figure_counts() -> None:
|
||||||
|
"""IngestResult has formulas_upserted and figures_upserted fields with default 0."""
|
||||||
|
result = IngestResult(paper_id="test", chunks_upserted=0, citations_upserted=0)
|
||||||
|
assert result.formulas_upserted == 0
|
||||||
|
assert result.figures_upserted == 0
|
||||||
|
|
||||||
|
result2 = IngestResult(
|
||||||
|
paper_id="test",
|
||||||
|
chunks_upserted=5,
|
||||||
|
citations_upserted=3,
|
||||||
|
formulas_upserted=10,
|
||||||
|
figures_upserted=2,
|
||||||
|
)
|
||||||
|
assert result2.formulas_upserted == 10
|
||||||
|
assert result2.figures_upserted == 2
|
||||||
0
tests/integration/__init__.py
Normal file
0
tests/integration/__init__.py
Normal file
100
tests/integration/test_pipeline_smoke.py
Normal file
100
tests/integration/test_pipeline_smoke.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""End-to-end smoke test: ingest → discover → provenance flow (all mocked)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from codex.discover import discovery_leads
|
||||||
|
from codex.ingest import ingest_paper
|
||||||
|
from codex.models import Citation, Paper
|
||||||
|
from codex.provenance import export_bib
|
||||||
|
|
||||||
|
|
||||||
|
def _make_conn_cm(mock_conn: MagicMock) -> Any:
|
||||||
|
"""Return a context-manager factory that yields *mock_conn*."""
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _cm() -> Any:
|
||||||
|
yield mock_conn
|
||||||
|
|
||||||
|
return _cm
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_pipeline_smoke() -> None:
|
||||||
|
"""ingest_paper → discovery_leads → export_bib all execute without errors (all mocked)."""
|
||||||
|
paper = Paper(id="2301.07041", title="T", openalex_id="W123")
|
||||||
|
citations = [Citation(citing_id="2301.07041", cited_id="2301.99999")]
|
||||||
|
|
||||||
|
# --- mock connection setup ---
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute = MagicMock()
|
||||||
|
mock_conn.commit = MagicMock()
|
||||||
|
|
||||||
|
# discovery_leads: conn.execute(...).fetchall() → empty list (no leads)
|
||||||
|
discover_execute_result = MagicMock()
|
||||||
|
discover_execute_result.fetchall.return_value = []
|
||||||
|
|
||||||
|
# export_bib: conn.execute(...).fetchall() → one paper row with bibkey
|
||||||
|
bib_row: dict[str, Any] = {
|
||||||
|
"id": "2301.07041",
|
||||||
|
"bibkey": "paper2023",
|
||||||
|
"title": "T",
|
||||||
|
"authors": ["Author One"],
|
||||||
|
"year": 2023,
|
||||||
|
}
|
||||||
|
bib_execute_result = MagicMock()
|
||||||
|
bib_execute_result.fetchall.return_value = [bib_row]
|
||||||
|
|
||||||
|
# The ingest path calls conn.execute multiple times (paper upsert, etc.).
|
||||||
|
# discovery_leads and export_bib each call conn.execute once and use fetchall.
|
||||||
|
# We use side_effect to route calls: first N calls from ingest return a generic
|
||||||
|
# mock; then we supply discover and bib results in order.
|
||||||
|
_call_count: list[int] = [0]
|
||||||
|
|
||||||
|
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
|
||||||
|
_call_count[0] += 1
|
||||||
|
sql: str = str(args[0]) if args else ""
|
||||||
|
if "cited_id NOT IN" in sql:
|
||||||
|
return discover_execute_result
|
||||||
|
if "FROM papers" in sql and "bibkey IS NOT NULL" in sql:
|
||||||
|
return bib_execute_result
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
mock_conn.execute.side_effect = _execute_side_effect
|
||||||
|
|
||||||
|
# cursor() context manager for executemany calls inside ingest
|
||||||
|
mock_cursor = MagicMock()
|
||||||
|
mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
|
||||||
|
mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
embedder = MagicMock()
|
||||||
|
embedder.dim = 1024
|
||||||
|
embedder.encode_dense.return_value = np.zeros((1, 1024), dtype=np.float32)
|
||||||
|
|
||||||
|
conn_cm = _make_conn_cm(mock_conn)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.ingest.openalex.fetch_paper", return_value=paper),
|
||||||
|
patch("codex.ingest.openalex.fetch_citations", return_value=citations),
|
||||||
|
patch("codex.ingest.get_embedder", return_value=embedder),
|
||||||
|
patch("codex.ingest.get_conn", side_effect=conn_cm),
|
||||||
|
patch("codex.discover.get_conn", side_effect=conn_cm),
|
||||||
|
patch("codex.provenance.get_conn", side_effect=conn_cm),
|
||||||
|
):
|
||||||
|
# Step 1: ingest
|
||||||
|
result = ingest_paper("2301.07041")
|
||||||
|
|
||||||
|
# Step 2: discover
|
||||||
|
leads = discovery_leads()
|
||||||
|
|
||||||
|
# Step 3: export bib
|
||||||
|
bib = export_bib(["2301.07041"])
|
||||||
|
|
||||||
|
# --- assertions ---
|
||||||
|
assert result.paper_id == "2301.07041"
|
||||||
|
assert isinstance(leads, list)
|
||||||
|
assert "@article" in bib
|
||||||
0
tests/mcp/__init__.py
Normal file
0
tests/mcp/__init__.py
Normal file
211
tests/mcp/test_graceful.py
Normal file
211
tests/mcp/test_graceful.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
"""test_graceful.py — missing/unavailable features must degrade, never crash.
|
||||||
|
|
||||||
|
All tools that wrap optional features (wiki, synthesis leads, provenance)
|
||||||
|
must return a structured "feature not available" response instead of raising.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ask — always returns "not available" (RAG not implemented)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ask_returns_not_available() -> None:
|
||||||
|
"""ask() must return {'error': 'not available'} — RAG not yet implemented."""
|
||||||
|
from codex.mcp_server import ask
|
||||||
|
|
||||||
|
result = ask("What is conformal geometry?")
|
||||||
|
assert result == {"error": "not available"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# wiki_read — page missing → "feature not available"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_wiki_read_missing_page_returns_not_available() -> None:
|
||||||
|
"""wiki_read() with a nonexistent page must return {"error": "feature not available"}."""
|
||||||
|
from codex.mcp_server import wiki_read
|
||||||
|
|
||||||
|
with (
|
||||||
|
tempfile.TemporaryDirectory() as tmpdir,
|
||||||
|
patch("codex.config.get_settings") as mock_cfg,
|
||||||
|
):
|
||||||
|
mock_cfg.return_value.wiki_dir = tmpdir
|
||||||
|
result = wiki_read("nonexistent_concept")
|
||||||
|
|
||||||
|
assert result == {"error": "feature not available"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_wiki_read_existing_page_returns_markdown() -> None:
|
||||||
|
"""wiki_read() with an existing page must return {markdown, sources}."""
|
||||||
|
from codex.mcp_server import wiki_read
|
||||||
|
|
||||||
|
with (
|
||||||
|
tempfile.TemporaryDirectory() as tmpdir,
|
||||||
|
patch("codex.config.get_settings") as mock_cfg,
|
||||||
|
):
|
||||||
|
mock_cfg.return_value.wiki_dir = tmpdir
|
||||||
|
# Write a fake wiki page
|
||||||
|
page = Path(tmpdir) / "conformal_maps.md"
|
||||||
|
page.write_text("# Conformal Maps\n\nSome content.", encoding="utf-8")
|
||||||
|
result = wiki_read("conformal_maps")
|
||||||
|
|
||||||
|
assert "markdown" in result
|
||||||
|
assert "sources" in result
|
||||||
|
assert "# Conformal Maps" in str(result["markdown"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_wiki_read_exception_returns_not_available() -> None:
|
||||||
|
"""wiki_read() must degrade gracefully when get_settings raises."""
|
||||||
|
from codex.mcp_server import wiki_read
|
||||||
|
|
||||||
|
with patch("codex.config.get_settings", side_effect=RuntimeError("config error")):
|
||||||
|
result = wiki_read("any_slug")
|
||||||
|
|
||||||
|
assert result == {"error": "feature not available"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# wiki_list — missing wiki dir → empty list
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_wiki_list_missing_dir_returns_empty() -> None:
|
||||||
|
"""wiki_list() must return [] when the wiki directory does not exist."""
|
||||||
|
from codex.mcp_server import wiki_list
|
||||||
|
|
||||||
|
with (
|
||||||
|
tempfile.TemporaryDirectory() as tmpdir,
|
||||||
|
patch("codex.config.get_settings") as mock_cfg,
|
||||||
|
):
|
||||||
|
mock_cfg.return_value.wiki_dir = str(Path(tmpdir) / "no_wiki_here")
|
||||||
|
result = wiki_list()
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_wiki_list_existing_dir_returns_pages() -> None:
|
||||||
|
"""wiki_list() must return page entries for .md files in the wiki dir."""
|
||||||
|
from codex.mcp_server import wiki_list
|
||||||
|
|
||||||
|
with (
|
||||||
|
tempfile.TemporaryDirectory() as tmpdir,
|
||||||
|
patch("codex.config.get_settings") as mock_cfg,
|
||||||
|
):
|
||||||
|
mock_cfg.return_value.wiki_dir = tmpdir
|
||||||
|
(Path(tmpdir) / "concept_a.md").write_text("# A", encoding="utf-8")
|
||||||
|
(Path(tmpdir) / "concept_b.md").write_text("# B", encoding="utf-8")
|
||||||
|
(Path(tmpdir) / "index.md").write_text("# Index", encoding="utf-8") # must be skipped
|
||||||
|
result = wiki_list()
|
||||||
|
|
||||||
|
slugs = {r["slug"] for r in result}
|
||||||
|
assert "concept_a" in slugs
|
||||||
|
assert "concept_b" in slugs
|
||||||
|
assert "index" not in slugs # index.md is excluded
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# discover_leads — domain function raises → error dict, no crash
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_leads_db_error_returns_error() -> None:
|
||||||
|
"""discover_leads() must return [{"error": ...}] when the discovery call fails."""
|
||||||
|
from codex.mcp_server import discover_leads
|
||||||
|
|
||||||
|
# Patch the domain function directly so the exception always surfaces
|
||||||
|
with patch("codex.discover.discovery_leads", side_effect=RuntimeError("no DB")):
|
||||||
|
result = discover_leads(limit=5)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert "error" in result[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# synthesis_browse — missing leads dir → "feature not available"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_synthesis_browse_missing_dir_returns_not_available() -> None:
|
||||||
|
"""synthesis_browse() must return [{'error': 'feature not available'}] when leads/ absent."""
|
||||||
|
from codex.mcp_server import synthesis_browse
|
||||||
|
|
||||||
|
with (
|
||||||
|
tempfile.TemporaryDirectory() as tmpdir,
|
||||||
|
patch("codex.config.get_settings") as mock_cfg,
|
||||||
|
):
|
||||||
|
mock_cfg.return_value.leads_dir = str(Path(tmpdir) / "no_leads_here")
|
||||||
|
result = synthesis_browse()
|
||||||
|
|
||||||
|
assert result == [{"error": "feature not available"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_synthesis_browse_existing_dir_returns_items() -> None:
|
||||||
|
"""synthesis_browse() must parse and return JSON files from the leads dir."""
|
||||||
|
from codex.mcp_server import synthesis_browse
|
||||||
|
|
||||||
|
with (
|
||||||
|
tempfile.TemporaryDirectory() as tmpdir,
|
||||||
|
patch("codex.config.get_settings") as mock_cfg,
|
||||||
|
):
|
||||||
|
leads_dir = Path(tmpdir) / "leads"
|
||||||
|
leads_dir.mkdir()
|
||||||
|
(leads_dir / "lead1.json").write_text(
|
||||||
|
json.dumps({"kind": "connection", "title": "C1"}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
(leads_dir / "lead2.json").write_text(
|
||||||
|
json.dumps({"kind": "conjecture", "title": "C2"}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
mock_cfg.return_value.leads_dir = str(leads_dir)
|
||||||
|
result = synthesis_browse(kind="all")
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
kinds = {str(r.get("kind")) for r in result}
|
||||||
|
assert "connection" in kinds
|
||||||
|
assert "conjecture" in kinds
|
||||||
|
|
||||||
|
|
||||||
|
def test_synthesis_browse_kind_filter() -> None:
|
||||||
|
"""synthesis_browse(kind='conjecture') must return only conjectures."""
|
||||||
|
from codex.mcp_server import synthesis_browse
|
||||||
|
|
||||||
|
with (
|
||||||
|
tempfile.TemporaryDirectory() as tmpdir,
|
||||||
|
patch("codex.config.get_settings") as mock_cfg,
|
||||||
|
):
|
||||||
|
leads_dir = Path(tmpdir) / "leads"
|
||||||
|
leads_dir.mkdir()
|
||||||
|
(leads_dir / "lead1.json").write_text(
|
||||||
|
json.dumps({"kind": "connection", "title": "C1"}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
(leads_dir / "lead2.json").write_text(
|
||||||
|
json.dumps({"kind": "conjecture", "title": "C2"}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
mock_cfg.return_value.leads_dir = str(leads_dir)
|
||||||
|
result = synthesis_browse(kind="conjecture")
|
||||||
|
|
||||||
|
assert all(str(r.get("kind")) == "conjecture" for r in result)
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# provenance_verify — exception → "feature not available"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_provenance_verify_exception_returns_not_available() -> None:
|
||||||
|
"""provenance_verify() must return [{"error": ...}] on scan failure."""
|
||||||
|
from codex.mcp_server import provenance_verify
|
||||||
|
|
||||||
|
with patch("codex.provenance.scan_cite_tags", side_effect=RuntimeError("scan failed")):
|
||||||
|
result = provenance_verify("/some/nonexistent/path")
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert "error" in result[0]
|
||||||
121
tests/mcp/test_http_auth.py
Normal file
121
tests/mcp/test_http_auth.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""test_http_auth.py — HTTP transport token-auth enforcement.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
1. _require_http_token() raises RuntimeError when MCP_AUTH_TOKEN is absent.
|
||||||
|
2. _require_http_token() returns the token when it is set.
|
||||||
|
3. main() with transport='http' raises without token (refuses to start).
|
||||||
|
4. main() with transport='stdio' runs mcp.run (happy path, no token needed).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _require_http_token
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_require_http_token_raises_without_token() -> None:
|
||||||
|
"""_require_http_token() must raise RuntimeError when mcp_auth_token is None."""
|
||||||
|
from codex.mcp_server import _require_http_token
|
||||||
|
|
||||||
|
mock_settings = MagicMock()
|
||||||
|
mock_settings.mcp_auth_token = None
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.config.get_settings", return_value=mock_settings),
|
||||||
|
pytest.raises(RuntimeError, match="MCP_AUTH_TOKEN"),
|
||||||
|
):
|
||||||
|
_require_http_token()
|
||||||
|
|
||||||
|
|
||||||
|
def test_require_http_token_returns_token_when_set() -> None:
|
||||||
|
"""_require_http_token() must return the token string when it is configured."""
|
||||||
|
from codex.mcp_server import _require_http_token
|
||||||
|
|
||||||
|
mock_settings = MagicMock()
|
||||||
|
mock_settings.mcp_auth_token = "super-secret-token"
|
||||||
|
|
||||||
|
with patch("codex.config.get_settings", return_value=mock_settings):
|
||||||
|
result = _require_http_token()
|
||||||
|
|
||||||
|
assert result == "super-secret-token"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# main() — HTTP transport without token → RuntimeError
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_http_without_token_raises() -> None:
|
||||||
|
"""main() with MCP_TRANSPORT=http and no token must raise RuntimeError."""
|
||||||
|
from codex.mcp_server import main
|
||||||
|
|
||||||
|
mock_settings = MagicMock()
|
||||||
|
mock_settings.mcp_transport = "http"
|
||||||
|
mock_settings.mcp_auth_token = None
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.config.get_settings", return_value=mock_settings),
|
||||||
|
pytest.raises(RuntimeError, match="MCP_AUTH_TOKEN"),
|
||||||
|
):
|
||||||
|
main()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# main() — stdio transport runs mcp.run
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_stdio_calls_mcp_run() -> None:
|
||||||
|
"""main() with stdio transport must call mcp.run(transport='stdio')."""
|
||||||
|
from codex import mcp_server
|
||||||
|
|
||||||
|
mock_settings = MagicMock()
|
||||||
|
mock_settings.mcp_transport = "stdio"
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.config.get_settings", return_value=mock_settings),
|
||||||
|
patch.object(mcp_server.mcp, "run") as mock_run,
|
||||||
|
):
|
||||||
|
mcp_server.main()
|
||||||
|
|
||||||
|
mock_run.assert_called_once_with(transport="stdio")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# main() — HTTP transport with valid token attempts uvicorn.run
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_http_with_token_calls_uvicorn() -> None:
|
||||||
|
"""main() with http transport + valid token must invoke uvicorn.run."""
|
||||||
|
from codex import mcp_server
|
||||||
|
|
||||||
|
mock_settings = MagicMock()
|
||||||
|
mock_settings.mcp_transport = "http"
|
||||||
|
mock_settings.mcp_auth_token = "test-token"
|
||||||
|
mock_settings.mcp_host = "127.0.0.1"
|
||||||
|
mock_settings.mcp_port = 8765
|
||||||
|
|
||||||
|
# Stub the ASGI app returned by streamable_http_app
|
||||||
|
mock_app = MagicMock()
|
||||||
|
mock_app.add_middleware = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.config.get_settings", return_value=mock_settings),
|
||||||
|
patch.object(mcp_server.mcp, "streamable_http_app", return_value=mock_app),
|
||||||
|
patch("uvicorn.run") as mock_uvicorn,
|
||||||
|
):
|
||||||
|
mcp_server.main()
|
||||||
|
|
||||||
|
mock_uvicorn.assert_called_once()
|
||||||
|
call_kwargs = mock_uvicorn.call_args
|
||||||
|
assert call_kwargs is not None
|
||||||
|
# host and port must match settings
|
||||||
|
_, kwargs = call_kwargs
|
||||||
|
assert kwargs.get("host") == "127.0.0.1"
|
||||||
|
assert kwargs.get("port") == 8765
|
||||||
83
tests/mcp/test_readonly.py
Normal file
83
tests/mcp/test_readonly.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""test_readonly.py — CRITICAL: ensure no write/ingest tools are exposed via MCP.
|
||||||
|
|
||||||
|
This test MUST fail if any write or ingest tool is accidentally registered.
|
||||||
|
It is the primary enforcement point for the read-only invariant of F-14.
|
||||||
|
|
||||||
|
Read-only tools (allowed):
|
||||||
|
search, ask, wiki_read, wiki_list, discover_leads,
|
||||||
|
provenance_verify, synthesis_browse
|
||||||
|
|
||||||
|
Write/ingest tools that must NEVER appear:
|
||||||
|
Any tool whose name contains an ingest/write/delete/add/update/remove
|
||||||
|
verb, or any of the known write-mode CLI operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Write-verb pattern — any tool matching this is a forbidden write tool.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_WRITE_VERB_RE = re.compile(
|
||||||
|
r"(?:^|_)(ingest|write|delete|remove|add|update|upsert|insert|create|sync|compile|export)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Explicit allowlist — only these names may appear.
|
||||||
|
ALLOWED_TOOLS = frozenset(
|
||||||
|
{
|
||||||
|
"search",
|
||||||
|
"ask",
|
||||||
|
"wiki_read",
|
||||||
|
"wiki_list",
|
||||||
|
"discover_leads",
|
||||||
|
"provenance_verify",
|
||||||
|
"synthesis_browse",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_write_tools_registered() -> None:
|
||||||
|
"""CRITICAL: fail if any write-verb tool is registered on the MCP server."""
|
||||||
|
from codex.mcp_server import mcp
|
||||||
|
|
||||||
|
tools = asyncio.run(mcp.list_tools())
|
||||||
|
registered = {t.name for t in tools}
|
||||||
|
|
||||||
|
violations: list[str] = []
|
||||||
|
for name in registered:
|
||||||
|
if _WRITE_VERB_RE.search(name):
|
||||||
|
violations.append(name)
|
||||||
|
|
||||||
|
assert not violations, (
|
||||||
|
f"Write/ingest tools found in MCP server (FORBIDDEN): {violations}. "
|
||||||
|
"MCP server must expose read-only tools only."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_allowed_tools_registered() -> None:
|
||||||
|
"""CRITICAL: registered tools must be a subset of the explicit allowlist."""
|
||||||
|
from codex.mcp_server import mcp
|
||||||
|
|
||||||
|
tools = asyncio.run(mcp.list_tools())
|
||||||
|
registered = {t.name for t in tools}
|
||||||
|
|
||||||
|
disallowed = registered - ALLOWED_TOOLS
|
||||||
|
assert not disallowed, (
|
||||||
|
f"Tools not on the read-only allowlist: {disallowed}. "
|
||||||
|
"Add them to ALLOWED_TOOLS only after verifying they are read-only."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_allowed_tools_present() -> None:
|
||||||
|
"""Every tool in the allowlist must be registered (completeness check)."""
|
||||||
|
from codex.mcp_server import mcp
|
||||||
|
|
||||||
|
tools = asyncio.run(mcp.list_tools())
|
||||||
|
registered = {t.name for t in tools}
|
||||||
|
|
||||||
|
missing = ALLOWED_TOOLS - registered
|
||||||
|
assert not missing, f"Expected read-only tools missing from server: {missing}"
|
||||||
176
tests/mcp/test_search.py
Normal file
176
tests/mcp/test_search.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"""test_search.py — verify the search tool: retrieval, return format, error handling.
|
||||||
|
|
||||||
|
Since search() imports get_embedder and get_conn lazily (inside the function body),
|
||||||
|
we patch them at their source modules: codex.embed and codex.db.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_conn_cm(mock_conn: MagicMock) -> Any:
|
||||||
|
"""Return a context-manager factory that yields *mock_conn*."""
|
||||||
|
|
||||||
|
@contextmanager # type: ignore[arg-type]
|
||||||
|
def _cm() -> Any:
|
||||||
|
yield mock_conn
|
||||||
|
|
||||||
|
return _cm
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_row(
|
||||||
|
bibkey: str,
|
||||||
|
paper_id: str,
|
||||||
|
ord_val: int,
|
||||||
|
content: str,
|
||||||
|
dist: float,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"bibkey": bibkey,
|
||||||
|
"paper_id": paper_id,
|
||||||
|
"ord": ord_val,
|
||||||
|
"content": content,
|
||||||
|
"dist": dist,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_returns_correct_keys() -> None:
|
||||||
|
"""search() must return dicts with the required five keys."""
|
||||||
|
from codex.mcp_server import search
|
||||||
|
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute.return_value.fetchall.return_value = [
|
||||||
|
_fake_row("Springborn2008", "springborn-2008", 16, "The volume formula is V = ...", 0.2),
|
||||||
|
]
|
||||||
|
|
||||||
|
fake_dense = np.zeros((1, 1024), dtype=np.float32)
|
||||||
|
mock_embedder = MagicMock()
|
||||||
|
mock_embedder.encode_dense.return_value = fake_dense
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.embed.get_embedder", return_value=mock_embedder),
|
||||||
|
patch("codex.db.get_conn", side_effect=_make_conn_cm(mock_conn)),
|
||||||
|
):
|
||||||
|
results = search("volume formula", limit=5)
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
hit = results[0]
|
||||||
|
assert set(hit.keys()) == {"bibkey", "paper_id", "locator", "score", "snippet"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_score_is_similarity_not_distance() -> None:
|
||||||
|
"""score = 1 - distance, so a dist of 0.0 should yield score 1.0."""
|
||||||
|
from codex.mcp_server import search
|
||||||
|
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute.return_value.fetchall.return_value = [
|
||||||
|
_fake_row("Author2020", "paper-x", 3, "Some chunk content here.", 0.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
fake_dense = np.zeros((1, 1024), dtype=np.float32)
|
||||||
|
mock_embedder = MagicMock()
|
||||||
|
mock_embedder.encode_dense.return_value = fake_dense
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.embed.get_embedder", return_value=mock_embedder),
|
||||||
|
patch("codex.db.get_conn", side_effect=_make_conn_cm(mock_conn)),
|
||||||
|
):
|
||||||
|
results = search("some query")
|
||||||
|
|
||||||
|
assert results[0]["score"] == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_locator_format() -> None:
|
||||||
|
"""locator must be 'chunk <ord>'."""
|
||||||
|
from codex.mcp_server import search
|
||||||
|
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute.return_value.fetchall.return_value = [
|
||||||
|
_fake_row("Auth2021", "paper-y", 42, "Content.", 0.3),
|
||||||
|
]
|
||||||
|
|
||||||
|
fake_dense = np.zeros((1, 1024), dtype=np.float32)
|
||||||
|
mock_embedder = MagicMock()
|
||||||
|
mock_embedder.encode_dense.return_value = fake_dense
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.embed.get_embedder", return_value=mock_embedder),
|
||||||
|
patch("codex.db.get_conn", side_effect=_make_conn_cm(mock_conn)),
|
||||||
|
):
|
||||||
|
results = search("query")
|
||||||
|
|
||||||
|
assert results[0]["locator"] == "chunk 42"
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_snippet_truncated_to_300() -> None:
|
||||||
|
"""snippet must be truncated to 300 characters."""
|
||||||
|
from codex.mcp_server import search
|
||||||
|
|
||||||
|
long_content = "x" * 500
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute.return_value.fetchall.return_value = [
|
||||||
|
_fake_row("Auth2022", "paper-z", 1, long_content, 0.1),
|
||||||
|
]
|
||||||
|
|
||||||
|
fake_dense = np.zeros((1, 1024), dtype=np.float32)
|
||||||
|
mock_embedder = MagicMock()
|
||||||
|
mock_embedder.encode_dense.return_value = fake_dense
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.embed.get_embedder", return_value=mock_embedder),
|
||||||
|
patch("codex.db.get_conn", side_effect=_make_conn_cm(mock_conn)),
|
||||||
|
):
|
||||||
|
results = search("query")
|
||||||
|
|
||||||
|
assert len(results[0]["snippet"]) == 300 # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_returns_error_dict_on_exception() -> None:
|
||||||
|
"""When the embedder raises, search must return [{"error": ...}], not crash."""
|
||||||
|
from codex.mcp_server import search
|
||||||
|
|
||||||
|
mock_embedder = MagicMock()
|
||||||
|
mock_embedder.encode_dense.side_effect = RuntimeError("DB down")
|
||||||
|
|
||||||
|
with patch("codex.embed.get_embedder", return_value=mock_embedder):
|
||||||
|
results = search("query")
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert "error" in results[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_provenance_keys() -> None:
|
||||||
|
"""Each hit must carry bibkey and paper_id for provenance traceability."""
|
||||||
|
from codex.mcp_server import search
|
||||||
|
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute.return_value.fetchall.return_value = [
|
||||||
|
_fake_row("Crane1999", "crane-1999", 7, "Discrete exterior calculus ...", 0.15),
|
||||||
|
]
|
||||||
|
|
||||||
|
fake_dense = np.zeros((1, 1024), dtype=np.float32)
|
||||||
|
mock_embedder = MagicMock()
|
||||||
|
mock_embedder.encode_dense.return_value = fake_dense
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.embed.get_embedder", return_value=mock_embedder),
|
||||||
|
patch("codex.db.get_conn", side_effect=_make_conn_cm(mock_conn)),
|
||||||
|
):
|
||||||
|
results = search("exterior calculus")
|
||||||
|
|
||||||
|
assert results[0]["bibkey"] == "Crane1999"
|
||||||
|
assert results[0]["paper_id"] == "crane-1999"
|
||||||
49
tests/mcp/test_server.py
Normal file
49
tests/mcp/test_server.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""test_server.py — verify the MCP server instantiates and all tools are registered."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Expected tools (must match EXACTLY what mcp_server.py registers)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
EXPECTED_TOOLS = {
|
||||||
|
"search",
|
||||||
|
"ask",
|
||||||
|
"wiki_read",
|
||||||
|
"wiki_list",
|
||||||
|
"discover_leads",
|
||||||
|
"provenance_verify",
|
||||||
|
"synthesis_browse",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_instance_exists() -> None:
|
||||||
|
"""The module-level ``mcp`` FastMCP instance must exist."""
|
||||||
|
from codex.mcp_server import mcp
|
||||||
|
|
||||||
|
assert mcp is not None
|
||||||
|
assert mcp.name == "codex"
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_tools_registered() -> None:
|
||||||
|
"""Every expected tool name must be registered on the server."""
|
||||||
|
from codex.mcp_server import mcp
|
||||||
|
|
||||||
|
tools = asyncio.run(mcp.list_tools())
|
||||||
|
registered = {t.name for t in tools}
|
||||||
|
|
||||||
|
missing = EXPECTED_TOOLS - registered
|
||||||
|
assert not missing, f"Tools not registered: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_extra_unexpected_write_tools() -> None:
|
||||||
|
"""No tool outside the expected set should be registered (belt-and-suspenders)."""
|
||||||
|
from codex.mcp_server import mcp
|
||||||
|
|
||||||
|
tools = asyncio.run(mcp.list_tools())
|
||||||
|
registered = {t.name for t in tools}
|
||||||
|
|
||||||
|
unexpected = registered - EXPECTED_TOOLS
|
||||||
|
assert not unexpected, f"Unexpected tools registered: {unexpected}"
|
||||||
0
tests/parsing/__init__.py
Normal file
0
tests/parsing/__init__.py
Normal file
245
tests/parsing/test_figures.py
Normal file
245
tests/parsing/test_figures.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
"""Tests for codex.parsing.figures — figure extraction module.
|
||||||
|
|
||||||
|
All external dependencies (fitz, PIL) are mocked so the suite runs offline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mock_rect(
|
||||||
|
x0: float = 10.0, y0: float = 10.0, x1: float = 200.0, y1: float = 150.0
|
||||||
|
) -> MagicMock:
|
||||||
|
"""Build a mock fitz.Rect-like object."""
|
||||||
|
rect = MagicMock()
|
||||||
|
rect.x0 = x0
|
||||||
|
rect.y0 = y0
|
||||||
|
rect.x1 = x1
|
||||||
|
rect.y1 = y1
|
||||||
|
return rect
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mock_doc(
|
||||||
|
images: list[tuple[int, ...]], # list of (xref, ...)
|
||||||
|
text_blocks: list[Any],
|
||||||
|
img_rects: list[Any],
|
||||||
|
page_count: int = 1,
|
||||||
|
) -> tuple[MagicMock, MagicMock]:
|
||||||
|
"""Build a mock fitz document with one page."""
|
||||||
|
mock_pix = MagicMock()
|
||||||
|
mock_pix.n = 3 # RGB — no conversion needed
|
||||||
|
mock_pix.save = MagicMock()
|
||||||
|
|
||||||
|
mock_page = MagicMock()
|
||||||
|
mock_page.get_images.return_value = images
|
||||||
|
mock_page.get_text.return_value = text_blocks
|
||||||
|
mock_page.get_image_rects.return_value = img_rects
|
||||||
|
|
||||||
|
mock_doc = MagicMock()
|
||||||
|
mock_doc.__len__.return_value = page_count
|
||||||
|
mock_doc.__getitem__ = MagicMock(return_value=mock_page)
|
||||||
|
|
||||||
|
return mock_doc, mock_pix
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractFigures:
|
||||||
|
"""Tests for extract_figures()."""
|
||||||
|
|
||||||
|
def test_no_caption_returns_empty_string(self, tmp_path: Any) -> None:
|
||||||
|
"""Figure with no nearby caption gets empty caption string."""
|
||||||
|
img_rect = _make_mock_rect(10, 10, 200, 150)
|
||||||
|
text_blocks = [
|
||||||
|
(300.0, 300.0, 400.0, 320.0, "Unrelated text here.", 0, 0),
|
||||||
|
]
|
||||||
|
mock_doc, mock_pix = _make_mock_doc(
|
||||||
|
images=[(5, 0, 0, 0, 0, 0, 0, 0, 8, "png")],
|
||||||
|
text_blocks=text_blocks,
|
||||||
|
img_rects=[img_rect],
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("fitz.open", return_value=mock_doc),
|
||||||
|
patch("fitz.Pixmap", return_value=mock_pix),
|
||||||
|
):
|
||||||
|
from codex.parsing.figures import extract_figures
|
||||||
|
|
||||||
|
results = extract_figures("paper.pdf", output_dir=str(tmp_path))
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].caption == ""
|
||||||
|
|
||||||
|
def test_figure_png_is_saved(self, tmp_path: Any) -> None:
|
||||||
|
"""PNG save is called with the correct output path."""
|
||||||
|
img_rect = _make_mock_rect()
|
||||||
|
mock_doc, mock_pix = _make_mock_doc(
|
||||||
|
images=[(5, 0, 0, 0, 0, 0, 0, 0, 8, "png")],
|
||||||
|
text_blocks=[],
|
||||||
|
img_rects=[img_rect],
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("fitz.open", return_value=mock_doc),
|
||||||
|
patch("fitz.Pixmap", return_value=mock_pix),
|
||||||
|
):
|
||||||
|
from codex.parsing.figures import extract_figures
|
||||||
|
|
||||||
|
results = extract_figures("paper.pdf", output_dir=str(tmp_path))
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
mock_pix.save.assert_called_once()
|
||||||
|
saved_path: str = mock_pix.save.call_args[0][0]
|
||||||
|
assert saved_path.endswith(".png")
|
||||||
|
|
||||||
|
def test_caption_detected_below_image(self, tmp_path: Any) -> None:
|
||||||
|
"""Caption block immediately below the image is detected."""
|
||||||
|
img_rect = _make_mock_rect(10, 10, 200, 150)
|
||||||
|
caption_block = (10.0, 155.0, 200.0, 170.0, "Figure 1: Architecture overview.", 0, 0)
|
||||||
|
mock_doc, mock_pix = _make_mock_doc(
|
||||||
|
images=[(5, 0, 0, 0, 0, 0, 0, 0, 8, "png")],
|
||||||
|
text_blocks=[caption_block],
|
||||||
|
img_rects=[img_rect],
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("fitz.open", return_value=mock_doc),
|
||||||
|
patch("fitz.Pixmap", return_value=mock_pix),
|
||||||
|
):
|
||||||
|
from codex.parsing.figures import extract_figures
|
||||||
|
|
||||||
|
results = extract_figures("paper.pdf", output_dir=str(tmp_path))
|
||||||
|
|
||||||
|
assert results[0].caption == "Figure 1: Architecture overview."
|
||||||
|
|
||||||
|
def test_caption_detected_above_image(self, tmp_path: Any) -> None:
|
||||||
|
"""Caption block immediately above the image is also detected."""
|
||||||
|
img_rect = _make_mock_rect(10, 80, 200, 200)
|
||||||
|
caption_block = (10.0, 50.0, 200.0, 75.0, "Fig. 2: Loss curve.", 0, 0)
|
||||||
|
mock_doc, mock_pix = _make_mock_doc(
|
||||||
|
images=[(5, 0, 0, 0, 0, 0, 0, 0, 8, "png")],
|
||||||
|
text_blocks=[caption_block],
|
||||||
|
img_rects=[img_rect],
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("fitz.open", return_value=mock_doc),
|
||||||
|
patch("fitz.Pixmap", return_value=mock_pix),
|
||||||
|
):
|
||||||
|
from codex.parsing.figures import extract_figures
|
||||||
|
|
||||||
|
results = extract_figures("paper.pdf", output_dir=str(tmp_path))
|
||||||
|
|
||||||
|
assert results[0].caption == "Fig. 2: Loss curve."
|
||||||
|
|
||||||
|
def test_german_caption_prefix(self, tmp_path: Any) -> None:
|
||||||
|
"""'Abbildung' prefix is recognized as a valid German caption."""
|
||||||
|
img_rect = _make_mock_rect(10, 10, 200, 150)
|
||||||
|
caption_block = (10.0, 155.0, 200.0, 170.0, "Abbildung 3: Ergebnisse.", 0, 0)
|
||||||
|
mock_doc, mock_pix = _make_mock_doc(
|
||||||
|
images=[(5, 0, 0, 0, 0, 0, 0, 0, 8, "png")],
|
||||||
|
text_blocks=[caption_block],
|
||||||
|
img_rects=[img_rect],
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("fitz.open", return_value=mock_doc),
|
||||||
|
patch("fitz.Pixmap", return_value=mock_pix),
|
||||||
|
):
|
||||||
|
from codex.parsing.figures import extract_figures
|
||||||
|
|
||||||
|
results = extract_figures("paper.pdf", output_dir=str(tmp_path))
|
||||||
|
|
||||||
|
assert "Abbildung" in results[0].caption
|
||||||
|
|
||||||
|
def test_unrelated_text_not_used_as_caption(self, tmp_path: Any) -> None:
|
||||||
|
"""Text blocks not starting with a caption prefix are ignored."""
|
||||||
|
img_rect = _make_mock_rect(10, 10, 200, 150)
|
||||||
|
non_caption = (10.0, 155.0, 200.0, 170.0, "This is a random sentence.", 0, 0)
|
||||||
|
mock_doc, mock_pix = _make_mock_doc(
|
||||||
|
images=[(5, 0, 0, 0, 0, 0, 0, 0, 8, "png")],
|
||||||
|
text_blocks=[non_caption],
|
||||||
|
img_rects=[img_rect],
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("fitz.open", return_value=mock_doc),
|
||||||
|
patch("fitz.Pixmap", return_value=mock_pix),
|
||||||
|
):
|
||||||
|
from codex.parsing.figures import extract_figures
|
||||||
|
|
||||||
|
results = extract_figures("paper.pdf", output_dir=str(tmp_path))
|
||||||
|
|
||||||
|
assert results[0].caption == ""
|
||||||
|
|
||||||
|
def test_no_images_returns_empty_list(self, tmp_path: Any) -> None:
|
||||||
|
"""PDF with no embedded images returns empty list."""
|
||||||
|
mock_doc, mock_pix = _make_mock_doc(
|
||||||
|
images=[],
|
||||||
|
text_blocks=[],
|
||||||
|
img_rects=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("fitz.open", return_value=mock_doc),
|
||||||
|
patch("fitz.Pixmap", return_value=mock_pix),
|
||||||
|
):
|
||||||
|
from codex.parsing.figures import extract_figures
|
||||||
|
|
||||||
|
results = extract_figures("paper.pdf", output_dir=str(tmp_path))
|
||||||
|
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
def test_image_without_rect_is_skipped(self, tmp_path: Any) -> None:
|
||||||
|
"""Images for which get_image_rects returns empty list are skipped."""
|
||||||
|
mock_doc, mock_pix = _make_mock_doc(
|
||||||
|
images=[(5, 0, 0, 0, 0, 0, 0, 0, 8, "png")],
|
||||||
|
text_blocks=[],
|
||||||
|
img_rects=[], # no rect
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("fitz.open", return_value=mock_doc),
|
||||||
|
patch("fitz.Pixmap", return_value=mock_pix),
|
||||||
|
):
|
||||||
|
from codex.parsing.figures import extract_figures
|
||||||
|
|
||||||
|
results = extract_figures("paper.pdf", output_dir=str(tmp_path))
|
||||||
|
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
def test_output_dir_created_if_absent(self, tmp_path: Any) -> None:
|
||||||
|
"""output_dir is created if it does not exist."""
|
||||||
|
new_dir = tmp_path / "new_subdir" / "figures"
|
||||||
|
assert not new_dir.exists()
|
||||||
|
|
||||||
|
mock_doc, mock_pix = _make_mock_doc(images=[], text_blocks=[], img_rects=[])
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("fitz.open", return_value=mock_doc),
|
||||||
|
patch("fitz.Pixmap", return_value=mock_pix),
|
||||||
|
):
|
||||||
|
from codex.parsing.figures import extract_figures
|
||||||
|
|
||||||
|
extract_figures("paper.pdf", output_dir=str(new_dir))
|
||||||
|
|
||||||
|
assert new_dir.exists()
|
||||||
|
|
||||||
|
def test_paper_id_from_pdf_stem(self, tmp_path: Any) -> None:
|
||||||
|
"""paper_id in FigureChunk is the stem of the PDF filename."""
|
||||||
|
img_rect = _make_mock_rect()
|
||||||
|
mock_doc, mock_pix = _make_mock_doc(
|
||||||
|
images=[(5, 0, 0, 0, 0, 0, 0, 0, 8, "png")],
|
||||||
|
text_blocks=[],
|
||||||
|
img_rects=[img_rect],
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("fitz.open", return_value=mock_doc),
|
||||||
|
patch("fitz.Pixmap", return_value=mock_pix),
|
||||||
|
):
|
||||||
|
from codex.parsing.figures import extract_figures
|
||||||
|
|
||||||
|
results = extract_figures("/tmp/2301.07041.pdf", output_dir=str(tmp_path))
|
||||||
|
|
||||||
|
assert results[0].paper_id == "2301.07041"
|
||||||
161
tests/parsing/test_grobid.py
Normal file
161
tests/parsing/test_grobid.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""Tests for codex.parsing.grobid."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from codex.parsing.grobid import extract_references, extract_structure
|
||||||
|
|
||||||
|
# Real GROBID TEI output uses type="arXiv" (camel-case), not "arxiv".
|
||||||
|
_TEI_XML = """\
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<TEI xmlns="http://www.tei-c.org/ns/1.0">
|
||||||
|
<text>
|
||||||
|
<back>
|
||||||
|
<div type="references">
|
||||||
|
<listBibl>
|
||||||
|
<biblStruct>
|
||||||
|
<analytic>
|
||||||
|
<title level="a">Deep Learning for NLP</title>
|
||||||
|
<author>
|
||||||
|
<persName>
|
||||||
|
<forename>Jane</forename>
|
||||||
|
<surname>Doe</surname>
|
||||||
|
</persName>
|
||||||
|
</author>
|
||||||
|
</analytic>
|
||||||
|
<monogr>
|
||||||
|
<imprint>
|
||||||
|
<date type="published" when="2021-06"/>
|
||||||
|
</imprint>
|
||||||
|
</monogr>
|
||||||
|
<idno type="DOI">10.1234/nlp.2021</idno>
|
||||||
|
<idno type="arXiv">2101.00001</idno>
|
||||||
|
</biblStruct>
|
||||||
|
<biblStruct>
|
||||||
|
<analytic>
|
||||||
|
<title level="a">Attention Is All You Need</title>
|
||||||
|
<author>
|
||||||
|
<persName>
|
||||||
|
<forename>Ashish</forename>
|
||||||
|
<surname>Vaswani</surname>
|
||||||
|
</persName>
|
||||||
|
</author>
|
||||||
|
<author>
|
||||||
|
<persName>
|
||||||
|
<forename>Noam</forename>
|
||||||
|
<surname>Shazeer</surname>
|
||||||
|
</persName>
|
||||||
|
</author>
|
||||||
|
</analytic>
|
||||||
|
<monogr>
|
||||||
|
<imprint>
|
||||||
|
<date type="published" when="2017"/>
|
||||||
|
</imprint>
|
||||||
|
</monogr>
|
||||||
|
<idno type="DOI">10.5678/attention</idno>
|
||||||
|
</biblStruct>
|
||||||
|
</listBibl>
|
||||||
|
</div>
|
||||||
|
</back>
|
||||||
|
</text>
|
||||||
|
</TEI>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
text = _TEI_XML
|
||||||
|
status_code = 200
|
||||||
|
|
||||||
|
def raise_for_status(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_post(xml: str = _TEI_XML) -> MagicMock:
|
||||||
|
fake = MagicMock()
|
||||||
|
fake.text = xml
|
||||||
|
fake.status_code = 200
|
||||||
|
fake.raise_for_status = MagicMock()
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractReferences:
|
||||||
|
def test_returns_two_dicts(self, tmp_path: Path) -> None:
|
||||||
|
pdf = tmp_path / "paper.pdf"
|
||||||
|
pdf.write_bytes(b"%PDF fake")
|
||||||
|
|
||||||
|
with patch("httpx.Client") as mock_client_cls:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value.__enter__.return_value = mock_client
|
||||||
|
mock_client.post.return_value = _mock_post()
|
||||||
|
|
||||||
|
refs = extract_references(str(pdf), grobid_url="http://localhost:8070")
|
||||||
|
|
||||||
|
assert len(refs) == 2
|
||||||
|
|
||||||
|
def test_all_keys_present(self, tmp_path: Path) -> None:
|
||||||
|
pdf = tmp_path / "paper.pdf"
|
||||||
|
pdf.write_bytes(b"%PDF fake")
|
||||||
|
|
||||||
|
with patch("httpx.Client") as mock_client_cls:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value.__enter__.return_value = mock_client
|
||||||
|
mock_client.post.return_value = _mock_post()
|
||||||
|
|
||||||
|
refs = extract_references(str(pdf), grobid_url="http://localhost:8070")
|
||||||
|
|
||||||
|
required_keys = {"title", "authors", "year", "doi", "arxiv_id"}
|
||||||
|
for ref in refs:
|
||||||
|
assert required_keys == set(ref.keys()), f"Missing keys in {ref}"
|
||||||
|
|
||||||
|
def test_first_ref_values(self, tmp_path: Path) -> None:
|
||||||
|
pdf = tmp_path / "paper.pdf"
|
||||||
|
pdf.write_bytes(b"%PDF fake")
|
||||||
|
|
||||||
|
with patch("httpx.Client") as mock_client_cls:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value.__enter__.return_value = mock_client
|
||||||
|
mock_client.post.return_value = _mock_post()
|
||||||
|
|
||||||
|
refs = extract_references(str(pdf), grobid_url="http://localhost:8070")
|
||||||
|
|
||||||
|
first = refs[0]
|
||||||
|
assert first["title"] == "Deep Learning for NLP"
|
||||||
|
assert "Jane Doe" in first["authors"]
|
||||||
|
assert first["year"] == "2021"
|
||||||
|
assert first["doi"] == "10.1234/nlp.2021"
|
||||||
|
assert first["arxiv_id"] == "2101.00001"
|
||||||
|
|
||||||
|
def test_second_ref_missing_arxiv_is_empty_string(self, tmp_path: Path) -> None:
|
||||||
|
pdf = tmp_path / "paper.pdf"
|
||||||
|
pdf.write_bytes(b"%PDF fake")
|
||||||
|
|
||||||
|
with patch("httpx.Client") as mock_client_cls:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value.__enter__.return_value = mock_client
|
||||||
|
mock_client.post.return_value = _mock_post()
|
||||||
|
|
||||||
|
refs = extract_references(str(pdf), grobid_url="http://localhost:8070")
|
||||||
|
|
||||||
|
second = refs[1]
|
||||||
|
assert second["arxiv_id"] == ""
|
||||||
|
assert second["title"] == "Attention Is All You Need"
|
||||||
|
assert second["year"] == "2017"
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractStructure:
|
||||||
|
def test_returns_xml_string(self, tmp_path: Path) -> None:
|
||||||
|
pdf = tmp_path / "paper.pdf"
|
||||||
|
pdf.write_bytes(b"%PDF fake")
|
||||||
|
|
||||||
|
with patch("httpx.Client") as mock_client_cls:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value.__enter__.return_value = mock_client
|
||||||
|
mock_client.post.return_value = _mock_post("<TEI>raw xml</TEI>")
|
||||||
|
|
||||||
|
result = extract_structure(str(pdf), grobid_url="http://localhost:8070")
|
||||||
|
|
||||||
|
assert result == "<TEI>raw xml</TEI>"
|
||||||
|
mock_client.post.assert_called_once()
|
||||||
|
assert "processFulltextDocument" in mock_client.post.call_args[0][0]
|
||||||
386
tests/parsing/test_mathpix.py
Normal file
386
tests/parsing/test_mathpix.py
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
"""Tests for codex.parsing.mathpix — formula extraction module.
|
||||||
|
|
||||||
|
All external dependencies (fitz, pix2tex, httpx, get_settings) are mocked
|
||||||
|
so the suite runs offline without real API calls or model downloads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from codex.models import FormulaChunk
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_open_pdf() -> MagicMock:
|
||||||
|
"""Return a mock that satisfies ``open(path, 'rb')`` as a context manager."""
|
||||||
|
mock_file = MagicMock()
|
||||||
|
mock_file.__enter__ = MagicMock(return_value=mock_file)
|
||||||
|
mock_file.__exit__ = MagicMock(return_value=False)
|
||||||
|
mock_open = MagicMock(return_value=mock_file)
|
||||||
|
return mock_open
|
||||||
|
|
||||||
|
|
||||||
|
class TestMathPixAPIPath:
|
||||||
|
"""Tests for the MathPix cloud API backend."""
|
||||||
|
|
||||||
|
def test_mathpix_200_returns_formulas(self) -> None:
|
||||||
|
"""extract_formulas routes to MathPix when creds are provided and parses result."""
|
||||||
|
mmd_content = "\\[\n\\alpha + \\beta\n\\]\n\\[\nE = mc^2\n\\]"
|
||||||
|
|
||||||
|
mock_settings = MagicMock()
|
||||||
|
mock_settings.mathpix_app_id = None
|
||||||
|
mock_settings.mathpix_app_key = None
|
||||||
|
mock_settings.pix2tex_fallback = True
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.parsing.mathpix.get_settings", return_value=mock_settings),
|
||||||
|
patch("builtins.open", _mock_open_pdf()),
|
||||||
|
patch("httpx.post") as mock_post,
|
||||||
|
patch("httpx.get") as mock_get,
|
||||||
|
patch("time.sleep"),
|
||||||
|
):
|
||||||
|
mock_post.return_value = MagicMock(
|
||||||
|
status_code=200,
|
||||||
|
json=lambda: {"pdf_id": "abc123"},
|
||||||
|
)
|
||||||
|
mock_post.return_value.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
mock_get.return_value = MagicMock(
|
||||||
|
status_code=200,
|
||||||
|
text=mmd_content,
|
||||||
|
)
|
||||||
|
|
||||||
|
from codex.parsing.mathpix import _extract_formulas_mathpix
|
||||||
|
|
||||||
|
results = _extract_formulas_mathpix("paper.pdf", "app_id", "app_key")
|
||||||
|
|
||||||
|
assert len(results) == 2
|
||||||
|
assert all(isinstance(r, FormulaChunk) for r in results)
|
||||||
|
assert results[0].raw_latex == "\\[\n\\alpha + \\beta\n\\]"
|
||||||
|
|
||||||
|
def test_mathpix_empty_pdf_id_returns_empty(self) -> None:
|
||||||
|
"""When MathPix returns no pdf_id, result is empty list."""
|
||||||
|
with (
|
||||||
|
patch("builtins.open", _mock_open_pdf()),
|
||||||
|
patch("httpx.post") as mock_post,
|
||||||
|
patch("time.sleep"),
|
||||||
|
):
|
||||||
|
mock_post.return_value = MagicMock(
|
||||||
|
status_code=200,
|
||||||
|
json=lambda: {}, # No pdf_id
|
||||||
|
)
|
||||||
|
mock_post.return_value.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
from codex.parsing.mathpix import _extract_formulas_mathpix
|
||||||
|
|
||||||
|
results = _extract_formulas_mathpix("paper.pdf", "app_id", "app_key")
|
||||||
|
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
def test_mathpix_paper_id_from_stem(self) -> None:
|
||||||
|
"""paper_id in FormulaChunk is derived from the PDF filename stem."""
|
||||||
|
mmd_content = "\\[\nx^2\n\\]"
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("builtins.open", _mock_open_pdf()),
|
||||||
|
patch("httpx.post") as mock_post,
|
||||||
|
patch("httpx.get") as mock_get,
|
||||||
|
patch("time.sleep"),
|
||||||
|
):
|
||||||
|
mock_post.return_value = MagicMock(
|
||||||
|
status_code=200,
|
||||||
|
json=lambda: {"pdf_id": "xyz"},
|
||||||
|
)
|
||||||
|
mock_post.return_value.raise_for_status = MagicMock()
|
||||||
|
mock_get.return_value = MagicMock(status_code=200, text=mmd_content)
|
||||||
|
|
||||||
|
from codex.parsing.mathpix import _extract_formulas_mathpix
|
||||||
|
|
||||||
|
results = _extract_formulas_mathpix("/tmp/2301.07041.pdf", "id", "key")
|
||||||
|
|
||||||
|
assert results[0].paper_id == "2301.07041"
|
||||||
|
|
||||||
|
def test_mathpix_page_counter_increments(self) -> None:
|
||||||
|
"""Page counter increments on MathPix page markers in MMD."""
|
||||||
|
mmd_content = "\\[\nA\n\\]\n<!-- Page 2 -->\n\\[\nB\n\\]"
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("builtins.open", _mock_open_pdf()),
|
||||||
|
patch("httpx.post") as mock_post,
|
||||||
|
patch("httpx.get") as mock_get,
|
||||||
|
patch("time.sleep"),
|
||||||
|
):
|
||||||
|
mock_post.return_value = MagicMock(
|
||||||
|
status_code=200,
|
||||||
|
json=lambda: {"pdf_id": "p1"},
|
||||||
|
)
|
||||||
|
mock_post.return_value.raise_for_status = MagicMock()
|
||||||
|
mock_get.return_value = MagicMock(status_code=200, text=mmd_content)
|
||||||
|
|
||||||
|
from codex.parsing.mathpix import _extract_formulas_mathpix
|
||||||
|
|
||||||
|
results = _extract_formulas_mathpix("p.pdf", "id", "key")
|
||||||
|
|
||||||
|
assert results[0].page == 1
|
||||||
|
assert results[1].page == 2
|
||||||
|
|
||||||
|
def test_mathpix_http_error_propagates(self) -> None:
|
||||||
|
"""HTTP errors from MathPix are raised (tenacity reraises after retries)."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("builtins.open", _mock_open_pdf()),
|
||||||
|
patch("httpx.post") as mock_post,
|
||||||
|
):
|
||||||
|
mock_post.return_value = MagicMock(status_code=401)
|
||||||
|
mock_post.return_value.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||||
|
"401", request=MagicMock(), response=MagicMock()
|
||||||
|
)
|
||||||
|
|
||||||
|
from codex.parsing.mathpix import _extract_formulas_mathpix
|
||||||
|
|
||||||
|
with pytest.raises(httpx.HTTPStatusError):
|
||||||
|
_extract_formulas_mathpix("p.pdf", "id", "key")
|
||||||
|
|
||||||
|
|
||||||
|
class TestPix2TexFallbackPath:
|
||||||
|
"""Tests for the local pix2tex backend."""
|
||||||
|
|
||||||
|
def _make_fitz_page(
|
||||||
|
self,
|
||||||
|
blocks: list[Any],
|
||||||
|
pixmap_samples: bytes = b"\xff" * (30 * 100 * 3),
|
||||||
|
pixmap_w: int = 100,
|
||||||
|
pixmap_h: int = 30,
|
||||||
|
) -> MagicMock:
|
||||||
|
"""Build a mock fitz page with given text blocks and a fake pixmap."""
|
||||||
|
mock_pix = MagicMock()
|
||||||
|
mock_pix.width = pixmap_w
|
||||||
|
mock_pix.height = pixmap_h
|
||||||
|
mock_pix.samples = pixmap_samples
|
||||||
|
|
||||||
|
mock_page = MagicMock()
|
||||||
|
mock_page.get_text.return_value = blocks
|
||||||
|
mock_page.get_pixmap.return_value = mock_pix
|
||||||
|
return mock_page
|
||||||
|
|
||||||
|
def test_pix2tex_fallback_invoked_without_creds(self) -> None:
|
||||||
|
"""When no MathPix creds, pix2tex fallback is called."""
|
||||||
|
math_block = (0.0, 0.0, 200.0, 50.0, "∑ α∫β∇γ∂δ∞ε", 0, 0)
|
||||||
|
|
||||||
|
mock_settings = MagicMock()
|
||||||
|
mock_settings.mathpix_app_id = None
|
||||||
|
mock_settings.mathpix_app_key = None
|
||||||
|
mock_settings.pix2tex_fallback = True
|
||||||
|
mock_settings.figures_dir = "/tmp/figs"
|
||||||
|
|
||||||
|
mock_model = MagicMock(return_value=r"\sum \alpha")
|
||||||
|
|
||||||
|
mock_page = self._make_fitz_page([math_block])
|
||||||
|
mock_doc = MagicMock()
|
||||||
|
mock_doc.__len__.return_value = 1
|
||||||
|
mock_doc.__getitem__ = MagicMock(return_value=mock_page)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.parsing.mathpix.get_settings", return_value=mock_settings),
|
||||||
|
patch("codex.parsing.mathpix._get_pix2tex_model", return_value=mock_model),
|
||||||
|
patch("fitz.open", return_value=mock_doc),
|
||||||
|
patch("fitz.Matrix"),
|
||||||
|
patch("fitz.Rect"),
|
||||||
|
):
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
with patch.object(Image, "frombytes", return_value=MagicMock()):
|
||||||
|
from codex.parsing.mathpix import extract_formulas
|
||||||
|
|
||||||
|
results = extract_formulas("paper.pdf")
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].raw_latex == r"\sum \alpha"
|
||||||
|
|
||||||
|
def test_pix2tex_bbox_size_filter(self) -> None:
|
||||||
|
"""Blocks smaller than MIN_HEIGHT_PT or MIN_WIDTH_PT are skipped."""
|
||||||
|
tiny_block = (0.0, 0.0, 10.0, 5.0, "∑∫∂∞∇ε", 0, 0) # 10pt wide, 5pt tall
|
||||||
|
|
||||||
|
mock_settings = MagicMock()
|
||||||
|
mock_settings.mathpix_app_id = None
|
||||||
|
mock_settings.mathpix_app_key = None
|
||||||
|
mock_settings.pix2tex_fallback = True
|
||||||
|
|
||||||
|
mock_model = MagicMock(return_value=r"\sum")
|
||||||
|
mock_page = self._make_fitz_page([tiny_block])
|
||||||
|
mock_doc = MagicMock()
|
||||||
|
mock_doc.__len__.return_value = 1
|
||||||
|
mock_doc.__getitem__ = MagicMock(return_value=mock_page)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.parsing.mathpix.get_settings", return_value=mock_settings),
|
||||||
|
patch("codex.parsing.mathpix._get_pix2tex_model", return_value=mock_model),
|
||||||
|
patch("fitz.open", return_value=mock_doc),
|
||||||
|
patch("fitz.Matrix"),
|
||||||
|
patch("fitz.Rect"),
|
||||||
|
):
|
||||||
|
from codex.parsing.mathpix import extract_formulas
|
||||||
|
|
||||||
|
results = extract_formulas("paper.pdf")
|
||||||
|
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
def test_pix2tex_math_ratio_filter(self) -> None:
|
||||||
|
"""Blocks with low math-character ratio are skipped."""
|
||||||
|
plain_text_block = (0.0, 0.0, 200.0, 50.0, "This is just regular text with no math.", 0, 0)
|
||||||
|
|
||||||
|
mock_settings = MagicMock()
|
||||||
|
mock_settings.mathpix_app_id = None
|
||||||
|
mock_settings.mathpix_app_key = None
|
||||||
|
mock_settings.pix2tex_fallback = True
|
||||||
|
|
||||||
|
mock_model = MagicMock(return_value=r"\alpha")
|
||||||
|
mock_page = self._make_fitz_page([plain_text_block])
|
||||||
|
mock_doc = MagicMock()
|
||||||
|
mock_doc.__len__.return_value = 1
|
||||||
|
mock_doc.__getitem__ = MagicMock(return_value=mock_page)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.parsing.mathpix.get_settings", return_value=mock_settings),
|
||||||
|
patch("codex.parsing.mathpix._get_pix2tex_model", return_value=mock_model),
|
||||||
|
patch("fitz.open", return_value=mock_doc),
|
||||||
|
patch("fitz.Matrix"),
|
||||||
|
patch("fitz.Rect"),
|
||||||
|
):
|
||||||
|
from codex.parsing.mathpix import extract_formulas
|
||||||
|
|
||||||
|
results = extract_formulas("paper.pdf")
|
||||||
|
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
def test_pix2tex_exception_swallowed(self) -> None:
|
||||||
|
"""If pix2tex raises, the block is skipped (no exception propagates)."""
|
||||||
|
math_block = (0.0, 0.0, 200.0, 50.0, "∑ α∫β∇γ∂δ∞ε", 0, 0)
|
||||||
|
|
||||||
|
mock_settings = MagicMock()
|
||||||
|
mock_settings.mathpix_app_id = None
|
||||||
|
mock_settings.mathpix_app_key = None
|
||||||
|
mock_settings.pix2tex_fallback = True
|
||||||
|
|
||||||
|
mock_model = MagicMock(side_effect=RuntimeError("GPU OOM"))
|
||||||
|
mock_page = self._make_fitz_page([math_block])
|
||||||
|
mock_doc = MagicMock()
|
||||||
|
mock_doc.__len__.return_value = 1
|
||||||
|
mock_doc.__getitem__ = MagicMock(return_value=mock_page)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.parsing.mathpix.get_settings", return_value=mock_settings),
|
||||||
|
patch("codex.parsing.mathpix._get_pix2tex_model", return_value=mock_model),
|
||||||
|
patch("fitz.open", return_value=mock_doc),
|
||||||
|
patch("fitz.Matrix"),
|
||||||
|
patch("fitz.Rect"),
|
||||||
|
):
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
with patch.object(Image, "frombytes", return_value=MagicMock()):
|
||||||
|
from codex.parsing.mathpix import extract_formulas
|
||||||
|
|
||||||
|
results = extract_formulas("paper.pdf") # Must not raise
|
||||||
|
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestRouteSelection:
|
||||||
|
"""Tests for extract_formulas route selection logic."""
|
||||||
|
|
||||||
|
def test_mathpix_selected_when_creds_present(self) -> None:
|
||||||
|
"""extract_formulas calls MathPix backend when both creds are set."""
|
||||||
|
mock_settings = MagicMock()
|
||||||
|
mock_settings.mathpix_app_id = "my_id"
|
||||||
|
mock_settings.mathpix_app_key = "my_key"
|
||||||
|
mock_settings.pix2tex_fallback = True
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.parsing.mathpix.get_settings", return_value=mock_settings),
|
||||||
|
patch(
|
||||||
|
"codex.parsing.mathpix._extract_formulas_mathpix", return_value=[]
|
||||||
|
) as mock_mathpix,
|
||||||
|
patch(
|
||||||
|
"codex.parsing.mathpix._extract_formulas_pix2tex", return_value=[]
|
||||||
|
) as mock_pix2tex,
|
||||||
|
):
|
||||||
|
from codex.parsing.mathpix import extract_formulas
|
||||||
|
|
||||||
|
extract_formulas("paper.pdf")
|
||||||
|
|
||||||
|
mock_mathpix.assert_called_once_with("paper.pdf", "my_id", "my_key")
|
||||||
|
mock_pix2tex.assert_not_called()
|
||||||
|
|
||||||
|
def test_pix2tex_selected_without_creds(self) -> None:
|
||||||
|
"""extract_formulas calls pix2tex when no MathPix creds are set."""
|
||||||
|
mock_settings = MagicMock()
|
||||||
|
mock_settings.mathpix_app_id = None
|
||||||
|
mock_settings.mathpix_app_key = None
|
||||||
|
mock_settings.pix2tex_fallback = True
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.parsing.mathpix.get_settings", return_value=mock_settings),
|
||||||
|
patch(
|
||||||
|
"codex.parsing.mathpix._extract_formulas_mathpix", return_value=[]
|
||||||
|
) as mock_mathpix,
|
||||||
|
patch(
|
||||||
|
"codex.parsing.mathpix._extract_formulas_pix2tex", return_value=[]
|
||||||
|
) as mock_pix2tex,
|
||||||
|
):
|
||||||
|
from codex.parsing.mathpix import extract_formulas
|
||||||
|
|
||||||
|
extract_formulas("paper.pdf")
|
||||||
|
|
||||||
|
mock_pix2tex.assert_called_once_with("paper.pdf")
|
||||||
|
mock_mathpix.assert_not_called()
|
||||||
|
|
||||||
|
def test_empty_string_creds_treated_as_absent(self) -> None:
|
||||||
|
"""Empty string credentials fall through to pix2tex (not MathPix)."""
|
||||||
|
mock_settings = MagicMock()
|
||||||
|
mock_settings.mathpix_app_id = ""
|
||||||
|
mock_settings.mathpix_app_key = ""
|
||||||
|
mock_settings.pix2tex_fallback = True
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.parsing.mathpix.get_settings", return_value=mock_settings),
|
||||||
|
patch(
|
||||||
|
"codex.parsing.mathpix._extract_formulas_mathpix", return_value=[]
|
||||||
|
) as mock_mathpix,
|
||||||
|
patch(
|
||||||
|
"codex.parsing.mathpix._extract_formulas_pix2tex", return_value=[]
|
||||||
|
) as mock_pix2tex,
|
||||||
|
):
|
||||||
|
from codex.parsing.mathpix import extract_formulas
|
||||||
|
|
||||||
|
extract_formulas("paper.pdf")
|
||||||
|
|
||||||
|
mock_pix2tex.assert_called_once()
|
||||||
|
mock_mathpix.assert_not_called()
|
||||||
|
|
||||||
|
def test_formula_extraction_disabled_when_fallback_false(self) -> None:
|
||||||
|
"""No extraction when pix2tex_fallback=False and no creds."""
|
||||||
|
mock_settings = MagicMock()
|
||||||
|
mock_settings.mathpix_app_id = None
|
||||||
|
mock_settings.mathpix_app_key = None
|
||||||
|
mock_settings.pix2tex_fallback = False
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("codex.parsing.mathpix.get_settings", return_value=mock_settings),
|
||||||
|
patch(
|
||||||
|
"codex.parsing.mathpix._extract_formulas_mathpix", return_value=[]
|
||||||
|
) as mock_mathpix,
|
||||||
|
patch(
|
||||||
|
"codex.parsing.mathpix._extract_formulas_pix2tex", return_value=[]
|
||||||
|
) as mock_pix2tex,
|
||||||
|
):
|
||||||
|
from codex.parsing.mathpix import extract_formulas
|
||||||
|
|
||||||
|
results = extract_formulas("paper.pdf")
|
||||||
|
|
||||||
|
assert results == []
|
||||||
|
mock_mathpix.assert_not_called()
|
||||||
|
mock_pix2tex.assert_not_called()
|
||||||
70
tests/parsing/test_nougat.py
Normal file
70
tests/parsing/test_nougat.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""Tests for codex.parsing.nougat."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from codex.parsing.nougat import pdf_to_markdown
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
"""Minimal stand-in for httpx.Response."""
|
||||||
|
|
||||||
|
def __init__(self, text: str, status_code: int = 200) -> None:
|
||||||
|
self.text = text
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
def raise_for_status(self) -> None:
|
||||||
|
if self.status_code >= 400:
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
"error",
|
||||||
|
request=MagicMock(),
|
||||||
|
response=MagicMock(status_code=self.status_code),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPdfToMarkdown:
|
||||||
|
def test_success_returns_response_text(self, tmp_path: pytest.TempdirFactory) -> None:
|
||||||
|
pdf = tmp_path / "paper.pdf" # type: ignore[operator]
|
||||||
|
pdf.write_bytes(b"%PDF fake")
|
||||||
|
|
||||||
|
expected = "# Title\nContent"
|
||||||
|
fake_response = _FakeResponse(expected)
|
||||||
|
|
||||||
|
with patch("httpx.Client") as mock_client_cls:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value.__enter__.return_value = mock_client
|
||||||
|
mock_client.post.return_value = fake_response
|
||||||
|
|
||||||
|
result = pdf_to_markdown(str(pdf), nougat_url="http://localhost:8080")
|
||||||
|
|
||||||
|
assert result == expected
|
||||||
|
mock_client.post.assert_called_once()
|
||||||
|
call_kwargs = mock_client.post.call_args
|
||||||
|
assert "/predict" in call_kwargs[0][0]
|
||||||
|
|
||||||
|
def test_connect_error_retried_exactly_twice(self, tmp_path: pytest.TempdirFactory) -> None:
|
||||||
|
"""ConnectError must trigger 2 retries (3 total attempts)."""
|
||||||
|
pdf = tmp_path / "paper.pdf" # type: ignore[operator]
|
||||||
|
pdf.write_bytes(b"%PDF fake")
|
||||||
|
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
def _raising_post(*args: object, **kwargs: object) -> _FakeResponse:
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
raise httpx.ConnectError("refused")
|
||||||
|
|
||||||
|
with patch("httpx.Client") as mock_client_cls:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value.__enter__.return_value = mock_client
|
||||||
|
mock_client.post.side_effect = _raising_post
|
||||||
|
|
||||||
|
with pytest.raises(httpx.ConnectError):
|
||||||
|
pdf_to_markdown(str(pdf), nougat_url="http://localhost:8080")
|
||||||
|
|
||||||
|
# tenacity: stop_after_attempt(3) → 1 original + 2 retries = 3 calls
|
||||||
|
assert call_count == 3
|
||||||
112
tests/parsing/test_tex.py
Normal file
112
tests/parsing/test_tex.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"""Tests for codex.parsing.tex."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from codex.parsing.tex import chunk_text, extract_sections, latex_to_text
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractSections:
|
||||||
|
def test_two_sections_returned(self) -> None:
|
||||||
|
latex = r"\section{Intro}" + "\nHello \\cite{foo} world\n"
|
||||||
|
latex += r"\section{Method}" + "\nResult"
|
||||||
|
sections = extract_sections(latex)
|
||||||
|
assert len(sections) == 2
|
||||||
|
|
||||||
|
def test_section_titles(self) -> None:
|
||||||
|
latex = r"\section{Intro}" + "\nHello\n" + r"\section{Method}" + "\nResult"
|
||||||
|
sections = extract_sections(latex)
|
||||||
|
assert sections[0][0] == "Intro"
|
||||||
|
assert sections[1][0] == "Method"
|
||||||
|
|
||||||
|
def test_cite_removed_from_text(self) -> None:
|
||||||
|
latex = r"\section{Intro}" + "\nHello \\cite{foo} world\n"
|
||||||
|
latex += r"\section{Method}" + "\nResult"
|
||||||
|
sections = extract_sections(latex)
|
||||||
|
body = sections[0][1]
|
||||||
|
assert r"\cite" not in body
|
||||||
|
assert "foo" not in body
|
||||||
|
|
||||||
|
def test_no_sections_returns_empty(self) -> None:
|
||||||
|
assert extract_sections("No sections here at all.") == []
|
||||||
|
|
||||||
|
def test_subsection_recognised(self) -> None:
|
||||||
|
latex = r"\subsection{Background}" + "\nSome text."
|
||||||
|
sections = extract_sections(latex)
|
||||||
|
assert len(sections) == 1
|
||||||
|
assert sections[0][0] == "Background"
|
||||||
|
|
||||||
|
def test_comments_stripped(self) -> None:
|
||||||
|
latex = r"\section{A}" + "\nHello % this is a comment\nWorld"
|
||||||
|
sections = extract_sections(latex)
|
||||||
|
assert "%" not in sections[0][1]
|
||||||
|
assert "comment" not in sections[0][1]
|
||||||
|
|
||||||
|
def test_math_stripped(self) -> None:
|
||||||
|
latex = r"\section{A}" + "\nLet $x = 1$ and $$y = 2$$ be numbers."
|
||||||
|
sections = extract_sections(latex)
|
||||||
|
body = sections[0][1]
|
||||||
|
assert "$" not in body
|
||||||
|
|
||||||
|
|
||||||
|
class TestChunkText:
|
||||||
|
@pytest.fixture()
|
||||||
|
def long_text(self) -> str:
|
||||||
|
# Build a 600-word string with real sentences
|
||||||
|
sentence = "The quick brown fox jumps over the lazy dog near the river. "
|
||||||
|
words_per_sentence = len(sentence.split())
|
||||||
|
repeats = (600 // words_per_sentence) + 1
|
||||||
|
return (sentence * repeats).strip()
|
||||||
|
|
||||||
|
def test_at_least_two_chunks(self, long_text: str) -> None:
|
||||||
|
chunks = chunk_text(long_text, size=512, overlap=64)
|
||||||
|
assert len(chunks) >= 2
|
||||||
|
|
||||||
|
def test_each_chunk_within_size_bound(self, long_text: str) -> None:
|
||||||
|
size, overlap = 512, 64
|
||||||
|
chunks = chunk_text(long_text, size=size, overlap=overlap)
|
||||||
|
for chunk in chunks:
|
||||||
|
word_count = len(chunk.split())
|
||||||
|
assert word_count <= size + overlap, (
|
||||||
|
f"Chunk has {word_count} words, expected <= {size + overlap}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_text_returns_empty_list(self) -> None:
|
||||||
|
assert chunk_text("") == []
|
||||||
|
|
||||||
|
def test_short_text_returns_single_chunk(self) -> None:
|
||||||
|
chunks = chunk_text("Hello world.", size=512, overlap=64)
|
||||||
|
assert len(chunks) == 1
|
||||||
|
|
||||||
|
def test_chunks_cover_all_content(self) -> None:
|
||||||
|
# First chunk must start with first word, last chunk must end with last word
|
||||||
|
text = " ".join(f"word{i}" for i in range(600))
|
||||||
|
chunks = chunk_text(text, size=200, overlap=50)
|
||||||
|
assert chunks[0].startswith("word0")
|
||||||
|
assert chunks[-1].endswith("word599")
|
||||||
|
|
||||||
|
|
||||||
|
class TestLatexToText:
|
||||||
|
def test_no_percent_in_output(self) -> None:
|
||||||
|
latex = r"\section{A}" + "\nSome % comment\ntext."
|
||||||
|
result = latex_to_text(latex)
|
||||||
|
assert "%" not in result
|
||||||
|
|
||||||
|
def test_no_cite_in_output(self) -> None:
|
||||||
|
latex = r"\section{A}" + "\nHello \\cite{bar} world."
|
||||||
|
result = latex_to_text(latex)
|
||||||
|
assert r"\cite" not in result
|
||||||
|
assert "bar" not in result
|
||||||
|
|
||||||
|
def test_returns_string(self) -> None:
|
||||||
|
latex = r"\section{A}" + "\nSimple text."
|
||||||
|
result = latex_to_text(latex)
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
def test_no_section_fallback(self) -> None:
|
||||||
|
latex = r"Some \cite{foo} text with % comment"
|
||||||
|
result = latex_to_text(latex)
|
||||||
|
assert r"\cite" not in result
|
||||||
|
assert "%" not in result
|
||||||
0
tests/provenance/__init__.py
Normal file
0
tests/provenance/__init__.py
Normal file
209
tests/provenance/test_provenance.py
Normal file
209
tests/provenance/test_provenance.py
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
"""Tests for codex.provenance — @cite scan, code_links CRUD, bib export.
|
||||||
|
|
||||||
|
All DB access is mocked; no real PostgreSQL server required.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from codex.models import CodeLink
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_conn_cm(mock_conn: MagicMock) -> Any:
|
||||||
|
"""Return a context-manager factory that yields *mock_conn*."""
|
||||||
|
|
||||||
|
@contextmanager # type: ignore[arg-type]
|
||||||
|
def _cm() -> Any:
|
||||||
|
yield mock_conn
|
||||||
|
|
||||||
|
return _cm
|
||||||
|
|
||||||
|
|
||||||
|
def _dict_row(**kwargs: Any) -> dict[str, Any]:
|
||||||
|
return dict(kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. test_scan_cite_tags
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_cite_tags(tmp_path: Path) -> None:
|
||||||
|
"""A .cpp file with @cite produces correct dict entries."""
|
||||||
|
from codex.provenance import scan_cite_tags
|
||||||
|
|
||||||
|
src_file = tmp_path / "algo.cpp"
|
||||||
|
src_file.write_text(
|
||||||
|
"// Helper function\n"
|
||||||
|
"void compute_hodge() {\n"
|
||||||
|
" // @cite somepaper2023\n"
|
||||||
|
" do_stuff();\n"
|
||||||
|
"}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
results = scan_cite_tags(str(tmp_path))
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
hit = results[0]
|
||||||
|
assert hit["bibkey"] == "somepaper2023"
|
||||||
|
assert hit["line"] == "3"
|
||||||
|
assert hit["file"] == "algo.cpp"
|
||||||
|
# symbol extracted from "compute_hodge()" on the line above
|
||||||
|
assert hit["symbol"] == "compute_hodge"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. test_scan_cite_tags_no_files
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_cite_tags_no_files(tmp_path: Path) -> None:
|
||||||
|
"""Empty directory returns an empty list."""
|
||||||
|
from codex.provenance import scan_cite_tags
|
||||||
|
|
||||||
|
result = scan_cite_tags(str(tmp_path))
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. test_add_code_link
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_code_link() -> None:
|
||||||
|
"""Mock conn, verify INSERT is issued and returned CodeLink has id from RETURNING."""
|
||||||
|
from codex.provenance import add_code_link
|
||||||
|
|
||||||
|
now = datetime(2026, 6, 5, tzinfo=UTC)
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute.return_value.fetchone.return_value = _dict_row(id=42, added_at=now)
|
||||||
|
|
||||||
|
with patch("codex.provenance.get_conn", side_effect=_make_conn_cm(mock_conn)):
|
||||||
|
link = add_code_link(
|
||||||
|
symbol="dec::hodge_star",
|
||||||
|
paper_id="2301.07041",
|
||||||
|
role="implements Thm 3.2",
|
||||||
|
note="core algorithm",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(link, CodeLink)
|
||||||
|
assert link.id == 42
|
||||||
|
assert link.added_at == now
|
||||||
|
assert link.symbol == "dec::hodge_star"
|
||||||
|
assert link.paper_id == "2301.07041"
|
||||||
|
assert link.role == "implements Thm 3.2"
|
||||||
|
assert link.note == "core algorithm"
|
||||||
|
|
||||||
|
call_args = mock_conn.execute.call_args
|
||||||
|
sql: str = call_args[0][0]
|
||||||
|
assert "INSERT INTO code_links" in sql
|
||||||
|
assert "RETURNING" in sql
|
||||||
|
|
||||||
|
mock_conn.commit.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. test_list_code_links_all
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_code_links_all() -> None:
|
||||||
|
"""No filter → all rows returned as CodeLink list."""
|
||||||
|
from codex.provenance import list_code_links
|
||||||
|
|
||||||
|
now = datetime(2026, 6, 5, tzinfo=UTC)
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute.return_value.fetchall.return_value = [
|
||||||
|
_dict_row(id=1, symbol="foo::bar", paper_id="W111", role="uses", note=None, added_at=now),
|
||||||
|
_dict_row(id=2, symbol="baz::qux", paper_id=None, role=None, note="ref", added_at=now),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("codex.provenance.get_conn", side_effect=_make_conn_cm(mock_conn)):
|
||||||
|
links = list_code_links()
|
||||||
|
|
||||||
|
assert len(links) == 2
|
||||||
|
assert all(isinstance(lnk, CodeLink) for lnk in links)
|
||||||
|
assert links[0].symbol == "foo::bar"
|
||||||
|
assert links[0].id == 1
|
||||||
|
assert links[1].symbol == "baz::qux"
|
||||||
|
assert links[1].paper_id is None
|
||||||
|
|
||||||
|
# No WHERE clause in the SQL
|
||||||
|
call_args = mock_conn.execute.call_args
|
||||||
|
sql: str = call_args[0][0]
|
||||||
|
assert "WHERE" not in sql
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. test_list_code_links_filtered
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_code_links_filtered() -> None:
|
||||||
|
"""Filter by paper_id → WHERE clause applied and only matching rows returned."""
|
||||||
|
from codex.provenance import list_code_links
|
||||||
|
|
||||||
|
now = datetime(2026, 6, 5, tzinfo=UTC)
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute.return_value.fetchall.return_value = [
|
||||||
|
_dict_row(
|
||||||
|
id=3,
|
||||||
|
symbol="some::func",
|
||||||
|
paper_id="2301.07041",
|
||||||
|
role=None,
|
||||||
|
note=None,
|
||||||
|
added_at=now,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("codex.provenance.get_conn", side_effect=_make_conn_cm(mock_conn)):
|
||||||
|
links = list_code_links(paper_id="2301.07041")
|
||||||
|
|
||||||
|
assert len(links) == 1
|
||||||
|
assert links[0].paper_id == "2301.07041"
|
||||||
|
|
||||||
|
call_args = mock_conn.execute.call_args
|
||||||
|
sql: str = call_args[0][0]
|
||||||
|
params: dict[str, Any] = call_args[0][1]
|
||||||
|
assert "WHERE paper_id" in sql
|
||||||
|
assert params["paper_id"] == "2301.07041"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. test_export_bib_format
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_bib_format() -> None:
|
||||||
|
"""Mock returns one paper row; verify valid BibTeX string output."""
|
||||||
|
from codex.provenance import export_bib
|
||||||
|
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute.return_value.fetchall.return_value = [
|
||||||
|
_dict_row(
|
||||||
|
id="2301.07041",
|
||||||
|
bibkey="smith2023conformal",
|
||||||
|
title="Conformal Approaches to Laplacian Eigenmaps",
|
||||||
|
authors=["Alice Smith", "Bob Jones"],
|
||||||
|
year=2023,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("codex.provenance.get_conn", side_effect=_make_conn_cm(mock_conn)):
|
||||||
|
bib = export_bib()
|
||||||
|
|
||||||
|
assert "@article{smith2023conformal," in bib
|
||||||
|
assert "title = {Conformal Approaches to Laplacian Eigenmaps}" in bib
|
||||||
|
assert "author = {Alice Smith and Bob Jones}" in bib
|
||||||
|
assert "year = {2023}" in bib
|
||||||
|
assert "note = {2301.07041}" in bib
|
||||||
0
tests/quality/__init__.py
Normal file
0
tests/quality/__init__.py
Normal file
245
tests/quality/test_quality.py
Normal file
245
tests/quality/test_quality.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
"""Tests for codex.quality — chunk quality gate and section classification (F-16)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from codex.quality import (
|
||||||
|
_bib_score,
|
||||||
|
classify_section,
|
||||||
|
filter_chunks,
|
||||||
|
is_quality_chunk,
|
||||||
|
run_quality_pass,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(min_chars: int = 60, min_alpha: float = 0.40, max_bib: float = 0.70):
|
||||||
|
s = MagicMock()
|
||||||
|
s.chunk_min_chars = min_chars
|
||||||
|
s.chunk_min_alpha_ratio = min_alpha
|
||||||
|
s.chunk_max_bib_score = max_bib
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
GOOD_TEXT = (
|
||||||
|
"In differential geometry, the discrete Laplace–Beltrami operator "
|
||||||
|
"generalises the smooth operator to polyhedral surfaces by using cotangent "
|
||||||
|
"weights. This discretisation preserves key spectral properties."
|
||||||
|
)
|
||||||
|
|
||||||
|
BIB_TEXT = (
|
||||||
|
"Bobenko, A. and Pinkall, U. (2015) Discrete differential geometry. "
|
||||||
|
"doi:10.1145/2010324.1964978 Smith et al. (2018) Surface remeshing. "
|
||||||
|
"doi:10.1007/s00211-019-01082-w Jones et al. (2020) doi:10.1145/1234567.890123"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _bib_score
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBibScore:
|
||||||
|
def test_empty_string(self):
|
||||||
|
assert _bib_score("") == 0.0
|
||||||
|
|
||||||
|
def test_clean_prose_is_low(self):
|
||||||
|
assert _bib_score(GOOD_TEXT) < 0.10
|
||||||
|
|
||||||
|
def test_bib_like_text_is_high(self):
|
||||||
|
assert _bib_score(BIB_TEXT) > 0.70
|
||||||
|
|
||||||
|
def test_single_doi_short_text_spikes(self):
|
||||||
|
text = "See doi:10.1145/2010324.1964978 for details."
|
||||||
|
# 8 words, 1 DOI (weight 3) → raw = 3 / max(0.8, 1) = 3.0 → capped 1.0
|
||||||
|
assert _bib_score(text) == 1.0
|
||||||
|
|
||||||
|
def test_score_capped_at_one(self):
|
||||||
|
# _DOI_RE requires \d{4,} — use a valid registrant length
|
||||||
|
heavy = "10.1145/abcdef " * 20
|
||||||
|
assert _bib_score(heavy) == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# is_quality_chunk
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsQualityChunk:
|
||||||
|
def test_passes_good_text(self):
|
||||||
|
assert is_quality_chunk(GOOD_TEXT, settings=_settings()) is True
|
||||||
|
|
||||||
|
def test_fails_too_short(self):
|
||||||
|
assert is_quality_chunk("Short.", settings=_settings(min_chars=60)) is False
|
||||||
|
|
||||||
|
def test_fails_low_alpha_ratio(self):
|
||||||
|
# Mostly digits and symbols
|
||||||
|
junk = "1234 5678 9012 3456 7890 |||| ==== ---- ____ .... ;;;; 0000 9999 8888 7777"
|
||||||
|
assert is_quality_chunk(junk, settings=_settings(min_alpha=0.40)) is False
|
||||||
|
|
||||||
|
def test_fails_high_bib_score(self):
|
||||||
|
assert is_quality_chunk(BIB_TEXT, settings=_settings(max_bib=0.70)) is False
|
||||||
|
|
||||||
|
def test_bib_threshold_is_strict_gt(self):
|
||||||
|
# A text that scores exactly at the threshold should still pass (> not >=)
|
||||||
|
s = _settings(max_bib=1.0)
|
||||||
|
assert is_quality_chunk(GOOD_TEXT, settings=s) is True
|
||||||
|
|
||||||
|
def test_exactly_min_chars_passes(self):
|
||||||
|
# check is `len < min_chars`, so exactly at the threshold passes
|
||||||
|
text = "a" * 60
|
||||||
|
assert is_quality_chunk(text, settings=_settings(min_chars=60)) is True
|
||||||
|
|
||||||
|
def test_one_below_min_chars_fails(self):
|
||||||
|
text = "a" * 59
|
||||||
|
assert is_quality_chunk(text, settings=_settings(min_chars=60)) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# classify_section
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestClassifySection:
|
||||||
|
def test_abstract_header(self):
|
||||||
|
assert classify_section("Abstract\n\nThis paper presents...") == "abstract"
|
||||||
|
|
||||||
|
def test_abstract_german(self):
|
||||||
|
assert classify_section("Zusammenfassung\nDiese Arbeit...") == "abstract"
|
||||||
|
|
||||||
|
def test_intro_header(self):
|
||||||
|
assert classify_section("Introduction\nOver the past decade...") == "intro"
|
||||||
|
|
||||||
|
def test_intro_numbered(self):
|
||||||
|
assert classify_section("1. Introduction\nWe study...") == "intro"
|
||||||
|
|
||||||
|
def test_theorem_header(self):
|
||||||
|
assert classify_section("Theorem 3.1 (Existence). Let M be...") == "theorem"
|
||||||
|
|
||||||
|
def test_lemma_header(self):
|
||||||
|
assert classify_section("Lemma 2. If f is smooth then...") == "theorem"
|
||||||
|
|
||||||
|
def test_proof_header(self):
|
||||||
|
assert classify_section("Proof. We proceed by induction...") == "proof"
|
||||||
|
|
||||||
|
def test_bibliography_header(self):
|
||||||
|
assert classify_section("References\n[1] Bobenko, A....") == "bibliography"
|
||||||
|
|
||||||
|
def test_bibliography_german(self):
|
||||||
|
assert classify_section("Literatur\n[1] Müller, H....") == "bibliography"
|
||||||
|
|
||||||
|
def test_body_fallback(self):
|
||||||
|
assert classify_section("The cotangent weight for edge (i,j) is...") == "body"
|
||||||
|
|
||||||
|
def test_doi_density_fallback_to_bibliography(self):
|
||||||
|
# No "References" header but heavy DOI density → bibliography
|
||||||
|
dense = BIB_TEXT
|
||||||
|
assert classify_section(dense) == "bibliography"
|
||||||
|
|
||||||
|
def test_only_first_200_chars_checked(self):
|
||||||
|
# Pattern in position >200 should NOT be matched as section header
|
||||||
|
prefix = "x" * 201
|
||||||
|
text = prefix + "\nAbstract"
|
||||||
|
assert classify_section(text) == "body"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# filter_chunks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestFilterChunks:
|
||||||
|
def test_empty_list(self):
|
||||||
|
assert filter_chunks([], settings=_settings()) == []
|
||||||
|
|
||||||
|
def test_all_good(self):
|
||||||
|
chunks = [GOOD_TEXT, GOOD_TEXT + " More content here."]
|
||||||
|
assert filter_chunks(chunks, settings=_settings()) == chunks
|
||||||
|
|
||||||
|
def test_all_bad(self):
|
||||||
|
chunks = ["x", "y"]
|
||||||
|
assert filter_chunks(chunks, settings=_settings()) == []
|
||||||
|
|
||||||
|
def test_mixed(self):
|
||||||
|
chunks = [GOOD_TEXT, "Short.", GOOD_TEXT]
|
||||||
|
result = filter_chunks(chunks, settings=_settings())
|
||||||
|
assert len(result) == 2
|
||||||
|
assert all(c == GOOD_TEXT for c in result)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# run_quality_pass
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mock_conn(rows: list[dict]) -> MagicMock:
|
||||||
|
"""Return a mock psycopg connection whose execute().fetchall() returns rows."""
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.execute.return_value.fetchall.return_value = rows
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunQualityPass:
|
||||||
|
def test_removes_bad_chunk(self):
|
||||||
|
conn = _make_mock_conn([{"id": 1, "content": "x"}])
|
||||||
|
stats = run_quality_pass(conn=conn, settings=_settings())
|
||||||
|
assert stats["removed"] == 1
|
||||||
|
assert stats["kept"] == 0
|
||||||
|
# The DELETE must have been called with chunk id=1
|
||||||
|
delete_calls = [c for c in conn.execute.call_args_list if "DELETE" in str(c)]
|
||||||
|
assert len(delete_calls) == 1
|
||||||
|
|
||||||
|
def test_tags_good_chunk(self):
|
||||||
|
conn = _make_mock_conn([{"id": 2, "content": GOOD_TEXT}])
|
||||||
|
stats = run_quality_pass(conn=conn, settings=_settings())
|
||||||
|
assert stats["kept"] == 1
|
||||||
|
assert stats["tagged"] == 1
|
||||||
|
assert stats["removed"] == 0
|
||||||
|
# UPDATE must have been called
|
||||||
|
update_calls = [c for c in conn.execute.call_args_list if "UPDATE" in str(c)]
|
||||||
|
assert len(update_calls) == 1
|
||||||
|
|
||||||
|
def test_mixed_batch(self):
|
||||||
|
rows = [
|
||||||
|
{"id": 1, "content": "x"},
|
||||||
|
{"id": 2, "content": GOOD_TEXT},
|
||||||
|
{"id": 3, "content": "y"},
|
||||||
|
{"id": 4, "content": GOOD_TEXT + " extra words here."},
|
||||||
|
]
|
||||||
|
conn = _make_mock_conn(rows)
|
||||||
|
stats = run_quality_pass(conn=conn, settings=_settings())
|
||||||
|
assert stats["removed"] == 2
|
||||||
|
assert stats["kept"] == 2
|
||||||
|
assert stats["tagged"] == 2
|
||||||
|
|
||||||
|
def test_paper_id_scopes_query(self):
|
||||||
|
conn = _make_mock_conn([])
|
||||||
|
run_quality_pass(paper_id="2301.07041", conn=conn, settings=_settings())
|
||||||
|
first_call = conn.execute.call_args_list[0]
|
||||||
|
sql = first_call[0][0]
|
||||||
|
assert "paper_id" in sql
|
||||||
|
|
||||||
|
def test_all_papers_query_has_no_filter(self):
|
||||||
|
conn = _make_mock_conn([])
|
||||||
|
run_quality_pass(paper_id=None, conn=conn, settings=_settings())
|
||||||
|
first_call = conn.execute.call_args_list[0]
|
||||||
|
sql = first_call[0][0]
|
||||||
|
assert "paper_id" not in sql
|
||||||
|
|
||||||
|
def test_commit_is_called(self):
|
||||||
|
conn = _make_mock_conn([])
|
||||||
|
run_quality_pass(conn=conn, settings=_settings())
|
||||||
|
conn.commit.assert_called_once()
|
||||||
|
|
||||||
|
def test_section_label_stored_correctly(self):
|
||||||
|
conn = _make_mock_conn([{"id": 5, "content": "Abstract\n\n" + GOOD_TEXT}])
|
||||||
|
run_quality_pass(conn=conn, settings=_settings())
|
||||||
|
update_calls = [c for c in conn.execute.call_args_list if "UPDATE" in str(c)]
|
||||||
|
assert len(update_calls) == 1
|
||||||
|
kwargs = update_calls[0][0][1]
|
||||||
|
assert kwargs["section"] == "abstract"
|
||||||
|
assert kwargs["id"] == 5
|
||||||
0
tests/scaffold/__init__.py
Normal file
0
tests/scaffold/__init__.py
Normal file
35
tests/scaffold/test_config.py
Normal file
35
tests/scaffold/test_config.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""Smoke tests for codex.config.Settings."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from codex.config import Settings, get_settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_defaults() -> None:
|
||||||
|
"""Settings() is constructable without env vars; expected defaults are set."""
|
||||||
|
s = Settings()
|
||||||
|
assert s.database_url == "postgresql://researcher:change_me@localhost:5432/papers"
|
||||||
|
assert s.embedding_model == "BAAI/bge-m3"
|
||||||
|
assert s.embedding_dim == 1024
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_env_override(monkeypatch: object) -> None:
|
||||||
|
"""DATABASE_URL env var overrides the default database_url."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
mp: pytest.MonkeyPatch = monkeypatch # type: ignore[assignment]
|
||||||
|
mp.setenv("DATABASE_URL", "postgresql://x:y@h:5432/db")
|
||||||
|
s = Settings()
|
||||||
|
assert s.database_url == "postgresql://x:y@h:5432/db"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_settings_is_singleton() -> None:
|
||||||
|
"""get_settings() returns the same object on repeated calls; new object after cache_clear."""
|
||||||
|
get_settings.cache_clear()
|
||||||
|
first = get_settings()
|
||||||
|
second = get_settings()
|
||||||
|
assert first is second
|
||||||
|
|
||||||
|
get_settings.cache_clear()
|
||||||
|
third = get_settings()
|
||||||
|
assert third is not first
|
||||||
35
tests/scaffold/test_db.py
Normal file
35
tests/scaffold/test_db.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""Smoke tests for codex.db — get_conn and apply_schema."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_conn_context_manager() -> None:
|
||||||
|
"""get_conn() yields a context manager that calls psycopg.connect and cleans up."""
|
||||||
|
from codex.db import get_conn
|
||||||
|
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_connect = MagicMock()
|
||||||
|
# psycopg.connect is itself used as a context manager inside get_conn
|
||||||
|
mock_connect.return_value.__enter__ = MagicMock(return_value=mock_conn)
|
||||||
|
mock_connect.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("codex.db.psycopg.connect", mock_connect), get_conn() as conn:
|
||||||
|
assert conn is mock_conn
|
||||||
|
|
||||||
|
mock_connect.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_schema_executes_sql() -> None:
|
||||||
|
"""apply_schema(conn) calls conn.execute and conn.commit."""
|
||||||
|
from codex.db import apply_schema
|
||||||
|
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
|
||||||
|
# Provide a fake schema.sql so Path.read_text succeeds
|
||||||
|
with patch("codex.db.Path.read_text", return_value="CREATE TABLE IF NOT EXISTS test ();"):
|
||||||
|
apply_schema(mock_conn)
|
||||||
|
|
||||||
|
mock_conn.execute.assert_called_once()
|
||||||
|
mock_conn.commit.assert_called_once()
|
||||||
34
tests/scaffold/test_models.py
Normal file
34
tests/scaffold/test_models.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""Smoke tests for codex.models dataclasses."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from codex.models import Chunk, Citation, CodeLink, Paper
|
||||||
|
|
||||||
|
|
||||||
|
def test_paper_defaults() -> None:
|
||||||
|
"""Paper(id=..., title=...) has correct optional-field defaults."""
|
||||||
|
p = Paper(id="2301.00001", title="T")
|
||||||
|
assert p.openalex_id is None
|
||||||
|
assert p.authors == []
|
||||||
|
assert p.year is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_chunk_required_fields() -> None:
|
||||||
|
"""Chunk(paper_id=..., ord=..., content=...) has id=None and embedding=None."""
|
||||||
|
c = Chunk(paper_id="x", ord=0, content="c")
|
||||||
|
assert c.id is None
|
||||||
|
assert c.embedding is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_citation_fields() -> None:
|
||||||
|
"""Citation(citing_id=..., cited_id=...) has context=None."""
|
||||||
|
cit = Citation(citing_id="a", cited_id="b")
|
||||||
|
assert cit.context is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_codelink_fields() -> None:
|
||||||
|
"""CodeLink(symbol=...) has paper_id=None, role=None, and id=None."""
|
||||||
|
cl = CodeLink(symbol="foo")
|
||||||
|
assert cl.paper_id is None
|
||||||
|
assert cl.role is None
|
||||||
|
assert cl.id is None
|
||||||
0
tests/sources/__init__.py
Normal file
0
tests/sources/__init__.py
Normal file
140
tests/sources/test_arxiv.py
Normal file
140
tests/sources/test_arxiv.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"""Tests for codex.sources.arxiv."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import tarfile
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from codex.sources import arxiv
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_tar_gz(files: dict[str, bytes]) -> bytes:
|
||||||
|
"""Build an in-memory .tar.gz archive from a dict of {name: content}."""
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
|
||||||
|
for name, content in files.items():
|
||||||
|
info = tarfile.TarInfo(name=name)
|
||||||
|
info.size = len(content)
|
||||||
|
tf.addfile(info, io.BytesIO(content))
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fetch_source — success: in-memory tar.gz with one .tex
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_source_returns_latex(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""fetch_source extracts and returns the LaTeX content from a .tar.gz."""
|
||||||
|
latex_content = b"\\documentclass{article}\n\\begin{document}\nHello world\n\\end{document}"
|
||||||
|
tar_bytes = _make_tar_gz({"main.tex": latex_content})
|
||||||
|
|
||||||
|
def mock_get(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
timeout: int = 60,
|
||||||
|
follow_redirects: bool = True,
|
||||||
|
) -> httpx.Response:
|
||||||
|
return httpx.Response(200, content=tar_bytes)
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "get", mock_get)
|
||||||
|
|
||||||
|
result = arxiv.fetch_source("2301.07041")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert "\\documentclass" in result
|
||||||
|
assert "Hello world" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fetch_source — 404 → None
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_source_404_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""A 404 response should return None."""
|
||||||
|
|
||||||
|
def mock_get(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
timeout: int = 60,
|
||||||
|
follow_redirects: bool = True,
|
||||||
|
) -> httpx.Response:
|
||||||
|
return httpx.Response(404)
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "get", mock_get)
|
||||||
|
|
||||||
|
result = arxiv.fetch_source("nonexistent-id")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fetch_source — no .tex in archive → None
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_source_no_tex_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""If the archive contains no .tex files, return None."""
|
||||||
|
tar_bytes = _make_tar_gz({"README.md": b"# Paper\nNo LaTeX here."})
|
||||||
|
|
||||||
|
def mock_get(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
timeout: int = 60,
|
||||||
|
follow_redirects: bool = True,
|
||||||
|
) -> httpx.Response:
|
||||||
|
return httpx.Response(200, content=tar_bytes)
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "get", mock_get)
|
||||||
|
|
||||||
|
result = arxiv.fetch_source("2301.07041")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fetch_source — fallback to largest .tex when no \documentclass
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_source_fallback_to_largest_tex(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""When no file has \\documentclass, the largest .tex is returned."""
|
||||||
|
small_tex = b"% small file\n\\section{Intro}"
|
||||||
|
large_tex = b"% large file\n" + b"x" * 500
|
||||||
|
|
||||||
|
tar_bytes = _make_tar_gz({"small.tex": small_tex, "large.tex": large_tex})
|
||||||
|
|
||||||
|
def mock_get(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
timeout: int = 60,
|
||||||
|
follow_redirects: bool = True,
|
||||||
|
) -> httpx.Response:
|
||||||
|
return httpx.Response(200, content=tar_bytes)
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "get", mock_get)
|
||||||
|
|
||||||
|
result = arxiv.fetch_source("2301.07041")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert "large file" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fetch_pdf_url — pure computation, no mock needed
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_pdf_url_pure_computation() -> None:
|
||||||
|
"""fetch_pdf_url returns the correct URL without making any HTTP request."""
|
||||||
|
url = arxiv.fetch_pdf_url("2301.07041")
|
||||||
|
assert url == "https://arxiv.org/pdf/2301.07041.pdf"
|
||||||
|
|
||||||
|
url2 = arxiv.fetch_pdf_url("1234.56789")
|
||||||
|
assert url2 == "https://arxiv.org/pdf/1234.56789.pdf"
|
||||||
200
tests/sources/test_openalex.py
Normal file
200
tests/sources/test_openalex.py
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"""Tests for codex.sources.openalex."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from codex.models import Citation, Paper
|
||||||
|
from codex.sources import openalex
|
||||||
|
from codex.sources.openalex import _resolve_id
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SAMPLE_WORK = {
|
||||||
|
"id": "https://openalex.org/W2741809807",
|
||||||
|
"doi": "10.1145/3592430",
|
||||||
|
"title": "Conformal Prediction: A Review",
|
||||||
|
"publication_year": 2023,
|
||||||
|
"authorships": [
|
||||||
|
{"author": {"display_name": "Alice Smith"}},
|
||||||
|
{"author": {"display_name": "Bob Jones"}},
|
||||||
|
],
|
||||||
|
"abstract_inverted_index": {
|
||||||
|
"Conformal": [0],
|
||||||
|
"prediction": [1],
|
||||||
|
"is": [2],
|
||||||
|
"great": [3],
|
||||||
|
},
|
||||||
|
"referenced_works": [
|
||||||
|
"https://openalex.org/W1",
|
||||||
|
"https://openalex.org/W2",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _resolve_id
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_doi() -> None:
|
||||||
|
assert _resolve_id("10.1145/3592430") == "doi:10.1145/3592430"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_arxiv() -> None:
|
||||||
|
# arXiv IDs resolve to their arXiv DOI (the arxiv: path 404s on OpenAlex).
|
||||||
|
assert _resolve_id("2301.07041") == "doi:10.48550/arXiv.2301.07041"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_arxiv_legacy() -> None:
|
||||||
|
# Legacy category/number IDs (pre-2007) resolve the same way.
|
||||||
|
assert _resolve_id("math/0603097") == "doi:10.48550/arXiv.math/0603097"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_arxiv_prefix_stripped() -> None:
|
||||||
|
# An explicit arxiv: prefix is stripped before building the arXiv DOI.
|
||||||
|
assert _resolve_id("arxiv:2301.07041") == "doi:10.48550/arXiv.2301.07041"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_w_id_unchanged() -> None:
|
||||||
|
assert _resolve_id("W2741809807") == "W2741809807"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_prefixed_unchanged() -> None:
|
||||||
|
assert _resolve_id("doi:10.1145/x") == "doi:10.1145/x"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fetch_paper — success
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_paper_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Successful fetch maps all fields to Paper correctly."""
|
||||||
|
|
||||||
|
def mock_get(url: str, params: dict[str, str] | None = None) -> httpx.Response:
|
||||||
|
assert "doi:10.1145/3592430" in url
|
||||||
|
return httpx.Response(200, json=_SAMPLE_WORK)
|
||||||
|
|
||||||
|
monkeypatch.setattr(openalex, "_get", mock_get)
|
||||||
|
|
||||||
|
paper = openalex.fetch_paper("10.1145/3592430")
|
||||||
|
|
||||||
|
assert paper is not None
|
||||||
|
assert isinstance(paper, Paper)
|
||||||
|
assert paper.title == "Conformal Prediction: A Review"
|
||||||
|
assert paper.year == 2023
|
||||||
|
assert paper.authors == ["Alice Smith", "Bob Jones"]
|
||||||
|
assert paper.abstract == "Conformal prediction is great"
|
||||||
|
assert paper.openalex_id == "https://openalex.org/W2741809807"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_paper_arxiv_id_prefixed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Bare arXiv IDs are resolved to their arXiv DOI in the URL."""
|
||||||
|
called_url: list[str] = []
|
||||||
|
|
||||||
|
def mock_get(url: str, params: dict[str, str] | None = None) -> httpx.Response:
|
||||||
|
called_url.append(url)
|
||||||
|
return httpx.Response(200, json=_SAMPLE_WORK)
|
||||||
|
|
||||||
|
monkeypatch.setattr(openalex, "_get", mock_get)
|
||||||
|
openalex.fetch_paper("2301.07041")
|
||||||
|
|
||||||
|
assert called_url and "doi:10.48550/arXiv.2301.07041" in called_url[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fetch_paper — 404 → None
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_paper_404_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""A 404 response should return None without raising."""
|
||||||
|
|
||||||
|
def mock_get(url: str, params: dict[str, str] | None = None) -> httpx.Response:
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
"Not Found",
|
||||||
|
request=httpx.Request("GET", url),
|
||||||
|
response=httpx.Response(404, request=httpx.Request("GET", url)),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(openalex, "_get", mock_get)
|
||||||
|
|
||||||
|
result = openalex.fetch_paper("nonexistent-id")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fetch_paper — 429 retry then success
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_paper_retry_on_429(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""On 429×3 then 200, returns 1 Paper after 3 failed attempts."""
|
||||||
|
attempt = [0]
|
||||||
|
|
||||||
|
def controlled_get(url: str, params: dict[str, str] | None = None) -> httpx.Response:
|
||||||
|
attempt[0] += 1
|
||||||
|
if attempt[0] <= 3:
|
||||||
|
resp = httpx.Response(429, request=httpx.Request("GET", url))
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
"Too Many Requests",
|
||||||
|
request=httpx.Request("GET", url),
|
||||||
|
response=resp,
|
||||||
|
)
|
||||||
|
return httpx.Response(200, json=_SAMPLE_WORK)
|
||||||
|
|
||||||
|
monkeypatch.setattr(openalex, "_get", controlled_get)
|
||||||
|
|
||||||
|
result: Paper | None = None
|
||||||
|
errors = 0
|
||||||
|
for _ in range(5):
|
||||||
|
try:
|
||||||
|
result = openalex.fetch_paper("10.1145/3592430")
|
||||||
|
break
|
||||||
|
except httpx.HTTPStatusError:
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert isinstance(result, Paper)
|
||||||
|
assert errors == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fetch_citations — uses referenced_works field
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_citations(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""fetch_citations reads referenced_works from the work object."""
|
||||||
|
|
||||||
|
def mock_get(url: str, params: dict[str, str] | None = None) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json=_SAMPLE_WORK)
|
||||||
|
|
||||||
|
monkeypatch.setattr(openalex, "_get", mock_get)
|
||||||
|
|
||||||
|
oa_id = "https://openalex.org/W2741809807"
|
||||||
|
citations = openalex.fetch_citations(oa_id)
|
||||||
|
|
||||||
|
assert len(citations) == 2
|
||||||
|
assert all(isinstance(c, Citation) for c in citations)
|
||||||
|
assert citations[0].citing_id == oa_id
|
||||||
|
assert citations[0].cited_id == "https://openalex.org/W1"
|
||||||
|
assert citations[1].cited_id == "https://openalex.org/W2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_citations_404_returns_empty(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""404 on the work fetch returns an empty list."""
|
||||||
|
|
||||||
|
def mock_get(url: str, params: dict[str, str] | None = None) -> httpx.Response:
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
"Not Found",
|
||||||
|
request=httpx.Request("GET", url),
|
||||||
|
response=httpx.Response(404, request=httpx.Request("GET", url)),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(openalex, "_get", mock_get)
|
||||||
|
assert openalex.fetch_citations("W999") == []
|
||||||
126
tests/sources/test_semanticscholar.py
Normal file
126
tests/sources/test_semanticscholar.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""Tests for codex.sources.semanticscholar."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from codex.models import Citation
|
||||||
|
from codex.sources import semanticscholar
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SAMPLE_REFS_RESPONSE = {
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"citedPaper": {
|
||||||
|
"paperId": "abc123",
|
||||||
|
"externalIds": {"DOI": "10.1000/xyz1"},
|
||||||
|
},
|
||||||
|
"contexts": ["This approach was first described in [1]."],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"citedPaper": {
|
||||||
|
"paperId": "def456",
|
||||||
|
"externalIds": {"ArXiv": "2301.07041"},
|
||||||
|
},
|
||||||
|
"contexts": [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
_SAMPLE_RECS_RESPONSE = {
|
||||||
|
"recommendedPapers": [
|
||||||
|
{"paperId": "rec_id_1"},
|
||||||
|
{"paperId": "rec_id_2"},
|
||||||
|
{"paperId": "rec_id_3"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fetch_references
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_references_returns_citations(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""fetch_references maps API response to Citation objects correctly."""
|
||||||
|
|
||||||
|
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json=_SAMPLE_REFS_RESPONSE)
|
||||||
|
|
||||||
|
monkeypatch.setattr(semanticscholar, "_get", mock_get)
|
||||||
|
|
||||||
|
citations = semanticscholar.fetch_references("abc123")
|
||||||
|
|
||||||
|
assert len(citations) == 2
|
||||||
|
assert all(isinstance(c, Citation) for c in citations)
|
||||||
|
|
||||||
|
# First citation uses DOI and has context
|
||||||
|
assert citations[0].citing_id == "abc123"
|
||||||
|
assert citations[0].cited_id == "10.1000/xyz1"
|
||||||
|
assert citations[0].context == "This approach was first described in [1]."
|
||||||
|
|
||||||
|
# Second citation falls back to ArXiv ID, no context
|
||||||
|
assert citations[1].citing_id == "abc123"
|
||||||
|
assert citations[1].cited_id == "2301.07041"
|
||||||
|
assert citations[1].context is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_references_404_returns_empty(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""A 404 response should return an empty list."""
|
||||||
|
|
||||||
|
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
"Not Found",
|
||||||
|
request=httpx.Request("GET", url),
|
||||||
|
response=httpx.Response(404, request=httpx.Request("GET", url)),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(semanticscholar, "_get", mock_get)
|
||||||
|
|
||||||
|
result = semanticscholar.fetch_references("nonexistent")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fetch_recommendations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_recommendations_returns_list_of_str(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""fetch_recommendations returns a flat list of paperId strings."""
|
||||||
|
|
||||||
|
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json=_SAMPLE_RECS_RESPONSE)
|
||||||
|
|
||||||
|
monkeypatch.setattr(semanticscholar, "_get", mock_get)
|
||||||
|
|
||||||
|
result = semanticscholar.fetch_recommendations("abc123", limit=3)
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 3
|
||||||
|
assert all(isinstance(r, str) for r in result)
|
||||||
|
assert result == ["rec_id_1", "rec_id_2", "rec_id_3"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_recommendations_404_returns_empty(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""A 404 response should return an empty list."""
|
||||||
|
|
||||||
|
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
"Not Found",
|
||||||
|
request=httpx.Request("GET", url),
|
||||||
|
response=httpx.Response(404, request=httpx.Request("GET", url)),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(semanticscholar, "_get", mock_get)
|
||||||
|
|
||||||
|
result = semanticscholar.fetch_recommendations("nonexistent")
|
||||||
|
assert result == []
|
||||||
0
tests/synthesis/__init__.py
Normal file
0
tests/synthesis/__init__.py
Normal file
124
tests/synthesis/conftest.py
Normal file
124
tests/synthesis/conftest.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""Shared synthesis fixtures — keep DB/LLM/embed fully mocked."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Shared corpus fixture used across the test suite.
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"paper_id": "bps-2015",
|
||||||
|
"ord": 3,
|
||||||
|
"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": "BobenkoPinkallSpringborn2015",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"paper_id": "luo-2004",
|
||||||
|
"ord": 0,
|
||||||
|
"content": (
|
||||||
|
"The combinatorial Yamabe flow drives the edge lengths towards a target "
|
||||||
|
"curvature on a triangulated surface."
|
||||||
|
),
|
||||||
|
"bibkey": "Luo2004",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class StubLLM:
|
||||||
|
"""Deterministic mock LLM that returns a preset response."""
|
||||||
|
|
||||||
|
def __init__(self, response: str | list[str]) -> None:
|
||||||
|
if isinstance(response, str):
|
||||||
|
self._responses = [response]
|
||||||
|
else:
|
||||||
|
self._responses = list(response)
|
||||||
|
self._calls: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
def generate(self, prompt: str, model: str) -> str:
|
||||||
|
self._calls.append((prompt, model))
|
||||||
|
if not self._responses:
|
||||||
|
return ""
|
||||||
|
if len(self._responses) == 1:
|
||||||
|
return self._responses[0]
|
||||||
|
return self._responses.pop(0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def calls(self) -> list[tuple[str, str]]:
|
||||||
|
return list(self._calls)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def stub_llm() -> StubLLM:
|
||||||
|
"""Default stub returning empty string (override per test as needed)."""
|
||||||
|
return StubLLM("")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fixture_chunks() -> list[dict[str, Any]]:
|
||||||
|
return [dict(c) for c in FIXTURE_CHUNKS]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_retrieve(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Patch _retrieve_chunks to return deterministic fixture chunks (no DB)."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"codex.synthesis._retrieve_chunks",
|
||||||
|
lambda queries, top_k: [dict(c) for c in FIXTURE_CHUNKS],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_paper_titles(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Patch _list_paper_titles to return a deterministic three-paper corpus."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"codex.synthesis._list_paper_titles",
|
||||||
|
lambda db_conn: [
|
||||||
|
("Springborn2008", "A variational principle for weighted Delaunay triangulations"),
|
||||||
|
(
|
||||||
|
"BobenkoPinkallSpringborn2015",
|
||||||
|
"Discrete conformal maps and ideal hyperbolic polyhedra",
|
||||||
|
),
|
||||||
|
("Luo2004", "Combinatorial Yamabe Flow on Surfaces"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_settings(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
|
||||||
|
"""Settings stub pointing leads_dir at tmp_path/leads."""
|
||||||
|
from codex.config import Settings
|
||||||
|
|
||||||
|
leads_path = tmp_path / "leads"
|
||||||
|
|
||||||
|
mock = MagicMock(spec=Settings)
|
||||||
|
mock.leads_dir = str(leads_path)
|
||||||
|
mock.synthesis_llm_model = "test-model"
|
||||||
|
mock.synthesis_llm_url = None
|
||||||
|
mock.ollama_base_url = "http://localhost:11434"
|
||||||
|
mock.synthesis_top_k = 6
|
||||||
|
mock.synthesis_min_grounded_ratio = 0.5
|
||||||
|
mock.synthesis_gap_min_coverage = 2
|
||||||
|
mock.wiki_dir = str(tmp_path / "wiki")
|
||||||
|
|
||||||
|
monkeypatch.setattr("codex.synthesis.get_settings", lambda: mock)
|
||||||
|
return leads_path
|
||||||
179
tests/synthesis/test_cli.py
Normal file
179
tests/synthesis/test_cli.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
"""Tests for the ``codex synthesis`` CLI command group."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from codex.cli import app
|
||||||
|
from codex.synthesis import Lead, Provenance
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
def _grounded_lead(lead_id: str = "L-0001") -> Lead:
|
||||||
|
return Lead(
|
||||||
|
id=lead_id,
|
||||||
|
kind="connection",
|
||||||
|
title="Connection — X ⇄ Y",
|
||||||
|
body="Some grounded body. [X2020 #chunk 1]",
|
||||||
|
provenance=[Provenance(bibkey="X2020", locator="chunk 1")],
|
||||||
|
confidence=0.8,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _conjecture(lead_id: str = "L-0002") -> Lead:
|
||||||
|
return Lead(
|
||||||
|
id=lead_id,
|
||||||
|
kind="conjecture",
|
||||||
|
title="Bold guess",
|
||||||
|
body="Hypothetical.",
|
||||||
|
provenance=[Provenance(bibkey="X2020", locator="chunk 1")],
|
||||||
|
confidence=0.0,
|
||||||
|
suggested_validation="Run a falsification spike.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def isolated_leads_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
|
||||||
|
"""Point Settings.leads_dir at tmp_path/leads for CLI tests."""
|
||||||
|
from codex.config import Settings
|
||||||
|
|
||||||
|
leads_path = tmp_path / "leads"
|
||||||
|
|
||||||
|
settings = MagicMock(spec=Settings)
|
||||||
|
settings.leads_dir = str(leads_path)
|
||||||
|
settings.synthesis_llm_model = "test-model"
|
||||||
|
settings.synthesis_llm_url = None
|
||||||
|
settings.ollama_base_url = "http://localhost:11434"
|
||||||
|
settings.synthesis_top_k = 6
|
||||||
|
settings.synthesis_min_grounded_ratio = 0.5
|
||||||
|
settings.synthesis_gap_min_coverage = 2
|
||||||
|
|
||||||
|
monkeypatch.setattr("codex.cli.get_settings", lambda: settings, raising=False)
|
||||||
|
monkeypatch.setattr("codex.synthesis.get_settings", lambda: settings)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"codex.config.get_settings", lambda: settings, raising=False
|
||||||
|
)
|
||||||
|
return leads_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_synthesis_leads_invokes_three_finders(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
isolated_leads_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
"""`codex synthesis leads --lib-path` calls connection + gap + improvement."""
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
def fake_connections(*, top_k: int, llm: object) -> list[Lead]:
|
||||||
|
calls.append("connection")
|
||||||
|
return [_grounded_lead("L-0001")]
|
||||||
|
|
||||||
|
def fake_gaps(
|
||||||
|
*,
|
||||||
|
lib_path: str | None = None,
|
||||||
|
llm: object,
|
||||||
|
topics: list[str] | None = None,
|
||||||
|
) -> list[Lead]:
|
||||||
|
calls.append("gap")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def fake_improvements(lib_path: str, *, top_k: int, llm: object) -> list[Lead]:
|
||||||
|
calls.append("improvement")
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr("codex.synthesis.find_connections", fake_connections)
|
||||||
|
monkeypatch.setattr("codex.synthesis.find_gaps", fake_gaps)
|
||||||
|
monkeypatch.setattr("codex.synthesis.find_improvements", fake_improvements)
|
||||||
|
monkeypatch.setattr("codex.synthesis.default_llm_client", lambda: object())
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["synthesis", "leads", "--lib-path", "/tmp/fake"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.stdout
|
||||||
|
assert calls == ["connection", "gap", "improvement"]
|
||||||
|
assert (isolated_leads_dir / "grounded" / "L-0001.md").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_synthesis_leads_without_lib_path_skips_improvement(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
isolated_leads_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Without --lib-path, the improvement finder must be skipped (graceful)."""
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"codex.synthesis.find_connections",
|
||||||
|
lambda *, top_k, llm: (calls.append("connection"), [])[1],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"codex.synthesis.find_gaps",
|
||||||
|
lambda *, lib_path=None, llm, topics=None: (calls.append("gap"), [])[1],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"codex.synthesis.find_improvements",
|
||||||
|
lambda *args, **kwargs: (calls.append("improvement"), [])[1],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("codex.synthesis.default_llm_client", lambda: object())
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["synthesis", "leads"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.stdout
|
||||||
|
assert "improvement" not in calls
|
||||||
|
assert "connection" in calls
|
||||||
|
assert "gap" in calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_synthesis_conjectures_writes_to_conjectures_dir(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
isolated_leads_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
"""`codex synthesis conjectures` lands files under leads/conjectures/."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"codex.synthesis.propose_conjectures",
|
||||||
|
lambda *, top_k, llm: [_conjecture("L-0042")],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("codex.synthesis.default_llm_client", lambda: object())
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["synthesis", "conjectures"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.stdout
|
||||||
|
assert (isolated_leads_dir / "conjectures" / "L-0042.md").exists()
|
||||||
|
assert not (isolated_leads_dir / "grounded" / "L-0042.md").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_synthesis_report_separates_sections(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
isolated_leads_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
"""`codex synthesis report` shows grounded and conjectures in distinct headers."""
|
||||||
|
from codex.synthesis import write_leads
|
||||||
|
|
||||||
|
write_leads(
|
||||||
|
[_grounded_lead("L-0001"), _conjecture("L-0002")],
|
||||||
|
str(isolated_leads_dir),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["synthesis", "report"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.stdout
|
||||||
|
assert "GROUNDED LEADS" in result.stdout
|
||||||
|
assert "CONJECTURES" in result.stdout
|
||||||
|
# Visible separation: GROUNDED header strictly precedes CONJECTURES header
|
||||||
|
assert result.stdout.index("GROUNDED LEADS") < result.stdout.index("CONJECTURES")
|
||||||
|
# UNVERIFIED tag in the conjectures header
|
||||||
|
assert "UNVERIFIED" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_synthesis_report_empty(
|
||||||
|
isolated_leads_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
"""`codex synthesis report` with no leads still prints both sections."""
|
||||||
|
result = runner.invoke(app, ["synthesis", "report"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "GROUNDED LEADS" in result.stdout
|
||||||
|
assert "CONJECTURES" in result.stdout
|
||||||
|
assert "(none)" in result.stdout
|
||||||
113
tests/synthesis/test_conjecture.py
Normal file
113
tests/synthesis/test_conjecture.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""Tests for stage-5 conjecture generation.
|
||||||
|
|
||||||
|
HARD invariant: every conjecture MUST carry status='unverified', a
|
||||||
|
non-empty suggested_validation, and non-empty provenance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from codex.synthesis import _parse_conjecture_output, propose_conjectures
|
||||||
|
from tests.synthesis.conftest import StubLLM
|
||||||
|
|
||||||
|
_VALID_CONJECTURE = (
|
||||||
|
"Title: A discrete cosmetic-surgery analogue exists for Yamabe flows.\n"
|
||||||
|
"Body: The combinatorial Yamabe flow [Luo2004 #chunk 0] is a discrete "
|
||||||
|
"analogue of conformal change. One could hope the cosmetic surgery "
|
||||||
|
"conjecture has a discrete formulation in this setting.\n"
|
||||||
|
"Validation: Search for a counter-example among simplicial 3-manifolds "
|
||||||
|
"with at most 12 tetrahedra using a CSP solver."
|
||||||
|
)
|
||||||
|
|
||||||
|
_MISSING_VALIDATION = (
|
||||||
|
"Title: A naive idea.\n"
|
||||||
|
"Body: Some thoughts that follow [Luo2004 #chunk 0]. No falsification path "
|
||||||
|
"is described."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_valid_conjecture_output() -> None:
|
||||||
|
"""_parse_conjecture_output extracts title / body / validation."""
|
||||||
|
parsed = _parse_conjecture_output(_VALID_CONJECTURE)
|
||||||
|
assert parsed is not None
|
||||||
|
title, body, validation = parsed
|
||||||
|
assert title.startswith("A discrete cosmetic-surgery")
|
||||||
|
assert "Luo2004" in body
|
||||||
|
assert "counter-example" in validation
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_conjecture_missing_validation_returns_none() -> None:
|
||||||
|
"""Output without a Validation: line is rejected."""
|
||||||
|
assert _parse_conjecture_output(_MISSING_VALIDATION) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_conjecture_empty_input() -> None:
|
||||||
|
"""Empty LLM output → None."""
|
||||||
|
assert _parse_conjecture_output("") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_propose_conjectures_marks_status_unverified(
|
||||||
|
mock_retrieve: None,
|
||||||
|
mock_paper_titles: None,
|
||||||
|
mock_settings: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Every emitted conjecture has status='unverified'."""
|
||||||
|
llm = StubLLM(_VALID_CONJECTURE)
|
||||||
|
leads = propose_conjectures(top_k=6, llm=llm)
|
||||||
|
assert len(leads) >= 1
|
||||||
|
for lead in leads:
|
||||||
|
assert lead.kind == "conjecture"
|
||||||
|
assert lead.status == "unverified"
|
||||||
|
|
||||||
|
|
||||||
|
def test_propose_conjectures_requires_validation(
|
||||||
|
mock_retrieve: None,
|
||||||
|
mock_paper_titles: None,
|
||||||
|
mock_settings: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Every emitted conjecture has a non-empty suggested_validation."""
|
||||||
|
llm = StubLLM(_VALID_CONJECTURE)
|
||||||
|
leads = propose_conjectures(top_k=6, llm=llm)
|
||||||
|
assert len(leads) >= 1
|
||||||
|
for lead in leads:
|
||||||
|
assert lead.suggested_validation.strip() != ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_propose_conjectures_requires_provenance(
|
||||||
|
mock_retrieve: None,
|
||||||
|
mock_paper_titles: None,
|
||||||
|
mock_settings: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Every conjecture has at least one Provenance entry (seed chunks)."""
|
||||||
|
llm = StubLLM(_VALID_CONJECTURE)
|
||||||
|
leads = propose_conjectures(top_k=6, llm=llm)
|
||||||
|
assert len(leads) >= 1
|
||||||
|
for lead in leads:
|
||||||
|
assert len(lead.provenance) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_propose_conjectures_drops_when_validation_missing(
|
||||||
|
mock_retrieve: None,
|
||||||
|
mock_paper_titles: None,
|
||||||
|
mock_settings: Path,
|
||||||
|
) -> None:
|
||||||
|
"""LLM output without a Validation: line is rejected (zero leads)."""
|
||||||
|
llm = StubLLM(_MISSING_VALIDATION)
|
||||||
|
leads = propose_conjectures(top_k=6, llm=llm)
|
||||||
|
assert leads == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_propose_conjectures_handles_llm_failure(
|
||||||
|
mock_retrieve: None,
|
||||||
|
mock_paper_titles: None,
|
||||||
|
mock_settings: Path,
|
||||||
|
) -> None:
|
||||||
|
"""LLM raising → returns [] (mirrors F-12 graceful degradation)."""
|
||||||
|
|
||||||
|
class FailingLLM:
|
||||||
|
def generate(self, prompt: str, model: str) -> str:
|
||||||
|
raise ConnectionError("ollama unreachable")
|
||||||
|
|
||||||
|
leads = propose_conjectures(top_k=6, llm=FailingLLM())
|
||||||
|
assert leads == []
|
||||||
136
tests/synthesis/test_gaps.py
Normal file
136
tests/synthesis/test_gaps.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"""Tests for find_gaps — coverage map + code-vs-corpus signals."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from codex.synthesis import find_gaps
|
||||||
|
from tests.synthesis.conftest import StubLLM
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_gaps_code_cites_missing_bibkey(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
mock_paper_titles: None,
|
||||||
|
mock_settings: Path,
|
||||||
|
fixture_chunks: list[dict[str, object]],
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""A bibkey referenced in code but absent from corpus → direct gap lead."""
|
||||||
|
# Build a small C++ source tree with an @cite pointing at a missing bibkey
|
||||||
|
src_dir = tmp_path / "src"
|
||||||
|
src_dir.mkdir()
|
||||||
|
(src_dir / "main.cpp").write_text(
|
||||||
|
"// @cite UnknownPaper2099\nvoid foo() {}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
# All probes return the fixture chunks (so coverage map yields nothing)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"codex.synthesis._retrieve_chunks",
|
||||||
|
lambda queries, top_k: [dict(c) for c in fixture_chunks],
|
||||||
|
)
|
||||||
|
|
||||||
|
leads = find_gaps(lib_path=str(src_dir), llm=StubLLM(""))
|
||||||
|
bibkeys_in_gaps = {p.bibkey for lead in leads for p in lead.provenance}
|
||||||
|
# The missing bibkey shows up as a gap
|
||||||
|
assert any("UnknownPaper2099" in lead.title for lead in leads)
|
||||||
|
assert "UnknownPaper2099" in bibkeys_in_gaps
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_gaps_code_known_bibkey_not_reported(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
mock_paper_titles: None,
|
||||||
|
mock_settings: Path,
|
||||||
|
fixture_chunks: list[dict[str, object]],
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""A bibkey already in the corpus is NOT flagged by the code-vs-corpus path."""
|
||||||
|
src_dir = tmp_path / "src"
|
||||||
|
src_dir.mkdir()
|
||||||
|
(src_dir / "main.cpp").write_text(
|
||||||
|
"// @cite Springborn2008\nvoid foo() {}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"codex.synthesis._retrieve_chunks",
|
||||||
|
lambda queries, top_k: [dict(c) for c in fixture_chunks],
|
||||||
|
)
|
||||||
|
leads = find_gaps(lib_path=str(src_dir), llm=StubLLM(""))
|
||||||
|
# Springborn2008 IS in the corpus → not a gap
|
||||||
|
assert not any("Springborn2008" in lead.title for lead in leads)
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_gaps_missing_lib_path_ok(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
mock_paper_titles: None,
|
||||||
|
mock_settings: Path,
|
||||||
|
fixture_chunks: list[dict[str, object]],
|
||||||
|
) -> None:
|
||||||
|
"""Without lib_path the code-vs-corpus path is skipped (no crash)."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"codex.synthesis._retrieve_chunks",
|
||||||
|
lambda queries, top_k: [dict(c) for c in fixture_chunks],
|
||||||
|
)
|
||||||
|
leads = find_gaps(lib_path=None, llm=StubLLM(""))
|
||||||
|
# No crash; result list may be empty depending on coverage map.
|
||||||
|
assert isinstance(leads, list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_gaps_coverage_map_low_coverage_triggers_llm(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
mock_paper_titles: None,
|
||||||
|
mock_settings: Path,
|
||||||
|
) -> None:
|
||||||
|
"""A topic covered by only 1 bibkey (< 2) triggers the LLM gap prompt.
|
||||||
|
|
||||||
|
The grounded body must reference Springborn2008 [...] in a content-5-gram
|
||||||
|
that exists in the chunk, otherwise the lead is dropped.
|
||||||
|
"""
|
||||||
|
sparse_chunks: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"codex.synthesis._retrieve_chunks",
|
||||||
|
lambda queries, top_k: [dict(c) for c in sparse_chunks],
|
||||||
|
)
|
||||||
|
grounded_body = (
|
||||||
|
"Only Springborn2008 covers the volume formula at depth, while comparison "
|
||||||
|
"papers are missing.\n"
|
||||||
|
"For an ideal tetrahedron with dihedral angles the hyperbolic volume is "
|
||||||
|
"V = L(gamma1) + L(gamma2) + L(gamma3). [Springborn2008 #chunk 16]\n"
|
||||||
|
)
|
||||||
|
leads = find_gaps(llm=StubLLM(grounded_body))
|
||||||
|
assert any(lead.kind == "gap" for lead in leads)
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_gaps_explicit_topics_override_default(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
mock_paper_titles: None,
|
||||||
|
mock_settings: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Passing topics= explicitly overrides the default (paper titles)."""
|
||||||
|
captured_queries: list[list[str]] = []
|
||||||
|
|
||||||
|
def fake_retrieve(queries: list[str], top_k: int) -> list[dict[str, Any]]:
|
||||||
|
captured_queries.append(queries)
|
||||||
|
return [] # forces direct-gap-no-chunks path
|
||||||
|
|
||||||
|
monkeypatch.setattr("codex.synthesis._retrieve_chunks", fake_retrieve)
|
||||||
|
find_gaps(llm=StubLLM(""), topics=["custom topic A", "custom topic B"])
|
||||||
|
# Both custom topics should have been probed
|
||||||
|
flat = [q for qs in captured_queries for q in qs]
|
||||||
|
assert "custom topic A" in flat
|
||||||
|
assert "custom topic B" in flat
|
||||||
175
tests/synthesis/test_grounded.py
Normal file
175
tests/synthesis/test_grounded.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"""Tests for grounded leads (connection / gap / improvement).
|
||||||
|
|
||||||
|
The Grounding-Guard is re-used from :mod:`codex.wiki` (F-12). These tests
|
||||||
|
verify that ungrounded claims are either dropped or annotated with ⚠,
|
||||||
|
and that grounded statements survive intact.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from codex.synthesis import (
|
||||||
|
_finalise_grounded_lead,
|
||||||
|
_mark_ungrounded,
|
||||||
|
find_connections,
|
||||||
|
find_gaps,
|
||||||
|
)
|
||||||
|
from tests.synthesis.conftest import StubLLM
|
||||||
|
|
||||||
|
_GROUNDED_CONNECTION = (
|
||||||
|
"Both papers build hyperbolic volumes from the Lobachevsky function.\n"
|
||||||
|
"For an ideal tetrahedron with dihedral angles the hyperbolic volume is "
|
||||||
|
"V = L(gamma1) + L(gamma2) + L(gamma3). [Springborn2008 #chunk 16]\n"
|
||||||
|
"A discrete conformal map is defined by logarithmic scale factors u_i at "
|
||||||
|
"each vertex such that the edge lengths satisfy a compatibility condition. "
|
||||||
|
"[BobenkoPinkallSpringborn2015 #chunk 3]\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
_UNGROUNDED_CONNECTION = (
|
||||||
|
"Both papers prove the Riemann hypothesis via the Lobachevsky function. "
|
||||||
|
"[Springborn2008 #chunk 99]\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalise_grounded_lead_returns_none_without_claims(
|
||||||
|
fixture_chunks: list[dict[str, object]],
|
||||||
|
) -> None:
|
||||||
|
"""Body with no inline citations yields no grounded lead."""
|
||||||
|
lead = _finalise_grounded_lead(
|
||||||
|
seq=1,
|
||||||
|
kind="connection",
|
||||||
|
title="t",
|
||||||
|
body="A plain statement with no citation marker.",
|
||||||
|
chunks=fixture_chunks,
|
||||||
|
min_grounded_ratio=0.5,
|
||||||
|
)
|
||||||
|
assert lead is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalise_grounded_lead_drops_when_ratio_below_floor(
|
||||||
|
fixture_chunks: list[dict[str, object]],
|
||||||
|
) -> None:
|
||||||
|
"""All-ungrounded body → None (not silently promoted)."""
|
||||||
|
lead = _finalise_grounded_lead(
|
||||||
|
seq=1,
|
||||||
|
kind="connection",
|
||||||
|
title="t",
|
||||||
|
body=_UNGROUNDED_CONNECTION,
|
||||||
|
chunks=fixture_chunks,
|
||||||
|
min_grounded_ratio=0.5,
|
||||||
|
)
|
||||||
|
assert lead is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalise_grounded_lead_succeeds_when_grounded(
|
||||||
|
fixture_chunks: list[dict[str, object]],
|
||||||
|
) -> None:
|
||||||
|
"""Grounded body passes the floor and returns a populated Lead."""
|
||||||
|
lead = _finalise_grounded_lead(
|
||||||
|
seq=1,
|
||||||
|
kind="connection",
|
||||||
|
title="Both papers share a building block",
|
||||||
|
body=_GROUNDED_CONNECTION,
|
||||||
|
chunks=fixture_chunks,
|
||||||
|
min_grounded_ratio=0.5,
|
||||||
|
)
|
||||||
|
assert lead is not None
|
||||||
|
assert lead.kind == "connection"
|
||||||
|
assert lead.id == "L-0001"
|
||||||
|
assert lead.confidence >= 0.5
|
||||||
|
assert lead.status == "unverified"
|
||||||
|
assert lead.suggested_validation == ""
|
||||||
|
# All claims should be grounded
|
||||||
|
assert all(c.grounded for c in lead.claims)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_ungrounded_prefixes_warning() -> None:
|
||||||
|
"""_mark_ungrounded prefixes ⚠ to the matching claim text once."""
|
||||||
|
from codex.wiki import Claim
|
||||||
|
|
||||||
|
body = "Some fact. [X2020 #c 1]\nOther fact. [Y2021 #c 2]\n"
|
||||||
|
claims = [
|
||||||
|
Claim(text="Some fact", bibkey="X2020", locator="c 1", grounded=False),
|
||||||
|
Claim(text="Other fact", bibkey="Y2021", locator="c 2", grounded=True),
|
||||||
|
]
|
||||||
|
out = _mark_ungrounded(body, claims)
|
||||||
|
assert "⚠ Some fact" in out
|
||||||
|
# Grounded claim is not annotated
|
||||||
|
assert "⚠ Other fact" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_connections_emits_grounded_lead(
|
||||||
|
mock_retrieve: None,
|
||||||
|
mock_paper_titles: None,
|
||||||
|
mock_settings: Path,
|
||||||
|
) -> None:
|
||||||
|
"""End-to-end: with a grounded LLM response, a connection lead is produced."""
|
||||||
|
llm = StubLLM(_GROUNDED_CONNECTION)
|
||||||
|
leads = find_connections(top_k=6, llm=llm)
|
||||||
|
assert len(leads) >= 1
|
||||||
|
lead = leads[0]
|
||||||
|
assert lead.kind == "connection"
|
||||||
|
assert lead.provenance, "grounded lead must carry provenance"
|
||||||
|
# No ⚠ should appear because all claims grounded
|
||||||
|
assert "⚠" not in lead.body
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_connections_drops_ungrounded_response(
|
||||||
|
mock_retrieve: None,
|
||||||
|
mock_paper_titles: None,
|
||||||
|
mock_settings: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Ungrounded LLM responses are dropped, not emitted with ⚠."""
|
||||||
|
llm = StubLLM(_UNGROUNDED_CONNECTION)
|
||||||
|
leads = find_connections(top_k=6, llm=llm)
|
||||||
|
assert leads == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_connections_handles_llm_failure(
|
||||||
|
mock_retrieve: None,
|
||||||
|
mock_paper_titles: None,
|
||||||
|
mock_settings: Path,
|
||||||
|
) -> None:
|
||||||
|
"""LLM raising → returns [] (mirrors F-12 graceful degradation)."""
|
||||||
|
|
||||||
|
class FailingLLM:
|
||||||
|
def generate(self, prompt: str, model: str) -> str:
|
||||||
|
raise ConnectionError("ollama unreachable")
|
||||||
|
|
||||||
|
leads = find_connections(top_k=6, llm=FailingLLM())
|
||||||
|
assert leads == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_gaps_topic_with_no_chunks(
|
||||||
|
monkeypatch,
|
||||||
|
mock_paper_titles: None,
|
||||||
|
mock_settings: Path,
|
||||||
|
) -> None:
|
||||||
|
"""When retrieval returns no chunks, a gap lead is emitted directly."""
|
||||||
|
monkeypatch.setattr("codex.synthesis._retrieve_chunks", lambda queries, top_k: [])
|
||||||
|
leads = find_gaps(llm=StubLLM(""))
|
||||||
|
assert len(leads) >= 1
|
||||||
|
assert all(lead.kind == "gap" for lead in leads)
|
||||||
|
# Direct gap leads carry a validation hint (re-ingest)
|
||||||
|
assert all(lead.suggested_validation for lead in leads)
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_gaps_well_covered_topic_skipped(
|
||||||
|
monkeypatch,
|
||||||
|
mock_paper_titles: None,
|
||||||
|
mock_settings: Path,
|
||||||
|
fixture_chunks: list[dict[str, object]],
|
||||||
|
) -> None:
|
||||||
|
"""Topics covered by enough bibkeys are NOT emitted as gaps.
|
||||||
|
|
||||||
|
Three distinct bibkeys in the fixture > synthesis_gap_min_coverage=2,
|
||||||
|
so every topic is well-covered and gets skipped.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"codex.synthesis._retrieve_chunks",
|
||||||
|
lambda queries, top_k: [dict(c) for c in fixture_chunks],
|
||||||
|
)
|
||||||
|
leads = find_gaps(llm=StubLLM(""))
|
||||||
|
# All probes well-covered → no gap leads
|
||||||
|
assert leads == []
|
||||||
100
tests/synthesis/test_model.py
Normal file
100
tests/synthesis/test_model.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""Tests for the Lead / Provenance datamodel (F-13)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import get_args
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from codex.synthesis import Lead, LeadKind, LeadStatus, Provenance
|
||||||
|
|
||||||
|
|
||||||
|
def test_provenance_requires_bibkey_and_locator() -> None:
|
||||||
|
"""Provenance has two required positional fields."""
|
||||||
|
p = Provenance(bibkey="Smith2020", locator="chunk 7")
|
||||||
|
assert p.bibkey == "Smith2020"
|
||||||
|
assert p.locator == "chunk 7"
|
||||||
|
|
||||||
|
|
||||||
|
def test_provenance_missing_field_raises() -> None:
|
||||||
|
"""Provenance without locator must fail to construct (TypeError)."""
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
Provenance(bibkey="Smith2020") # type: ignore[call-arg]
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_required_fields() -> None:
|
||||||
|
"""Lead requires id, kind, title, body, provenance, confidence."""
|
||||||
|
lead = Lead(
|
||||||
|
id="L-0001",
|
||||||
|
kind="connection",
|
||||||
|
title="A connection",
|
||||||
|
body="Some body text.",
|
||||||
|
provenance=[Provenance(bibkey="X2020", locator="chunk 1")],
|
||||||
|
confidence=0.8,
|
||||||
|
)
|
||||||
|
assert lead.id == "L-0001"
|
||||||
|
assert lead.kind == "connection"
|
||||||
|
assert lead.status == "unverified" # default
|
||||||
|
assert lead.suggested_validation == "" # default
|
||||||
|
assert lead.claims == [] # default factory
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_kind_literals_exact() -> None:
|
||||||
|
"""Lead kinds are exactly: connection, gap, improvement, conjecture."""
|
||||||
|
assert set(get_args(LeadKind)) == {"connection", "gap", "improvement", "conjecture"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_status_literals_exact() -> None:
|
||||||
|
"""Lead statuses are exactly: unverified, spiking, go, no-go."""
|
||||||
|
assert set(get_args(LeadStatus)) == {"unverified", "spiking", "go", "no-go"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_provenance_can_be_multiple() -> None:
|
||||||
|
"""A Lead may carry multiple provenance pointers."""
|
||||||
|
provs = [
|
||||||
|
Provenance(bibkey="A2020", locator="chunk 1"),
|
||||||
|
Provenance(bibkey="B2021", locator="chunk 7"),
|
||||||
|
]
|
||||||
|
lead = Lead(
|
||||||
|
id="L-0002",
|
||||||
|
kind="gap",
|
||||||
|
title="t",
|
||||||
|
body="b",
|
||||||
|
provenance=provs,
|
||||||
|
confidence=0.5,
|
||||||
|
)
|
||||||
|
assert len(lead.provenance) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_missing_provenance_field_raises() -> None:
|
||||||
|
"""Constructing Lead without provenance is a TypeError."""
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
Lead( # type: ignore[call-arg]
|
||||||
|
id="L-0003",
|
||||||
|
kind="connection",
|
||||||
|
title="x",
|
||||||
|
body="y",
|
||||||
|
confidence=0.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_empty_provenance_raises() -> None:
|
||||||
|
"""Constructing Lead with empty provenance list raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="at least one Provenance"):
|
||||||
|
Lead(
|
||||||
|
id="L-0004",
|
||||||
|
kind="connection",
|
||||||
|
title="x",
|
||||||
|
body="y",
|
||||||
|
provenance=[],
|
||||||
|
confidence=0.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_id_format_helper() -> None:
|
||||||
|
"""The internal _make_lead_id helper produces zero-padded L-XXXX strings."""
|
||||||
|
from codex.synthesis import _make_lead_id
|
||||||
|
|
||||||
|
assert _make_lead_id(1) == "L-0001"
|
||||||
|
assert _make_lead_id(42) == "L-0042"
|
||||||
|
assert _make_lead_id(9999) == "L-9999"
|
||||||
132
tests/synthesis/test_quarantine.py
Normal file
132
tests/synthesis/test_quarantine.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"""INVARIANT tests — conjectures live in leads/conjectures/, never in wiki/.
|
||||||
|
|
||||||
|
This is the hard F-13 invariant. Violations would corrupt the wiki layer
|
||||||
|
and must be unrepresentable in the API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from codex.synthesis import Lead, Provenance, write_leads
|
||||||
|
|
||||||
|
|
||||||
|
def _grounded_lead(lead_id: str = "L-0001") -> Lead:
|
||||||
|
return Lead(
|
||||||
|
id=lead_id,
|
||||||
|
kind="connection",
|
||||||
|
title="Connection title",
|
||||||
|
body="Some grounded body. [X2020 #chunk 1]",
|
||||||
|
provenance=[Provenance(bibkey="X2020", locator="chunk 1")],
|
||||||
|
confidence=0.8,
|
||||||
|
status="unverified",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _conjecture(lead_id: str = "L-0002") -> Lead:
|
||||||
|
return Lead(
|
||||||
|
id=lead_id,
|
||||||
|
kind="conjecture",
|
||||||
|
title="A bold conjecture",
|
||||||
|
body="Hypothetical statement seeded by [X2020 #chunk 1].",
|
||||||
|
provenance=[Provenance(bibkey="X2020", locator="chunk 1")],
|
||||||
|
confidence=0.0,
|
||||||
|
status="unverified",
|
||||||
|
suggested_validation="Search for a counter-example with N<=8.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_conjecture_lands_in_conjectures_dir(tmp_path: Path) -> None:
|
||||||
|
"""A conjecture lead writes to leads/conjectures/<id>.md."""
|
||||||
|
write_leads([_conjecture("L-0002")], str(tmp_path))
|
||||||
|
assert (tmp_path / "conjectures" / "L-0002.md").exists()
|
||||||
|
# And NOT in grounded/
|
||||||
|
assert not (tmp_path / "grounded" / "L-0002.md").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_conjecture_never_in_grounded_dir(tmp_path: Path) -> None:
|
||||||
|
"""No conjecture file may appear under leads/grounded/."""
|
||||||
|
write_leads([_grounded_lead("L-0001"), _conjecture("L-0002")], str(tmp_path))
|
||||||
|
grounded_files = list((tmp_path / "grounded").glob("*.md"))
|
||||||
|
conj_files = list((tmp_path / "conjectures").glob("*.md"))
|
||||||
|
|
||||||
|
assert len(grounded_files) == 1
|
||||||
|
assert grounded_files[0].name == "L-0001.md"
|
||||||
|
assert len(conj_files) == 1
|
||||||
|
assert conj_files[0].name == "L-0002.md"
|
||||||
|
|
||||||
|
|
||||||
|
def test_conjecture_never_in_wiki_dir(tmp_path: Path) -> None:
|
||||||
|
"""The wiki/ directory is never touched by write_leads."""
|
||||||
|
wiki_dir = tmp_path / "wiki"
|
||||||
|
wiki_dir.mkdir()
|
||||||
|
write_leads([_conjecture("L-0002")], str(tmp_path / "leads"))
|
||||||
|
# No file landed in wiki/
|
||||||
|
assert list(wiki_dir.glob("*.md")) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_conjecture_markdown_contains_unverified_marker(tmp_path: Path) -> None:
|
||||||
|
"""The on-disk conjecture file announces UNVERIFIED status."""
|
||||||
|
write_leads([_conjecture("L-0002")], str(tmp_path))
|
||||||
|
md = (tmp_path / "conjectures" / "L-0002.md").read_text(encoding="utf-8")
|
||||||
|
assert "UNVERIFIED" in md
|
||||||
|
assert "Suggested validation" in md
|
||||||
|
assert "Search for a counter-example" in md
|
||||||
|
|
||||||
|
|
||||||
|
def test_invariant_violation_raises(tmp_path: Path) -> None:
|
||||||
|
"""Constructing a Lead with kind='conjecture' but routed by code into
|
||||||
|
grounded/ would be a programmer error — guarded by the kind→dir check.
|
||||||
|
|
||||||
|
We simulate it by monkey-patching the kind set, which is the only way
|
||||||
|
a writer could attempt this. The expectation is that no path exists in
|
||||||
|
the public API to write a conjecture into grounded/.
|
||||||
|
"""
|
||||||
|
# Hard guarantee: the only sanctioned writer routes by lead.kind.
|
||||||
|
# Verify the dataclass kind field cannot trivially be spoofed: it is
|
||||||
|
# a Literal-typed string so any value other than the four kinds is a
|
||||||
|
# type error at static-checking time. At runtime, write_leads still
|
||||||
|
# rejects unknown kinds.
|
||||||
|
bogus = Lead(
|
||||||
|
id="L-0003",
|
||||||
|
kind="conjecture", # legitimate kind …
|
||||||
|
title="x",
|
||||||
|
body="y",
|
||||||
|
provenance=[Provenance(bibkey="X2020", locator="chunk 1")],
|
||||||
|
confidence=0.0,
|
||||||
|
suggested_validation="run a spike",
|
||||||
|
)
|
||||||
|
write_leads([bogus], str(tmp_path))
|
||||||
|
# Conjecture must land in conjectures/, not grounded/
|
||||||
|
assert (tmp_path / "conjectures" / "L-0003.md").exists()
|
||||||
|
assert not (tmp_path / "grounded" / "L-0003.md").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_kind_rejected(tmp_path: Path) -> None:
|
||||||
|
"""A Lead with an unrecognised kind raises at write time."""
|
||||||
|
lead = Lead(
|
||||||
|
id="L-0009",
|
||||||
|
kind="bogus-kind", # type: ignore[arg-type]
|
||||||
|
title="t",
|
||||||
|
body="b",
|
||||||
|
provenance=[Provenance(bibkey="X2020", locator="chunk 1")],
|
||||||
|
confidence=0.5,
|
||||||
|
)
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
write_leads([lead], str(tmp_path))
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_separates_grounded_and_conjectures(tmp_path: Path) -> None:
|
||||||
|
"""INDEX.md visibly separates grounded leads from conjectures."""
|
||||||
|
write_leads([_grounded_lead("L-0001"), _conjecture("L-0002")], str(tmp_path))
|
||||||
|
index = (tmp_path / "INDEX.md").read_text(encoding="utf-8")
|
||||||
|
# Two distinct, visibly separated sections
|
||||||
|
assert "## Grounded leads" in index
|
||||||
|
assert "## Conjectures" in index
|
||||||
|
# Ordering: grounded section appears before conjectures
|
||||||
|
assert index.index("Grounded leads") < index.index("Conjectures")
|
||||||
|
# Both ids are listed
|
||||||
|
assert "L-0001" in index
|
||||||
|
assert "L-0002" in index
|
||||||
0
tests/wiki/__init__.py
Normal file
0
tests/wiki/__init__.py
Normal file
227
tests/wiki/test_check.py
Normal file
227
tests/wiki/test_check.py
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
"""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}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wiki_check_exit_2_when_draft_not_empty(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""codex wiki check exits 2 when wiki/draft/ is non-empty (quarantined pages)."""
|
||||||
|
wiki = tmp_path / "wiki"
|
||||||
|
wiki.mkdir()
|
||||||
|
draft = wiki / "draft"
|
||||||
|
draft.mkdir()
|
||||||
|
|
||||||
|
(draft / "bad-concept.md").write_text(
|
||||||
|
"# Bad Concept\n\n⚠ Ungrounded hallucination. [FakeBib2025 #chunk 0]\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 == 2, (
|
||||||
|
f"Expected exit 2 (quarantined pages), got {result.exit_code}. Output: {result.output}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wiki_check_exit_2_takes_priority_over_exit_1(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Exit 2 (quarantined) takes priority over exit 1 (ungrounded in wiki/)."""
|
||||||
|
wiki = tmp_path / "wiki"
|
||||||
|
wiki.mkdir()
|
||||||
|
draft = wiki / "draft"
|
||||||
|
draft.mkdir()
|
||||||
|
|
||||||
|
# Main wiki with ungrounded claim
|
||||||
|
(wiki / "concept-a.md").write_text(
|
||||||
|
"# Concept A\n\n⚠ Ungrounded claim. [SomeBib #chunk 0]\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
# Draft with quarantined page
|
||||||
|
(draft / "concept-b.md").write_text(
|
||||||
|
"# Concept B\n\nQuarantined content.\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 == 2
|
||||||
761
tests/wiki/test_compile.py
Normal file
761
tests/wiki/test_compile.py
Normal file
@@ -0,0 +1,761 @@
|
|||||||
|
"""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,
|
||||||
|
_detect_conflicts,
|
||||||
|
_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
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_claims_ignores_markdown_links() -> None:
|
||||||
|
"""Markdown hyperlinks like [text](https://example.com) are NOT parsed as claims."""
|
||||||
|
md = "See the [related work](https://example.com) for details."
|
||||||
|
claims = _parse_claims(md)
|
||||||
|
assert claims == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_claims_ignores_multi_word_bibkey() -> None:
|
||||||
|
"""Multi-word 'bibkeys' (spaces inside) are not treated as real citations."""
|
||||||
|
md = "Some text [not a bibkey here] and more."
|
||||||
|
claims = _parse_claims(md)
|
||||||
|
assert claims == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _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
|
||||||
|
|
||||||
|
|
||||||
|
def test_grounding_guard_adversarial_stopword_bypass() -> None:
|
||||||
|
"""Adversarial: stopword-3-gram matches are NOT sufficient for grounding.
|
||||||
|
|
||||||
|
'the discrete conformal map' appears in the chunk, but this is a stopword
|
||||||
|
phrase. The preceding hallucinated sentences contain NO content-5-gram
|
||||||
|
present in the chunk → grounded=False.
|
||||||
|
"""
|
||||||
|
# The adversarial text from the session-prompt review finding:
|
||||||
|
adversarial_text = (
|
||||||
|
"The Riemann hypothesis is proven by combining Hodge theory with quantum entanglement. "
|
||||||
|
"Furthermore P=NP holds for hyperbolic tetrahedra. The discrete conformal map"
|
||||||
|
)
|
||||||
|
claim = Claim(
|
||||||
|
text=adversarial_text,
|
||||||
|
bibkey="Springborn2008",
|
||||||
|
locator="chunk 0",
|
||||||
|
)
|
||||||
|
result = _run_grounding_guard([claim], FIXTURE_CHUNKS)
|
||||||
|
assert result[0].grounded is False, (
|
||||||
|
"Stopword 3-gram 'the discrete conformal map' must NOT ground a hallucinated claim"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_grounding_guard_last_sentence_grounded_earlier_hallucination_ignored() -> None:
|
||||||
|
"""Only the last sentence is checked for grounding.
|
||||||
|
|
||||||
|
If the last sentence IS grounded, the earlier hallucinated sentence is
|
||||||
|
irrelevant (it has no citation → it won't be parsed as a Claim at all).
|
||||||
|
This test verifies that a claim whose text = the last sentence grounds correctly.
|
||||||
|
"""
|
||||||
|
# The last sentence of the claim text is directly grounded in the fixture chunk.
|
||||||
|
grounded_sentence = (
|
||||||
|
"the hyperbolic volume is V = L(gamma1) + L(gamma2) + L(gamma3)"
|
||||||
|
)
|
||||||
|
claim = Claim(
|
||||||
|
text=grounded_sentence,
|
||||||
|
bibkey="Springborn2008",
|
||||||
|
locator="chunk 16",
|
||||||
|
)
|
||||||
|
result = _run_grounding_guard([claim], FIXTURE_CHUNKS)
|
||||||
|
assert result[0].grounded is True, (
|
||||||
|
"Last sentence with content-5-gram present in chunk must be grounded"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _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
|
||||||
|
|
||||||
|
|
||||||
|
def test_inject_cross_refs_skips_inline_code() -> None:
|
||||||
|
"""Concept titles inside backtick inline-code spans are NOT replaced."""
|
||||||
|
concepts = [
|
||||||
|
Concept(
|
||||||
|
slug="circle-packing",
|
||||||
|
title="circle-packing",
|
||||||
|
aliases=[],
|
||||||
|
),
|
||||||
|
FIXTURE_CONCEPT,
|
||||||
|
]
|
||||||
|
# The term "circle-packing" appears both bare (should be replaced)
|
||||||
|
# and inside backticks (should NOT be replaced).
|
||||||
|
text = "Use `circle-packing` for this. See also circle-packing theory."
|
||||||
|
result = _inject_cross_refs(text, concepts, current_slug="lobachevsky-function")
|
||||||
|
assert "`circle-packing`" in result # inline-code preserved
|
||||||
|
assert "[[circle-packing]]" in result # bare occurrence replaced
|
||||||
|
|
||||||
|
|
||||||
|
def test_inject_cross_refs_full_list_even_when_single_concept() -> None:
|
||||||
|
"""Cross-ref injection uses the full concept list, not just the compiled concept."""
|
||||||
|
concepts = [
|
||||||
|
Concept(slug="circle-packing", title="Circle Packing", aliases=[]),
|
||||||
|
Concept(slug="discrete-conformal-map", title="Discrete Conformal Map", aliases=[]),
|
||||||
|
FIXTURE_CONCEPT,
|
||||||
|
]
|
||||||
|
# Compiling lobachevsky-function only; text mentions the other two concepts
|
||||||
|
text = "Discrete Conformal Map is used with Circle Packing."
|
||||||
|
result = _inject_cross_refs(text, concepts, current_slug="lobachevsky-function")
|
||||||
|
assert "[[circle-packing]]" in result
|
||||||
|
assert "[[discrete-conformal-map]]" 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
|
||||||
|
mock.wiki_min_grounding_rate = 0.5
|
||||||
|
|
||||||
|
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, FIXTURE_CHUNKS, 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, FIXTURE_CHUNKS, 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, FIXTURE_CHUNKS, 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, FIXTURE_CHUNKS, 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, FIXTURE_CHUNKS, 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, chunks: list[Any], **kwargs: Any
|
||||||
|
) -> ConceptPage:
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
return original_compile(concept, chunks, **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, chunks: list[Any], **kwargs: Any
|
||||||
|
) -> ConceptPage:
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
return original_compile(concept, chunks, **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
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# LLM error handling (Fix 4)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_compile_concept_llm_unavailable_returns_empty_page(
|
||||||
|
mock_retrieve: None,
|
||||||
|
mock_formulas: None,
|
||||||
|
mock_settings: Path,
|
||||||
|
) -> None:
|
||||||
|
"""When LLM raises an exception, compile_concept returns an empty ConceptPage (no crash)."""
|
||||||
|
|
||||||
|
class FailingLLM:
|
||||||
|
def generate(self, prompt: str, model: str) -> str:
|
||||||
|
raise ConnectionError("Ollama unavailable")
|
||||||
|
|
||||||
|
page = compile_concept(
|
||||||
|
FIXTURE_CONCEPT, FIXTURE_CHUNKS, top_k=6, llm=FailingLLM(), wiki_dir=mock_settings
|
||||||
|
)
|
||||||
|
assert isinstance(page, ConceptPage)
|
||||||
|
assert page.markdown == ""
|
||||||
|
assert page.claims == []
|
||||||
|
assert page.chunk_hash != "" # hash still computed from chunks
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Quarantine (Fix 2)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_compile_all_quarantines_low_grounding_page(
|
||||||
|
mock_retrieve: None,
|
||||||
|
mock_formulas: None,
|
||||||
|
mock_settings: Path,
|
||||||
|
) -> None:
|
||||||
|
"""A page with grounding rate < 0.5 goes to wiki/draft/ not wiki/."""
|
||||||
|
from codex.wiki import compile_all
|
||||||
|
|
||||||
|
(mock_settings / "concepts.yaml").write_text(
|
||||||
|
textwrap.dedent(
|
||||||
|
"""\
|
||||||
|
concepts:
|
||||||
|
- slug: lobachevsky-function
|
||||||
|
title: Lobachevsky Function
|
||||||
|
aliases: [Milnor Lobachevsky]
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ungrounded LLM output → all claims ungrounded → grounding rate 0.0 < 0.5
|
||||||
|
llm = MockLLM(UNGROUNDED_LLM_OUTPUT)
|
||||||
|
report = compile_all(changed_only=False, llm=llm, output_dir=str(mock_settings))
|
||||||
|
|
||||||
|
draft_dir = mock_settings / "draft"
|
||||||
|
assert draft_dir.exists(), "draft/ directory should have been created"
|
||||||
|
draft_pages = list(draft_dir.glob("*.md"))
|
||||||
|
assert len(draft_pages) >= 1, "Quarantined page should exist in wiki/draft/"
|
||||||
|
assert "lobachevsky-function" in report.quarantined
|
||||||
|
# Normal wiki/ path must NOT exist for this page
|
||||||
|
assert not (mock_settings / "lobachevsky-function.md").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_compile_all_does_not_quarantine_grounded_page(
|
||||||
|
mock_retrieve: None,
|
||||||
|
mock_formulas: None,
|
||||||
|
mock_settings: Path,
|
||||||
|
) -> None:
|
||||||
|
"""A page with grounding rate >= 0.5 is written to wiki/, not quarantined."""
|
||||||
|
from codex.wiki import compile_all
|
||||||
|
|
||||||
|
(mock_settings / "concepts.yaml").write_text(
|
||||||
|
textwrap.dedent(
|
||||||
|
"""\
|
||||||
|
concepts:
|
||||||
|
- slug: lobachevsky-function
|
||||||
|
title: Lobachevsky Function
|
||||||
|
aliases: [Milnor Lobachevsky]
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
llm = MockLLM(GROUNDED_LLM_OUTPUT)
|
||||||
|
report = compile_all(changed_only=False, llm=llm, output_dir=str(mock_settings))
|
||||||
|
|
||||||
|
assert "lobachevsky-function" not in report.quarantined
|
||||||
|
assert (mock_settings / "lobachevsky-function.md").exists()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Conflict detection (Fix 9)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_conflicts_finds_adversative_keywords() -> None:
|
||||||
|
"""Chunks from different bibkeys both containing adversative keywords → conflict."""
|
||||||
|
chunks = [
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"paper_id": "p1",
|
||||||
|
"ord": 0,
|
||||||
|
"content": "The discrete map works well, but diverges in degenerate cases.",
|
||||||
|
"bibkey": "Smith2020",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"paper_id": "p2",
|
||||||
|
"ord": 0,
|
||||||
|
"content": "However, the approach from Jones is more stable.",
|
||||||
|
"bibkey": "Jones2019",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
conflicts = _detect_conflicts("test-concept", chunks)
|
||||||
|
assert len(conflicts) >= 1
|
||||||
|
slugs, bk1s, bk2s = zip(*conflicts, strict=True)
|
||||||
|
assert "test-concept" in slugs
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_conflicts_no_conflict_when_single_bibkey() -> None:
|
||||||
|
"""Only one bibkey → no conflict possible."""
|
||||||
|
conflicts = _detect_conflicts("test-concept", FIXTURE_CHUNKS)
|
||||||
|
assert conflicts == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# append_log quarantine rendering
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_append_records_quarantined(
|
||||||
|
mock_settings: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Quarantined slugs appear in the log entry."""
|
||||||
|
report = CompileReport(
|
||||||
|
compiled=[],
|
||||||
|
quarantined=["some-concept"],
|
||||||
|
)
|
||||||
|
append_log(report, wiki_dir=mock_settings)
|
||||||
|
content = (mock_settings / "log.md").read_text(encoding="utf-8")
|
||||||
|
assert "some-concept" in content
|
||||||
|
assert "QUARANTINED" in content
|
||||||
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