Compare commits
128 Commits
scratch/sp
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 15b782fa8d | |||
|
|
53c40f6da2 | ||
| 5aa2f7db40 | |||
|
|
ab621fc5f5 | ||
|
|
b2f01ce010 | ||
|
|
9e5184385a | ||
|
|
e817022097 | ||
|
|
2d78f9c48e | ||
|
|
6911f0065e | ||
|
|
aaa86dee2f | ||
|
|
42608c73b8 | ||
|
|
49c251eafa | ||
|
|
f761ee63f8 | ||
|
|
200b7f8188 | ||
|
|
a050f6622a | ||
|
|
5c60a7eda4 | ||
|
|
1a879d4827 | ||
|
|
c70ff9aa1e | ||
|
|
5672358bbe | ||
|
|
c22c0db3d0 | ||
|
|
f504ca62db | ||
|
|
121b582f46 | ||
|
|
1da81c7b28 | ||
|
|
bf90c6cd57 | ||
|
|
82cea2a33b | ||
|
|
1516684bbb | ||
|
|
b55aa5b429 | ||
|
|
0021859094 | ||
|
|
b371119264 | ||
|
|
ea73b1475c | ||
|
|
fa43f69dfd | ||
|
|
ff03443af4 | ||
|
|
51dc74470c | ||
|
|
553ae3995d | ||
| d64edfc324 | |||
| 07929bd4fe | |||
| dca0dd793f | |||
| fb06c6f65a | |||
|
|
64e3ee188c | ||
|
|
e1c0a98262 | ||
|
|
1f677211f7 | ||
|
|
b52303d22b | ||
|
|
45c3b16dd7 | ||
|
|
f422b0fb84 | ||
|
|
92928bab99 | ||
|
|
c7fae5c877 | ||
|
|
b4fbd7930e | ||
|
|
e7b854db10 | ||
|
|
77f9d79da0 | ||
|
|
c4106b0f51 | ||
|
|
cfbfa53398 | ||
|
|
ae5e9a528b | ||
|
|
10ff5ba3ab | ||
|
|
cdb7d254df | ||
|
|
b4cf924bf1 | ||
|
|
9f172f5c68 | ||
|
|
1ff8052b69 | ||
|
|
ce75bc02e5 | ||
|
|
8e2f9e037d | ||
|
|
c36e7c6ee7 | ||
|
|
6193f5630d | ||
|
|
eee147275a | ||
|
|
3092f1814e | ||
|
|
115bb63f2d | ||
|
|
8af9f9ad0a | ||
|
|
33fb91d478 | ||
|
|
2d1b0b5e23 | ||
|
|
7682ff112f | ||
|
|
a0ad069b1d | ||
|
|
0c89eebeb8 | ||
|
|
9726042964 | ||
|
|
5dfa6e4bae | ||
|
|
664600fdc7 | ||
|
|
a1a6f4590b | ||
| 2c476dc038 | |||
|
|
1f1e0e82b3 | ||
|
|
fd51b70000 | ||
| 9647897173 | |||
| 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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
15
.env.example
15
.env.example
@@ -5,6 +5,12 @@
|
||||
# Example: postgresql://researcher:change_me@localhost:5432/papers
|
||||
DATABASE_URL=postgresql://researcher:change_me@localhost:5432/papers
|
||||
|
||||
# Optional: privileged connection used by `codex migrate` to apply schema DDL.
|
||||
# The app DATABASE_URL is often a least-privilege DML role that cannot CREATE/
|
||||
# ALTER owner-held tables; point this at an owner/superuser connection.
|
||||
# Falls back to DATABASE_URL when unset.
|
||||
# MIGRATION_DATABASE_URL=postgresql://postgres:change_me@localhost:5432/papers
|
||||
|
||||
# GROBID service base URL (containerised — see infra/docker-compose.yml)
|
||||
GROBID_URL=http://localhost:8070
|
||||
|
||||
@@ -23,3 +29,12 @@ EMBEDDING_DIM=1024
|
||||
# E-mail address for the OpenAlex Polite Pool (faster rate limits).
|
||||
# Required by OpenAlex ToS when making automated requests.
|
||||
OPENALEX_MAILTO=you@example.com
|
||||
|
||||
# Nougat HTTP-Server (Jetson: http://jetson.local: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
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -18,6 +18,8 @@ build/
|
||||
|
||||
# Environment secrets — NEVER commit
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Type-checker and linter caches
|
||||
.mypy_cache/
|
||||
@@ -33,3 +35,9 @@ htmlcov/
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# Claude Code — settings.json IS committed (team-wide); only personal overrides ignored
|
||||
.claude/settings.local.json
|
||||
|
||||
# Generated wiki index (regenerated by `codex wiki compile`; concepts.yaml is the source)
|
||||
wiki/index.md
|
||||
|
||||
89
README.md
89
README.md
@@ -19,13 +19,11 @@ $EDITOR .env
|
||||
# 3. Install Python dependencies (requires uv)
|
||||
uv sync
|
||||
|
||||
# 4. Apply the database schema (first run only)
|
||||
uv run python -c "
|
||||
from codex.db import get_conn, apply_schema
|
||||
with get_conn() as conn:
|
||||
apply_schema(conn)
|
||||
print('Schema applied.')
|
||||
"
|
||||
# 4. Apply the database schema (idempotent — safe to re-run after upgrades)
|
||||
# Needs a role that can run DDL. If DATABASE_URL is a least-privilege app
|
||||
# role, point MIGRATION_DATABASE_URL at an owner/superuser connection first:
|
||||
# MIGRATION_DATABASE_URL=postgresql://postgres:...@host:5432/papers
|
||||
uv run codex migrate
|
||||
```
|
||||
|
||||
---
|
||||
@@ -34,7 +32,8 @@ print('Schema applied.')
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `DATABASE_URL` | `postgresql://researcher:change_me@localhost:5432/papers` | libpq connection string |
|
||||
| `DATABASE_URL` | `postgresql://researcher:change_me@localhost:5432/papers` | libpq connection string (app role; DML) |
|
||||
| `MIGRATION_DATABASE_URL` | *(falls back to `DATABASE_URL`)* | Privileged connection used by `codex migrate` for schema DDL (owner/superuser) |
|
||||
| `GROBID_URL` | `http://localhost:8070` | GROBID HTTP API base URL |
|
||||
| `OLLAMA_BASE_URL` | `http://localhost:11434` | Local Ollama endpoint (optional) |
|
||||
| `EMBEDDING_MODEL` | `BAAI/bge-m3` | sentence-transformers model name |
|
||||
@@ -81,8 +80,8 @@ Maps C++ symbols (qualified names or `file.cpp:line` references) to the
|
||||
papers they implement. The workflow:
|
||||
|
||||
1. Tag C++ source with `@cite <bibkey>` in Doxygen comments.
|
||||
2. Run `codex provenance sync --lib-path <path>` to scan and resolve tags.
|
||||
3. Run `codex provenance export-bib <out.bib>` to generate a `.bib` file
|
||||
2. Run `codex provenance scan --lib-path <path>` to scan and resolve tags.
|
||||
3. Run `codex provenance bib <out.bib>` to generate a `.bib` file
|
||||
containing **only the cited subset** of your collection.
|
||||
|
||||
The exported `.bib` is a derived view of the master catalogue — regenerate
|
||||
@@ -97,13 +96,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 search "<query>" [--hybrid]
|
||||
codex discover leads
|
||||
codex provenance sync --lib-path <path>
|
||||
codex provenance export-bib <out.bib>
|
||||
codex provenance scan --lib-path <path>
|
||||
codex provenance bib <out.bib>
|
||||
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
|
||||
|
||||
```bash
|
||||
|
||||
3
codex/__main__.py
Normal file
3
codex/__main__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from codex.cli import app
|
||||
|
||||
app()
|
||||
724
codex/cli.py
724
codex/cli.py
@@ -1,8 +1,726 @@
|
||||
"""codex CLI — commands wired to domain modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
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).")
|
||||
graph_app = typer.Typer(help="Citation graph analytics: PageRank, coupling, dangling leads (F-15).")
|
||||
# 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(graph_app, name="graph")
|
||||
app.add_typer(search_app, name="search")
|
||||
|
||||
|
||||
@app.callback()
|
||||
def _main() -> None:
|
||||
"""codex CLI — commands land here in F-07."""
|
||||
@app.command()
|
||||
def migrate() -> None:
|
||||
"""Apply infra/schema.sql to bring the database schema up to date (idempotent).
|
||||
|
||||
Must connect as a role that can run DDL (owns / can CREATE + ALTER the tables).
|
||||
The application's DATABASE_URL is usually a least-privilege DML role and will
|
||||
fail with InsufficientPrivilege; set MIGRATION_DATABASE_URL to a privileged
|
||||
(owner/superuser) connection (audit M-1).
|
||||
"""
|
||||
import psycopg
|
||||
import psycopg.rows
|
||||
|
||||
from codex.config import get_settings
|
||||
from codex.db import apply_schema
|
||||
|
||||
settings = get_settings()
|
||||
url = (settings.migration_database_url or settings.database_url).get_secret_value()
|
||||
try:
|
||||
with psycopg.connect(url, row_factory=psycopg.rows.dict_row) as conn:
|
||||
apply_schema(conn)
|
||||
except psycopg.errors.InsufficientPrivilege as exc:
|
||||
typer.echo(
|
||||
"ERROR: schema migration needs a role that owns the tables "
|
||||
"(CREATE/ALTER). Set MIGRATION_DATABASE_URL to a privileged connection "
|
||||
f"and retry. ({exc})",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(1) from exc
|
||||
typer.echo("Schema applied — database is up to date.")
|
||||
|
||||
|
||||
@app.command()
|
||||
def ingest(
|
||||
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"),
|
||||
cite_boost: bool = typer.Option(
|
||||
False,
|
||||
"--cite-boost",
|
||||
help="Re-rank the top results by citation PageRank — a within-page "
|
||||
"tie-breaker, not a hard re-ranking (F-15). Graceful when corpus < 5 papers.",
|
||||
),
|
||||
) -> None:
|
||||
"""Semantic similarity search over paper abstracts."""
|
||||
from codex.config import get_settings
|
||||
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::vector AS distance
|
||||
FROM papers
|
||||
WHERE abstract_emb IS NOT NULL
|
||||
ORDER BY abstract_emb <-> %(emb)s::vector
|
||||
LIMIT %(limit)s
|
||||
""",
|
||||
{"emb": emb, "limit": limit},
|
||||
).fetchall()
|
||||
|
||||
if cite_boost and rows:
|
||||
from codex.graph import build_citation_graph, citation_pagerank
|
||||
|
||||
settings = get_settings()
|
||||
graph = build_citation_graph(conn)
|
||||
pr = citation_pagerank(graph)
|
||||
alpha = settings.graph_cite_boost_alpha
|
||||
results = []
|
||||
for row in rows:
|
||||
pr_score = pr.get(row["id"], 0.0)
|
||||
# distance is lower=better; divide to reduce distance for high-PR papers
|
||||
boosted = row["distance"] / (1.0 + alpha * pr_score)
|
||||
results.append((boosted, row))
|
||||
results.sort(key=lambda x: x[0])
|
||||
for boosted_dist, row in results:
|
||||
typer.echo(f"[{boosted_dist:.3f}] {row['id']} ({row['year']}) — {row['title']}")
|
||||
return
|
||||
|
||||
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("recommend")
|
||||
def discover_recommend(
|
||||
paper_id: str = typer.Argument(
|
||||
..., help="Semantic Scholar paper ID (or arXiv:/DOI: prefixed)."
|
||||
),
|
||||
limit: int = typer.Option(20, "--limit", "-n", help="Number of recommendations."),
|
||||
) -> None:
|
||||
"""Recommended papers for PAPER_ID via Semantic Scholar."""
|
||||
from codex.sources.semanticscholar import fetch_recommendations
|
||||
|
||||
rec = fetch_recommendations(paper_id, limit=limit)
|
||||
if not rec:
|
||||
typer.echo(f"No recommendations found for {paper_id}.")
|
||||
return
|
||||
for pid in rec:
|
||||
typer.echo(pid)
|
||||
|
||||
|
||||
@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."
|
||||
),
|
||||
),
|
||||
topic: list[str] | None = typer.Option( # noqa: B008
|
||||
None,
|
||||
"--topic",
|
||||
help=(
|
||||
"Topic to check for coverage gaps (repeatable). Opt-in: without --topic "
|
||||
"no coverage-map gaps run (avoids per-paper false positives, audit R-8)."
|
||||
),
|
||||
),
|
||||
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, topics=topic or None)
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# F-15 — graph sub-commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@graph_app.command("report")
|
||||
def graph_report(
|
||||
top_n: int = typer.Option(10, "--top-n", "-n", help="Number of hub papers to show."),
|
||||
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
||||
) -> None:
|
||||
"""Show citation graph report: top hub papers, dangling citations, cluster summary."""
|
||||
from codex.config import get_settings
|
||||
from codex.db import get_conn
|
||||
from codex.graph import (
|
||||
build_citation_graph,
|
||||
citation_pagerank,
|
||||
citing_coverage,
|
||||
dangling_citations,
|
||||
)
|
||||
|
||||
settings = get_settings()
|
||||
with get_conn() as conn:
|
||||
graph = build_citation_graph(conn)
|
||||
paper_rows = conn.execute("SELECT id, title FROM papers").fetchall()
|
||||
known_ids = {row["id"] for row in paper_rows}
|
||||
titles = {row["id"]: row["title"] for row in paper_rows}
|
||||
|
||||
if graph.number_of_nodes() == 0:
|
||||
typer.echo("Citation graph is empty — ingest some papers first.")
|
||||
return
|
||||
|
||||
n_papers = len(known_ids)
|
||||
pr = citation_pagerank(graph)
|
||||
hubs = sorted(pr.items(), key=lambda x: -x[1])[:top_n]
|
||||
dangling = sorted(dangling_citations(graph, known_ids))
|
||||
n_citing, _ = citing_coverage(graph, known_ids)
|
||||
coverage = n_citing / n_papers if n_papers else 0.0
|
||||
small_corpus = n_papers < settings.graph_min_corpus_size
|
||||
warning = (
|
||||
f"only {n_papers} ingested papers; recommended >= {settings.graph_min_corpus_size} "
|
||||
"for meaningful ranking"
|
||||
if small_corpus
|
||||
else None
|
||||
)
|
||||
|
||||
if output_json:
|
||||
typer.echo(
|
||||
json.dumps(
|
||||
{
|
||||
"nodes": graph.number_of_nodes(),
|
||||
"edges": graph.number_of_edges(),
|
||||
"citing_coverage": {
|
||||
"citing": n_citing,
|
||||
"papers": n_papers,
|
||||
"fraction": round(coverage, 4),
|
||||
},
|
||||
"small_corpus_warning": warning, # null unless below threshold (audit U-3)
|
||||
"hubs": [
|
||||
{"paper_id": pid, "title": titles.get(pid), "pagerank": score}
|
||||
for pid, score in hubs
|
||||
],
|
||||
"dangling_count": len(dangling),
|
||||
"dangling": dangling,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if warning:
|
||||
typer.echo(f"Warning: {warning}.", err=True)
|
||||
# R-D: low citing-paper coverage starves PageRank/coupling even at a healthy
|
||||
# paper count — warn on the share of papers that actually have out-edges.
|
||||
if coverage < settings.graph_min_citing_coverage:
|
||||
typer.echo(
|
||||
f"Warning: only {n_citing}/{n_papers} papers ({coverage:.0%}) have out-edges "
|
||||
f"(recommended ≥ {settings.graph_min_citing_coverage:.0%}); "
|
||||
f"low citing coverage weakens PageRank/coupling.",
|
||||
err=True,
|
||||
)
|
||||
typer.echo(f"Graph: {graph.number_of_nodes()} nodes, {graph.number_of_edges()} edges")
|
||||
typer.echo(f"Citing coverage: {n_citing}/{n_papers} papers with out-edges ({coverage:.0%})")
|
||||
typer.echo("")
|
||||
typer.echo(f"TOP-{top_n} HUB PAPERS (PageRank)")
|
||||
typer.echo("-" * 48)
|
||||
for pid, score in hubs:
|
||||
# in-KB hubs show their title; dangling (OpenAlex-id) hubs are flagged (U-1)
|
||||
label = titles.get(pid) or "(dangling — not ingested)"
|
||||
typer.echo(f" {score:.4f} {pid} {label}")
|
||||
typer.echo("")
|
||||
typer.echo(f"DANGLING CITATIONS ({len(dangling)} cited but not ingested)")
|
||||
typer.echo("-" * 48)
|
||||
for pid in dangling[:top_n]:
|
||||
typer.echo(f" {pid}")
|
||||
if len(dangling) > top_n:
|
||||
typer.echo(f" … and {len(dangling) - top_n} more")
|
||||
|
||||
|
||||
@graph_app.command("related")
|
||||
def graph_related(
|
||||
paper_id: str = typer.Argument(..., help="Paper ID to find related papers for."),
|
||||
min_shared: int = typer.Option(
|
||||
2, "--min-shared", help="Minimum shared references for bibliographic coupling."
|
||||
),
|
||||
) -> None:
|
||||
"""List bibliographically related papers (shared references ≥ min-shared)."""
|
||||
from codex.db import get_conn
|
||||
from codex.graph import build_citation_graph, find_related
|
||||
|
||||
with get_conn() as conn:
|
||||
graph = build_citation_graph(conn)
|
||||
|
||||
related = find_related(paper_id, graph, min_shared=min_shared)
|
||||
if not related:
|
||||
typer.echo(f"No papers with ≥ {min_shared} shared references found for {paper_id}.")
|
||||
return
|
||||
typer.echo(
|
||||
f"Papers related to {paper_id} (bibliographic coupling, ≥ {min_shared} shared refs):"
|
||||
)
|
||||
for pid in related:
|
||||
typer.echo(f" {pid}")
|
||||
|
||||
|
||||
@graph_app.command("cocited")
|
||||
def graph_cocited(
|
||||
paper_id: str = typer.Argument(..., help="Paper ID to find co-cited papers for."),
|
||||
) -> None:
|
||||
"""List papers frequently co-cited with PAPER_ID (graph co-citation)."""
|
||||
from codex.db import get_conn
|
||||
from codex.graph import build_citation_graph, find_co_cited
|
||||
|
||||
with get_conn() as conn:
|
||||
graph = build_citation_graph(conn)
|
||||
|
||||
results = find_co_cited(paper_id, graph)
|
||||
if not results:
|
||||
typer.echo(f"No papers co-cited with {paper_id}.")
|
||||
return
|
||||
typer.echo(f"Papers co-cited with {paper_id} (count desc):")
|
||||
for pid, count in results:
|
||||
typer.echo(f" {count:>3}× {pid}")
|
||||
|
||||
262
codex/config.py
262
codex/config.py
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import AliasChoices, Field, SecretStr
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
@@ -26,14 +26,25 @@ class Settings(BaseSettings):
|
||||
# ------------------------------------------------------------------
|
||||
# Database
|
||||
# ------------------------------------------------------------------
|
||||
database_url: str = Field(
|
||||
default="postgresql://researcher:change_me@localhost:5432/papers",
|
||||
database_url: SecretStr = Field(
|
||||
default=SecretStr("postgresql://researcher:change_me@localhost:5432/papers"),
|
||||
description=(
|
||||
"libpq-compatible connection string consumed by psycopg. "
|
||||
"Example: postgresql://user:pass@host:5432/dbname"
|
||||
),
|
||||
)
|
||||
|
||||
migration_database_url: SecretStr | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Optional privileged connection string used by `codex migrate` to apply "
|
||||
"schema DDL. The app's database_url is typically a least-privilege DML "
|
||||
"role that cannot CREATE/ALTER owner-held tables (raises "
|
||||
"InsufficientPrivilege); point this at an owner/superuser connection. "
|
||||
"Falls back to database_url when unset."
|
||||
),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# External services
|
||||
# ------------------------------------------------------------------
|
||||
@@ -42,6 +53,12 @@ class Settings(BaseSettings):
|
||||
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(
|
||||
default="http://localhost:11434",
|
||||
description="Base URL of the local Ollama endpoint (optional Q&A layer).",
|
||||
@@ -78,6 +95,245 @@ 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://jetson.local: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: SecretStr | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"MathPix App ID for cloud formula extraction. "
|
||||
"When None, pix2tex local OCR is used as fallback."
|
||||
),
|
||||
)
|
||||
|
||||
mathpix_app_key: SecretStr | 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: SecretStr | 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."
|
||||
),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# F-15 Literature Graph
|
||||
# ------------------------------------------------------------------
|
||||
graph_cite_boost_alpha: float = Field(
|
||||
default=0.3,
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
description=(
|
||||
"Weight of the PageRank citation-boost in hybrid search. "
|
||||
"boosted_distance = distance / (1 + alpha * pagerank_score) — dividing "
|
||||
"lowers the cosine distance of high-PageRank papers (lower = closer). "
|
||||
"Set to 0.0 to disable the boost without removing the flag."
|
||||
),
|
||||
)
|
||||
|
||||
graph_min_corpus_size: int = Field(
|
||||
default=15,
|
||||
gt=0,
|
||||
description=(
|
||||
"Minimum number of ingested papers for citation_pagerank to yield "
|
||||
"meaningful rankings. Below this threshold a warning is logged and "
|
||||
"uniform scores are returned."
|
||||
),
|
||||
)
|
||||
|
||||
graph_min_citing_coverage: float = Field(
|
||||
default=0.8,
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
description=(
|
||||
"Minimum share of ingested papers that must have ≥1 out-edge (i.e. "
|
||||
"cite something) before `codex graph report` warns. Low citing "
|
||||
"coverage starves PageRank/coupling even when the paper count looks "
|
||||
"healthy — the share of *citing* papers is what matters (R-D)."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
|
||||
13
codex/db.py
13
codex/db.py
@@ -36,7 +36,7 @@ def get_conn() -> Generator[psycopg.Connection[psycopg.rows.DictRow], None, None
|
||||
"""
|
||||
settings = get_settings()
|
||||
with psycopg.connect(
|
||||
settings.database_url,
|
||||
settings.database_url.get_secret_value(),
|
||||
row_factory=psycopg.rows.dict_row,
|
||||
) as conn:
|
||||
yield conn
|
||||
@@ -45,11 +45,16 @@ def get_conn() -> Generator[psycopg.Connection[psycopg.rows.DictRow], None, None
|
||||
def apply_schema(conn: psycopg.Connection[psycopg.rows.DictRow]) -> None:
|
||||
"""Idempotently apply ``infra/schema.sql`` to the connected database.
|
||||
|
||||
Safe to call on every startup — all DDL statements use
|
||||
``CREATE … IF NOT EXISTS``.
|
||||
Safe to call repeatedly — every statement is ``CREATE … IF NOT EXISTS`` or
|
||||
``ADD COLUMN IF NOT EXISTS``, so re-application is a no-op.
|
||||
|
||||
Requires a connection whose role can run DDL (owns / can CREATE + ALTER the
|
||||
tables). The least-privilege application role is typically DML-only and will
|
||||
raise ``InsufficientPrivilege``; use a privileged connection — see
|
||||
``codex migrate`` and ``MIGRATION_DATABASE_URL`` (audit M-1).
|
||||
|
||||
Args:
|
||||
conn: An open psycopg connection (obtained via :func:`get_conn`).
|
||||
conn: An open psycopg connection with DDL privileges.
|
||||
"""
|
||||
schema_path = Path(__file__).parent.parent / "infra" / "schema.sql"
|
||||
sql = schema_path.read_text(encoding="utf-8")
|
||||
|
||||
81
codex/discover.py
Normal file
81
codex/discover.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Discovery queries over the citation graph stored in PostgreSQL.
|
||||
|
||||
Every query resolves ``citations.cited_id`` to a canonical ``papers.id`` through
|
||||
the shared :data:`codex.graph.RESOLVED_CITATIONS_SQL`, so this module and the
|
||||
citation-graph layer agree on what counts as "ingested". Previously this module
|
||||
compared the raw OpenAlex ``cited_id`` against ``papers.id`` (a DOI/arXiv id),
|
||||
which mis-reported already-ingested papers as discovery leads (audit C-1).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from codex.db import get_conn
|
||||
from codex.graph import RESOLVED_CITATIONS_SQL
|
||||
|
||||
|
||||
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) — canonical id of the referenced (not-yet-ingested) work
|
||||
pull (int) — how many already-ingested papers cite this target
|
||||
|
||||
Ordered by pull DESC, limited to `limit` rows. References that resolve to a
|
||||
paper already in the corpus are excluded — they are not leads.
|
||||
"""
|
||||
sql = f"""
|
||||
WITH resolved AS ({RESOLVED_CITATIONS_SQL})
|
||||
SELECT cited_id, count(*) AS pull
|
||||
FROM resolved
|
||||
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 = f"""
|
||||
WITH resolved AS ({RESOLVED_CITATIONS_SQL})
|
||||
SELECT citing_id FROM resolved 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 = f"""
|
||||
WITH resolved AS ({RESOLVED_CITATIONS_SQL})
|
||||
SELECT cited_id FROM resolved 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 = f"""
|
||||
WITH resolved AS ({RESOLVED_CITATIONS_SQL})
|
||||
SELECT c2.cited_id AS paper_id, count(*) AS co_citations
|
||||
FROM resolved c1
|
||||
JOIN resolved 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]
|
||||
160
codex/embed.py
Normal file
160
codex/embed.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Dense (+ optional 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.
|
||||
|
||||
Status: only the **dense** path is wired into ingest/search today; the search
|
||||
"hybrid" is dense + Postgres FTS. :meth:`Embedder.encode_sparse` / :meth:`encode`
|
||||
are provided for a future sparse-retrieval layer but are not yet consumed by the
|
||||
pipeline (audit C-14).
|
||||
|
||||
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
|
||||
209
codex/graph.py
Normal file
209
codex/graph.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""Citation graph analytics (F-15).
|
||||
|
||||
Builds an in-memory NetworkX DiGraph from the ``citations`` table and
|
||||
provides PageRank, bibliographic coupling, co-citation, and dangling-
|
||||
citation queries. The graph is ephemeral — rebuilt per call, < 1 s for
|
||||
≤ 100 papers.
|
||||
|
||||
Graceful degradation
|
||||
--------------------
|
||||
* ``citation_pagerank`` returns uniform scores when the graph has fewer
|
||||
than 5 nodes, and logs a warning.
|
||||
* All functions accept an empty graph without raising.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
import networkx as nx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MIN_PAGERANK_NODES = 5
|
||||
|
||||
# Single source of truth for resolving ``citations.cited_id`` to a canonical
|
||||
# paper id. ``cited_id`` stores OpenAlex IDs while ``papers.id`` stores DOIs /
|
||||
# arXiv ids, so an in-KB reference cited by its OpenAlex id does not match
|
||||
# ``papers.id`` directly. This resolves it to the canonical ``papers.id`` (via
|
||||
# ``papers.openalex_id``, then the ``paper_identifiers`` alias table); dangling
|
||||
# references keep their raw id. Both :func:`build_citation_graph` and
|
||||
# :mod:`codex.discover` query through this so the "what counts as ingested" rule
|
||||
# cannot drift between them again (audit C-1). Trusted constant — no user input.
|
||||
RESOLVED_CITATIONS_SQL = """
|
||||
SELECT c.citing_id,
|
||||
COALESCE(p.id, pi.paper_id, c.cited_id) AS cited_id
|
||||
FROM citations c
|
||||
LEFT JOIN papers p ON p.openalex_id = c.cited_id
|
||||
LEFT JOIN paper_identifiers pi
|
||||
ON pi.openalex_id = c.cited_id AND p.id IS NULL
|
||||
"""
|
||||
|
||||
|
||||
def build_citation_graph(conn: Any) -> nx.DiGraph:
|
||||
"""Load the ``citations`` table into a directed graph.
|
||||
|
||||
Nodes are paper IDs (strings). An edge ``citing → cited`` means
|
||||
the citing paper references the cited paper. Both ingested papers
|
||||
and dangling targets (cited but not yet ingested) appear as nodes.
|
||||
|
||||
``cited_id`` in the citations table stores OpenAlex IDs; ``papers.id``
|
||||
stores DOIs. A LEFT JOIN resolves cited papers that are in the KB to
|
||||
their canonical DOI key via ``papers.openalex_id``. Dangling references
|
||||
(not in KB) keep their raw OpenAlex ID.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
conn:
|
||||
Open psycopg connection (dict-row factory assumed).
|
||||
|
||||
Returns
|
||||
-------
|
||||
nx.DiGraph with every (citing_id, cited_id) pair as an edge.
|
||||
"""
|
||||
rows = conn.execute(RESOLVED_CITATIONS_SQL).fetchall()
|
||||
g: nx.DiGraph = nx.DiGraph()
|
||||
for row in rows:
|
||||
g.add_edge(row["citing_id"], row["cited_id"])
|
||||
logger.debug(
|
||||
"Built citation graph: %d nodes, %d edges", g.number_of_nodes(), g.number_of_edges()
|
||||
)
|
||||
return g
|
||||
|
||||
|
||||
def citation_pagerank(graph: nx.DiGraph, *, damping: float = 0.85) -> dict[str, float]:
|
||||
"""Compute PageRank over the citation graph.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
graph:
|
||||
DiGraph from :func:`build_citation_graph`.
|
||||
damping:
|
||||
PageRank damping factor (default 0.85).
|
||||
|
||||
Returns
|
||||
-------
|
||||
``{paper_id: score}`` dict. Scores sum to approximately 1.0.
|
||||
|
||||
When ``graph`` has fewer than :data:`_MIN_PAGERANK_NODES` (5) nodes,
|
||||
returns a uniform distribution and logs a warning — the graph is too
|
||||
sparse for the random-walk model to converge meaningfully. The
|
||||
``damping`` parameter is ignored in this branch. The user-facing
|
||||
corpus-size threshold (default 15) lives in
|
||||
:attr:`codex.config.Settings.graph_min_corpus_size` and is surfaced
|
||||
by ``codex graph report``.
|
||||
"""
|
||||
n = graph.number_of_nodes()
|
||||
if n == 0:
|
||||
return {}
|
||||
if n < _MIN_PAGERANK_NODES:
|
||||
logger.warning(
|
||||
"citation_pagerank: only %d nodes — corpus too small for meaningful ranking "
|
||||
"(need ≥ %d). Returning uniform scores.",
|
||||
n,
|
||||
_MIN_PAGERANK_NODES,
|
||||
)
|
||||
uniform = 1.0 / n
|
||||
return {node: uniform for node in graph.nodes()}
|
||||
return cast(dict[str, float], nx.pagerank(graph, alpha=damping))
|
||||
|
||||
|
||||
def citing_coverage(graph: nx.DiGraph, known_ids: set[str]) -> tuple[int, int]:
|
||||
"""Return ``(papers_with_out_edges, total_papers)`` — the citing-paper coverage.
|
||||
|
||||
A paper "covers" the citation graph when it has ≥1 out-edge (it cites at least
|
||||
one work). Low citing coverage starves the F-15 layer (PageRank / coupling /
|
||||
co-citation) even when the total paper count looks healthy, because those
|
||||
metrics are driven by out-edges, not by node count. Surfaced by
|
||||
``codex graph report`` (R-D); the warning threshold is
|
||||
:attr:`codex.config.Settings.graph_min_citing_coverage`.
|
||||
"""
|
||||
n_citing = sum(1 for pid in known_ids if pid in graph and graph.out_degree(pid) > 0)
|
||||
return n_citing, len(known_ids)
|
||||
|
||||
|
||||
def find_related(paper_id: str, graph: nx.DiGraph, *, min_shared: int = 2) -> list[str]:
|
||||
"""Bibliographic coupling: papers sharing ≥ ``min_shared`` references with ``paper_id``.
|
||||
|
||||
Two papers are bibliographically coupled when they cite the same sources.
|
||||
The more shared references, the more likely they treat related topics.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
paper_id:
|
||||
Source paper whose references form the seed set.
|
||||
graph:
|
||||
DiGraph from :func:`build_citation_graph`.
|
||||
min_shared:
|
||||
Minimum number of shared references to be included.
|
||||
|
||||
Returns
|
||||
-------
|
||||
List of paper IDs ordered by shared-reference count DESC.
|
||||
"""
|
||||
if paper_id not in graph:
|
||||
return []
|
||||
# papers cited by paper_id
|
||||
references: set[str] = set(graph.successors(paper_id))
|
||||
if not references:
|
||||
return []
|
||||
|
||||
shared: dict[str, int] = {}
|
||||
for ref in references:
|
||||
# other papers that also cite this reference
|
||||
for co_citer in graph.predecessors(ref):
|
||||
if co_citer == paper_id:
|
||||
continue
|
||||
shared[co_citer] = shared.get(co_citer, 0) + 1
|
||||
|
||||
return [
|
||||
pid for pid, count in sorted(shared.items(), key=lambda x: -x[1]) if count >= min_shared
|
||||
]
|
||||
|
||||
|
||||
def find_co_cited(paper_id: str, graph: nx.DiGraph) -> list[tuple[str, int]]:
|
||||
"""Co-citation: papers frequently cited alongside ``paper_id``.
|
||||
|
||||
A paper X is co-cited with ``paper_id`` when some third paper cites both.
|
||||
|
||||
Returns
|
||||
-------
|
||||
``[(paper_id, count)]`` ordered by count DESC.
|
||||
"""
|
||||
if paper_id not in graph:
|
||||
return []
|
||||
# papers that cite paper_id
|
||||
citers: set[str] = set(graph.predecessors(paper_id))
|
||||
if not citers:
|
||||
return []
|
||||
|
||||
co_cited: dict[str, int] = {}
|
||||
for citer in citers:
|
||||
for other in graph.successors(citer):
|
||||
if other == paper_id:
|
||||
continue
|
||||
co_cited[other] = co_cited.get(other, 0) + 1
|
||||
|
||||
return sorted(co_cited.items(), key=lambda x: -x[1])
|
||||
|
||||
|
||||
def dangling_citations(graph: nx.DiGraph, known_ids: set[str]) -> list[str]:
|
||||
"""Return graph nodes not present in ``known_ids`` (cited but not yet ingested).
|
||||
|
||||
Complements :func:`codex.discover.discovery_leads` with graph context:
|
||||
the returned IDs are reachable in the citation graph but have no paper
|
||||
record in the DB.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
graph:
|
||||
DiGraph from :func:`build_citation_graph`.
|
||||
known_ids:
|
||||
Set of paper IDs that exist in the ``papers`` table.
|
||||
|
||||
Returns
|
||||
-------
|
||||
List of dangling paper IDs (unordered).
|
||||
"""
|
||||
return [node for node in graph.nodes() if node not in known_ids]
|
||||
505
codex/ingest.py
Normal file
505
codex/ingest.py
Normal file
@@ -0,0 +1,505 @@
|
||||
"""End-to-end idempotent ingest pipeline for a single paper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import psycopg
|
||||
|
||||
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 is_quality_chunk, section_label
|
||||
from codex.sources import arxiv, crossref, 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 _s2_id_for(pid: str) -> str | None:
|
||||
"""Format a canonical paper id for the Semantic Scholar paper endpoint.
|
||||
|
||||
S2 needs a namespaced id (``arXiv:<id>``, ``DOI:<doi>``) or a bare 40-hex
|
||||
S2 paperId — a bare arXiv id like ``2305.10988`` 404s. DOIs are detected by
|
||||
the ``10.`` prefix; everything else is assumed to be an arXiv id (modern
|
||||
``2305.10988`` or legacy ``math/0603097``).
|
||||
"""
|
||||
p = (pid or "").strip()
|
||||
if not p:
|
||||
return None
|
||||
if p.lower().startswith(("arxiv:", "doi:")):
|
||||
return p
|
||||
if p.startswith("10."):
|
||||
return f"DOI:{p}"
|
||||
return f"arXiv:{p}"
|
||||
|
||||
|
||||
def _norm_cited_id(cited_id: str) -> str:
|
||||
"""Normalize a DOI cited-id to its canonical bare, lower-cased form.
|
||||
|
||||
DOIs are case-insensitive and may arrive bare (``10.…``), as a
|
||||
``https://doi.org/…`` URL, or ``doi:…``-prefixed: GROBID emits whichever
|
||||
form the PDF reference text used, while S2/OpenAlex hand back bare DOIs.
|
||||
Stripping the prefix and lower-casing keeps the citation-graph join from
|
||||
fragmenting the same reference into distinct case-/URL-variant nodes. arXiv
|
||||
ids and S2 paperIds are left untouched. Mirrors ``openalex._normalize_doi``."""
|
||||
s = cited_id.strip()
|
||||
lowered = s.lower()
|
||||
for prefix in ("https://doi.org/", "http://doi.org/", "doi:"):
|
||||
if lowered.startswith(prefix):
|
||||
return lowered[len(prefix) :]
|
||||
return lowered if lowered.startswith("10.") else s
|
||||
|
||||
|
||||
def _recover_abstract(paper: Paper) -> str | None:
|
||||
"""Recover a missing abstract from S2, then Crossref (DQ-2 supplement).
|
||||
|
||||
Tried in order of coverage for this corpus: Semantic Scholar (indexes most
|
||||
arXiv + journal abstracts), then Crossref (publisher-deposited, DOI only).
|
||||
Network/parse failures degrade to None rather than aborting the ingest.
|
||||
"""
|
||||
s2_id = _s2_id_for(paper.id)
|
||||
if s2_id is not None:
|
||||
try:
|
||||
abstract = semanticscholar.fetch_abstract(s2_id)
|
||||
if abstract and abstract.strip():
|
||||
return abstract.strip()
|
||||
except Exception:
|
||||
logger.warning("S2 abstract recovery failed for %s", paper.id, exc_info=True)
|
||||
if paper.id.startswith("10."):
|
||||
try:
|
||||
abstract = crossref.fetch_abstract(paper.id)
|
||||
if abstract and abstract.strip():
|
||||
return abstract.strip()
|
||||
except Exception:
|
||||
logger.warning("Crossref abstract recovery failed for %s", paper.id, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _s2_reference_supplement(paper: Paper) -> list[Citation]:
|
||||
"""Fetch a paper's references from Semantic Scholar (DQ-1 supplement).
|
||||
|
||||
Used when OpenAlex returns an empty ``referenced_works`` list — common for
|
||||
arXiv preprints and institutional theses that OpenAlex indexes without a
|
||||
parsed bibliography. ``citing_id`` is rewritten to the canonical ``paper.id``
|
||||
so the FK holds, and cited DOIs are case-normalised. Network/parse failures
|
||||
degrade to an empty list rather than aborting the ingest.
|
||||
"""
|
||||
s2_id = _s2_id_for(paper.id)
|
||||
if s2_id is None:
|
||||
return []
|
||||
try:
|
||||
raw = semanticscholar.fetch_references(s2_id)
|
||||
except Exception:
|
||||
logger.warning("S2 reference supplement failed for %s", paper.id, exc_info=True)
|
||||
return []
|
||||
return [
|
||||
Citation(citing_id=paper.id, cited_id=_norm_cited_id(c.cited_id), context=c.context)
|
||||
for c in raw
|
||||
if c.cited_id
|
||||
]
|
||||
|
||||
|
||||
def _crossref_reference_supplement(paper: Paper) -> list[Citation]:
|
||||
"""Fetch a paper's references from Crossref (roadmap R-B supplement).
|
||||
|
||||
Third leg of the citation fallback chain (OpenAlex-empty → S2 → Crossref).
|
||||
Crossref carries publisher-deposited reference lists keyed on the citing
|
||||
work's DOI, so only DOI papers can be looked up (arXiv-only ids → []).
|
||||
``citing_id`` is rewritten to the canonical ``paper.id`` and cited DOIs are
|
||||
case-normalised. Network/parse failures degrade to [] rather than aborting.
|
||||
"""
|
||||
if not paper.id.startswith("10."):
|
||||
return []
|
||||
try:
|
||||
raw = crossref.fetch_references(paper.id)
|
||||
except Exception:
|
||||
logger.warning("Crossref reference supplement failed for %s", paper.id, exc_info=True)
|
||||
return []
|
||||
return [
|
||||
Citation(citing_id=paper.id, cited_id=_norm_cited_id(c.cited_id), context=c.context)
|
||||
for c in raw
|
||||
if c.cited_id
|
||||
]
|
||||
|
||||
|
||||
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:
|
||||
# OpenAlex 404 on an arXiv id → recover authoritative metadata from
|
||||
# the arXiv API (DQ-2); fall back to a minimal stub if arXiv also
|
||||
# has nothing. Citation recovery from S2 happens in the citation
|
||||
# block below (DQ-1 supplement).
|
||||
paper = arxiv.fetch_metadata(paper_id) or 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}")
|
||||
|
||||
# Canonical identity is the caller-supplied id — what the CLI / ingest scripts
|
||||
# use and what papers.id is contracted to hold (arXiv id or DOI). Pin it to the
|
||||
# caller's id (audit C-7), but normalize it the same way cited-ids are (strip a
|
||||
# https://doi.org/ or doi: prefix, lower-case DOIs) so a non-bare caller cannot
|
||||
# store a URL-form papers.id that would defeat DQ-5's ON CONFLICT (id) idempotency
|
||||
# or the `paper.id.startswith("10.")` recovery gates below. arXiv/W ids pass
|
||||
# through unchanged. The OpenAlex work id is retained as openalex_id / in
|
||||
# paper_identifiers.
|
||||
paper.id = _norm_cited_id(paper_id.strip())
|
||||
|
||||
# DQ-2: OpenAlex often has the paper but no abstract (it cannot redistribute
|
||||
# some publishers' abstracts). Supplement from S2, then Crossref (uses the
|
||||
# canonical paper.id set above), so the paper gets a real (non-zero) abstract
|
||||
# embedding instead of a zero vector.
|
||||
if not (paper.abstract or "").strip():
|
||||
recovered = _recover_abstract(paper)
|
||||
if recovered:
|
||||
paper.abstract = recovered
|
||||
|
||||
# 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 …)
|
||||
# ---------------------------------------------------------------
|
||||
upsert_sql = """
|
||||
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
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
# The id-keyed upsert does not catch a papers.bibkey UNIQUE collision (two
|
||||
# same-author/year papers generate the same key). On such a collision retry
|
||||
# with an a/b/c… suffix instead of failing the whole ingest (audit C-15).
|
||||
base_bibkey = paper.bibkey
|
||||
for attempt in range(27):
|
||||
try:
|
||||
conn.execute(
|
||||
upsert_sql,
|
||||
{
|
||||
"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,
|
||||
},
|
||||
)
|
||||
break
|
||||
except psycopg.errors.UniqueViolation as exc:
|
||||
msg = str(exc).lower()
|
||||
conn.rollback()
|
||||
if "openalex" in msg:
|
||||
# Two different papers.id claim the same OpenAlex work — a real data
|
||||
# conflict, not something to auto-rename. Surface it clearly instead
|
||||
# of the raw psycopg error (companion gap to C-15's bibkey case).
|
||||
raise ValueError(
|
||||
f"openalex_id {paper.openalex_id!r} is already attached to a "
|
||||
f"different paper; ingesting '{paper.id}' would duplicate it — "
|
||||
"resolve the conflicting paper first."
|
||||
) from exc
|
||||
if base_bibkey is None or "bibkey" not in msg or attempt == 26:
|
||||
raise
|
||||
paper.bibkey = f"{base_bibkey}{chr(ord('a') + attempt)}"
|
||||
logger.warning(
|
||||
"bibkey %r already exists; ingesting '%s' as %r instead",
|
||||
base_bibkey,
|
||||
paper.id,
|
||||
paper.bibkey,
|
||||
)
|
||||
|
||||
# Register openalex_id in paper_identifiers (alias table).
|
||||
# Every ingest records the primary openalex_id so the graph JOIN
|
||||
# resolves it even if papers.openalex_id is later updated.
|
||||
if paper.openalex_id:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO paper_identifiers (paper_id, openalex_id)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(paper.id, paper.openalex_id),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 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_sections, chunk_text
|
||||
|
||||
suffix = Path(source_path).suffix.lower()
|
||||
# (section_title | None, chunk_text) pairs. `.tex` is section-aware so
|
||||
# the stored `section` column reflects the real heading (R-C / R-12);
|
||||
# `.txt`/`.pdf` carry no section structure, so the title is None.
|
||||
raw_pairs: list[tuple[str | None, str]] = []
|
||||
|
||||
if suffix == ".tex":
|
||||
raw_pairs = chunk_sections(Path(source_path).read_text())
|
||||
elif suffix == ".txt":
|
||||
text = Path(source_path).read_text(encoding="utf-8", errors="replace")
|
||||
raw_pairs = [(None, c) for c in chunk_text(text)]
|
||||
elif suffix == ".pdf":
|
||||
raw_pairs = [(None, c) for c in chunk_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:
|
||||
# Normalize the GROBID DOI (bare/URL/``doi:`` → bare lower-case)
|
||||
# so PDF-derived edges join the same graph nodes as the
|
||||
# S2/OpenAlex paths; arXiv ids pass through untouched.
|
||||
pdf_citations.append(
|
||||
Citation(citing_id=paper.id, cited_id=_norm_cited_id(cited_id))
|
||||
)
|
||||
else:
|
||||
logger.warning("Unbekannter Dateityp: %s — kein Text-Parsing", source_path)
|
||||
|
||||
# Quality-gate the pairs (keeps each chunk aligned with its section title).
|
||||
settings = get_settings()
|
||||
kept_pairs = [
|
||||
(title, content)
|
||||
for title, content in raw_pairs
|
||||
if is_quality_chunk(content, settings=settings)
|
||||
]
|
||||
|
||||
# Delete existing chunks for this paper
|
||||
conn.execute("DELETE FROM chunks WHERE paper_id = %s", (paper.id,))
|
||||
|
||||
if kept_pairs:
|
||||
# Embed all chunks in one batch
|
||||
chunk_contents = [content for _, content in kept_pairs]
|
||||
chunk_embeddings = embedder.encode_dense(chunk_contents)
|
||||
chunk_rows = [
|
||||
(
|
||||
paper.id,
|
||||
ord_idx,
|
||||
content,
|
||||
chunk_embeddings[ord_idx].tolist(),
|
||||
# .tex: label from the real \section heading — keeps the
|
||||
# controlled bucket when it matches, else stores the cleaned
|
||||
# title (R-F); .txt/.pdf (title=None) classify by content.
|
||||
# NB: `section` semantics now differ by source (write-only col).
|
||||
section_label(title, content),
|
||||
)
|
||||
for ord_idx, (title, content) in enumerate(kept_pairs)
|
||||
]
|
||||
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 (fallback chain: OpenAlex → S2 → Crossref)
|
||||
# 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
|
||||
]
|
||||
# DQ-1: OpenAlex indexes many arXiv preprints / theses WITHOUT a
|
||||
# parsed reference list (referenced_works == []). Fall back to the
|
||||
# Semantic Scholar references, which often has them.
|
||||
if not api_citations:
|
||||
api_citations = _s2_reference_supplement(paper)
|
||||
else:
|
||||
api_citations = _s2_reference_supplement(paper)
|
||||
# R-B: if OpenAlex and S2 both came back empty, try Crossref's
|
||||
# publisher-deposited references (DOI papers only) as a third leg.
|
||||
if not api_citations:
|
||||
api_citations = _crossref_reference_supplement(paper)
|
||||
|
||||
# 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)
|
||||
""",
|
||||
[
|
||||
# paper.id, not f.paper_id: the extractor derives its
|
||||
# paper_id from the PDF filename stem, which need not match
|
||||
# papers.id and would violate the FK (audit C-10).
|
||||
(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)
|
||||
""",
|
||||
[
|
||||
# paper.id, not fig.paper_id (filename stem) — see C-10 above.
|
||||
(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,
|
||||
)
|
||||
358
codex/mcp_server.py
Normal file
358
codex/mcp_server.py
Normal file
@@ -0,0 +1,358 @@
|
||||
"""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
|
||||
import secrets
|
||||
|
||||
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:
|
||||
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 the actual cited bibkeys from the page's inline [BibKey #loc]
|
||||
# citations (audit C-3: this previously returned the concept slug itself,
|
||||
# not the cited sources).
|
||||
from codex.wiki import _parse_claims
|
||||
|
||||
sources = sorted({claim.bibkey for claim in _parse_claims(markdown)})
|
||||
|
||||
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()
|
||||
token = settings.mcp_auth_token.get_secret_value() if settings.mcp_auth_token else ""
|
||||
if not token:
|
||||
raise RuntimeError(
|
||||
"HTTP transport requires MCP_AUTH_TOKEN to be set. "
|
||||
"Set the environment variable or add it to .env."
|
||||
)
|
||||
return 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", "")
|
||||
# Constant-time comparison to avoid a token-length/equality timing
|
||||
# side-channel (audit S-2).
|
||||
if not secrets.compare_digest(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
|
||||
id: int | 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
|
||||
138
codex/parsing/grobid.py
Normal file
138
codex/parsing/grobid.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""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,
|
||||
timeout: float = 60.0,
|
||||
) -> 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``.
|
||||
timeout:
|
||||
HTTP client timeout in seconds. Large PDFs (e.g. theses) can exceed the
|
||||
60s default, especially against a slower/emulated GROBID; raise it then.
|
||||
|
||||
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=timeout) 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
|
||||
319
codex/parsing/mathpix.py
Normal file
319
codex/parsing/mathpix.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""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()
|
||||
|
||||
settings_id = settings.mathpix_app_id.get_secret_value() if settings.mathpix_app_id else ""
|
||||
settings_key = settings.mathpix_app_key.get_secret_value() if settings.mathpix_app_key else ""
|
||||
app_id = mathpix_app_id or settings_id
|
||||
app_key = mathpix_app_key or settings_key
|
||||
|
||||
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()
|
||||
223
codex/parsing/tex.py
Normal file
223
codex/parsing/tex.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""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,
|
||||
)
|
||||
|
||||
# \input{file} / \include{file} — arXiv papers routinely split the body across
|
||||
# files, so these must be inlined before parsing (R-C multi-file flattening).
|
||||
_INPUT_RE = re.compile(r"\\(?:input|include)\s*\{([^}]+)\}")
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
def _norm_texkey(name: str) -> str:
|
||||
"""Normalize a tex file name / ``\\input`` argument to a comparable key.
|
||||
|
||||
Strips a leading ``./``, any directory-irrelevant whitespace, and a trailing
|
||||
``.tex`` so ``\\input{sections/intro}`` matches the archive member
|
||||
``sections/intro.tex``.
|
||||
"""
|
||||
n = name.strip()
|
||||
if n.startswith("./"): # a single leading "./" only — don't eat "../" or ".hidden"
|
||||
n = n[2:]
|
||||
return n[:-4] if n.endswith(".tex") else n
|
||||
|
||||
|
||||
def flatten_inputs(primary: str, files: dict[str, str]) -> str:
|
||||
"""Recursively inline ``\\input``/``\\include`` directives (R-C).
|
||||
|
||||
*files* maps each source file's archive name to its contents (keys may carry
|
||||
a ``.tex`` suffix or a ``./`` prefix — they are normalized internally). arXiv
|
||||
multi-file projects leave the ``\\documentclass`` file as little more than a
|
||||
skeleton of ``\\input`` lines, so the body must be assembled before parsing.
|
||||
Unresolved references are dropped; recursion is capped at depth 10 to guard
|
||||
against include cycles.
|
||||
"""
|
||||
norm = {_norm_texkey(k): v for k, v in files.items()}
|
||||
|
||||
def _expand(text: str, depth: int) -> str:
|
||||
if depth > 10:
|
||||
# Cap reached (likely an \input cycle): strip any still-unresolved
|
||||
# directives rather than leak a literal "\input{…}" into the parsed text.
|
||||
return _INPUT_RE.sub("", text)
|
||||
|
||||
def _repl(match: re.Match[str]) -> str:
|
||||
content = norm.get(_norm_texkey(match.group(1)))
|
||||
return _expand(content, depth + 1) if content is not None else ""
|
||||
|
||||
return _INPUT_RE.sub(_repl, text)
|
||||
|
||||
return _expand(primary, 0)
|
||||
|
||||
|
||||
def chunk_sections(latex: str, size: int = 512, overlap: int = 64) -> list[tuple[str | None, str]]:
|
||||
"""Chunk LaTeX *within* each section, pairing every chunk with its title.
|
||||
|
||||
Unlike :func:`latex_to_text` (which discards titles and flattens the whole
|
||||
document into one block before word-window chunking), this keeps chunks from
|
||||
spanning section boundaries and remembers which ``\\section`` each came from.
|
||||
The caller can then label the stored ``section`` column from the real heading
|
||||
instead of mostly ``body`` (R-C / audit R-12). Falls back to title-less
|
||||
chunks of the whole cleaned document when there are no section commands.
|
||||
"""
|
||||
sections = extract_sections(latex)
|
||||
if not sections:
|
||||
return [(None, chunk) for chunk in chunk_text(_clean_latex(latex), size, overlap)]
|
||||
pairs: list[tuple[str | None, str]] = []
|
||||
for title, body in sections:
|
||||
for chunk in chunk_text(body, size, overlap):
|
||||
pairs.append((title or None, chunk))
|
||||
return pairs
|
||||
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)
|
||||
248
codex/quality.py
Normal file
248
codex/quality.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""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"
|
||||
|
||||
|
||||
def _clean_title(title: str) -> str:
|
||||
"""Normalize a LaTeX ``\\section`` title for use as a section label.
|
||||
|
||||
Strips LaTeX word-commands (``\\emph`` …), accent/escape macros (``\\"o`` →
|
||||
``o``, ``\\&``), ``~`` ties and ``{}$``, drops leading section numbering
|
||||
(``3.2 ``), collapses whitespace, lower-cases, and truncates to 60 chars at a
|
||||
word boundary so the stored ``section`` value is a clean, comparable string.
|
||||
"""
|
||||
t = title.replace("~", " ") # LaTeX non-breaking tie → space
|
||||
t = re.sub(r"\\[a-zA-Z]+\*?", " ", t) # word-commands: \emph, \mathcal …
|
||||
t = re.sub(r"\\[^a-zA-Z]", "", t) # accent/escape macros: \"o → o, \', \`, \&
|
||||
t = re.sub(r"[{}$]", "", t) # braces / math delimiters
|
||||
t = re.sub(r"^\s*\d+(?:\.\d+)*\.?\s*", "", t) # leading numbering "3.2 "
|
||||
t = " ".join(t.split()).strip().lower()
|
||||
return t[:60].rsplit(" ", 1)[0] if len(t) > 60 else t
|
||||
|
||||
|
||||
# A heading collapses to a controlled bucket only when it IS one of these canonical
|
||||
# section words — NOT when it merely starts with one. Prefix-matching via
|
||||
# classify_section mislabels real sections ("Abstract Nonsense and Categories" →
|
||||
# abstract, "References Architecture" → bibliography); an exact map avoids that while
|
||||
# keeping cross-source consistency for the bare headings.
|
||||
_SECTION_TITLE_BUCKETS = {
|
||||
"abstract": "abstract",
|
||||
"zusammenfassung": "abstract",
|
||||
"introduction": "intro",
|
||||
"einleitung": "intro",
|
||||
"proof": "proof",
|
||||
"beweis": "proof",
|
||||
"references": "bibliography",
|
||||
"bibliography": "bibliography",
|
||||
"bibliographie": "bibliography",
|
||||
"literatur": "bibliography",
|
||||
}
|
||||
|
||||
|
||||
def section_label(title: str | None, content: str) -> str:
|
||||
"""Label a chunk's section, preferring the real ``\\section`` heading (R-F).
|
||||
|
||||
For section-aware ``.tex`` ingest *title* is the real heading: a bare canonical
|
||||
heading ("Introduction", "References", "Proof") maps to its controlled bucket
|
||||
(cross-source consistency); any other heading is stored as its cleaned real
|
||||
title (e.g. ``"preliminaries"``, ``"introduction to operator algebras"``) — far
|
||||
more signal than collapsing to ``body`` AND without the prefix-match mislabels
|
||||
that ``classify_section`` would produce on descriptive titles.
|
||||
|
||||
When *title* is None / blank (the ``.pdf`` / ``.txt`` / ``run_quality_pass``
|
||||
paths) this falls back to content-based :func:`classify_section` — unchanged
|
||||
behaviour. Safe to store free-text titles because the ``section`` column is
|
||||
write-only (no consumer filters on the controlled vocabulary).
|
||||
"""
|
||||
if not title or not title.strip():
|
||||
return classify_section(content)
|
||||
cleaned = _clean_title(title)
|
||||
if not cleaned:
|
||||
return classify_section(content)
|
||||
return _SECTION_TITLE_BUCKETS.get(cleaned, cleaned)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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}
|
||||
206
codex/sources/arxiv.py
Normal file
206
codex/sources/arxiv.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""arXiv API client.
|
||||
|
||||
Provides:
|
||||
- fetch_metadata: resolve an arXiv ID to a Paper via the Atom export API.
|
||||
- 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 gzip
|
||||
import io
|
||||
import logging
|
||||
import tarfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import httpx
|
||||
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
|
||||
|
||||
from codex.models import Paper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BASE = "https://arxiv.org"
|
||||
_EXPORT = "https://export.arxiv.org"
|
||||
_ATOM = "{http://www.w3.org/2005/Atom}"
|
||||
|
||||
|
||||
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(4),
|
||||
wait=wait_exponential(min=1, max=20),
|
||||
before_sleep=lambda rs: logger.warning("arXiv retry %d", rs.attempt_number),
|
||||
)
|
||||
def _query(arxiv_id: str) -> httpx.Response:
|
||||
response = httpx.get(
|
||||
f"{_EXPORT}/api/query",
|
||||
params={"id_list": arxiv_id},
|
||||
timeout=30,
|
||||
follow_redirects=True,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
|
||||
def fetch_metadata(arxiv_id: str) -> Paper | None:
|
||||
"""Resolve an arXiv ID to a Paper via the Atom export API.
|
||||
|
||||
Authoritative metadata source for arXiv preprints — used as an ingest
|
||||
fallback when OpenAlex 404s on an arXiv id (DQ-2). The arXiv id (bare,
|
||||
e.g. ``"1911.00966"`` or legacy ``"math/0603097"``) becomes ``Paper.id``;
|
||||
``openalex_id`` and ``bibkey`` are left for the caller to populate.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Paper | None
|
||||
Populated Paper (title, authors, year, abstract), or None if arXiv
|
||||
has no entry for the id.
|
||||
"""
|
||||
bare = arxiv_id[len("arxiv:") :] if arxiv_id.lower().startswith("arxiv:") else arxiv_id
|
||||
try:
|
||||
response = _query(bare)
|
||||
except httpx.HTTPError:
|
||||
logger.warning("arXiv metadata fetch failed for %s", bare, exc_info=True)
|
||||
return None
|
||||
try:
|
||||
root = ET.fromstring(response.text)
|
||||
except ET.ParseError:
|
||||
return None
|
||||
entry = root.find(f"{_ATOM}entry")
|
||||
if entry is None:
|
||||
return None
|
||||
# An id-not-found query still returns a feed but with no <entry>.
|
||||
title = (entry.findtext(f"{_ATOM}title") or "").strip()
|
||||
if not title:
|
||||
return None
|
||||
summary = (entry.findtext(f"{_ATOM}summary") or "").strip()
|
||||
published = entry.findtext(f"{_ATOM}published") or ""
|
||||
year = int(published[:4]) if published[:4].isdigit() else None
|
||||
authors = [
|
||||
name.strip()
|
||||
for a in entry.findall(f"{_ATOM}author")
|
||||
if (name := a.findtext(f"{_ATOM}name")) and name.strip()
|
||||
]
|
||||
return Paper(
|
||||
id=bare,
|
||||
title=" ".join(title.split()),
|
||||
authors=authors,
|
||||
year=year,
|
||||
abstract=" ".join(summary.split()) or None,
|
||||
)
|
||||
|
||||
|
||||
def _has_documentclass(content: str) -> bool:
|
||||
"""True if *content* has an UN-commented ``\\documentclass`` line.
|
||||
|
||||
A plain ``"\\documentclass" in content`` substring test also matches a
|
||||
commented-out ``%\\documentclass`` (common in arXiv preambles); requiring the
|
||||
command to start its line avoids selecting the wrong primary file.
|
||||
"""
|
||||
return any(line.lstrip().startswith("\\documentclass") for line in content.splitlines())
|
||||
|
||||
|
||||
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 inlines its ``\\input``/
|
||||
``\\include`` directives from the other archive members so multi-file projects
|
||||
return the full document body, not just the primary file's include skeleton.
|
||||
|
||||
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}"
|
||||
response = httpx.get(url, timeout=60, follow_redirects=True)
|
||||
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()
|
||||
|
||||
from codex.parsing.tex import flatten_inputs
|
||||
|
||||
raw = response.content
|
||||
# Image/font/binary members are never \input'd as text; skip them so the source
|
||||
# map stays text-only (decode-with-replace would otherwise store garbled bytes).
|
||||
skip_ext = (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".pdf", ".eps", ".ps", ".ttf", ".otf")
|
||||
try:
|
||||
with tarfile.open(fileobj=io.BytesIO(raw), mode="r:gz") as tf:
|
||||
files: dict[str, str] = {}
|
||||
primary_name: str | None = None
|
||||
for member in tf.getmembers():
|
||||
if not member.isfile() or member.name.lower().endswith(skip_ext):
|
||||
continue
|
||||
f = tf.extractfile(member)
|
||||
if f is None:
|
||||
continue
|
||||
# Keep ALL text members resolvable so \input of a non-.tex include
|
||||
# (e.g. a .def or extension-less body file) is not silently dropped (R-C).
|
||||
files[member.name] = f.read().decode("utf-8", errors="replace")
|
||||
# Primary doc = a .tex with an UN-commented \documentclass.
|
||||
if (
|
||||
primary_name is None
|
||||
and member.name.endswith(".tex")
|
||||
and _has_documentclass(files[member.name])
|
||||
):
|
||||
primary_name = member.name
|
||||
|
||||
tex_files = {k: v for k, v in files.items() if k.endswith(".tex")}
|
||||
if not tex_files:
|
||||
logger.debug("No .tex files found in arXiv source for %s", arxiv_id)
|
||||
return None
|
||||
|
||||
# No \documentclass anywhere → fall back to the largest .tex.
|
||||
if primary_name is None:
|
||||
primary_name = max(tex_files, key=lambda k: len(tex_files[k]))
|
||||
|
||||
# Inline \input/\include so multi-file projects yield the full body,
|
||||
# not just the primary file's skeleton of include directives (R-C).
|
||||
return flatten_inputs(files[primary_name], files)
|
||||
except tarfile.TarError:
|
||||
# Old arXiv submissions are served as a single bare gzipped .tex with no
|
||||
# tar wrapper (e.g. legacy math/* papers) — gunzip the body directly.
|
||||
try:
|
||||
single = gzip.decompress(raw).decode("utf-8", errors="replace")
|
||||
except (OSError, EOFError) as exc:
|
||||
logger.warning("Failed to read arXiv source for %s: %s", arxiv_id, exc)
|
||||
return None
|
||||
if "\\" in single[:5000]: # looks like LaTeX (has backslash commands)
|
||||
return single
|
||||
logger.debug("arXiv source for %s is not LaTeX", arxiv_id)
|
||||
return None
|
||||
|
||||
|
||||
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"
|
||||
126
codex/sources/crossref.py
Normal file
126
codex/sources/crossref.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Crossref API client.
|
||||
|
||||
Provides:
|
||||
- fetch_abstract: retrieve a work's abstract (JATS-stripped) by DOI.
|
||||
- fetch_references: retrieve a work's reference list (DOI edges) by DOI.
|
||||
|
||||
Crossref is a *third* metadata source after OpenAlex and Semantic Scholar
|
||||
(DQ-2 abstracts / roadmap R-B references). Requests use the Polite Pool (mailto
|
||||
query parameter) and are retried on 429/5xx with exponential back-off via tenacity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BASE = "https://api.crossref.org"
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _polite_params() -> dict[str, str]:
|
||||
# Reuse the OpenAlex mailto for Crossref's Polite Pool (same address).
|
||||
mailto = get_settings().openalex_mailto
|
||||
return {"mailto": mailto} if mailto else {}
|
||||
|
||||
|
||||
@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(
|
||||
"Crossref 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, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
|
||||
def _strip_jats(abstract: str | None) -> str | None:
|
||||
"""Strip JATS/XML tags from a Crossref abstract and collapse whitespace."""
|
||||
if not abstract:
|
||||
return None
|
||||
text = _TAG_RE.sub("", abstract)
|
||||
text = " ".join(text.split()).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def fetch_abstract(doi: str) -> str | None:
|
||||
"""Fetch a work's abstract from Crossref by DOI.
|
||||
|
||||
Crossref stores abstracts as JATS-tagged markup (``<jats:p>…</jats:p>``);
|
||||
tags are stripped before returning. Many works (notably book chapters)
|
||||
have no abstract deposited — returns None in that case and on 404.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
doi:
|
||||
A bare DOI (``"10.1007/s00454-019-00132-8"``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
str | None
|
||||
The plain-text abstract, or None when absent.
|
||||
"""
|
||||
url = f"{_BASE}/works/{doi}"
|
||||
try:
|
||||
response = _get(url)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
message: dict[str, Any] = response.json().get("message", {})
|
||||
return _strip_jats(message.get("abstract"))
|
||||
|
||||
|
||||
def fetch_references(doi: str) -> list[Citation]:
|
||||
"""Fetch a work's reference list from Crossref by DOI (roadmap R-B).
|
||||
|
||||
Crossref carries publisher-deposited reference lists in ``message.reference``.
|
||||
Each entry that cites a DOI-registered work exposes a bare ``DOI`` field;
|
||||
only those become citation-graph edges (book / older references carry just
|
||||
``unstructured`` text with no resolvable id and are skipped). ``citing_id``
|
||||
is the queried *doi* — the caller rewrites it to the canonical ``papers.id``
|
||||
and normalises the cited DOIs (mirrors the Semantic Scholar reference path).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
doi:
|
||||
A bare DOI (``"10.1007/s00454-019-00132-8"``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[Citation]
|
||||
One Citation per reference that carries a DOI. Empty on 404 or when no
|
||||
references are deposited for the work.
|
||||
"""
|
||||
url = f"{_BASE}/works/{doi}"
|
||||
try:
|
||||
response = _get(url)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code == 404:
|
||||
return []
|
||||
raise
|
||||
message: dict[str, Any] = response.json().get("message", {})
|
||||
references: list[dict[str, Any]] = message.get("reference") or []
|
||||
return [Citation(citing_id=doi, cited_id=ref["DOI"]) for ref in references if ref.get("DOI")]
|
||||
200
codex/sources/openalex.py
Normal file
200
codex/sources/openalex.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""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 _normalize_doi(doi: str) -> str:
|
||||
"""Normalize an OpenAlex DOI to the canonical bare, lower-cased form.
|
||||
|
||||
OpenAlex returns the ``doi`` field as a full URL
|
||||
(``https://doi.org/10.1007/s00454-019-00132-8``), but the canonical
|
||||
``papers.id`` produced by the M-1 migration is the BARE, lower-cased DOI
|
||||
(``10.1007/s00454-019-00132-8``). Stripping the ``https://doi.org/`` /
|
||||
``doi:`` prefix and lower-casing (DOIs are case-insensitive by spec) makes a
|
||||
re-ingested DOI paper match its stored row, so the ingest
|
||||
``INSERT … ON CONFLICT (id)`` updates in place instead of attempting a fresh
|
||||
INSERT that trips the separate ``papers_openalex_id_key`` unique constraint
|
||||
(DQ-5). Mirrors the cited-id convention in ``ingest._norm_cited_id``.
|
||||
"""
|
||||
s = doi.strip().lower()
|
||||
for prefix in ("https://doi.org/", "http://doi.org/", "doi:"):
|
||||
if s.startswith(prefix):
|
||||
return s[len(prefix) :]
|
||||
return s
|
||||
|
||||
|
||||
# arXiv works are registered under a DOI of the form 10.48550/arXiv.<id>.
|
||||
_ARXIV_DOI_PREFIX = "10.48550/arxiv."
|
||||
|
||||
|
||||
def _canonical_id(doi: str) -> str:
|
||||
"""Return the canonical ``papers.id`` for an OpenAlex ``doi`` value.
|
||||
|
||||
Builds on :func:`_normalize_doi` (bare, lower-cased DOI), then strips the
|
||||
arXiv DOI prefix ``10.48550/arxiv.`` so an arXiv work's id is the BARE arXiv
|
||||
id (``2305.10988`` / ``math/0603097``) — the form the corpus is keyed on —
|
||||
not the arXiv DOI. Without this, re-ingesting an arXiv paper by its bare id
|
||||
misses ``INSERT … ON CONFLICT (id)`` and trips ``papers_openalex_id_key``.
|
||||
This is the arXiv half of DQ-5 (the DOI half was fixed there; this was
|
||||
explicitly deferred and surfaced again driving the R-C .tex re-ingest).
|
||||
"""
|
||||
normalized = _normalize_doi(doi)
|
||||
if normalized.startswith(_ARXIV_DOI_PREFIX):
|
||||
return normalized[len(_ARXIV_DOI_PREFIX) :]
|
||||
return normalized
|
||||
|
||||
|
||||
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"))
|
||||
doi = data.get("doi")
|
||||
return Paper(
|
||||
id=_canonical_id(doi) if doi else (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
|
||||
]
|
||||
157
codex/sources/semanticscholar.py
Normal file
157
codex/sources/semanticscholar.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""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
|
||||
from urllib.parse import quote
|
||||
|
||||
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.
|
||||
"""
|
||||
# quote the id so a legacy-arXiv "/" (e.g. arXiv:math/0603097) doesn't break
|
||||
# the URL path; keep ":" for the arXiv:/DOI: scheme prefix (audit R-5).
|
||||
url = f"{_BASE_GRAPH}/paper/{quote(paper_id, safe=':')}/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()
|
||||
# S2 returns HTTP 200 with ``{"data": null}`` for a known paper that has no
|
||||
# parsed reference list (e.g. some theses). ``.get("data", [])`` would yield
|
||||
# the explicit ``None`` (the default only applies when the key is absent),
|
||||
# so iterate over a guaranteed list instead.
|
||||
raw_refs: list[dict[str, Any]] = data.get("data") or []
|
||||
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_abstract(paper_id: str) -> str | None:
|
||||
"""Fetch just the abstract for a paper from Semantic Scholar.
|
||||
|
||||
Used as an ingest abstract supplement (DQ-2) when OpenAlex has the paper
|
||||
but no abstract. ``paper_id`` should be namespaced (``arXiv:…`` / ``DOI:…``).
|
||||
Returns None on 404 or when S2 has no abstract.
|
||||
"""
|
||||
url = f"{_BASE_GRAPH}/paper/{paper_id}"
|
||||
try:
|
||||
response = _get(url, params={"fields": "abstract"})
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
abstract = response.json().get("abstract")
|
||||
return abstract or None
|
||||
|
||||
|
||||
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/{quote(paper_id, safe=':')}"
|
||||
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")]
|
||||
874
codex/synthesis.py
Normal file
874
codex/synthesis.py
Normal file
@@ -0,0 +1,874 @@
|
||||
"""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)
|
||||
|
||||
|
||||
_KIND_PREFIX: dict[LeadKind, str] = {
|
||||
"connection": "C",
|
||||
"gap": "G",
|
||||
"improvement": "I",
|
||||
"conjecture": "X",
|
||||
}
|
||||
|
||||
|
||||
def _make_lead_id(seq: int, kind: LeadKind) -> str:
|
||||
"""Format a per-kind sequential lead id like ``L-C-0001``.
|
||||
|
||||
Each synthesis stage numbers its leads from 1, so without a kind prefix a
|
||||
connection, a gap, and an improvement all produce ``L-0001`` and clobber
|
||||
one another in the shared ``grounded/`` directory (audit C-11). The kind
|
||||
prefix namespaces the ids so they stay distinct.
|
||||
"""
|
||||
return f"L-{_KIND_PREFIX[kind]}-{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=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 **explicitly-requested** topic in
|
||||
``topics``, retrieve chunks and check bibkey coverage. A topic covered by
|
||||
< ``synthesis_gap_min_coverage`` bibkeys is a gap candidate and the LLM is
|
||||
asked to articulate it. Defaulting to paper titles flagged almost every
|
||||
paper as a gap (a title retrieves mostly its own single bibkey), so the
|
||||
coverage map is opt-in now; pass ``topics`` / CLI ``--topic`` (audit R-8).
|
||||
* **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] = []
|
||||
|
||||
# Coverage-map probes are the explicit topics only — no default-to-titles,
|
||||
# which turned every paper into a spurious gap (audit R-8).
|
||||
probe_topics: list[str] = list(topics or [])
|
||||
|
||||
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, "gap"),
|
||||
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, "gap"),
|
||||
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, "conjecture"),
|
||||
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 rebuilds ``leads_dir/INDEX.md`` from the on-disk files on
|
||||
every run (see :func:`_update_index`): it reflects whatever lead files
|
||||
currently exist under ``grounded/`` and ``conjectures/``, listed as two
|
||||
visibly separated sections. It is not append-only — a deleted lead file
|
||||
drops out of the index on the next run.
|
||||
|
||||
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)
|
||||
883
codex/wiki.py
Normal file
883
codex/wiki.py
Normal file
@@ -0,0 +1,883 @@
|
||||
"""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 look like "[12]" refs or "Surname, I." authors).
|
||||
# The author alternative excludes common math-structure words ("Theorem A.",
|
||||
# "Lemma B.") so theorem-dense chunks aren't mistaken for a reference list (R-2).
|
||||
ref_pattern = re.compile(
|
||||
r"^\s*(\[\d+\]|(?!(?:Theorem|Lemma|Proposition|Corollary|Definition|Proof|"
|
||||
r"Section|Figure|Fig|Table|Equation|Eq|Remark|Example|Chapter)\b)"
|
||||
r"[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",
|
||||
}
|
||||
)
|
||||
|
||||
# Split on sentence-ending punctuation only when followed by whitespace/end, so a
|
||||
# decimal like "1.5" does not create a spurious sentence boundary (audit R-1).
|
||||
_SENTENCE_SPLIT_RE = re.compile(r"[.!?]+(?=\s|$)")
|
||||
|
||||
# Punctuation stripped from token edges before grounding comparison — sentence/
|
||||
# clause marks and quotes, NOT math delimiters like ()=+ (audit R-1).
|
||||
_EDGE_PUNCT = ".,;:!?\"'"
|
||||
|
||||
|
||||
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 content tokens, edge-punctuation-stripped, stopwords removed.
|
||||
|
||||
Stripping leading/trailing sentence punctuation makes grounding robust to
|
||||
surface variation ("volume," vs "volume") so a faithful paraphrase is not
|
||||
marked ungrounded over a comma (audit R-1). Math delimiters (``()=+``) are
|
||||
preserved. Claim and chunk tokens are normalised identically, so matching
|
||||
stays consistent.
|
||||
"""
|
||||
tokens = (w.strip(_EDGE_PUNCT) for w in text.lower().split())
|
||||
return [w for w in tokens if w and 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, space-padded so a 5-gram match is bounded
|
||||
# by whole tokens, not a substring bleeding across words (audit C-6).
|
||||
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]) + " " # padded: token-boundary match
|
||||
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"`[^`]+`")
|
||||
|
||||
# LaTeX math spans — protected from cross-ref injection so a concept name inside
|
||||
# a formula is not rewritten to a [[slug]] link, corrupting the LaTeX (audit R-9).
|
||||
_MATH_SPAN_RE = re.compile(
|
||||
r"\$\$.*?\$\$|\$[^$\n]*?\$|\\\(.*?\\\)|\\\[.*?\\\]",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
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, inline code spans, or LaTeX math spans are replaced.
|
||||
Inline-code (`` `…` ``) and math (``$…$``, ``$$…$$``, ``\\(…\\)``,
|
||||
``\\[…\\]``) spans are temporarily protected by null-byte placeholders and
|
||||
restored after injection.
|
||||
"""
|
||||
# Step 1: protect inline-code and math 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)
|
||||
markdown = _MATH_SPAN_RE.sub(_protect, markdown) # don't rewrite names inside LaTeX (R-9)
|
||||
|
||||
# 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)
|
||||
|
||||
# Graceful: formula-embedding hook (F-09) — currently a no-op (audit C-12).
|
||||
_try_embed_formulas(concept, chunks)
|
||||
|
||||
compiled_at = datetime.now(UTC)
|
||||
# Mark ungrounded claims on the raw body FIRST, then inject cross-refs: a
|
||||
# concept name inside a claim must not stop the ⚠ regex from matching (C-13).
|
||||
markdown = _render_page_markdown(concept, raw_output, claims, compiled_at)
|
||||
markdown = _inject_cross_refs(markdown, all_concepts or [], concept.slug)
|
||||
|
||||
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:
|
||||
"""Reserved hook for embedding F-09 formula chunks into a page.
|
||||
|
||||
Currently a no-op — formula embedding is not implemented yet. Kept as a stable
|
||||
seam for compile_concept's call site. The previous body issued a per-concept
|
||||
DB round-trip (information_schema lookup) that did nothing (audit C-12).
|
||||
"""
|
||||
return
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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.
|
||||
# A page with NO citations at all (total_claims == 0) carries zero grounding
|
||||
# evidence — this also covers the LLM-outage case where compile_concept
|
||||
# returns an empty page — and must NOT be published (audit C-4). Previously
|
||||
# the `total_claims > 0` guard let such pages fall through to wiki/.
|
||||
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 or grounding_rate < settings.wiki_min_grounding_rate:
|
||||
# Quarantine and do NOT update the compile-state, so a transient failure
|
||||
# (e.g. LLM outage) is retried on the next run instead of being frozen as
|
||||
# "compiled". An empty body is not worth a draft file — just record it.
|
||||
if page.markdown.strip():
|
||||
draft_dir = wiki_dir / "draft"
|
||||
draft_dir.mkdir(parents=True, exist_ok=True)
|
||||
(draft_dir / f"{concept.slug}.md").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)
|
||||
59
docs/SESSION-2026-06-17-open-items.md
Normal file
59
docs/SESSION-2026-06-17-open-items.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Open items — session 2026-06-17 (handoff notice)
|
||||
|
||||
Everything still open after this session. Done items are in
|
||||
`docs/audit/DATA-QUALITY-2026-06-15.md` (DQ-1..DQ-5 + roadmap R-A,R-B,R-C,R-D,R-F all
|
||||
DONE; R-E deferred). This file is only the **loose ends**.
|
||||
|
||||
## 1. ✅ RESOLVED — Jetson is online (the tunnel had dropped, not the host)
|
||||
- What looked like the Jetson going offline was the **SSH tunnel** breaking, not the
|
||||
host. The Jetson (`jetson.local`) is online. Re-open the tunnel with keepalive:
|
||||
`ssh -o ServerAliveInterval=15 -f -N -L 5433:localhost:5432 alfred@jetson.local`.
|
||||
Better still: run long ops in tmux-on-Jetson (item #4) to avoid the tunnel entirely.
|
||||
|
||||
## 2. ✅ DONE — R-F accent re-clean
|
||||
- R-F is functionally DONE (section signal 34/329 → **314/329 = 95 %**). The remaining
|
||||
cosmetic accent/tie artifacts in live `chunks.section` labels were re-cleaned in place
|
||||
once the tunnel was back: applied `codex.quality._clean_title` to every label
|
||||
containing `\` or `~` — **8 labels fixed**, no re-embed. Verified **0 artifacts**
|
||||
remain. R-F fully complete.
|
||||
|
||||
## 3. ✅ READY TO MERGE — all branches integrated + validated
|
||||
All five feature branches are merged (**zero conflicts**) into **`integration/roadmap`**
|
||||
(off `audit/loop-1`) and validated green: **391 tests pass, ruff + ruff-format + mypy
|
||||
clean, tree clean**. This is the single merge candidate for `main`.
|
||||
|
||||
Contents (DQ-1..DQ-5 + R-A..R-F + infra docs):
|
||||
| Source branch | Contents |
|
||||
|---|---|
|
||||
| `audit/loop-1` (base) | DQ-1..DQ-5, **R-A** (GROBID backfill + native arm64 GROBID), this notice |
|
||||
| `feat/section-labels` (incl. `feat/tex-ingest`) | **R-C** (.tex ingest) + DQ-5 arXiv-id fix + gzip fallback + **R-F** (section labels) |
|
||||
| `feat/crossref-references` | **R-B** (Crossref refs) + GROBID-on-demand tweak |
|
||||
| `feat/graph-coverage-warning` | **R-D** (citing-coverage warning) |
|
||||
| `chore/infra-ssh-stability` | infra TODO (#4) |
|
||||
|
||||
**To land on `main`** (user-triggered):
|
||||
```
|
||||
git checkout main && git merge --no-ff integration/roadmap
|
||||
```
|
||||
…or open a PR `integration/roadmap` → `main`. The individual feature branches are
|
||||
preserved unchanged for per-feature review if preferred.
|
||||
|
||||
**Cleanup — branches prunable AFTER the merge lands** (not deleted here; destructive):
|
||||
- Already in `main`: `fix/cli-readme-drift`, `fix/d04-d05-ingest-fixes`.
|
||||
- Folded into `integration/roadmap`: the 5 feature branches + `audit/loop-1`.
|
||||
- Pre-session / stale (verify before pruning): `feat/F-15-literature-graph`,
|
||||
`feat/F-16-chunk-quality`, `fix/audit-wave-1`/`-2`/`-3`, `scratch/spike-embed`.
|
||||
- Working tree is clean — only gitignored `.venv`/caches/`.env`; no stray tracked files.
|
||||
|
||||
## 4. Infra TODO — tmux-on-Jetson for stable long ops (documented, not implemented)
|
||||
- `docs/infra/jetson-ssh-stability.md` (branch `chore/infra-ssh-stability`). The
|
||||
Mac↔Jetson tunnel dropped repeatedly on multi-minute writes all session. Fix: run long
|
||||
ops **on the Jetson in `tmux`** (DB is localhost there → no tunnel; survives SSH drops);
|
||||
`autossh` for interactive tunnels. Open.
|
||||
|
||||
## 5. Known limitations (documented, NOT action items)
|
||||
- `math/0306167` (old AMS-TeX, no `\section{}`) stays `section=body` — inherent.
|
||||
- The 2 edited-volume books yielded 0 GROBID-parsed DOI/arXiv refs in the R-A sweep —
|
||||
inherent (per-chapter refs, no clean end-bibliography).
|
||||
- **DQ-6** (upstream PDF→`.txt` extraction fidelity, done outside codex-py) — flagged
|
||||
earlier, largely **mooted** by R-A/R-C ingesting from PDF/`.tex` directly.
|
||||
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.
|
||||
204
docs/adr/ADR-F15-literature-graph.md
Normal file
204
docs/adr/ADR-F15-literature-graph.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# ADR-F15 — Literature Graph / Citation PageRank
|
||||
|
||||
**Status:** IMPL — GO after live-corpus spike 2026-06-15. See **Audit Update
|
||||
(2026-06-15)** at the end: the ID-format mismatch is now resolved in code
|
||||
(audit C-1, C-7) and the Spike Result numbers below predate that resolution.
|
||||
**Owners:** F-15 Worker (Sonnet)
|
||||
**Last updated:** 2026-06-15 (audit addendum)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
F-15 adds a citation graph layer on top of the corpus DB. The goals are:
|
||||
|
||||
1. **Hub detection** — identify which papers are most central in the citation
|
||||
network (PageRank) so that `codex search paper --cite-boost` can
|
||||
up-weight them in dense retrieval.
|
||||
2. **Bibliographic coupling / co-citation** — surface related papers that
|
||||
share many references or are frequently cited together.
|
||||
3. **Dangling-citation discovery** — enumerate works cited by the corpus but
|
||||
not yet ingested, serving as an acquisition queue.
|
||||
|
||||
Primary risk: **degenerate PageRank** on a small, sparse corpus producing
|
||||
scores so flat that the boost has no practical effect or — worse — distorts
|
||||
results by promoting low-quality dangling works.
|
||||
|
||||
---
|
||||
|
||||
## Spike Result (2026-06-15)
|
||||
|
||||
Run on the live Jetson corpus: **29 ingested papers, 590 citation edges,
|
||||
495 total graph nodes, 478 dangling nodes**.
|
||||
|
||||
### Hub-Paper Ranking
|
||||
|
||||
| Rank | OpenAlex ID | Year | Authors | Title | PR |
|
||||
|------|-------------|------|---------|-------|----|
|
||||
| 1 | W1974956622 | 1993 | Pinkall, Polthier | Computing Discrete Minimal Surfaces and Their Conjugates | 0.002282 |
|
||||
| 2 | W1964119857 | 1962 | Ford, Fulkerson | Flows in Networks | 0.002228 |
|
||||
| 3 | W312477465 | 1968 | Trudinger | Remarks on conformal deformation of Riemannian structures | 0.002204 |
|
||||
| 4 | W1595775335 | 1960 | Yamabe | On a deformation of Riemannian structures on compact manifolds | 0.002204 |
|
||||
| 5 | W2023551584 | 1996 | Cooper, Rivin | Combinatorial scalar curvature and rigidity of ball packings | 0.002204 |
|
||||
| 6 | W2159531493 | 1984 | Schoen | Conformal deformation of a Riemannian metric to constant scalar curvature | 0.002204 |
|
||||
| 7 | **W2163787581** | **2007** | **Bobenko, Springborn** | **A Discrete Laplace–Beltrami Operator for Simplicial Surfaces** | **0.002195** |
|
||||
| 8 | W3099917509 | 2012 | Mercat | Discrete Riemann Surfaces and the Ising Model | 0.002190 |
|
||||
| 9 | W2021879624 | 2008 | Mullen, Tong, et al. | Spectral Conformal Parameterization | 0.002164 |
|
||||
| 10 | W1976584051 | 2002 | Desbrun, Meyer, et al. | Intrinsic Parameterizations of Surface Meshes | 0.002159 |
|
||||
|
||||
### Validation Against Manual Expectation
|
||||
|
||||
| Criterion | Target | Result |
|
||||
|-----------|--------|--------|
|
||||
| Bobenko or Springborn in top-10 hubs | ✓ expected hub | **PASS — Bobenko & Springborn 2007 at rank 7** |
|
||||
| Pinkall & Polthier 1993 prominent | ✓ foundational DGP paper | **PASS — rank 1** |
|
||||
| Score flatness acceptable | < 2× spread over top-10 | PASS — 5.7 % spread (0.002282 → 0.002159) |
|
||||
| Graceful small-corpus warning | shown when < 15 in-KB papers | **PASS — shown at 29 in-KB papers** (< `graph_min_corpus_size=15` not triggered; Springborn 2008 is not in-KB so not visible as "ingested hub") |
|
||||
|
||||
**GO** — PageRank produces a plausible field-specific ranking. The top hits
|
||||
(Pinkall/Polthier, Yamabe, Schoen, Bobenko/Springborn) are exactly the
|
||||
foundational works a DGP researcher would expect to see elevated.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
### Graph Construction
|
||||
|
||||
`build_citation_graph` builds an in-memory `nx.DiGraph` from the `citations`
|
||||
table: `citing_id → cited_id`. No schema migration needed.
|
||||
|
||||
- `citing_id` stores DOIs (matches `papers.id`).
|
||||
- `cited_id` stores OpenAlex IDs (external references).
|
||||
|
||||
**Consequence:** in the current corpus, ingested papers do not appear as
|
||||
in-degree targets of each other — inter-KB citations are structurally
|
||||
invisible because the `cited_id` column uses OpenAlex IDs while `papers.id`
|
||||
uses DOIs. The graph is therefore bipartite-like (29 DOI-nodes →
|
||||
478 OpenAlex-nodes), and all PageRank mass concentrates on the dangling
|
||||
OpenAlex-nodes cited by many of our 29 papers.
|
||||
|
||||
This is acceptable for the `--cite-boost` use-case: dangling hub papers are
|
||||
*foundational works* our corpus cites repeatedly, so boosting search results
|
||||
that match them is desirable.
|
||||
|
||||
### PageRank Graceful Degradation
|
||||
|
||||
`citation_pagerank` returns a **uniform distribution** when the graph has
|
||||
fewer than 5 nodes (`_MIN_PAGERANK_NODES`) and logs a warning.
|
||||
|
||||
The CLI `graph report` emits a separate user-visible warning when the count
|
||||
of in-KB papers is below `graph_min_corpus_size` (default 15) — because at
|
||||
that point the *hub ordering* (which uses PageRank over all 495 nodes) is
|
||||
still valid, but its *discriminative power* is limited by the few citing
|
||||
sources.
|
||||
|
||||
At 29 ingested papers, `graph_min_corpus_size=15` is satisfied; the score
|
||||
spread is narrow (5.7 %) but the ranking order is meaningful.
|
||||
|
||||
### cite-boost Formula
|
||||
|
||||
```
|
||||
boosted_distance = distance / (1.0 + alpha * pr_score)
|
||||
```
|
||||
|
||||
`alpha = 0.3` (config: `graph_cite_boost_alpha`). Dividing reduces cosine
|
||||
distance for high-PR papers (lower = closer in pgvector). Multiplying
|
||||
would invert the boost — this was the critical bug corrected during the
|
||||
F-15 review gate.
|
||||
|
||||
At `alpha=0.3` and the observed PR scores (~0.002), the maximum boost is
|
||||
approximately 0.06 % per unit score — effectively a tie-breaker on equally
|
||||
relevant papers rather than a hard re-ranking. This is intentional: we do
|
||||
not want citation authority to override semantic relevance for queries that
|
||||
genuinely target niche papers.
|
||||
|
||||
### Dangling Citations as Acquisition Queue
|
||||
|
||||
`dangling_citations(graph, known_ids)` returns the 478 OpenAlex IDs that
|
||||
are cited but not ingested. `codex graph report` surfaces these so the
|
||||
researcher can prioritize which papers to add to the corpus next.
|
||||
|
||||
The top hubs (rank 1–10) are the highest-value acquisition targets: adding
|
||||
Pinkall & Polthier 1993, Yamabe 1960, and Schoen 1984 would improve both
|
||||
retrieval quality and PageRank discrimination.
|
||||
|
||||
---
|
||||
|
||||
## Fallback
|
||||
|
||||
If the corpus has < `_MIN_PAGERANK_NODES` (5) nodes: `citation_pagerank`
|
||||
returns uniform scores; `--cite-boost` has no effect (each paper is boosted
|
||||
equally); no crash.
|
||||
|
||||
If `--cite-boost` is omitted: search works as F-03/F-04 baseline.
|
||||
|
||||
If the `citations` table is empty: graph has 0 nodes; `graph report` prints
|
||||
"Graph is empty" and exits 0; all other commands degrade gracefully.
|
||||
|
||||
---
|
||||
|
||||
## Known Limitation: ID-Format Mismatch
|
||||
|
||||
> **RESOLVED (audit 2026-06-15) — see Audit Update below.** This no longer holds:
|
||||
> `build_citation_graph` resolves OpenAlex `cited_id`s to the canonical
|
||||
> `papers.id` via the shared `RESOLVED_CITATIONS_SQL` (C-1) and ingest stores
|
||||
> canonical ids (C-7), so inter-KB citations *are* now edges. Original text kept
|
||||
> for the record.
|
||||
|
||||
`cited_id` uses OpenAlex IDs; `papers.id` uses DOIs. Cross-citations
|
||||
between ingested papers are therefore not represented as graph edges.
|
||||
This is a D-05-class issue (same root cause as the `citing_id`/`openalex_id`
|
||||
FK mismatch fixed in PR #9). A future migration could add an `openalex_id`
|
||||
column to `papers` and populate `cited_id` cross-links; but the benefit is
|
||||
small while the corpus is < 100 papers.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
- R-41: `build_citation_graph` — in-memory DiGraph, no schema change ✓
|
||||
- R-42: `citation_pagerank` + `codex search paper --cite-boost` ✓
|
||||
- R-43: `find_related` (bibliographic coupling) + `find_co_cited` ✓
|
||||
- R-44: `codex graph report [--top-n N] [--json]` ✓
|
||||
- R-45: graceful degradation < 5 nodes: uniform scores, warning ✓
|
||||
- R-46: **this document** — GO; Bobenko+Springborn 2007 at rank 7 validates hub detection; score flatness expected at corpus size 29; increase to > 100 papers for stronger PageRank differentiation
|
||||
|
||||
---
|
||||
|
||||
## Audit Update (2026-06-15)
|
||||
|
||||
A post-hoc audit of the shipped F-15 code found that the live-corpus spike above
|
||||
was measured on a graph the current code no longer produces:
|
||||
|
||||
- **ID-format mismatch resolved.** `build_citation_graph` resolves OpenAlex
|
||||
`cited_id`s to the canonical `papers.id` through the shared
|
||||
`RESOLVED_CITATIONS_SQL`, now also used by `codex.discover` so the two cannot
|
||||
diverge (audit C-1). Ingest stores the caller's canonical id as `papers.id`
|
||||
instead of OpenAlex's URL-form doi (audit C-7). Inter-KB citations are
|
||||
therefore represented as edges — the "Known Limitation" above is obsolete.
|
||||
- **Spike re-measured after migration (2026-06-15).** The corpus was re-ingested
|
||||
onto canonical bare ids (29 papers, all bare — verified `url_form_ids = 0`), and
|
||||
the graph re-run on the post-migration corpus:
|
||||
|
||||
| Metric | Original spike (pre-fix) | Post-migration (re-measured) |
|
||||
|--------|--------------------------|------------------------------|
|
||||
| Ingested papers | 29 | 29 |
|
||||
| Graph nodes | 495 | 489 |
|
||||
| Graph edges | 590 | 590 |
|
||||
| Dangling nodes | 478 | 472 |
|
||||
| Top-10 PR spread | 5.7 % | 5.4 % |
|
||||
| Rank-1 hub | W1974956622 (dangling) | **Pinkall/Polthier 1993 — in-KB (resolved)** |
|
||||
| Bobenko/Springborn 2007 | rank 7 | rank 7 |
|
||||
|
||||
The resolution fix's signature is visible: Pinkall/Polthier 1993 now resolves to
|
||||
its canonical DOI and rises to **rank 1 as an in-KB hub** (previously a dangling
|
||||
OpenAlex node). Bobenko/Springborn 2007 holds rank 7, and the foundational works
|
||||
(Pinkall/Polthier, Yamabe, Schoen, Bobenko/Springborn) still surface. **GO
|
||||
re-affirmed** on the shipping topology.
|
||||
|
||||
Migration footnotes: two papers initially survived in URL form because the
|
||||
id-keyed upsert did not catch a `papers.openalex_id` collision, and `2305.10988`
|
||||
failed on a `papers.bibkey` collision with `2310.17529` (both →
|
||||
`BobenkoLutz2023`). Same upsert gap — resolved by the bibkey-retry (audit C-15)
|
||||
plus a delete + re-ingest of the two stragglers; corpus is now 29/29 canonical.
|
||||
616
docs/audit/AUDIT-2026-06-15-full-repo.md
Normal file
616
docs/audit/AUDIT-2026-06-15-full-repo.md
Normal file
@@ -0,0 +1,616 @@
|
||||
# AUDIT — Full Repository (iterative)
|
||||
|
||||
**Auditor:** Audit-Loop (Opus)
|
||||
**Started:** 2026-06-15
|
||||
**Scope:** entire `main` tree, audited iteratively module-by-module.
|
||||
**Companion doc:** [`AUDIT-2026-06-15-loop-1.md`](AUDIT-2026-06-15-loop-1.md) —
|
||||
the F-15 / dev-loop-1 findings (S-1…U-4). This document continues the **same
|
||||
finding-ID register** (so `C-4` here follows `C-3` there) and covers the rest of
|
||||
the repo.
|
||||
|
||||
> Severity: **HIGH** fix before relying · **MED** fix soon · **LOW** polish ·
|
||||
> **INFO** accepted/no-action. Findings only — remediation planned separately.
|
||||
|
||||
## Coverage tracker
|
||||
|
||||
| Part | Area | Status |
|
||||
|------|------|--------|
|
||||
| (loop-1) | F-15 graph, db/schema, ingest, discover, mcp, provenance-scan, cli(search+graph), config(graph) | ✅ documented |
|
||||
| **A** | Grounding guard (`wiki.py` `_parse_claims`/`_run_grounding_guard`, quarantine) — shared by `synthesis.py` | ✅ documented |
|
||||
| **B** | `sources/` (arxiv, openalex, semanticscholar) | ✅ documented |
|
||||
| **C** | `parsing/` (grobid, nougat, mathpix, figures, tex) | ✅ documented |
|
||||
| **D** | `synthesis.py` orchestration (find_connections/gaps/improvements, conjectures, write_leads) | ✅ documented |
|
||||
| **E** | `wiki.py` rest (retrieve, compile_concept, cross-refs, index/log, embed) | ✅ documented |
|
||||
| **F** | `embed.py`, `quality.py`, `models.py` | ✅ documented |
|
||||
| **G** | `cli.py` rest (ingest/wiki/synthesis/provenance commands), `config.py` rest | ✅ documented |
|
||||
|
||||
---
|
||||
|
||||
## Part A — Grounding guard (the "zero-fact-leak" core)
|
||||
|
||||
The guard lives in `wiki.py` (`_parse_claims:216`, `_run_grounding_guard:299`)
|
||||
and is imported by `synthesis.py:57-59`, so **one implementation gates both**
|
||||
the wiki pages and the synthesis leads. A claim is "grounded" iff the **last
|
||||
sentence** before its `[BibKey #loc]` citation contains a run of 5 consecutive
|
||||
non-stopword tokens that appears as a substring of any chunk of the same bibkey.
|
||||
|
||||
### C-4 — Zero-claim pages bypass quarantine and publish as "compiled" · **HIGH**
|
||||
- **Evidence:** `wiki.py:730` — `if total_claims > 0 and grounding_rate < threshold:`
|
||||
quarantines; the `else` (`:737-742`) **publishes to `wiki/` and records the
|
||||
hash** as successfully compiled.
|
||||
- **Bug:** when the LLM output has **no parseable citations** (`total_claims == 0`,
|
||||
so `grounding_rate == 0.0`), the `total_claims > 0` guard is false → it takes
|
||||
the *publish* branch. The quarantine gate is skipped exactly when grounding
|
||||
evidence is **weakest** (zero citations).
|
||||
- **Trigger paths:** (a) the LLM emits uncited prose; (b) **LLM outage** — the
|
||||
module header guarantees "LLM unavailable → empty ConceptPage, no crash"
|
||||
(`wiki.py:13-14`), so a transient Ollama outage writes near-empty stub pages to
|
||||
`wiki/`, marks them compiled, and stores their hash. On the next
|
||||
`changed_only` run the hash matches → the stub is **skipped forever** until a
|
||||
forced recompile. A research wiki silently fills with empty/uncited pages.
|
||||
|
||||
### C-5 — "Every claim is grounded" holds only for the last cited sentence · **MED**
|
||||
- **Evidence:** `_CLAIM_RE` (`wiki.py:211-213`) captures `text = [^\[]+?` (the
|
||||
span back to the previous `]`/start); `_last_sentence` (`:279-291`) then keeps
|
||||
only the final sentence; `_run_grounding_guard` checks that sentence only.
|
||||
- **Two holes in the guarantee:**
|
||||
1. **Paragraph passengers** — any sentence that is *not* the last one before a
|
||||
citation is never grounding-checked and never `⚠`-marked. "Hallucination one.
|
||||
Hallucination two. Grounded tail [Bib]." passes with only the tail verified.
|
||||
2. **Uncited text is invisible** — a sentence with no `[BibKey]` is not a Claim
|
||||
at all (`_parse_claims` only yields cited spans), so it is never grounded
|
||||
nor marked. The LLM can emit fully uncited assertions into a published page.
|
||||
- **Impact:** the effective invariant is "*the last sentence immediately before
|
||||
each citation has lexical overlap with the cited paper*" — materially weaker
|
||||
than the N-02 "zero-fact-leak enforcement" framing. The module **header is
|
||||
honest** ("Substring-Match MVP", `wiki.py:4,13`); the commit/ADR language is
|
||||
not. Recommend aligning the claim or strengthening the guard (check every
|
||||
sentence; flag uncited sentences).
|
||||
|
||||
### C-6 — Substring match does not enforce the documented "5 consecutive tokens" · **LOW**
|
||||
- **Evidence:** `wiki.py:347-349` — `gram = " ".join(content[i:i+5])`;
|
||||
`if any(gram in src for src in sources)` where `src` is the space-joined
|
||||
content-word string of the chunk.
|
||||
- **Issue:** a *substring* test on the joined string lets the first/last gram
|
||||
token match as a **prefix/suffix of a longer chunk word** (e.g. gram
|
||||
`"cat dog …"` substring-matches inside `"scat dogma …"`). The docstring claims
|
||||
matching "as 5 consecutive non-stopword **tokens**" — true sequence matching
|
||||
would compare token lists with a sliding window (or add boundary sentinels).
|
||||
Low real-world impact at 5-gram length, but the claimed invariant is not what
|
||||
the code enforces.
|
||||
|
||||
### R-1 — Tokenizer brittleness causes false-negative over-quarantine · **MED**
|
||||
- **Evidence:** `_content_words` (`wiki.py:294-296`) lowercases and `.split()`s on
|
||||
whitespace only — **no punctuation stripping**. `_last_sentence` splits on
|
||||
`.!?` (`:276`).
|
||||
- **Impact:** punctuation stays glued to tokens, so a faithful paraphrase grounds
|
||||
only on *exact* surface form — `"volume,"` ≠ `"volume"`, `"L(gamma1)"` must
|
||||
match character-for-character, and a trailing decimal like `"V = 1.5"` makes
|
||||
`_last_sentence` return the fragment `"5"` (split at the `.`), which then has
|
||||
`< 5` content words → **forced ungrounded** (`:339-344`). Math-heavy and terse
|
||||
claims are systematically marked ungrounded even when verbatim, inflating the
|
||||
ungrounded ratio and pushing real pages into quarantine (and into C-4's
|
||||
blind spot when it drives `total_claims` interactions). The passing test
|
||||
`test_grounding_guard_…` only succeeds because its fixture reproduces the chunk
|
||||
text *exactly*.
|
||||
|
||||
### R-2 — Reference-list filter can drop theorem-dense chunks · **LOW**
|
||||
- **Evidence:** `_retrieve_chunks` (`wiki.py:188-199`) skips a chunk when
|
||||
`>60 %` of its lines match `^\s*(\[\d+\]|[A-Z][a-z]+,?\s+[A-Z]\.)`.
|
||||
- **Issue:** the second alternative (`[A-Z][a-z]+,?\s+[A-Z]\.`) also matches prose
|
||||
like `"Theorem A."`, `"Lemma B."`, `"Section C."`. A chunk formatted as a list
|
||||
of theorems/lemmas can exceed 60 % and be discarded as a "reference list" —
|
||||
silently dropping exactly the mathematical content the wiki wants.
|
||||
|
||||
### R-3 — Conflict detection has near-zero precision · **LOW**
|
||||
- **Evidence:** `_detect_conflicts` (`wiki.py:367-…`) flags a pair `(bk1, bk2)` if
|
||||
**each** has *any* chunk containing a hedging keyword
|
||||
(`but|however|in contrast|…`, `_CONFLICT_KEYWORDS:361-364`).
|
||||
- **Issue:** it never checks the keywords relate to a shared topic or to each
|
||||
other. In a corpus where most papers contain "however"/"but" somewhere, this
|
||||
flags ~O(n²) pairs as "conflicting" — noise surfaced in `CompileReport`.
|
||||
Labeled "heuristic signal only", but as-is it is not actionable.
|
||||
|
||||
**Part A net:** 1 HIGH (C-4), 2 MED (C-5, R-1), 3 LOW (C-6, R-2, R-3). The guard
|
||||
is a reasonable lexical MVP, but (a) it has a concrete publish-bypass bug for
|
||||
zero-citation output, and (b) the "zero-fact-leak" framing overstates what a
|
||||
last-sentence substring check can guarantee.
|
||||
|
||||
---
|
||||
|
||||
## Part B — `sources/` (external API clients)
|
||||
|
||||
Solid foundations: all three clients set httpx timeouts; OpenAlex and S2 retry
|
||||
429/5xx with exponential back-off (tenacity); S2 enforces a ≥1 req/s floor with a
|
||||
monotonic clock under a lock. The findings are about **ID format consistency**
|
||||
and a couple of dead/inert paths.
|
||||
|
||||
### C-7 — `papers.id` is stored in OpenAlex *URL* form, breaking the bare-ID contract · **HIGH**
|
||||
- **Evidence:** `openalex.py:94` — `_map_paper` sets
|
||||
`id = data.get("doi") or data.get("id") or ""`. Real OpenAlex returns `doi` as
|
||||
a **full URL** (`https://doi.org/10.x`) and `id` as `https://openalex.org/W…`
|
||||
(the repo's own test confirms the URL form for `openalex_id`,
|
||||
`test_openalex.py:92`). `ingest.py:82` takes `paper.id` from this unchanged and
|
||||
upserts it as `papers.id` (`ingest.py:138`).
|
||||
- **Why it's currently masked:** the test fixture `_SAMPLE_WORK["doi"]` is a
|
||||
**bare** DOI `"10.1145/3592430"` (`test_openalex.py:18`) — not the URL form real
|
||||
OpenAlex emits — and **no test asserts `paper.id`** at all. So the suite is
|
||||
green while production stores `papers.id = "https://doi.org/10.1145/3592430"`.
|
||||
- **Impact:**
|
||||
1. **Bare-ID lookups fail.** `ingest_all.sh` ingests `"10.1145/1964921.1964997"`,
|
||||
but `papers.id` becomes the URL; `codex graph related 10.1145/…`,
|
||||
`discover citing 10.1145/…`, search-by-id, etc. find nothing.
|
||||
2. **Second ID-mismatch axis (compounds C-1).** `cited_id` from the S2/GROBID
|
||||
paths is a **bare** DOI/arXiv (`semanticscholar.py:98-99`,
|
||||
`ingest.py:187`), so a bare-DOI reference can never equal a URL-form
|
||||
`papers.id` — bare-DOI cross-citations stay "dangling" regardless of the
|
||||
F-15 W-ID resolution (which only matches `papers.openalex_id`, the URL form).
|
||||
- **Action:** confirm with `SELECT id FROM papers LIMIT 5` on the Jetson DB; if
|
||||
URL-form, normalize `papers.id` to a canonical bare form at ingest (and the
|
||||
`cited_id` writers to match), then add a representative fixture + `paper.id`
|
||||
assertion (see T-3).
|
||||
|
||||
### T-3 — OpenAlex test fixture is unrepresentative and skips `paper.id` · **MED**
|
||||
- **Evidence:** `_SAMPLE_WORK["doi"] = "10.1145/3592430"` (bare) vs the real
|
||||
`https://doi.org/…` URL form; `test_fetch_paper_*` assert title/year/authors/
|
||||
abstract/`openalex_id` but **never** `paper.id`.
|
||||
- **Impact:** the id-mapping — the exact surface of C-7 — is untested, and the
|
||||
fixture actively hides the URL-form behaviour. Any normalization fix needs this
|
||||
closed first or it will regress invisibly.
|
||||
|
||||
### R-5 — S2 `fetch_references` breaks on legacy arXiv IDs containing `/` · **LOW**
|
||||
- **Evidence:** `semanticscholar.py:80` interpolates `paper_id` into the path
|
||||
`…/paper/{paper_id}/references`. The ingest fallback passes `arXiv:math/0603097`
|
||||
(`ingest.py:91`) — the embedded `/` yields a malformed path → 404 →
|
||||
`fetch_references` silently returns `[]`. Legacy-arXiv papers that miss the
|
||||
OpenAlex path get **zero references** with no error.
|
||||
|
||||
### R-4 — `arxiv.fetch_source` no-op try/except + no retry · **LOW**
|
||||
- **Evidence:** `arxiv.py:41-44` — `try: httpx.get(...) except httpx.RequestError: raise`
|
||||
catches and immediately re-raises, doing nothing. Unlike OpenAlex/S2, the arXiv
|
||||
client has **no** retry/back-off, so a transient network blip aborts ingest.
|
||||
Minor resilience asymmetry.
|
||||
|
||||
### C-9 — `semanticscholar.fetch_recommendations` is unreachable app code · **LOW**
|
||||
- **Evidence:** referenced only by `tests/sources/test_semanticscholar.py`; no
|
||||
`codex/` module or CLI calls it (`grep fetch_recommendations`). Tested but
|
||||
never wired — dead feature surface (decide: wire a `discover recommend`
|
||||
command, or drop).
|
||||
|
||||
**Part B net:** 1 HIGH (C-7, ID-form contract — the big one; confirm on live DB),
|
||||
1 MED (T-3, fixture masks it), 3 LOW (R-4, R-5, C-9). The retry/timeout/rate-limit
|
||||
hygiene is genuinely good; the weakness is the **un-normalized, heterogeneous ID
|
||||
space** (URL vs bare, W-ID vs DOI vs arXiv vs S2-hash) threaded through `papers.id`
|
||||
and `citations.cited_id` — the same root cause behind C-1 and the F-15 ID dance.
|
||||
|
||||
---
|
||||
|
||||
## Part C — `parsing/` (tex, grobid, nougat, mathpix, figures)
|
||||
|
||||
> **Scope reality:** the live corpus is ingested entirely as **`.txt`**
|
||||
> (`ingest_all.sh` maps every paper to a `*.txt` file), so the `.tex`-clean,
|
||||
> GROBID, Nougat, Mathpix, and Figures paths are **latent** — only
|
||||
> `tex.chunk_text` + `quality.*` run on the live data. The findings below are
|
||||
> real but mostly fire only once `.tex`/`.pdf --rich` ingest is used.
|
||||
>
|
||||
> **Not a finding:** the broad `except Exception` in `mathpix.py:109,151` and
|
||||
> `figures.py:125,152` are *justified* per-item graceful degradation
|
||||
> (`fitz.open` fails → return empty; pix2tex/image fails → skip that item), not
|
||||
> swallowed bugs.
|
||||
|
||||
### C-10 — Formulas/figures key on the PDF *filename*, not `paper.id` · **MED** (latent, `.pdf --rich`)
|
||||
- **Evidence:** `figures.py:119` and the mathpix extractor set
|
||||
`paper_id = Path(pdf_path).stem`. `ingest.py:289,303` insert
|
||||
`f.paper_id` / `fig.paper_id` **unchanged** into `formulas`/`figures`, whose
|
||||
schema declares `paper_id TEXT REFERENCES papers(id)` (`schema.sql:97,115`).
|
||||
- **Impact:** the filename stem (e.g. `springborn-2008-weighted-delaunay…`) will
|
||||
not equal `papers.id` (an OpenAlex URL/bare id — see C-7), so the FK insert
|
||||
**fails and aborts rich ingest**, or (without FK enforcement) writes orphaned
|
||||
rows. Identity is derived from the *file*, not the *paper*. Fix: pass `paper.id`
|
||||
into the extractors, or have ingest overwrite `f.paper_id = paper.id` before insert.
|
||||
|
||||
### R-7 — `_clean_latex` misses `\(…\)` / `\[…\]` math + strips all math from chunks · **LOW** (`.tex` path)
|
||||
- **Evidence:** `tex.py:21-56` strips `%comment`, `$…$`, `$$…$$`,
|
||||
`equation`/`align` envs, `\cite/\label/\ref` — but **not** the `\(…\)` / `\[…\]`
|
||||
math delimiters. Papers using those leave raw `\( … \)` markup in the chunk text.
|
||||
- **Impact:** (a) leftover math markup pollutes embeddings; (b) *all* math is
|
||||
removed from chunk text, so chunk-level semantic search can never match formula
|
||||
content — it relies entirely on the separate formula FTS (F-09), which is itself
|
||||
latent here. Acceptable as a separation-of-concerns choice, but worth stating.
|
||||
|
||||
### R-6 — PDF-path external calls are unwrapped / un-retried · **LOW** (`.pdf` path)
|
||||
- **Evidence:** `ingest.py:185` calls `extract_references()` outside any
|
||||
`try`; GROBID (`grobid.py:54,133`) and the mathpix HTTP calls `raise_for_status`
|
||||
with **no** retry/back-off (unlike OpenAlex/S2). A transient GROBID/Mathpix
|
||||
5xx aborts the whole PDF ingest.
|
||||
- **INFO:** `grobid.py:56` uses stdlib `ET.fromstring` on the server's XML.
|
||||
Fine for a trusted self-hosted GROBID; switch to `defusedxml` if it is ever
|
||||
pointed at an untrusted endpoint.
|
||||
|
||||
**Part C net:** 1 MED (C-10), 2 LOW (R-6, R-7), all **latent** for the current
|
||||
`.txt` corpus. `nougat.py` was skimmed only (54 lines, `.pdf`-only wrapper) — no
|
||||
deep review yet; flagged in the ledger. Parsing hygiene is otherwise good.
|
||||
|
||||
> **Request (a) — second audit pass — is now complete:** §8 of the loop-1 doc
|
||||
> (grounding guard ✓ Part A, `sources/` ✓ Part B, `parsing/` ✓ Part C) is closed.
|
||||
> Remaining whole-repo parts D–G are the broader sweep.
|
||||
|
||||
---
|
||||
|
||||
## Part D — `synthesis.py` orchestration
|
||||
|
||||
The lead pipeline (connections → gaps → improvements → conjectures) reuses the
|
||||
Part-A grounding guard for the three grounded kinds and degrades gracefully on
|
||||
LLM/DB failure (`_safe_llm_generate`/`_list_paper_titles` swallow + log). The
|
||||
conjecture invariants are genuinely solid (status hard-set `"unverified"`,
|
||||
mandatory falsification path or the lead is dropped, written to `conjectures/`
|
||||
only). Two real defects and a doc contradiction:
|
||||
|
||||
### C-11 — Lead-ID collision silently overwrites synthesis output · **HIGH**
|
||||
- **Evidence:** every stage restarts its counter at `seq = 1`
|
||||
(`synthesis.py:341` connections, `:415` gaps, `:530` improvements,
|
||||
`:634` conjectures) and ids are `L-0001, L-0002, …` (`_make_lead_id`). The CLI
|
||||
concatenates the grounded kinds — `all_leads = connections + gaps + improvements`
|
||||
(`cli.py:421`) — and `write_leads` routes **all** grounded kinds to the *same*
|
||||
`grounded/<id>.md` (`synthesis.py:783-796`), writing in list order.
|
||||
- **Bug:** connection `L-0001`, gap `L-0001`, improvement `L-0001` all target
|
||||
`grounded/L-0001.md`; later writes overwrite earlier → **improvements clobber
|
||||
gaps clobber connections** at each number. Surviving files =
|
||||
`max(#connections, #gaps, #improvements)`, **not** the sum. With 5/3/4 leads you
|
||||
get 5 files, not 12; connections are lost first. Silent data loss of the
|
||||
synthesis engine's core output, masked because "files do get written".
|
||||
- **Fix direction:** namespace ids by kind (`L-C-0001` / `L-G-0001` / `L-I-0001`)
|
||||
or use a single shared counter across stages.
|
||||
|
||||
### R-8 — Default-topic gap detection is a false-positive generator · **MED**
|
||||
- **Evidence:** `find_gaps` defaults `probe_topics` to paper **titles** when
|
||||
`topics is None` (`synthesis.py:418-420`); a title query retrieves mostly its
|
||||
*own* paper's chunks → `bibkeys_for_topic` ≈ 1, which is `< synthesis_gap_min_coverage`
|
||||
(`:450-453`) → the topic is flagged as a gap and the LLM is asked to articulate
|
||||
one.
|
||||
- **Impact:** nearly every ingested paper becomes a spurious "Gap: '<title>'
|
||||
covered by only 1 bibkey(s)" candidate — the LLM invents a gap for material that
|
||||
is actually well covered. The grounding guard catches some, but the default
|
||||
signal is mostly noise. (User-supplied `--topics` avoid this.)
|
||||
|
||||
### D-4 — `write_leads` vs `_update_index` docstrings contradict each other · **LOW**
|
||||
- **Evidence:** `write_leads` (`synthesis.py:762-765`) claims it "maintains an
|
||||
**append-only** `INDEX.md`: a previously-recorded id is kept on subsequent runs",
|
||||
but `_update_index` (`:802-803`) states — and implements — a "**full rebuild**,
|
||||
not append-only" by globbing the on-disk dirs.
|
||||
- **Impact:** there is no append-only / id-retention logic; the index purely
|
||||
reflects current disk state. The guarantee in the `write_leads` docstring is
|
||||
false (and interacts with C-11: overwritten ids simply vanish from the index).
|
||||
|
||||
**Part D net:** 1 HIGH (C-11, silent lead overwrite), 1 MED (R-8, gap noise),
|
||||
1 LOW (D-4, doc contradiction). The conjecture path is exemplary; the grounded
|
||||
path's persistence layer is where it breaks.
|
||||
|
||||
---
|
||||
|
||||
## Part E — `wiki.py` rest (compile, cross-refs, state, index/log)
|
||||
|
||||
Good: `yaml.safe_load`, deterministic `_chunk_hash` (sorted by chunk id) driving a
|
||||
sound incremental-skip, append-only `log.md`, inline-code protection during
|
||||
cross-ref injection. The issues are math-corruption, a dead step, and a marking
|
||||
order bug.
|
||||
|
||||
### R-9 — Cross-ref injection corrupts LaTeX math · **MED**
|
||||
- **Evidence:** `_inject_cross_refs` (`wiki.py:426`) protects only inline-code
|
||||
spans (`_INLINE_CODE_RE`); it then runs whole-word, case-insensitive title/alias
|
||||
→ `[[slug]]` replacement over everything else (`:429-438`). **Math spans
|
||||
(`$…$`, `$$…$$`, `\(…\)`) are not protected.**
|
||||
- **Impact:** the synthesis prompt explicitly asks for LaTeX `$ … $`
|
||||
(`wiki.py:495`), so bodies contain math. A concept title/alias that appears
|
||||
inside a formula (e.g. a concept named "Energy" inside `$E_{\text{Energy}}$`,
|
||||
or a single-word alias) is rewritten to `[[slug]]`, corrupting the LaTeX. The
|
||||
risk scales with how common the concept titles/aliases are as math tokens.
|
||||
|
||||
### C-12 — `_try_embed_formulas` is a no-op that still costs a DB round-trip per concept · **LOW**
|
||||
- **Evidence:** `wiki.py:623-637` opens a connection, queries
|
||||
`information_schema.tables` for `formulas`, and returns — the actual embed is
|
||||
`"(Future: …)"`. Called unconditionally per concept (`compile_concept:609`).
|
||||
- **Impact:** step 4 of `compile_concept`'s docstring ("Embed formula chunks")
|
||||
does nothing, and each concept compile pays an extra DB connection + query for
|
||||
no effect. Placeholder masquerading as a feature.
|
||||
|
||||
### C-13 — Ungrounded-claim marking can miss claims mutated by cross-ref injection · **LOW**
|
||||
- **Evidence:** order in `compile_concept` — claims parsed from `raw_output`
|
||||
(`:602-603`), then `raw_output` is rewritten by `_inject_cross_refs` (`:606`),
|
||||
then `_render_page_markdown` (`:612`) re-finds the *original* `claim.text` by
|
||||
regex in the **injected** body (`:544-552`).
|
||||
- **Impact:** if an ungrounded claim's text contained a term that got replaced
|
||||
with `[[slug]]`, the original text no longer matches → the `⚠` mark is silently
|
||||
not applied. (Also note: wiki's marking lacks the `(?<!⚠ )` guard that
|
||||
synthesis's `_mark_ungrounded` has — two divergent mark implementations.)
|
||||
|
||||
### R-10 — Non-atomic compile-state write · **LOW**
|
||||
- **Evidence:** `_save_compile_state` (`wiki.py:475-477`) writes
|
||||
`.compile-state.json` in place. A crash mid-write corrupts it; `_load`
|
||||
then catches `JSONDecodeError` → `{}` → every concept recompiles. Graceful but
|
||||
loses incrementality. Write to a temp file + `os.replace` for atomicity.
|
||||
|
||||
### R-11 — `load_concepts` raises `KeyError` on malformed YAML · **LOW**
|
||||
- **Evidence:** `entry["slug"]` / `entry["title"]` (`wiki.py:135-136`) are
|
||||
unguarded; a concepts.yaml entry missing a key crashes the whole compile with a
|
||||
bare `KeyError` rather than a actionable message.
|
||||
|
||||
> **Minor:** `compile_all` builds the index from `state.keys()` (`:745`), which
|
||||
> accumulates slugs across runs — a concept removed from `concepts.yaml` lingers
|
||||
> in `index.md`/state until manually pruned. Low.
|
||||
|
||||
**Part E net:** 1 MED (R-9, math corruption), 4 LOW (C-12, C-13, R-10, R-11). The
|
||||
incremental-compile machinery is sound; the rendering/cross-ref layer has the rough
|
||||
edges. `OllamaClient.generate` (no retry, 120 s timeout, `raise` on error) feeds
|
||||
directly into **C-4** (its exception → empty page → published), so C-4's blast
|
||||
radius includes every transient Ollama hiccup.
|
||||
|
||||
---
|
||||
|
||||
## Part F — `embed.py`, `quality.py`, `models.py`
|
||||
|
||||
The best-built corner of the repo. `embed.py` L2-normalises correctly (cosine ⇒
|
||||
dot product), guards empty input and zero-norm rows, and caches a process
|
||||
singleton. `quality.py` thresholds are all config-driven, `run_quality_pass` is
|
||||
CLI-wired (`cli.py:513-517`) and atomic (single commit). `models.py` dataclasses
|
||||
mirror the schema cleanly. Two dead/low-signal items and one corroboration:
|
||||
|
||||
### C-14 — The sparse-embedding ("hybrid") path is dead code · **LOW**
|
||||
- **Evidence:** `Embedder.encode_sparse` / `encode` / `_coerce_sparse` are called
|
||||
nowhere in `codex/` (grep for `encode_sparse` / `.encode(` finds only
|
||||
`str.encode` in `wiki.py:455`). Only `encode_dense` is wired (ingest, search,
|
||||
wiki). There is no sparse column in the schema.
|
||||
- **Impact:** the `embed.py` header and ADR-0002 advertise "**Hybrid dense +
|
||||
sparse** embeddings via BGE-M3" as a headline, but the sparse half is
|
||||
unreachable — the actual "hybrid" elsewhere is dense + Postgres FTS. Either wire
|
||||
sparse retrieval or drop the capability + correct the docs.
|
||||
|
||||
### R-12 — `classify_section` is low-signal as fed · **LOW**
|
||||
- **Evidence:** `classify_section` matches section headers in the **first 200
|
||||
chars** (`quality.py:103-106`), but `tex.chunk_text` produces overlapping
|
||||
*word-window* chunks that almost never begin at a header. So most chunks fall
|
||||
through to `"body"` (or the DOI-density `"bibliography"` fallback).
|
||||
- **Impact:** the `chunks.section` column is mostly `"body"` — limited value for
|
||||
any section-aware retrieval that depends on it. Not wrong, just weak signal
|
||||
given the chunker.
|
||||
|
||||
### Corroboration of C-7 (not a new finding)
|
||||
- `models.Paper.id` docstring (`models.py:20-21`) states the canonical id is "an
|
||||
arXiv ID … **or a DOI (e.g. `10.1145/3592430`)**" — the **bare** form. Production
|
||||
fills it from OpenAlex in **URL** form (C-7), so the code violates its own model
|
||||
contract. This makes C-7 a documented-contract breach, not a judgement call.
|
||||
|
||||
**Part F net:** 2 LOW (C-14, R-12) + a C-7 corroboration. These three modules are
|
||||
clean; no correctness defects.
|
||||
|
||||
---
|
||||
|
||||
## Part G — `cli.py` rest + `config.py`
|
||||
|
||||
Most CLI commands are thin Typer wrappers delegating to already-audited modules
|
||||
(`ingest`, `search`, `discover`, `wiki`, `synthesis`, `provenance`, `quality`,
|
||||
`graph`). Settings are pydantic-validated with sensible bounds (`gt=0`, `0..1`).
|
||||
Findings are config-coupling and output hygiene.
|
||||
|
||||
### C-15 — `embedding_dim` is a footgun decoupled from the hardcoded schema · **MED**
|
||||
- **Evidence:** `embedding_dim` is configurable (`config.py:67-74`, default 1024)
|
||||
and `.env.example`/`schema.sql:7` invite other models (Qwen3 1024, **Jina v4
|
||||
2048**). But `schema.sql` hardcodes `vector(1024)` on every embedding column
|
||||
(`:25,38,102,119`), and `Embedder` derives the real output dim from the *model*,
|
||||
using the `dim` setting only for empty-input zero-vectors (`embed.py:50,89`).
|
||||
- **Impact:** set `EMBEDDING_DIM=2048` (or pick a non-1024 model) and inserts
|
||||
fail on a pgvector dimension mismatch — silently for empty abstracts
|
||||
(`(1,2048)` zero-vector into `vector(1024)`), structurally for real vectors.
|
||||
Nothing couples the knob to the DDL; the setting reads as supported but is not.
|
||||
|
||||
### R-13 — `export_bib` does no BibTeX escaping and forces `@article` · **LOW**
|
||||
- **Evidence:** `provenance.py:174-181` interpolates `title`/`author`/`year` raw
|
||||
into `@article{…}`; no escaping/brace-balancing of BibTeX specials
|
||||
(`{ } \ % # $ _ &`).
|
||||
- **Impact:** a title with an unbalanced brace or a stray special char produces a
|
||||
malformed `.bib` entry that breaks Doxygen/BibTeX. Also every paper is emitted
|
||||
as `@article` although the corpus includes theses/books/chapters
|
||||
(`ingest_all.sh`) — bibliographically wrong (functional for `@cite` keys though).
|
||||
|
||||
### S-4 — Secrets stored as plain `str`, not `SecretStr` · **LOW**
|
||||
- **Evidence:** `database_url`, `mathpix_app_key`, `mcp_auth_token`
|
||||
(`config.py:29,148,249`) are plain `str`. Any `repr(Settings())` or debug log of
|
||||
the settings object prints the DB password / API key / bearer token in clear.
|
||||
- **Impact:** log/exception-dump leakage surface (complements S-1). Use
|
||||
`pydantic.SecretStr` for credential fields.
|
||||
|
||||
### R-14 — `mcp_transport` enum not enforced · **LOW**
|
||||
- **Evidence:** `config.py:229-236` documents "stdio or http" but no validator;
|
||||
`main()` (`mcp_server.py:323`) treats anything non-`http` as stdio. A typo
|
||||
(`htttp`) silently starts stdio instead of failing loudly.
|
||||
|
||||
### D-5 — Inconsistent `validation_alias` on `nougat_url` only · **LOW**
|
||||
- **Evidence:** `nougat_url` adds `AliasChoices("NOUGAT_URL", "nougat_url")`
|
||||
(`config.py:47`) that no other field has — redundant under
|
||||
`case_sensitive=False`, and the inconsistency invites confusion.
|
||||
|
||||
> **Corroboration of C-7:** `cli.py:45` ingest does **no** id normalization and
|
||||
> echoes `result.paper_id` (the URL-form id) straight back — no normalization
|
||||
> layer exists at any tier (source → ingest → CLI).
|
||||
|
||||
**Part G net:** 1 MED (C-15, dim/schema footgun), 4 LOW (R-13, S-4, R-14, D-5).
|
||||
CLI/config are otherwise solid and well-documented.
|
||||
|
||||
---
|
||||
|
||||
# Executive Summary — whole-repo audit complete (loop-1 + Parts A–G)
|
||||
|
||||
**Tally: 45 findings — 8 HIGH, 11 MED, 26 LOW/INFO** (after live-DB verification:
|
||||
**7 HIGH, 12 MED** — M-1 reclassified, see the verification section at the bottom).
|
||||
Test suite 330 green, `ruff`/`mypy` clean. The codebase is well-structured and idiomatic; the defects
|
||||
cluster into five root themes, not random scatter.
|
||||
|
||||
### The 8 HIGH findings
|
||||
| ID | Theme | One-liner |
|
||||
|----|-------|-----------|
|
||||
| S-1 | secrets | live DB password in un-ignored `.env.jetson-ingest` (mitigated this session) |
|
||||
| M-1 | migration | `paper_identifiers` has no migration path; `schema.sql` not re-applyable, `apply_schema` never called |
|
||||
| C-1 | identity | `discover.py` keeps the ID-mismatch bug F-15 fixed in `graph.py` (over-reports ingested papers as leads) |
|
||||
| C-7 | identity | `papers.id` stored in OpenAlex **URL** form, violating the bare-ID contract (breaks lookups + cross-citation match) |
|
||||
| C-4 | silent failure | zero-citation/LLM-outage wiki pages **bypass quarantine** and publish as "compiled" |
|
||||
| C-11 | silent data loss | synthesis leads collide on `L-000N` across stages → improvements overwrite gaps overwrite connections |
|
||||
| D-1 | doc integrity | ADR-F15 GO rests on spike numbers measured **before** the shipped resolution fix |
|
||||
| T-1 | verification | the F-15 resolution SQL (the substance of the last 2 commits) has **zero** real-DB coverage |
|
||||
|
||||
### Five root-cause themes
|
||||
1. **Un-normalized identity (the dominant theme).** C-1, C-7, C-10, M-1, T-3,
|
||||
and most of the F-15 ID dance are one problem: a paper's identity exists in
|
||||
many un-reconciled forms (bare vs URL · DOI vs arXiv vs OpenAlex-W vs S2-hash
|
||||
vs PDF-filename) with **no canonical normalization layer**. One normalization
|
||||
boundary at ingest would retire ~5 HIGH/MED findings at once.
|
||||
2. **"Green but unverified."** T-1/T-2/T-3 — the most error-prone code (resolution
|
||||
SQL, migrations, id-mapping) is mocked or tested with unrepresentative fixtures,
|
||||
so real bugs (C-4, C-7, C-11) sit behind 330 passing tests.
|
||||
3. **Silent failure / data loss under degradation.** C-4 (publish empty), C-11
|
||||
(overwrite leads), R-1 (over-quarantine) corrupt *outputs* instead of erroring
|
||||
— the worst failure mode for a research tool whose value is trust.
|
||||
4. **Overstated guarantees.** D-1 (stale ADR), D-2/D-4 (doc↔code drift), C-5
|
||||
("zero-fact-leak" ≫ a last-sentence substring check), C-14 ("hybrid" sparse is
|
||||
dead) — the docs/commit-language claim more than the code delivers.
|
||||
5. **Latent rich-path debt.** C-10, R-6, R-7 are real but dormant because the live
|
||||
corpus is `.txt`; they detonate on the first `.pdf --rich` ingest.
|
||||
|
||||
### Suggested remediation sequencing (themes, not a plan — planning deferred)
|
||||
- **First:** S-1 (rotate credential — secret already protected), M-1 (migration
|
||||
path), C-7+C-1 (one identity-normalization layer), T-1 (real-DB test) — these
|
||||
unblock trust in the live corpus.
|
||||
- **Then:** C-4 + C-11 (stop silent output corruption), D-1 (re-validate ADR-F15).
|
||||
- **Then:** MED batch (R-1, R-8, R-9, C-15, C-10) and the LOW polish.
|
||||
|
||||
### Coverage honesty
|
||||
Every `codex/` module was read. After the follow-up pass **all are deep-reviewed**
|
||||
(`nougat.py` included — see Runtime Validation). Runtime state that was initially
|
||||
*inferred from code* (C-7 id-form, M-1 table existence, and the C-4/C-11
|
||||
behaviours) has since been **empirically confirmed** — see the *Live DB
|
||||
Verification* and *Runtime Validation* sections below.
|
||||
|
||||
---
|
||||
|
||||
# Live DB Verification — 2026-06-15 (read-only, via existing SSH tunnel)
|
||||
|
||||
Ran read-only queries against the live Jetson corpus (`papers=36`,
|
||||
`citations=811`). This **confirms the two identity HIGHs with hard numbers** and
|
||||
**corrects an over-statement in M-1**.
|
||||
|
||||
### C-7 — CONFIRMED ✅ (severity stands: HIGH)
|
||||
`papers.id` form distribution: **`doi_url=35`, `bare_arxiv=1`, everything else 0**
|
||||
(of 36). Samples: `https://doi.org/10.48550/arxiv.math/0603097`,
|
||||
`https://doi.org/10.48550/arxiv.2310.17529`. Even arXiv papers are stored as their
|
||||
arXiv-DOI **URL**, never the bare `math/0603097` / `2301.x` the schema, `models.Paper`,
|
||||
and `ingest_all.sh` assume. The single bare id is the one paper OpenAlex 404'd
|
||||
(fell to `ingest.py:94`). → bare-ID lookups fail for 35/36 papers. Confirmed.
|
||||
|
||||
### C-1 — CONFIRMED ✅ with impact (severity stands: HIGH)
|
||||
- `citations.cited_id` is uniformly **`oa_url=811`** (all OpenAlex URLs) — the live
|
||||
corpus was ingested via the OpenAlex path, so the heterogeneous bare-DOI/S2 mix
|
||||
theorised in Part B is **not present here** (it would appear only via S2/GROBID,
|
||||
i.e. `.pdf`/fallback ingest).
|
||||
- **`discover.py` wrongly flags 13 already-ingested papers as "dangling / not
|
||||
ingested"** (their `cited_id` is an OpenAlex URL whose paper *is* in the corpus
|
||||
via `openalex_id`). Meanwhile **`graph.py` correctly resolves 57 cross-citation
|
||||
edges** via the `openalex_id` JOIN. The two "dangling" implementations
|
||||
measurably disagree — exactly the C-1 divergence, now quantified.
|
||||
|
||||
### M-1 — REFINED ⤵ (downgrade acute severity; structural finding stands)
|
||||
`paper_identifiers` **exists on the live DB with 39 rows** — the catastrophic
|
||||
"graph JOIN crashes with relation-does-not-exist" scenario **does not occur**;
|
||||
the table was applied (manually or via DB re-create). The *structural* gap stands:
|
||||
`apply_schema` is still non-idempotent and never invoked, so the **next** schema
|
||||
change has no migration path — but the acute "live graph is broken" framing was
|
||||
**over-stated**. Re-rank M-1 from HIGH → **MED** (latent migration debt, not an
|
||||
active outage).
|
||||
|
||||
### C-2 — PARTIALLY REFUTED ⤵
|
||||
`paper_identifiers` has **39 rows vs ~35 papers-with-openalex_id**, so ~4 papers
|
||||
carry a diverging alias → the `pi` JOIN is **not** fully inert (it resolves a
|
||||
handful of cases the `p` JOIN can't). Keep as LOW/INFO: the table earns its keep
|
||||
for a few papers, though the cost/benefit at this corpus size is still marginal.
|
||||
|
||||
### Net adjustment to the tally
|
||||
HIGH **8 → 7** (M-1 → MED). C-7 and C-1 are now **confirmed**, not inferred — they
|
||||
should lead remediation. The dominant root theme (**un-normalized identity**) is
|
||||
empirically validated: 35/36 ids in the "wrong" form, 13 papers mis-classified by
|
||||
the older code path, 57 edges rescued by the newer one.
|
||||
|
||||
---
|
||||
|
||||
# Runtime Validation — 2026-06-15 (C-4 and C-11 reproduced end-to-end)
|
||||
|
||||
The two HIGH findings flagged "code-certain, runtime-confirmable" were exercised
|
||||
against the **real** code paths (injected fake LLM; no Ollama, no DB needed).
|
||||
|
||||
### C-11 — CONFIRMED ✅ (lead-ID collision → silent loss; stays HIGH)
|
||||
Reproduced the exact CLI assembly (`cli.py:421` `connections + gaps +
|
||||
improvements`, each stage numbered from `L-0001`): **4 distinct leads** (2
|
||||
connection, 1 gap, 1 improvement) → `write_leads` produced **2 files**.
|
||||
`grounded/L-0001.md` survived as the **improvement** ("IMPROVEMENT-one"); the
|
||||
connection and gap `L-0001` were silently overwritten. **2 of 4 leads lost.**
|
||||
|
||||
### C-4 — CONFIRMED ✅ (zero-claim page bypasses quarantine; stays HIGH)
|
||||
Drove the real `compile_all` with a fake LLM returning confident **uncited** prose
|
||||
(→ 0 parseable claims). With `wiki_min_grounding_rate = 0.5` and an actual
|
||||
grounding rate of `0.0`, the page was **published to `wiki/` and marked
|
||||
compiled** (`report.compiled=['test-concept']`, `report.quarantined=[]`) — not
|
||||
quarantined to `wiki/draft/`. The `total_claims > 0` guard (`wiki.py:730`) is the
|
||||
exact bypass; the bug fires precisely when grounding evidence is weakest.
|
||||
|
||||
### `nougat.py` — now fully reviewed (no new finding)
|
||||
Folds into **R-6** (PDF-path resilience). Detail: `pdf_to_markdown` retries **only**
|
||||
`httpx.ConnectError` with `wait_fixed(0)` (no 5xx retry, no back-off) and is
|
||||
unwrapped at `ingest.py:183` — a Nougat 5xx aborts PDF ingest.
|
||||
|
||||
**Closure:** every `codex/` module is deep-reviewed; the two runtime-confirmable
|
||||
HIGHs are empirically reproduced. The **audit / finding-identification pass is
|
||||
complete**. Remediation planning is the open next phase.
|
||||
|
||||
---
|
||||
|
||||
# Migration Incident — 2026-06-15 (M-1 confirmed live, new privilege dimension)
|
||||
|
||||
While running the C-7 re-ingest migration (`infra/reingest_canonical_ids.sh`),
|
||||
M-1 manifested exactly as predicted — and exposed a dimension the static audit
|
||||
did **not** capture.
|
||||
|
||||
**What happened.** The helper ran `TRUNCATE papers CASCADE` (corpus wiped to 0),
|
||||
then `ingest_all.sh` failed on the first chunk insert:
|
||||
`UndefinedColumn: column "section" of relation "chunks" does not exist`. The live
|
||||
`chunks` table predates the F-16 `section` column, and nothing had ever applied
|
||||
that DDL to the live DB (M-1: `apply_schema` is never called; `schema.sql` is not
|
||||
re-applyable). The ingest code was ahead of the live schema.
|
||||
|
||||
**New finding — privilege separation (extends M-1).** The fix
|
||||
`ALTER TABLE chunks ADD COLUMN IF NOT EXISTS section TEXT` then failed with
|
||||
`InsufficientPrivilege: must be owner of table chunks`. The live ownership:
|
||||
|
||||
| table | owner |
|
||||
|-------|-------|
|
||||
| papers, chunks, citations, code_links, formulas, figures | `postgres` |
|
||||
| paper_identifiers | `researcher` (app user) |
|
||||
|
||||
The app role `researcher` has DML + `TRUNCATE` but **not** DDL on the
|
||||
postgres-owned core tables. So a `codex migrate` / `apply_schema` invoked with the
|
||||
application's `DATABASE_URL` would fail on every `ALTER` / `CREATE INDEX` against
|
||||
those tables. **Implication for the M-1 fix:** making `schema.sql` idempotent is
|
||||
necessary but *not sufficient* — the migration must run under a privileged role
|
||||
(owner/superuser), via a separate admin connection, not the least-privilege app
|
||||
user. This is now part of M-1's remediation scope.
|
||||
|
||||
**Consequences observed.**
|
||||
- Corpus left wiped (papers = 0) until the owner adds the column and re-ingest is
|
||||
re-run — a destructive-then-fail sequence.
|
||||
- Helper hardened (`fix(infra)` on `fix/audit-wave-2`): a **preflight** verifies
|
||||
`chunks.section` exists *before* the `TRUNCATE`, aborting early with the
|
||||
owner-level `ALTER` to run, so a schema gap can no longer cost the corpus.
|
||||
|
||||
**Unblock (owner action):**
|
||||
`sudo -u postgres psql -d papers -c "ALTER TABLE chunks ADD COLUMN IF NOT EXISTS section TEXT;"`
|
||||
then re-run `bash ingest_all.sh`.
|
||||
|
||||
This incident is the strongest possible validation of M-1 and should anchor its
|
||||
Wave-3 fix: **idempotent `schema.sql` + a `codex migrate` command that connects as
|
||||
a privileged role**, plus a documented owner/grant model for the app user.
|
||||
227
docs/audit/AUDIT-2026-06-15-loop-1.md
Normal file
227
docs/audit/AUDIT-2026-06-15-loop-1.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# AUDIT — Development Loop 1 (main branch)
|
||||
|
||||
**Auditor:** Audit-Loop (Opus)
|
||||
**Date:** 2026-06-15
|
||||
**Scope:** entire `main` branch (HEAD `5dfa6e4`)
|
||||
**Method:** static review of source + schema + tests, branch-wide pattern scan
|
||||
(`ruff`, `mypy`, defect-class greps), full test run (`330 passed`).
|
||||
|
||||
> Status legend — **HIGH** = fix before relying on the feature ·
|
||||
> **MED** = fix soon, correctness/maintainability · **LOW** = polish ·
|
||||
> **INFO** = accepted risk / no action, documented for the record.
|
||||
|
||||
This document **only records findings**. Remediation planning is a separate
|
||||
step (next phase of the audit loop).
|
||||
|
||||
---
|
||||
|
||||
## 0. Headline
|
||||
|
||||
The most recent dev-loop work (**F-15 citation graph** + the follow-up fixes
|
||||
`cited_id→papers.id` resolution and the `paper_identifiers` alias table) is
|
||||
**functionally green but unverified where it matters**: the SQL that *is* the
|
||||
fix has zero real-database coverage, the live-corpus validation in the ADR
|
||||
predates the fix, and the same ID-mismatch bug the fix solves still lives in a
|
||||
parallel code path (`discover.py`). Separately, a **live DB password sits in an
|
||||
un-ignored file** and the new table has **no migration path onto the live DB**.
|
||||
|
||||
Test suite: **330 passed**, `ruff check` clean, `ruff format --check` clean,
|
||||
`mypy` clean on reviewed modules.
|
||||
|
||||
---
|
||||
|
||||
## 1. Security
|
||||
|
||||
### S-1 — Live DB credential in an un-ignored file · **HIGH**
|
||||
- **Evidence:** `.env.jetson-ingest` contains
|
||||
`DATABASE_URL=postgresql://researcher:05761b87…@localhost:5433/papers`.
|
||||
`.gitignore` ignores only the literal `.env` (line: `# Environment secrets — NEVER commit` → `.env`).
|
||||
`git check-ignore .env.jetson-ingest` → **NOT IGNORED**.
|
||||
- **Impact:** one `git add -A && git commit` leaks a real Postgres password to
|
||||
history. The file is currently untracked (`??`), so the leak has **not**
|
||||
happened yet — this is a near-miss, not an incident.
|
||||
- **Note:** the same gap exposes any future `.env.<profile>` file.
|
||||
|
||||
### S-2 — Non-constant-time token comparison (MCP HTTP) · **LOW**
|
||||
- **Evidence:** `codex/mcp_server.py:340` — `if auth != f"Bearer {token}":`.
|
||||
- **Impact:** theoretical timing side-channel on the Bearer token. Low on a
|
||||
trusted LAN, but trivially fixable with `secrets.compare_digest`.
|
||||
|
||||
### S-3 — DNS-rebinding protection disabled · **INFO (accepted)**
|
||||
- **Evidence:** `codex/mcp_server.py:35-37`,
|
||||
`TransportSecuritySettings(enable_dns_rebinding_protection=False)` (commit `d29dcf2`).
|
||||
- **Assessment:** documented and mitigated — HTTP transport requires a Bearer
|
||||
token (`_TokenAuth`) and is firewalled to LAN IPs (UFW). A rebound browser
|
||||
request lacks the token. Residual risk only if the firewall assumption fails.
|
||||
No action; recorded so the trade-off stays visible.
|
||||
|
||||
---
|
||||
|
||||
## 2. Correctness
|
||||
|
||||
### C-1 — `discover.py` still has the ID-format-mismatch bug F-15 fixed · **HIGH**
|
||||
- **Evidence:** `codex/discover.py:20` —
|
||||
`WHERE cited_id NOT IN (SELECT id FROM papers)`; same pattern in
|
||||
`cocited_papers` (`discover.py:52-61`), `citing_papers`, `cited_by`.
|
||||
- **Root cause:** `citations.cited_id` is a **mix** of OpenAlex W-IDs (OpenAlex
|
||||
API path, `ingest.py:229-231` ← `sources/openalex.py:155`) and DOIs/arXiv
|
||||
(GROBID/S2 paths). `papers.id` is the canonical DOI/arXiv. An ingested paper
|
||||
cited by its **OpenAlex ID** is *not* in `(SELECT id FROM papers)`, so it is
|
||||
wrongly reported as a discovery lead.
|
||||
- **Impact:** `codex discover leads`, `codex discover cocited`, and the MCP
|
||||
`discover_leads` tool over-report — foundational papers the user has **already
|
||||
ingested** show up as "cited but not ingested". This is exactly the
|
||||
D-05-class issue the F-15 graph path resolves via the
|
||||
`papers.openalex_id` JOIN (`graph.py:48-56`) — but the fix was **not
|
||||
propagated** to `discover.py`. Two divergent "dangling" implementations now
|
||||
disagree on what "ingested" means.
|
||||
|
||||
### C-2 — `paper_identifiers` JOIN is effectively inert · **MED**
|
||||
- **Evidence:** `graph.py:50-56` second LEFT JOIN; populated only at
|
||||
`ingest.py:153-161`, which inserts **the same** `paper.openalex_id` that
|
||||
`papers.openalex_id` already holds.
|
||||
- **Impact:** because ingest never records an alias that differs from
|
||||
`papers.openalex_id`, the `pi` JOIN can only resolve what the `p` JOIN already
|
||||
resolves. It adds value **only** in the narrow case where a paper's
|
||||
`openalex_id` changes across re-ingests (old alias retained via
|
||||
`ON CONFLICT DO NOTHING`). The table + unique index + seed INSERT + migration
|
||||
burden buy near-zero behaviour today — speculative generality. No test
|
||||
exercises an alias that diverges from `papers.openalex_id`.
|
||||
|
||||
### C-3 — MCP `wiki_read` returns the slug as its own "sources" · **MED**
|
||||
- **Evidence:** `codex/mcp_server.py:138-149` — comment says "Collect cited
|
||||
bibkeys", code sets `sources = [concept_slug]`.
|
||||
- **Impact:** the `sources` field is a placeholder masquerading as data; any MCP
|
||||
client trusting it to list cited bibkeys gets the concept's own slug.
|
||||
|
||||
---
|
||||
|
||||
## 3. Documentation / Code Drift
|
||||
|
||||
### D-1 — ADR-F15 validation predates the shipped graph · **HIGH (integrity)**
|
||||
- **Evidence:** ADR-F15 (commit `a1a6f45`) lands **before** the resolution fixes
|
||||
`664906f` (`cited_id→papers.id`) and `5dfa6e4` (`paper_identifiers`).
|
||||
- **Stale claims:**
|
||||
- "Spike Result": *478 dangling nodes*, specific PageRank scores,
|
||||
*Bobenko/Springborn rank 7*, *5.7 % spread* — all measured on the
|
||||
**pre-resolution** (bipartite DOI→OpenAlex) graph. Post-fix, in-KB cited
|
||||
papers resolve to DOIs, so dangling count and PageRank distribution change.
|
||||
- "Decision → Graph Construction": "inter-KB citations are structurally
|
||||
invisible" — **now false**.
|
||||
- "Known Limitation: ID-Format Mismatch": "cross-citations… not represented"
|
||||
— **now resolved by the code**, contradicting the section.
|
||||
- **Impact:** the **GO** decision rests on numbers the shipping code no longer
|
||||
produces. Validation must be re-run on the post-fix graph (or the ADR marked
|
||||
validation-pending).
|
||||
|
||||
### D-2 — `graph_cite_boost_alpha` doc describes the *fixed* bug · **MED**
|
||||
- **Evidence:** `config.py:296-300` describes
|
||||
`Final score = dense_score * (1 + alpha * pagerank_score)` — a **multiply**.
|
||||
Actual code (`cli.py:107`) divides distance:
|
||||
`boosted = distance / (1.0 + alpha * pr_score)`. The ADR explicitly states the
|
||||
multiply form was the "critical bug corrected during the F-15 review gate".
|
||||
- **Impact:** the config docstring still documents the inverted (buggy) formula.
|
||||
|
||||
### D-3 — `paper_identifiers` tagged "F-17" in schema, shipped as F-15 fix · **LOW**
|
||||
- **Evidence:** `infra/schema.sql:129` comment "F-17 Paper identifier aliases",
|
||||
but the table is introduced by the F-15 graph follow-up (`5dfa6e4`) and the
|
||||
ADR-F15 work. Feature-tag inconsistency; ADR-F15 doesn't mention the table at all.
|
||||
|
||||
---
|
||||
|
||||
## 4. Schema / Migration
|
||||
|
||||
### M-1 — New table has no migration path onto the live DB · **HIGH (operational)**
|
||||
- **Evidence chain:**
|
||||
1. `apply_schema` docstring (`db.py:46-49`) claims "all DDL statements use
|
||||
CREATE … IF NOT EXISTS" — **false**: `papers`, `chunks`, `citations`,
|
||||
`code_links` and their indexes use bare `CREATE TABLE/INDEX`
|
||||
(`schema.sql:16,33,56,68,30,41,…`). Only F-09/F-16/F-17 DDL is idempotent.
|
||||
2. `apply_schema` is **never called** anywhere in the app — no `init`/`migrate`
|
||||
CLI command (`grep apply_schema` → only `db.py` + a mocked unit test).
|
||||
3. `ingest_all.sh` does **not** apply the schema; it only runs `codex ingest`.
|
||||
- **Impact:** re-running `infra/schema.sql` against the **existing** Jetson DB
|
||||
fails on the first `CREATE TABLE papers` (already exists) and rolls back the
|
||||
whole script in one implicit transaction — so the new `paper_identifiers`
|
||||
table (and its seed INSERT) is **never created** on a DB that predates it.
|
||||
`build_citation_graph` then fails with `relation "paper_identifiers" does not
|
||||
exist` on the live corpus. The migration step is currently manual and undocumented.
|
||||
|
||||
---
|
||||
|
||||
## 5. Test Integrity
|
||||
|
||||
### T-1 — The F-15 resolution SQL has zero real coverage · **HIGH**
|
||||
- **Evidence:** every test in `tests/graph/test_graph.py` and
|
||||
`tests/graph/test_cli.py` builds the graph from a `MagicMock` conn
|
||||
(`_make_conn`, `_make_conn_with_paper_ids`). `test_cross_citation_uses_doi_when_in_kb`
|
||||
(`test_graph.py:83-99`) **pre-resolves** the rows in Python and asserts the
|
||||
graph builds — it tests the mock, not the `COALESCE(p.id, pi.paper_id, …)` +
|
||||
two LEFT JOINs that *are* the fix.
|
||||
- **Impact:** the substance of the two most recent commits is unverified. 330
|
||||
green tests give false confidence about the resolution logic, the `p.id IS NULL`
|
||||
correlated guard, and the `paper_identifiers` unique-index behaviour. Needs an
|
||||
integration test against a real (or `pytest`-managed) Postgres.
|
||||
|
||||
### T-2 — `apply_schema` idempotency untested · **MED**
|
||||
- **Evidence:** `tests/scaffold/test_db.py:24-32` mocks the conn and only checks
|
||||
`execute`/`commit` are called. The (false) idempotency claim in M-1 is never
|
||||
exercised.
|
||||
|
||||
---
|
||||
|
||||
## 6. Design / Usability
|
||||
|
||||
### U-1 — `graph report` prints bare IDs, no titles · **LOW**
|
||||
- `cli.py:582-588` emits `score <id>` and bare dangling IDs — a mix of DOIs and
|
||||
`W…` OpenAlex IDs with no title/year. The ADR's readable author/title table is
|
||||
not what the CLI produces; output is hard to interpret.
|
||||
|
||||
### U-2 — `--cite-boost` can only reorder within the top-`limit` page · **LOW**
|
||||
- `cli.py:84-94` applies `ORDER BY … LIMIT limit` in SQL **before** the boost
|
||||
(`cli.py:96-109`). A high-PR paper just outside the semantic cut can never be
|
||||
pulled in. With α=0.3 and PR≈0.002 the effect is ~0.06 % — effectively a
|
||||
within-page tie-breaker. Consistent with ADR intent, but worth stating as a
|
||||
limitation (the flag does far less than "weight results by PageRank" implies).
|
||||
|
||||
### U-3 — `graph report --json` drops the small-corpus warning · **LOW**
|
||||
- `cli.py:557-570` returns before the `graph_min_corpus_size` warning
|
||||
(`cli.py:572-577`). JSON consumers get neither the warning nor a flag for it.
|
||||
|
||||
### U-4 — `graph.find_co_cited` implemented + tested but not wired to a CLI · **LOW**
|
||||
- `graph.find_co_cited` (`graph.py:142`) is unit-tested but exposed by no `graph`
|
||||
sub-command (only the separate SQL-based `discover cocited` is wired). ADR-F15
|
||||
R-43's co-citation is only half-surfaced.
|
||||
|
||||
---
|
||||
|
||||
## 7. State of Tree (INFO — no defect)
|
||||
|
||||
- The uncommitted diff (`wiki.py`, `synthesis.py`, `semanticscholar.py`,
|
||||
`tests/synthesis/test_cli.py`, `tests/wiki/test_compile.py`) is a **legitimate
|
||||
`ruff format` normalization** to `line-length=100`. `ruff format --check`
|
||||
reports all 25 files formatted *with* these changes applied, meaning the
|
||||
**committed** code was non-compliant. These edits should be **committed**, not
|
||||
reverted. (Not a finding; clarifies the working-tree churn.)
|
||||
|
||||
---
|
||||
|
||||
## 8. Coverage of this audit (honesty ledger)
|
||||
|
||||
**Deep-reviewed:** `graph.py`, `db.py`, `infra/schema.sql`, `ingest.py`,
|
||||
`discover.py`, `provenance.py` (scan), `mcp_server.py`, `cli.py` (search +
|
||||
graph), `config.py` (graph settings), F-15 tests, ADR-F15; branch-wide pattern
|
||||
scan (SQL injection, bare except, eval/exec/subprocess, secrets, timeouts).
|
||||
|
||||
**Not yet deep-reviewed (no findings asserted):**
|
||||
- `synthesis.py` grounding guard / claim parser (`_run_grounding_guard`,
|
||||
`_parse_claims`) — the "zero-fact-leak (N-02)" core. Prompts reviewed; guard
|
||||
logic not yet traced.
|
||||
- `wiki.py` grounding guard + conflict detection (only the format diff seen).
|
||||
- `sources/` (arxiv, openalex, semanticscholar) beyond citation shapes.
|
||||
- `parsing/` (grobid, nougat, mathpix, figures, tex) — error handling in
|
||||
`mathpix.py`/`figures.py` uses broad `except Exception` without `noqa`
|
||||
(`mathpix.py:109,151`, `figures.py:125,152`) — flagged for a later pass.
|
||||
- `embed.py`, `quality.py`, `models.py`.
|
||||
|
||||
A second pass should close these before the audit is declared complete.
|
||||
575
docs/audit/DATA-QUALITY-2026-06-15.md
Normal file
575
docs/audit/DATA-QUALITY-2026-06-15.md
Normal file
@@ -0,0 +1,575 @@
|
||||
# DATA-QUALITY AUDIT — codex knowledge base (handoff for a fresh session)
|
||||
|
||||
**Status:** baseline scan done 2026-06-15. **DQ-1 RESOLVED 2026-06-16** (S2 supplement
|
||||
applied to live DB + wired into ingest). **DQ-4 MEASURED 2026-06-16** — retrieval is
|
||||
strong after fixing a P0 `codex search paper` crash. **DQ-2 RESOLVED 2026-06-16**
|
||||
(1911.00966 fully recovered from arXiv; s00454 abstract from S2; book chapter is a
|
||||
documented limit; recovery wired into ingest). **DQ-3 RESOLVED 2026-06-16**
|
||||
(content fidelity clean — full coverage, only OCR junk dropped). **All four
|
||||
assessment items (DQ-1..DQ-4) DONE. DQ-5 RESOLVED 2026-06-17** (DOI id normalized
|
||||
to bare canonical form in `openalex._map_paper`; live re-ingest of
|
||||
`10.1007/s00454-019-00132-8` confirmed idempotent → **unblocks R-A/R-C**).
|
||||
**Roadmap R-A..R-E** added 2026-06-16 (free-source acquisition levers — see Roadmap section).
|
||||
**Author:** Audit-Loop (Opus). **Intended reader:** a *cold* session with no memory
|
||||
of the audit conversation — everything needed to continue is in this file.
|
||||
|
||||
---
|
||||
|
||||
## Why this exists — the second audit axis
|
||||
|
||||
The code/pipeline audit (`AUDIT-2026-06-15-loop-1.md`, `-full-repo.md`) verified the
|
||||
**loader is correct**: IDs are canonical, the citation graph is consistent, the
|
||||
ingest pipeline does what it claims. It did **not** ask whether the *information
|
||||
in the database is actually good*. A correct loader can faithfully load thin,
|
||||
incomplete, or unrepresentative data. This document is that second axis — a
|
||||
**data-quality** assessment of the live corpus, independent of code correctness.
|
||||
|
||||
---
|
||||
|
||||
## How to reach the data (self-contained)
|
||||
|
||||
- **Live DB:** Jetson PostgreSQL via an SSH tunnel (open it first):
|
||||
```
|
||||
ssh -f -N -L 5433:localhost:5432 alfred@jetson.local
|
||||
```
|
||||
- **Credentials:** `.env.jetson-ingest` (gitignored) holds `DATABASE_URL`
|
||||
(`postgresql://researcher:…@localhost:5433/papers`). The `researcher` role is
|
||||
DML-only (read freely; no DDL).
|
||||
- **Run probes:** `set -a; source .env.jetson-ingest; set +a` then
|
||||
`PYTHONPATH=. .venv/bin/python` with `psycopg` + `psycopg.rows.dict_row`.
|
||||
Reusable domain helpers: `codex.quality` (`is_quality_chunk`, `_bib_score`,
|
||||
`classify_section`), `codex.graph`, `codex.discover`.
|
||||
- **Corpus snapshot (2026-06-15, post canonical-id migration):**
|
||||
**29 papers, 1507 chunks, 590 citations.**
|
||||
|
||||
---
|
||||
|
||||
## Baseline — what is already GOOD (measured, no action needed)
|
||||
|
||||
- **Chunk content is clean.** Re-running the F-16 quality gate over all stored
|
||||
chunks: **0 / 1507 fail** `is_quality_chunk` (none too-short, none low-alpha /
|
||||
OCR-ish, none bibliography-like). The gate did its job on the `.txt` ingest.
|
||||
- **Chunk sizing healthy.** chars: min 478 / median 2913 / max 4154; **0 exact
|
||||
duplicate** chunk bodies.
|
||||
- **`section` column is low-signal** (98 % `body`; 13 `bibliography`, 9 `theorem`,
|
||||
2 `intro`, 1 `proof`). This confirms code-audit **R-12** — word-window chunks
|
||||
rarely start at a section header. Not a data defect; the column is just weak.
|
||||
|
||||
---
|
||||
|
||||
## Findings (open investigation items)
|
||||
|
||||
### DQ-1 — Citation coverage: 12 / 29 papers (41 %) had ZERO citations · **RESOLVED 2026-06-16**
|
||||
|
||||
**Resolution (what was done):**
|
||||
- **Verified upstream, not a bug.** Queried the OpenAlex API directly for all 11
|
||||
zero-citation papers that have an `openalex_id`: every one returns
|
||||
`referenced_works_count = 0` server-side, W-ids all match what we stored. They
|
||||
are `type: preprint` (arXiv) / `article` / `dissertation` — works OpenAlex
|
||||
indexes without a parsed bibliography. Confirmed source-coverage limit.
|
||||
- **Semantic Scholar supplement applied to the live DB** (idempotent backfill,
|
||||
`ON CONFLICT (citing_id,cited_id) DO NOTHING`): **+330 citations (590 → 920)**.
|
||||
Zero-out-edge papers **12 → 2**; in-corpus citing coverage **17/29 → 27/29**;
|
||||
graph 489→704 nodes, 590→920 edges; discovery-lead targets 472→677. 9 of the
|
||||
new edges are in-corpus (e.g. `2305.10988 → 10.1007/s00454-019-00132-8`).
|
||||
- **Wired into ingest** so future re-ingests self-heal: when OpenAlex returns an
|
||||
empty `referenced_works`, `ingest.py` now falls back to
|
||||
`_s2_reference_supplement()` (citing_id rewritten to canonical `papers.id`,
|
||||
DOI cited-ids lowercased). Same helper drove the one-off backfill.
|
||||
- **Bug fixed (would have crashed the batch):** `semanticscholar.fetch_references`
|
||||
used `data.get("data", [])`, but S2 returns HTTP 200 `{"data": null}` for a
|
||||
known paper with no parsed refs (e.g. `depositonce-5415`) → `TypeError`.
|
||||
Changed to `data.get("data") or []`. Removed a now-redundant discarded S2 probe
|
||||
in the OpenAlex-404 arXiv branch. New regression test added.
|
||||
- **Irreducible remainder (documented limit):** the **2 `depositonce` theses**
|
||||
(`10.14279/depositonce-20357`, `-5415`) stay at zero — neither OpenAlex nor S2
|
||||
indexes references for TU-Berlin institutional DOIs. Accept as inherent.
|
||||
- **Files touched (uncommitted in working tree):** `codex/sources/semanticscholar.py`,
|
||||
`codex/ingest.py`, `tests/ingest/test_ingest.py`. Full suite: 331 passed.
|
||||
|
||||
**Original finding (for context):**
|
||||
- **Measured:** 12 papers contribute **no** out-edges; the 590-edge citation graph
|
||||
comes from only ~17 papers. The whole F-15 layer (PageRank / coupling /
|
||||
co-citation / discovery leads) therefore runs on ~59 % of the corpus.
|
||||
- **Sharpening — it is mostly a source-coverage limit, not an ingest bug:**
|
||||
**11 of the 12** zero-citation papers *do* have an `openalex_id`, i.e. ingest
|
||||
called `openalex.fetch_citations(openalex_id)` and OpenAlex returned an **empty
|
||||
`referenced_works`** list. Only `1911.00966` has no `openalex_id` (OpenAlex
|
||||
404 → arXiv/S2 fallback, which also yielded nothing).
|
||||
- **Zero-citation ids:** `1005.2698`, `1505.01341`, `1911.00966` (no oa),
|
||||
`2206.13461`, `2305.10988`, `2310.17529`, `2601.22903`, `math/0001176`,
|
||||
`math/0503219`, `math/0603097`, `10.14279/depositonce-20357`,
|
||||
`10.14279/depositonce-5415`. (Pattern: arXiv preprints + the two `depositonce`
|
||||
theses — works OpenAlex indexes without parsed references.)
|
||||
- **To investigate next:**
|
||||
1. Confirm it is OpenAlex coverage, not a `fetch_citations` bug: for 2–3 of the
|
||||
ids, hit `GET https://api.openalex.org/works/<openalex_id>` and check whether
|
||||
`referenced_works` is genuinely empty server-side.
|
||||
2. If genuinely empty: enrich via the **Semantic Scholar references** path
|
||||
(`semanticscholar.fetch_references("arXiv:<id>")`) as a *supplement* for
|
||||
OpenAlex-empty papers — S2 often has references where OpenAlex doesn't. Note
|
||||
S2 cited_ids are bare DOI/arXiv, so they flow through the existing
|
||||
`RESOLVED_CITATIONS_SQL` resolver (audit C-1) fine.
|
||||
3. Decide whether the F-15 `graph_min_corpus_size` warning should also flag *low
|
||||
citing-paper coverage*, not just paper count.
|
||||
- **Acceptance:** either (a) citation coverage materially improves after an S2
|
||||
supplement, or (b) documented as an inherent OpenAlex-coverage limit with the
|
||||
graph caveated accordingly.
|
||||
|
||||
### DQ-2 — Metadata gaps: 3 no-abstract, 1 fully metadata-less · **RESOLVED 2026-06-16**
|
||||
|
||||
**Resolution (what was done):**
|
||||
- **`1911.00966` fully recovered** from the arXiv Atom API (OpenAlex 404'd on it):
|
||||
title "A discrete version of Liouville's theorem on conformal maps",
|
||||
Pinkall & Springborn, 2019, 384-char abstract → bibkey `PinkallSpringborn2019`
|
||||
auto-generated, abstract embedded (non-zero). It was the worst node in the
|
||||
corpus — now `@cite`-able, in wiki grounding, and its **10 chunks are visible
|
||||
to chunk search** (previously filtered out by `bibkey IS NOT NULL`). Citations
|
||||
were already added in DQ-1 (27 S2 refs).
|
||||
- **`10.1007/s00454-019-00132-8` abstract recovered from S2** (1186 chars) and
|
||||
re-embedded (targeted `UPDATE`, not full re-ingest — see DQ-5).
|
||||
- **`10.1007/978-3-642-17413-1_7` (book chapter): documented inherent limit.**
|
||||
No abstract in OpenAlex, S2, **or Crossref** (verified). Left as the single
|
||||
remaining zero-vector paper; user may add an abstract by hand.
|
||||
- **Corpus now: 1 paper without abstract** (the book chapter), down from 3+1.
|
||||
Zero-vector search pollution (DQ-4 secondary finding) reduced from 2 tail
|
||||
fillers to 1.
|
||||
- **Wired into ingest** so this self-heals: OpenAlex-404 on an arXiv id now
|
||||
recovers metadata via `arxiv.fetch_metadata` (new); an empty abstract is
|
||||
supplemented via `_recover_abstract` → S2 then Crossref (new
|
||||
`codex/sources/crossref.py`). New `semanticscholar.fetch_abstract`.
|
||||
- **Files touched:** `codex/sources/arxiv.py`, `codex/sources/crossref.py` (new),
|
||||
`codex/sources/semanticscholar.py`, `codex/ingest.py`, + tests. Full suite: 342.
|
||||
|
||||
**Original finding (for context):**
|
||||
- **Measured:**
|
||||
- **No abstract (3):** `10.1007/978-3-642-17413-1_7`,
|
||||
`10.1007/s00454-019-00132-8`, `1911.00966`. These get a **zero-vector**
|
||||
`abstract_emb`, so paper-level semantic search can't place them.
|
||||
- **No bibkey / year (1):** `1911.00966` only — and its authors array is empty
|
||||
too. This paper is **fully degraded** (OpenAlex 404 → `Paper(id, title="")`
|
||||
fallback): no abstract, no bibkey, no year, empty authors, no citations. With
|
||||
no bibkey it cannot be `@cite`d and is invisible to wiki grounding (which keys
|
||||
on bibkey).
|
||||
- **To investigate next:**
|
||||
1. `1911.00966` — confirm the arXiv id is correct and whether OpenAlex/S2 has it
|
||||
under a different id (DOI?); if recoverable, re-ingest to populate metadata.
|
||||
If not, decide: keep as a degraded node or drop.
|
||||
2. The 2 no-abstract DOIs — check if OpenAlex has an `abstract_inverted_index`
|
||||
that the mapper missed, or if the abstract is genuinely absent upstream.
|
||||
- **Acceptance:** every paper has at least a bibkey + non-zero abstract embedding,
|
||||
or the exceptions are documented with rationale.
|
||||
|
||||
### DQ-3 — Content fidelity (chunks vs source `.txt`): VERIFIED CLEAN · **RESOLVED 2026-06-16**
|
||||
|
||||
**Resolution (read-only; re-ran `chunk_text` + `is_quality_chunk` on all 29
|
||||
source files and compared to stored chunks):**
|
||||
- **No paper is silently gutted.** Coverage (stored chunk chars / source chars)
|
||||
is **109–114% for every paper** — the >100% is exactly the 64/512-word overlap
|
||||
inflation, i.e. full retention. `stored == kept` for all 29, so the live DB
|
||||
matches what the current pipeline produces.
|
||||
- **Only 18 chunks dropped corpus-wide, all `low_alpha`** (0 short, 0 bib),
|
||||
concentrated in `10.1007/0-387-29555-0_13` (6) and `depositonce-5415` (10).
|
||||
Inspected: they are **garbled OCR tables** from scanned-book sources (alpha
|
||||
0.30–0.36, e.g. `"(u: 1) 7 < u D:= ud3424 — else H®: out.v. Family 1…"`) —
|
||||
exactly the artefacts the alpha-ratio gate targets. **No real math content lost**
|
||||
(the handoff's feared failure mode did not occur; math prose stays > 0.40 alpha).
|
||||
- **Head/tail alignment exact** on sampled papers: chunk 0 begins at the source
|
||||
head (title/authors/abstract captured), last chunk ends at the source tail → no
|
||||
front/back truncation.
|
||||
- **Note:** 0 bibliography drops across the corpus → the upstream `.txt` extraction
|
||||
had already stripped reference lists, which is why DQ-1's citation graph relies
|
||||
on the API/GROBID sources rather than text-mined refs.
|
||||
- **Acceptance met** for all 29 papers (not just a sample). No action needed.
|
||||
- **⚠ SCOPE LIMIT — DQ-3 covers `.txt → chunks` ONLY.** It treats each source
|
||||
`.txt` as ground truth and verifies the *ingest pipeline* preserves it. It does
|
||||
**not** measure the layer above — **PDF/source → `.txt`** — which was done by an
|
||||
upstream tool in `ConformalLabpp` (outside codex-py) and is unexamined. There is
|
||||
already evidence that layer is lossy: the 18 dropped chunks were garbled OCR
|
||||
tables *already present in the `.txt`* (i.e. the PDF→txt step garbled the
|
||||
table/formula regions), and the bibliographies were stripped upstream. So
|
||||
"VERIFIED CLEAN" means **the loader faithfully preserves whatever the `.txt`
|
||||
contains, not that no information was lost from the source paper.** See DQ-6.
|
||||
|
||||
### DQ-6 — PDF/source → `.txt` extraction fidelity: NOT MEASURED · **MED, open**
|
||||
- **Open question:** how much did the upstream PDF→`.txt` extraction (done in
|
||||
`ConformalLabpp`, outside codex-py) drop or garble vs the original papers?
|
||||
Math/equations, tables, multi-column layouts, and figure captions are the usual
|
||||
casualties of PDF text extraction — and we have direct evidence of garble
|
||||
(DQ-3's low_alpha drops were already-garbled tables in the `.txt`).
|
||||
- **Checkable:** the 36 original PDFs are present in `…/ConformalLabpp/papers/`
|
||||
(map each paper's `source_path` `.txt` to its sibling `.pdf` by filename stem).
|
||||
- **To investigate:** for a sample (incl. the known-bad scanned book
|
||||
`0-387-29555-0_13` and a born-digital arXiv PDF), extract PDF text independently
|
||||
and compare length / page-coverage to the `.txt`; spot-check whether specific
|
||||
theorem statements & equations survive. Flag papers where the `.txt` is missing
|
||||
large spans.
|
||||
- **Acceptance:** a per-sample source→txt coverage estimate, or a list of papers
|
||||
whose `.txt` is materially degraded (candidates for re-extraction — note roadmap
|
||||
**R-A/R-C** would re-ingest from PDF/`.tex` directly and largely sidestep this).
|
||||
|
||||
**Original open question (for context):**
|
||||
- **Open question:** does the stored chunk set faithfully reconstruct each source
|
||||
file, or did the chunker / `filter_chunks` silently drop material (e.g. an
|
||||
abstract, a section, math-heavy passages)? The whole corpus was ingested from
|
||||
`.txt` (`PAPERS_DIR=/Users/tarikmoussa/Desktop/ConformalLabpp/papers/txt`), so
|
||||
the `.tex`/`.pdf` paths never ran.
|
||||
- **To investigate next:** for a sample of ~5 papers, compare
|
||||
`len("".join(stored chunks))` against `len(source.txt)` (coverage %), and
|
||||
eyeball the first/last chunk vs the file head/tail. Flag papers where coverage
|
||||
is low (content lost) — F-16 dropping >X % of a paper is a signal.
|
||||
- **Acceptance:** sampled papers retain ≳ the expected fraction of source text;
|
||||
no paper is silently gutted by the quality gate.
|
||||
|
||||
### DQ-4 — Retrieval quality · **MEASURED 2026-06-16 — good, after fixing a P0 crash**
|
||||
|
||||
**P0 bug found + fixed: `codex search paper` was crash-broken on the live DB.**
|
||||
The CLI passed the query embedding uncast, so pgvector raised
|
||||
`operator does not exist: vector <-> double precision[]` on every paper search.
|
||||
The working call sites (`mcp_server.py`, `wiki.py`) cast `%(emb)s::vector`; the
|
||||
CLI did not. Fixed both `<->` occurrences in `codex/cli.py search_paper`; added a
|
||||
regression guard in `tests/cli/test_cli.py` asserting the cast (mocked-DB unit
|
||||
tests can't catch the type error — this is why it survived the code audit).
|
||||
|
||||
**Relevance verdict (post-fix): strong.** Six domain queries, both surfaces
|
||||
(paper-level abstract search + chunk-level). **rank-1 was the exactly-correct
|
||||
paper in 6/6 queries** for chunk search and 5/6 for paper-level (Laplace–Beltrami
|
||||
put the exact paper at rank-2 behind the closely-related "Polygon Laplacian Made
|
||||
Simple" — acceptable). Examples: "combinatorial Yamabe flow" → `math/0306167`
|
||||
(exact, all 5 top chunks that paper); "variational principle for Delaunay
|
||||
triangulations" → `math/0603097` (exact); "circle packing rigidity" →
|
||||
`2601.22903` (exact). Three of the DQ-1-rescued papers (`2305.10988`,
|
||||
`1005.2698`, `depositonce-5415`) now surface as top hits. The KB is trustworthy
|
||||
for lookups.
|
||||
|
||||
**Secondary finding → reinforces DQ-2: zero-vector abstract pollution.** Papers
|
||||
with no abstract get a zero `abstract_emb`, which sits at constant L2 distance
|
||||
≈1.000 from every query and appears as filler in the paper-level tail whenever
|
||||
< 5 strongly-relevant papers exist. `1911.00966` (fully degraded, DQ-2) showed up
|
||||
at distance 1.000 in 4/6 queries. Harmless at rank>1 with a visible 1.000 score,
|
||||
but noise — and a concrete reason to fix DQ-2. Chunk-level search is immune (it
|
||||
filters `bibkey IS NOT NULL`, and 1911.00966 has no bibkey/chunks).
|
||||
|
||||
**Note:** the MCP `search` docstring claims "hybrid dense + FTS" but the SQL is
|
||||
dense-only; `score = 1.0 - dist` can go negative (bge-m3 L2 ranges 0–2). Ranking
|
||||
is fine (monotonic); only the absolute score label is misleading.
|
||||
|
||||
**Original open question (for context):**
|
||||
- **Open question:** end-to-end, do real queries return *relevant* results? Clean
|
||||
chunks + a working index don't guarantee useful retrieval.
|
||||
- **To investigate next:** run a handful of domain queries through the actual
|
||||
search path (`codex search paper "<q>"` and the chunk-level MCP `search`), e.g.
|
||||
"discrete conformal map", "circle packing rigidity", "combinatorial Yamabe
|
||||
flow", "discrete Laplace-Beltrami operator", and judge whether the top-5 hits
|
||||
are on-topic and point at the right papers. Cross-check a couple against
|
||||
`--cite-boost` to see if the boost helps or hurts on this corpus.
|
||||
- **Acceptance:** a short relevance table (query → top-5 → on/off-topic) good
|
||||
enough to trust the KB for lookups, or a list of failure modes to fix.
|
||||
|
||||
### DQ-5 — `ingest_paper` is NOT idempotent for DOI papers · **RESOLVED 2026-06-17** (found 2026-06-16)
|
||||
|
||||
**Resolution (what was done):**
|
||||
- **Root-cause fix, minimal + contained:** added `openalex._normalize_doi()` and
|
||||
applied it in `_map_paper` so the DOI branch yields the canonical **bare,
|
||||
lower-cased** form (strips `https://doi.org/` / `http://doi.org/` / `doi:`;
|
||||
lower-cases — DOIs are case-insensitive, matching `ingest._norm_cited_id`). The
|
||||
no-DOI fallback (OpenAlex W-id URL) is left untouched. `_map_paper`→`fetch_paper`
|
||||
→`ingest_paper` is the only consumer chain, so blast radius is one function.
|
||||
- **Checked nothing depended on the URL form.** It was the opposite: the full-URL
|
||||
id silently *defeated* `_s2_id_for(paper.id)` and `_recover_abstract`'s
|
||||
`paper.id.startswith("10.")` (a `https://…` string is neither `10.`- nor
|
||||
`doi:`-prefixed), so S2/Crossref recovery never fired for DOI papers. The fix
|
||||
repairs those paths too.
|
||||
- **Tests (offline, mocked DB):** closed audit **T-3** — the OpenAlex fixture now
|
||||
emits the real URL-form DOI and asserts `paper.id` is the bare form; added
|
||||
`_normalize_doi` unit tests (https/http/`doi:`/upper-case/already-bare) and a
|
||||
W-id-fallback test; added ingest regression `test_ingest_doi_paper_upserts_bare
|
||||
_canonical_id` (runs the real `_map_paper` through ingest, asserts the papers
|
||||
upsert carries the **bare** id into `%(id)s` and uses `ON CONFLICT (id)`).
|
||||
Verified it has teeth (fails on the pre-fix code). Full suite **348 passed**;
|
||||
ruff + mypy --strict clean.
|
||||
- **Live idempotency confirmed (tunnel up):** re-ingested
|
||||
`10.1007/s00454-019-00132-8` against the Jetson DB → no `UniqueViolation`;
|
||||
`IngestResult(paper_id='10.1007/s00454-019-00132-8', citations_upserted=45)`.
|
||||
After-state: **UPDATE in place** — `id` and `openalex_id` unchanged, **`added_at`
|
||||
unchanged** (proves no fresh INSERT), exactly **1** row for the DOI (no URL-form
|
||||
duplicate), citations 45→45, total papers 29→29.
|
||||
- **Unblocks R-A (GROBID re-ingest) and R-C (.tex re-ingest)** — both re-ingest
|
||||
existing papers. **Files touched:** `codex/sources/openalex.py`,
|
||||
`tests/sources/test_openalex.py`, `tests/ingest/test_ingest.py`.
|
||||
- **Note (out of scope, pre-existing):** arXiv papers stored as bare ids
|
||||
(`2305.10988`) are still not round-trip idempotent if OpenAlex returns an
|
||||
arXiv-DOI/W-id instead of the bare arXiv id — a separate canonicalization
|
||||
concern, not this UniqueViolation. The DOI path (this finding) is fixed.
|
||||
|
||||
**Original finding (for context):**
|
||||
- **Symptom:** re-ingesting an existing DOI paper crashes with
|
||||
`UniqueViolation: papers_openalex_id_key`. Surfaced trying to re-ingest
|
||||
`10.1007/s00454-019-00132-8` during the DQ-2 backfill.
|
||||
- **Root cause:** `openalex._map_paper` sets `Paper.id = data["doi"]`, which is the
|
||||
**full URL** `https://doi.org/10.1007/…`. The stored canonical id is the **bare**
|
||||
`10.1007/…` (post the M-1 canonical-id migration). So `INSERT … ON CONFLICT (id)`
|
||||
finds no id match, attempts a fresh INSERT, and trips the *separate* unique
|
||||
constraint on `openalex_id` (already held by the canonical-id row).
|
||||
- **Confirmed:** `openalex.fetch_paper("10.1007/s00454-019-00132-8").id ==
|
||||
"https://doi.org/10.1007/s00454-019-00132-8"` (≠ stored bare id).
|
||||
- **Impact:** any re-ingest of a DOI paper aborts. This **blocks roadmap R-A
|
||||
(GROBID on PDFs) and R-C (.tex ingest)** — both re-ingest existing papers. Also
|
||||
implies *new* DOI ingests store full-URL ids, inconsistent with the bare-id
|
||||
corpus (latent split-identity risk).
|
||||
- **Proposed fix (handle with care — M-1 incident territory):** normalize the DOI
|
||||
in `_map_paper` to the canonical bare, lower-cased form (strip
|
||||
`https://doi.org/`), matching what the M-1 migration produced. Add a re-ingest
|
||||
idempotency test over a DOI paper. Did **not** fix inline this session — the
|
||||
canonical-id derivation is exactly where the M-1 migration incident occurred, so
|
||||
it needs its own focused change + a live re-ingest check.
|
||||
- **Workaround used for DQ-2:** targeted `UPDATE … SET abstract, abstract_emb`
|
||||
instead of full re-ingest.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap — data-acquisition levers (post-audit, all free sources)
|
||||
|
||||
These are *forward-looking acquisition improvements*, distinct from the DQ-1..DQ-4
|
||||
audit findings (which assessed existing data). They came out of a strategy
|
||||
discussion on 2026-06-16: for this Math/CS corpus the valuable data (references,
|
||||
abstracts, full text) is almost entirely **open** (arXiv, OpenAlex, Crossref, S2),
|
||||
so the quality lever is *combining more free sources*, not buying paywall access.
|
||||
Paywall/uni-login was explicitly considered and **deferred** (see R-E). Each item
|
||||
is self-contained so a cold session can pick it up.
|
||||
|
||||
### R-A — Run GROBID reference extraction on PDFs · **CORE DONE 2026-06-17** (full sweep optional)
|
||||
|
||||
**Resolution (what was done) — citing coverage 27/29 → 29/29 (100%); edges 920 → 1022:**
|
||||
- **References-only backfill, not full re-ingest.** Added
|
||||
[scripts/ra_grobid_backfill.py](../../scripts/ra_grobid_backfill.py): per paper,
|
||||
run `grobid.extract_references(pdf)`, normalize cited-ids to the canonical bare
|
||||
form (DOIs de-URL'd + lower-cased; `arXiv:` stripped), INSERT into `citations`
|
||||
(`ON CONFLICT DO NOTHING`). Deliberately avoids `ingest_paper(source_path=pdf)`,
|
||||
whose PDF branch re-runs Nougat + `DELETE FROM chunks` and would overwrite the
|
||||
DQ-3-verified-clean `.txt` chunks (and needs Nougat). Idempotent; dry-run by
|
||||
default; 7 tests; ruff/mypy clean.
|
||||
- **GROBID provisioning:** the pinned `grobid/grobid:0.8.2` is amd64-only, but the
|
||||
Jetson and the dev Mac are both arm64 (this is why GROBID never ran). Ran the
|
||||
lighter CRF image (`grobid/grobid:0.8.2-crf`) on the Mac under Podman+Rosetta
|
||||
(PDFs are local; citations written over the SSH DB tunnel). Made
|
||||
`extract_references` timeout configurable (theses need >60s emulated). A separate
|
||||
task is provisioning a native arm64 GROBID on the Jetson (`docs/infra/grobid-jetson.md`).
|
||||
- **Both `depositonce` theses closed:** `…depositonce-20357` (lutz-2024) **+96**
|
||||
edges (5 in-corpus); `…depositonce-5415` (sechelmann-2016) **+6** edges — its
|
||||
147 MB/173-page scan blew pdfalto's 120s limit, so the 9 bibliography pages were
|
||||
extracted with PyMuPDF first (77 refs, only 6 with a DOI/arXiv). **No paper has
|
||||
zero out-edges now.**
|
||||
- **Optional remaining (secondary acceptance):** sweep the other 27 already-covered
|
||||
papers for *extra* GROBID refs via `python scripts/ra_grobid_backfill.py --write`
|
||||
(the 2 book PDFs need the same bib-page extraction as sechelmann).
|
||||
|
||||
**Original plan (for context):**
|
||||
- **✓ Unblocked (DQ-5 RESOLVED 2026-06-17):** DOI re-ingest is now idempotent, so
|
||||
the GROBID re-ingest no longer aborts on `papers_openalex_id_key`.
|
||||
- **What:** the whole corpus was ingested from `.txt` (`PAPERS_DIR=…/papers/txt`),
|
||||
so the GROBID PDF path never ran. Re-ingest with a PDF `source_path` per paper
|
||||
so `codex.parsing.grobid.extract_references` parses each bibliography.
|
||||
- **Why:** extracts references the APIs lack — a *complement* to DQ-1's S2
|
||||
supplement. Crucially, the **2 `depositonce` theses** that stayed at zero in
|
||||
DQ-1 (no API references anywhere) almost certainly have a references section in
|
||||
their open-access PDFs → GROBID could finally give them out-edges. Closes the
|
||||
last 2/29.
|
||||
- **How:** no new code — ingest already merges + dedups `api_citations +
|
||||
pdf_citations` ([codex/ingest.py:288](../../codex/ingest.py)). GROBID server is
|
||||
already configured (`GROBID_URL=http://jetson.local:8070` in
|
||||
`.env.jetson-ingest`). Download free arXiv PDFs, re-ingest with `source_path`.
|
||||
- **Acceptance:** the 2 theses gain references; published papers gain
|
||||
GROBID-parsed refs beyond OpenAlex/S2 coverage; citing coverage 27/29 → 29/29.
|
||||
|
||||
### R-B — Add Crossref as a third citation + abstract source · **DONE 2026-06-17**
|
||||
|
||||
**Resolution (what was done):**
|
||||
- **`crossref.fetch_references(doi)`** added (mirrors `semanticscholar.fetch_references`):
|
||||
GETs `/works/{doi}`, returns one `Citation` per `message.reference[]` entry that
|
||||
carries a registered `DOI` (book/`unstructured`-only refs are skipped — they
|
||||
can't join the graph). `citing_id` is the queried DOI; the ingest supplement
|
||||
rewrites it to `paper.id`.
|
||||
- **Wired as the third leg of the citation fallback chain** in `ingest.py`:
|
||||
OpenAlex-empty → S2 → **Crossref** (`_crossref_reference_supplement`, DOI papers
|
||||
only — Crossref's works endpoint is DOI-keyed). cited DOIs pass through
|
||||
`_norm_cited_id` (bare, lower-cased). Network/parse failures degrade to [].
|
||||
- **Tests:** `fetch_references` unit tests (DOI edges / `unstructured` skipped / no
|
||||
`reference` key / 404) + ingest tests asserting the Crossref leg fires when
|
||||
OpenAlex+S2 are empty (cited DOI normalised) **and** does *not* fire when OpenAlex
|
||||
has citations. Suite **361 green**, ruff/mypy clean. The DQ-2 abstract half
|
||||
(`fetch_abstract`) already shipped earlier.
|
||||
- **Live-validated:** `fetch_references` against the real API returned 33 / 40 / 24
|
||||
DOI-bearing refs for `10.1007/s00454-019-00132-8` / `10.1145/3132705` /
|
||||
`10.1111/cgf.13931` (Springer / ACM / Wiley).
|
||||
- **No live corpus change (by design):** as a *fallback*, Crossref fires only when
|
||||
OpenAlex **and** S2 are both empty. Post-DQ-1/R-A the only such papers were the 2
|
||||
`depositonce` theses, which Crossref doesn't index either — so R-B is a
|
||||
forward-looking third leg for future DOI ingests, not a backfill. (Harvesting
|
||||
Crossref's *unique* refs for already-covered papers would need an always-merged
|
||||
supplement instead — a deliberate scope choice not taken, per the doc's "fallback
|
||||
chain" spec.) **Files:** `codex/sources/crossref.py`, `codex/ingest.py`, + tests.
|
||||
|
||||
**Original plan (for context):**
|
||||
- **What:** new `codex.sources.crossref` client hitting Crossref REST
|
||||
`/works/{doi}` for the `reference` array and `abstract`.
|
||||
- **Why:** Crossref carries **publisher-deposited references** for many DOIs (free,
|
||||
polite pool via `mailto`) — a third leg after OpenAlex/S2. It also recovers
|
||||
abstracts OpenAlex is contractually barred from redistributing (likely the 2
|
||||
Springer DOIs missing abstracts in DQ-2). cited_ids are DOIs → flow through the
|
||||
existing graph resolver unchanged.
|
||||
- **How:** mirror `codex/sources/openalex.py` (tenacity retry, polite pool). Extend
|
||||
the ingest fallback chain to OpenAlex-empty → S2 → Crossref (same
|
||||
`_s2_reference_supplement` shape). Add unit tests with mocked HTTP.
|
||||
- **Acceptance:** Crossref recovers refs and/or an abstract for ≥1 paper where
|
||||
OpenAlex+S2 are empty.
|
||||
|
||||
### R-C — Prefer arXiv `.tex` source over `.txt` for chunk fidelity · **DONE 2026-06-17**
|
||||
|
||||
**Resolution (what was done) — all 13 arXiv papers re-ingested from `.tex`:**
|
||||
- **Multi-file flatten:** `arxiv.fetch_source` now inlines `\input`/`\include`
|
||||
(`tex.flatten_inputs`) so multi-file projects yield the full body, not the
|
||||
primary file's include skeleton (e.g. math/0603097 = 15 files / 12 includes;
|
||||
2305.10988 = 6 / 5). Also handles legacy single-file **bare-gzipped** `.tex`
|
||||
(no tar wrapper) for old math/* papers (commit 121b582).
|
||||
- **Section-aware chunking:** new `tex.chunk_sections` returns `(title, chunk)`
|
||||
pairs (chunks never span a `\section`); the ingest `.tex` path labels the stored
|
||||
`section` from the real heading instead of running `classify_section` on a
|
||||
header-less word-window. Quality-gated as pairs so labels stay aligned.
|
||||
- **Unblocked by the arXiv half of DQ-5** (commit f504ca6): `_canonical_id`
|
||||
strips the `10.48550/arxiv.` DOI prefix so an arXiv paper's id is the bare
|
||||
arXiv id — without it, re-ingest tripped `papers_openalex_id_key` (confirmed
|
||||
live, then fixed; idempotent re-ingest verified).
|
||||
- **Outcome:** `section` column gained signal — ~34/329 arXiv chunks now
|
||||
intro/theorem/proof (was ~all `body`); math/0603097 `{body:31}`→`{body:29,
|
||||
proof:9}`, 2310.17529 gained proof+intro, etc. Math papers with descriptively
|
||||
titled sections stay `body` (classify_section's fixed vocab) — modest but real
|
||||
(R-12). No paper gutted (chunk counts stable/▲); F-16 gate unchanged.
|
||||
- **Files:** `codex/parsing/tex.py`, `codex/sources/arxiv.py`, `codex/ingest.py`,
|
||||
`codex/sources/openalex.py`, `scripts/rc_tex_reingest.py` (driver, dry-run
|
||||
default), + tests. Full suite **370 passed**; ruff/mypy clean. Branch
|
||||
`feat/tex-ingest` (commits 1da81c7, 121b582, f504ca6).
|
||||
|
||||
**Original plan (for context):**
|
||||
- **What:** re-ingest from arXiv LaTeX source through the existing
|
||||
`codex.parsing.tex.latex_to_text` path instead of flattened `.txt`.
|
||||
- **Why:** highest-fidelity input — preserves math, structure, and section
|
||||
boundaries. Directly serves **DQ-3** (content fidelity) and audit **R-12** (the
|
||||
`section` column is low-signal because word-window `.txt` chunks rarely start at
|
||||
a real header; `.tex` headers fix this).
|
||||
- **How:** download free arXiv source tarballs, feed `.tex` to ingest with
|
||||
`source_path`. Handle multi-file projects (main `.tex` + includes).
|
||||
- **Acceptance:** `section` column gains signal (fewer `body`-only); DQ-3 coverage
|
||||
vs source improves; no regression in the F-16 quality gate.
|
||||
|
||||
### R-D — F-15: warn on low *citing-paper* coverage, not just paper count · **DONE 2026-06-17**
|
||||
|
||||
**Resolution (what was done):**
|
||||
- Added `graph.citing_coverage(graph, known_ids)` → `(papers_with_out_edges,
|
||||
total)`, a `graph_min_citing_coverage` setting (default 0.8), and wired both into
|
||||
`codex graph report`: it now prints `Citing coverage: N/M papers with out-edges
|
||||
(P%)` (and in `--json`), and emits a `Warning` when the share is below the
|
||||
threshold ("low citing coverage weakens PageRank/coupling"). Separate from the
|
||||
existing `graph_min_corpus_size` (total-count) warning.
|
||||
- Tests: `citing_coverage` unit tests + CLI tests (line shown; warning fires at
|
||||
low coverage; suppressed at 100%; JSON field). Full suite green; ruff/mypy clean.
|
||||
- **Live:** `codex graph report` on the corpus prints `Citing coverage: 29/29
|
||||
papers with out-edges (100%)` with no warning (post-R-A). Branch
|
||||
`feat/graph-coverage-warning`. Files: `codex/config.py`, `codex/graph.py`,
|
||||
`codex/cli.py`, + tests.
|
||||
|
||||
**Original plan (for context):**
|
||||
- **What:** the open DQ-1 sub-item: `graph_min_corpus_size` only flags total paper
|
||||
count. Add a warning when the share of papers with ≥1 out-edge is low (e.g.
|
||||
< 80%), since that is what actually starves PageRank/coupling.
|
||||
- **Why:** at 17/29 the graph silently ran on 59% of the corpus with no signal.
|
||||
Post-DQ-1 it's 27/29 (93%) — but the guard should catch future regressions.
|
||||
- **Acceptance:** `codex graph report` surfaces citing-coverage % and warns below
|
||||
threshold.
|
||||
|
||||
### R-E — Paywall / institutional access · **DEFERRED — only for a paywall-only expansion**
|
||||
- **Decision (2026-06-16):** a uni login does **not** help the current corpus —
|
||||
every DQ-1/DQ-2 gap is in openly-available works (arXiv preprints, OA theses),
|
||||
and all metadata/citation sources are free APIs. Revisit *only* if the corpus
|
||||
expands to papers that exist solely behind a paywall with no preprint; then
|
||||
institutional access → full-text PDF → GROBID refs (R-A) + clean chunks (R-C).
|
||||
- **ToS caveat:** manual download of individual papers you have legitimate access
|
||||
to is fine; **automated bulk download through a uni proxy (EZproxy/Shibboleth)
|
||||
violates most publisher terms and can get the whole institution's access
|
||||
revoked.** Do not wire a publisher login into the ingest pipeline.
|
||||
|
||||
### R-F — Richer section labels: store real `\section` titles · **DONE 2026-06-17**
|
||||
|
||||
**Resolution (what was done):**
|
||||
- **Why:** R-C labelled `.tex` chunks by mapping the real `\section` heading through
|
||||
`classify_section`'s fixed vocab, so descriptively-titled Math sections collapsed
|
||||
to `body` (only **34/329 = 10 %** non-`body`). A research pass confirmed the
|
||||
`section` column is **write-only** (nothing filters on the vocab — `mcp_server.py`
|
||||
and `wiki.py` don't even SELECT it), so storing free-text titles is safe.
|
||||
- **Change:** new `quality.section_label(title, content)` (+ `_clean_title`) — keep the
|
||||
controlled bucket (intro/theorem/proof/abstract/bibliography) when the heading maps
|
||||
to one, else store the cleaned real title ("the flip algorithm", "main results", …).
|
||||
The `.tex` ingest path uses it; `.pdf`/`.txt`/`run_quality_pass` keep
|
||||
`classify_section` unchanged. `_clean_title` strips LaTeX commands/accents/ties +
|
||||
leading numbering, lower-cases, truncates at a word boundary. ~1 helper + 1 ingest
|
||||
line; no schema change (column already free `TEXT`).
|
||||
- **Result (live):** re-applied to the 13 arXiv papers → non-`body` **34/329 (10 %) →
|
||||
314/329 (95 %)**. Only `math/0306167` (old AMS-TeX, no `\section{}`) stays `body`.
|
||||
- **Tests:** `section_label` / `_clean_title` units + `.tex` ingest stores a
|
||||
descriptive heading verbatim. Full suite 377 passed; ruff/mypy clean. Branch
|
||||
`feat/section-labels` (off `feat/tex-ingest`).
|
||||
- **Accent re-clean — DONE 2026-06-17.** The `_clean_title` polish (`m\"obius`→`mobius`,
|
||||
`~` ties, word-boundary truncation) landed in code, and the existing live labels were
|
||||
re-cleaned in place (apply `_clean_title` to any `chunks.section` containing `\` or
|
||||
`~`; **8 labels** fixed, no re-embed). Verified: **0** labels with LaTeX artifacts
|
||||
remain; non-`body` holds at **314/329 (95 %)**. R-F fully complete.
|
||||
|
||||
---
|
||||
|
||||
## Priority for the next session
|
||||
1. ~~**DQ-1**~~ — **DONE 2026-06-16** (live DB at 920 citations, 27/29 citing
|
||||
coverage; remedy wired into ingest).
|
||||
2. ~~**DQ-4**~~ — **DONE 2026-06-16** (P0 `search paper` crash fixed; relevance
|
||||
verified strong; surfaced the zero-vector pollution that motivates DQ-2).
|
||||
3. ~~**DQ-2**~~ — **DONE 2026-06-16** (1911.00966 fully recovered; s00454 abstract
|
||||
from S2; book chapter documented; recovery wired into ingest).
|
||||
4. ~~**DQ-3**~~ — **DONE 2026-06-16** (content fidelity clean; full coverage, only
|
||||
18 OCR-junk chunks dropped, no math lost). **Assessment axis DQ-1..DQ-4 complete.**
|
||||
5. ~~**DQ-5**~~ — **DONE 2026-06-17** (DOI id normalized to bare form in
|
||||
`openalex._map_paper`; live re-ingest of `10.1007/s00454-019-00132-8` idempotent
|
||||
— UPDATE in place, `added_at` unchanged; suite 348 green). **Unblocks R-A/R-C.**
|
||||
6. **Roadmap levers** (see section above — now all unblocked): **R-A** (GROBID on
|
||||
arXiv PDFs — closes the last 2 zero-citation theses), **R-B** (Crossref refs —
|
||||
abstract half already shipped in DQ-2), **R-C** (`.tex` ingest — serves DQ-3),
|
||||
**R-D** (F-15 coverage warning, quick). **R-E** (paywall) deferred.
|
||||
|
||||
DQ-1..DQ-4 are *data*/assessment work; DQ-5 + roadmap R-A..R-D add **new code**
|
||||
(canonical-id fix, re-ingest scripts, Crossref references, a `.tex` path, a graph
|
||||
warning) on top of the already-remediated pipeline (see the AUDIT-* docs and PRs
|
||||
#12–#14). The DQ-1/DQ-2 ingest wiring + Crossref/arXiv abstract recovery already
|
||||
landed this session.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — reproduce the baseline scan
|
||||
|
||||
```python
|
||||
import os, psycopg, statistics
|
||||
from collections import Counter, defaultdict
|
||||
from psycopg.rows import dict_row
|
||||
from codex.config import Settings
|
||||
from codex.quality import is_quality_chunk, _bib_score
|
||||
|
||||
s = Settings()
|
||||
with psycopg.connect(os.environ["DATABASE_URL"], row_factory=dict_row) as c:
|
||||
papers = c.execute("SELECT id, bibkey, title, abstract, year, authors, openalex_id FROM papers").fetchall()
|
||||
chunks = c.execute("SELECT paper_id, content, section FROM chunks").fetchall()
|
||||
cit = c.execute("SELECT citing_id, count(*) n FROM citations GROUP BY citing_id").fetchall()
|
||||
|
||||
# zero-citation papers (DQ-1)
|
||||
cited = {r["citing_id"] for r in cit}
|
||||
zero = [p["id"] for p in papers if p["id"] not in cited]
|
||||
# metadata gaps (DQ-2): p["abstract"] empty / p["bibkey"] None / not p["authors"]
|
||||
# F-16 re-gate (baseline): [ch for ch in chunks if not is_quality_chunk(ch["content"], settings=s)]
|
||||
# section dist (R-12): Counter(ch["section"] or "(null)" for ch in chunks)
|
||||
```
|
||||
192
docs/infra/grobid-jetson.md
Normal file
192
docs/infra/grobid-jetson.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# GROBID on the Jetson (aarch64) — deploy runbook
|
||||
|
||||
**Status:** ACTIVE
|
||||
**Host:** `alfred@jetson.local` — NVIDIA Jetson Orin Nano (aarch64, 8 GB RAM, 8 GB swap, 6 cores)
|
||||
**Service:** GROBID reference extraction for roadmap **R-A** (`scripts/ra_grobid_backfill.py`)
|
||||
**Last updated:** 2026-06-17
|
||||
**Verified:** 2026-06-17 — `grobid/grobid:0.9.0-crf` pulled as native `arm64`, `/api/isalive`→`true`,
|
||||
end-to-end `extract_references` on `lutz-2024-thesis.pdf` → 116 refs (101 with DOI/arXiv) in ~36s.
|
||||
|
||||
---
|
||||
|
||||
## Why this image (`grobid/grobid:0.9.0-crf`)
|
||||
|
||||
GROBID had never come up on the Jetson because `infra/docker-compose.yml` pinned
|
||||
`grobid/grobid:0.8.2`, and **every `0.8.x` tag (and the full `0.9.0`) is published
|
||||
amd64-only**. A Docker tag resolves to a multi-arch *manifest list*; on aarch64
|
||||
the `0.8.2` list has no `linux/arm64` entry, so there is simply nothing to pull or
|
||||
run (hence `curl localhost:8070` → connection refused, nothing listening).
|
||||
|
||||
The fix is to select a tag whose manifest list actually contains a `linux/arm64`
|
||||
build:
|
||||
|
||||
| Candidate | arm64? | Size | Notes |
|
||||
|-------------------------------|:------:|--------:|----------------------------------------|
|
||||
| `grobid/grobid:0.8.2` | ✗ | ~9.5 GB | full DL image, amd64-only (old pin) |
|
||||
| `grobid/grobid:0.9.0` / `-full`| ✗ | ~10 GB | full DL image, amd64-only |
|
||||
| **`grobid/grobid:0.9.0-crf`** | **✓** | ~500 MB | **CRF-only, official, multi-arch** ← |
|
||||
| `lfoppiano/grobid:0.9.0-crf` | ✓ | ~500 MB | same build, community namespace (alt.) |
|
||||
|
||||
We use **`grobid/grobid:0.9.0-crf`**:
|
||||
|
||||
- **CRF-only** is sufficient for reference extraction (`/api/processReferences`)
|
||||
and is ~500 MB vs ~10 GB for the deep-learning image.
|
||||
- **Native arm64** — no qemu emulation (too slow / RAM-heavy on a Jetson).
|
||||
- **Official namespace**, **pinned version** (not `latest-crf`, which is a moving
|
||||
target). arm64 builds only exist from `0.9.0-crf` onward — there is no arm64
|
||||
`0.8.x`, so this is a forced (but compatible) bump from the old `0.8.2` pin. The
|
||||
TEI that `codex/parsing/grobid.py` parses (`biblStruct`, `persName`,
|
||||
`idno[@type='DOI']`, …) is unchanged across the bump.
|
||||
|
||||
> Caveat from upstream: the arm64 image is documented as "tested only on macOS,
|
||||
> not linux/arm64". The container is the *same* `linux/arm64` ELF either way, so
|
||||
> bare-metal Jetson works; the end-to-end check below is what de-risks it.
|
||||
|
||||
---
|
||||
|
||||
## One-time prerequisites
|
||||
|
||||
### 1. Docker permission for `alfred`
|
||||
|
||||
The SSH user `alfred` is in the `sudo` group but **not** the `docker` group, so
|
||||
`docker …` fails with `permission denied … /var/run/docker.sock`. Grant access
|
||||
once (requires the sudo password):
|
||||
|
||||
```bash
|
||||
ssh alfred@jetson.local
|
||||
sudo usermod -aG docker alfred # add to docker group (persistent)
|
||||
```
|
||||
|
||||
Then **start a fresh login** so the new group takes effect (group membership is
|
||||
only re-evaluated at login — an existing session/`ssh` won't see it; a *new*
|
||||
`ssh` connection will):
|
||||
|
||||
```bash
|
||||
exit && ssh alfred@jetson.local
|
||||
docker ps # should now work without sudo
|
||||
```
|
||||
|
||||
(Alternatively run all `docker` commands below under `sudo`, but group membership
|
||||
is cleaner and lets `ra_grobid_backfill.py` / curl checks be driven over plain SSH.)
|
||||
|
||||
### 2. Docker Compose v2 plugin (arm64)
|
||||
|
||||
This host has Docker Engine but **not** the Compose v2 CLI plugin (on Linux they
|
||||
are separate packages; only Docker Desktop bundles Compose). Without it
|
||||
`docker compose …` fails with `docker: unknown command: docker compose`. Install
|
||||
the arm64 plugin into the user-local plugin dir (no sudo needed):
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
curl -sSL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-aarch64 \
|
||||
-o ~/.docker/cli-plugins/docker-compose
|
||||
chmod +x ~/.docker/cli-plugins/docker-compose
|
||||
docker compose version # confirm it resolves
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deploy
|
||||
|
||||
GROBID is **service-scoped** on purpose: Postgres (`papers-db`) is already running
|
||||
on this host, so we bring up only the `grobid` service and never `docker compose up`
|
||||
the whole stack (that would try to start a second `papers-db`).
|
||||
|
||||
```bash
|
||||
# copy this repo's compose file to the Jetson (first time only)
|
||||
scp infra/docker-compose.yml infra/schema.sql alfred@jetson.local:~/codex-infra/
|
||||
|
||||
ssh alfred@jetson.local
|
||||
cd ~/codex-infra
|
||||
docker compose up -d grobid # pulls grobid/grobid:0.9.0-crf (arm64), starts papers-grobid
|
||||
docker compose logs -f grobid # wait for "Started ... in N seconds" (model load ~20–40s)
|
||||
```
|
||||
|
||||
The `deploy.resources.limits.memory: 4g` cap in the compose file is honored by
|
||||
Compose v2 here (no swarm needed). 3 GB suffices for references; 4 GB leaves
|
||||
headroom alongside Postgres. The `restart: "no"` policy means GROBID does **not**
|
||||
auto-start on boot — see "Run on-demand" below.
|
||||
|
||||
---
|
||||
|
||||
## Run on-demand (stop when idle)
|
||||
|
||||
GROBID is only needed **during reference extraction** (`scripts/ra_grobid_backfill.py`);
|
||||
nothing else in the stack uses it. Idle it still holds ~3.5 GB, so on the 8 GB Jetson
|
||||
keep it stopped and start it only for an R-A run:
|
||||
|
||||
```bash
|
||||
ssh alfred@jetson.local 'docker start papers-grobid' # ~30s model load
|
||||
curl -s --retry 30 --retry-delay 2 --retry-connrefused \
|
||||
http://jetson.local:8070/api/isalive # wait for: true
|
||||
|
||||
# ... run R-A from the Mac ...
|
||||
PYTHONPATH=. python scripts/ra_grobid_backfill.py --write
|
||||
|
||||
ssh alfred@jetson.local 'docker stop papers-grobid' # reclaim ~3.5 GB
|
||||
```
|
||||
|
||||
First-time deploy uses `docker compose up -d grobid` (above); afterwards the
|
||||
container persists, so `docker start/stop papers-grobid` is all you need.
|
||||
|
||||
> Why on-demand rather than a lighter tool: refextract was evaluated head-to-head
|
||||
> on the Jetson and rejected — far worse DOI recall on this math corpus (lutz
|
||||
> thesis: GROBID **101** usable IDs vs refextract **2**), and it was *slower* per
|
||||
> PDF despite a smaller footprint. GROBID stays; running it on-demand removes its
|
||||
> only real downside (idle RAM).
|
||||
|
||||
---
|
||||
|
||||
## Verify `GET /api/isalive` → `true`
|
||||
|
||||
```bash
|
||||
# on the Jetson
|
||||
curl -s http://localhost:8070/api/isalive # => true
|
||||
|
||||
# from the Mac (LAN) — same value the corpus' GROBID_URL points at
|
||||
curl -s http://jetson.local:8070/api/isalive # => true
|
||||
```
|
||||
|
||||
Anything other than `true` (timeout / connection refused / `false`) means the
|
||||
container isn't up — check `docker compose logs grobid` and that nothing else
|
||||
holds port 8070.
|
||||
|
||||
---
|
||||
|
||||
## End-to-end reference-extraction check + running R-A
|
||||
|
||||
With `GROBID_URL=http://jetson.local:8070` (already set in `.env.jetson-ingest`):
|
||||
|
||||
```bash
|
||||
# one PDF, references only — should print N>0 parsed references
|
||||
PYTHONPATH=. python - <<'PY'
|
||||
from codex.parsing.grobid import extract_references
|
||||
refs = extract_references("/Users/tarikmoussa/Desktop/ConformalLabpp/papers/lutz-2024-thesis.pdf",
|
||||
grobid_url="http://jetson.local:8070")
|
||||
print(f"{len(refs)} references; first DOI/arXiv:",
|
||||
next(((r['doi'], r['arxiv_id']) for r in refs if r['doi'] or r['arxiv_id']), None))
|
||||
PY
|
||||
```
|
||||
|
||||
Then run the references-only backfill (DB tunnel up — see
|
||||
`docs/audit/DATA-QUALITY-2026-06-15.md`):
|
||||
|
||||
```bash
|
||||
ssh -N -L 5433:localhost:5432 alfred@jetson.local & # DB tunnel
|
||||
cp .env.jetson-ingest .env
|
||||
PYTHONPATH=. python scripts/ra_grobid_backfill.py --zero-edge-only # dry-run first
|
||||
PYTHONPATH=. python scripts/ra_grobid_backfill.py --write # apply
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Container killed mid-request / exit 137** — OOM. The CRF image needs ~3 GB for
|
||||
references; raise the compose `memory` limit or check Postgres isn't starving the
|
||||
box (`free -h`, `docker stats papers-grobid`).
|
||||
- **`isalive` true but `processReferences` 500s** — usually a `pdfalto` failure on a
|
||||
malformed PDF; confirm with a clean PDF (`lutz-2024-thesis.pdf`) and check
|
||||
`docker compose logs grobid`.
|
||||
- **`permission denied … docker.sock`** — the docker-group prerequisite above wasn't
|
||||
applied, or the SSH session predates it (re-login).
|
||||
66
docs/infra/jetson-ssh-stability.md
Normal file
66
docs/infra/jetson-ssh-stability.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Infra TODO — stable long-running ops against the Jetson (tmux + autossh)
|
||||
|
||||
**Status:** OPEN (infra). **Filed:** 2026-06-17. **Priority:** MED — it slows live ops
|
||||
but has a known workaround (short/idempotent ops).
|
||||
|
||||
## Problem (observed)
|
||||
|
||||
Live data ops run on the **Mac** and reach the Jetson Postgres through an SSH
|
||||
port-forward tunnel (`ssh -f -N -L 5433:localhost:5432 alfred@jetson.local`,
|
||||
`DATABASE_URL` → `localhost:5433`). During the R-A / R-C / R-F work this tunnel
|
||||
**dropped repeatedly mid-run**: any operation longer than a few minutes (corpus
|
||||
re-ingest, the bge-m3 embed loop, the GROBID sweep) had a high chance of failing
|
||||
partway when the tunnel died, leaving the work half-applied. At one point the Jetson
|
||||
went **fully offline** (ping 100 % loss, `ssh:22` timeout) mid-operation.
|
||||
|
||||
`ingest_paper()` and the backfill scripts open one DB connection for the whole run, so
|
||||
a tunnel drop raises `OperationalError: connection refused` and aborts — partial state,
|
||||
manual retry. Adding `ServerAliveInterval` to the tunnel helped only marginally.
|
||||
|
||||
## Recommended fix
|
||||
|
||||
**1. Run long ops *on the Jetson* inside `tmux` (primary fix).**
|
||||
The Jetson is where Postgres (and GROBID) live, so running ingest there means the DB is
|
||||
`localhost` — **no tunnel at all** — and `tmux` keeps the process alive across SSH
|
||||
disconnects (detach / reattach to monitor). This removes the tunnel from the critical
|
||||
path for every long-running write.
|
||||
|
||||
- [ ] Confirm the `codex` env is deployable on the Jetson (Python 3.12+, deps incl. the
|
||||
bge-m3 embedder; the Orin Nano GPU should handle it — GROBID already runs there).
|
||||
If not, stand up a venv / container for it.
|
||||
- [ ] Standard pattern: `ssh alfred@jetson`, then
|
||||
`tmux new -s codex` → `set -a; source .env; set +a` →
|
||||
`PYTHONPATH=. python scripts/<job>.py --write` → detach (`Ctrl-b d`).
|
||||
Reattach later with `tmux attach -t codex`. The job survives the SSH drop.
|
||||
- [ ] Point the Jetson-side `DATABASE_URL` at `localhost:5432` directly (no `:5433`).
|
||||
- [ ] Document this as the default for any multi-minute write (re-ingest, sweeps,
|
||||
embed loops) in `docs/audit/DATA-QUALITY-2026-06-15.md` "How to reach the data".
|
||||
|
||||
**2. `autossh` for when a tunnel *is* needed (secondary).**
|
||||
For interactive/dev use from the Mac (psql, quick probes, read-only queries), replace
|
||||
the plain `ssh -f -N -L` with `autossh -M 0 -f -N -o ServerAliveInterval=15 -o
|
||||
ServerAliveCountMax=3 -L 5433:localhost:5432 alfred@jetson` so a dropped tunnel
|
||||
auto-reconnects. NB: autossh only restarts the *tunnel* — an in-flight transaction
|
||||
still fails, so this is for short/interactive use, not long writes (use tmux-on-Jetson
|
||||
for those).
|
||||
|
||||
**3. (Optional, code) make backfill scripts resilient.**
|
||||
Lower-value once (1)/(2) are in place, but: open a fresh `get_conn()` per paper (not one
|
||||
for the whole run) and/or wrap the per-item write in a small retry, so a transient drop
|
||||
costs one item, not the whole batch. The R-A backfill already does per-paper connections;
|
||||
`rc_tex_reingest.py` opens a connection per paper too — but `ingest_paper` itself holds
|
||||
one connection for its whole body, which is the unit that fails.
|
||||
|
||||
## Why this matters
|
||||
|
||||
The chronic flakiness directly cost time this session and left one cosmetic task
|
||||
unfinished (the R-F accent re-clean — see DATA-QUALITY-2026-06-15.md R-F "Pending"). The
|
||||
**robust pattern that already works** is short, idempotent operations (per-paper, or a
|
||||
single quick `UPDATE`); tmux-on-Jetson makes even the long ops safe.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A documented, repeatable way to run a full corpus re-ingest that survives an SSH drop
|
||||
(tmux-on-Jetson), verified by detaching/reattaching across a disconnect.
|
||||
- The DATA-QUALITY doc's "How to reach the data" section recommends tmux-on-Jetson for
|
||||
long writes and `autossh` for interactive tunnels.
|
||||
@@ -28,20 +28,34 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
grobid:
|
||||
# CRF-only image is sufficient for reference extraction and is lighter than
|
||||
# the full deeplearning variant. Check https://hub.docker.com/r/grobid/grobid
|
||||
# for the latest 0.8.x tag before deploying.
|
||||
image: grobid/grobid:0.8.2
|
||||
# CRF-only image: sufficient for reference extraction (/api/processReferences)
|
||||
# and ~500MB vs ~10GB for the full deep-learning variant.
|
||||
#
|
||||
# The `-crf` tag is also the ONLY GROBID variant published as a multi-arch
|
||||
# manifest that includes linux/arm64 — the full image (grobid/grobid:0.9.0)
|
||||
# and every 0.8.x tag are amd64-only. So this is what lets GROBID run
|
||||
# *natively* on the aarch64 Jetson (no slow/RAM-heavy qemu emulation). arm64
|
||||
# builds start at 0.9.0-crf; there is no arm64 0.8.x.
|
||||
# Deploy + docker-permission fix: docs/infra/grobid-jetson.md
|
||||
image: grobid/grobid:0.9.0-crf
|
||||
container_name: papers-grobid
|
||||
ports:
|
||||
- "8070:8070"
|
||||
restart: unless-stopped
|
||||
# GROBID is RAM-hungry; uncomment to cap usage on constrained hardware
|
||||
# (e.g. Nvidia Jetson):
|
||||
# deploy:
|
||||
# resources:
|
||||
# limits:
|
||||
# memory: 4g
|
||||
init: true # reap zombie children (GROBID docs recommend --init)
|
||||
ulimits:
|
||||
core: 0 # disable core dumps (GROBID docs: --ulimit core=0)
|
||||
# On-demand only: GROBID is needed solely during reference extraction
|
||||
# (scripts/ra_grobid_backfill.py), not for normal corpus operations — start it
|
||||
# for an R-A run, stop it after to reclaim ~3.5GB. "no" = no auto-start on boot.
|
||||
# See docs/infra/grobid-jetson.md ("Run on-demand").
|
||||
restart: "no"
|
||||
# GROBID is RAM-hungry; cap usage on the constrained Jetson (Orin Nano, 8GB
|
||||
# RAM shared with Postgres). 3GB suffices for references; 4g leaves headroom.
|
||||
# Honored by `docker compose up` under Compose v2 (no swarm needed).
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4g
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
141
infra/reingest_canonical_ids.sh
Executable file
141
infra/reingest_canonical_ids.sh
Executable file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-time migration to canonical paper IDs (audit C-7).
|
||||
#
|
||||
# Before the C-7 fix, ingest stored OpenAlex's URL-form doi/id as papers.id
|
||||
# (e.g. "https://doi.org/10.48550/arxiv.math/0603097"). After the fix, ingest
|
||||
# keys papers.id on the bare caller id ("math/0603097"). The existing rows keep
|
||||
# their old URL-form primary keys, so a plain re-ingest would DUPLICATE every
|
||||
# paper (new bare-id row alongside the old URL-id row). This script wipes the
|
||||
# corpus and rebuilds it on canonical ids.
|
||||
#
|
||||
# DESTRUCTIVE: `TRUNCATE papers CASCADE` removes papers + chunks + citations +
|
||||
# formulas + figures + paper_identifiers (code_links.paper_id is set NULL). All
|
||||
# content is rebuilt by ingest_all.sh, which re-fetches OpenAlex and re-embeds
|
||||
# (minutes for ~36 papers).
|
||||
#
|
||||
# Prerequisites: SSH tunnel on :5433 (see ingest_all.sh) and .env.jetson-ingest.
|
||||
# Usage: bash infra/reingest_canonical_ids.sh
|
||||
set -euo pipefail
|
||||
|
||||
CODEX_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
ENV_FILE="$CODEX_DIR/.env.jetson-ingest"
|
||||
PY="$CODEX_DIR/.venv/bin/python"
|
||||
TUNNEL_PORT=5433
|
||||
|
||||
# ── 1. Preconditions ─────────────────────────────────────────────────────────
|
||||
if ! nc -z localhost "$TUNNEL_PORT" 2>/dev/null; then
|
||||
echo "ERROR: SSH tunnel not active on :$TUNNEL_PORT"
|
||||
echo "Run: ssh -f -N -L 5433:localhost:5432 alfred@jetson.local"
|
||||
exit 1
|
||||
fi
|
||||
[[ -f "$ENV_FILE" ]] || { echo "ERROR: $ENV_FILE missing"; exit 1; }
|
||||
[[ -x "$PY" ]] || { echo "ERROR: venv python not found at $PY (run uv sync)"; exit 1; }
|
||||
# Load DATABASE_URL into the environment (not echoed — keeps the password out of logs).
|
||||
set -a; source "$ENV_FILE"; set +a
|
||||
|
||||
# run_sql <SQL>: execute one statement via psycopg, print any result rows.
|
||||
# Reads DATABASE_URL from the environment; suppresses the DSN on error.
|
||||
run_sql() {
|
||||
"$PY" - "$1" <<'PYEOF'
|
||||
import os, sys, psycopg
|
||||
from psycopg.rows import dict_row
|
||||
try:
|
||||
with psycopg.connect(os.environ["DATABASE_URL"], row_factory=dict_row, connect_timeout=10) as c:
|
||||
cur = c.execute(sys.argv[1])
|
||||
if cur.description:
|
||||
for row in cur.fetchall():
|
||||
print(" " + " ".join(f"{k}={v!r}" for k, v in row.items()))
|
||||
c.commit()
|
||||
except Exception as e:
|
||||
print(f" DB ERROR: {type(e).__name__} (details suppressed to avoid DSN leak)")
|
||||
sys.exit(1)
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# ── 2. BEFORE snapshot ───────────────────────────────────────────────────────
|
||||
echo "── BEFORE migration ──────────────────────────────────────────"
|
||||
run_sql "SELECT count(*) AS papers, count(*) FILTER (WHERE starts_with(id, 'https://')) AS url_form_ids FROM papers"
|
||||
echo " sample ids:"
|
||||
run_sql "SELECT id FROM papers ORDER BY added_at LIMIT 3"
|
||||
|
||||
# ── 2b. Preflight: verify the schema is re-ingestable BEFORE wiping anything ──
|
||||
# ingest writes chunks.section (F-16). On a live DB that predates it the column
|
||||
# is missing, and the app user cannot ALTER tables owned by 'postgres' (audit
|
||||
# M-1). Check FIRST so we never TRUNCATE and then fail mid-re-ingest.
|
||||
echo "── Preflight: schema readiness ───────────────────────────────"
|
||||
SECTION_OK="$("$PY" - <<'PYEOF'
|
||||
import os, psycopg
|
||||
try:
|
||||
with psycopg.connect(os.environ["DATABASE_URL"], connect_timeout=10) as c:
|
||||
cols = [r[0] for r in c.execute(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_name='chunks'").fetchall()]
|
||||
print("yes" if "section" in cols else "no")
|
||||
except Exception:
|
||||
print("error")
|
||||
PYEOF
|
||||
)"
|
||||
if [[ "$SECTION_OK" != "yes" ]]; then
|
||||
echo "ABORT — schema not ready: chunks.section is missing (audit M-1)."
|
||||
echo "The app user cannot add it (core tables are owned by 'postgres'). Sync the"
|
||||
echo "schema with a privileged role, then re-run this script. Either:"
|
||||
echo ""
|
||||
echo " (a) MIGRATION_DATABASE_URL=postgresql://postgres:PASS@HOST:PORT/papers \\"
|
||||
echo " \"$CODEX_DIR/.venv/bin/codex\" migrate # applies schema.sql idempotently"
|
||||
echo " (b) sudo -u postgres psql -d papers \\"
|
||||
echo " -c \"ALTER TABLE chunks ADD COLUMN IF NOT EXISTS section TEXT;\""
|
||||
echo ""
|
||||
echo "Nothing was changed — no TRUNCATE was issued."
|
||||
exit 1
|
||||
fi
|
||||
echo " chunks.section present — schema OK."
|
||||
|
||||
# ── 3. Confirmation gate (destructive) ───────────────────────────────────────
|
||||
echo ""
|
||||
echo "This TRUNCATEs papers CASCADE (papers/chunks/citations/formulas/figures/"
|
||||
echo "paper_identifiers) and re-ingests via ingest_all.sh. The DB content is"
|
||||
echo "rebuilt from scratch."
|
||||
read -r -p "Type 'MIGRATE' to proceed: " confirm
|
||||
[[ "$confirm" == "MIGRATE" ]] || { echo "Aborted — nothing changed."; exit 1; }
|
||||
|
||||
# ── 4. Wipe ──────────────────────────────────────────────────────────────────
|
||||
echo "── TRUNCATE papers CASCADE ───────────────────────────────────"
|
||||
run_sql "TRUNCATE papers CASCADE"
|
||||
echo " wiped."
|
||||
|
||||
# ── 5. Re-ingest on canonical ids ────────────────────────────────────────────
|
||||
echo "── Re-ingest (ingest_all.sh) ─────────────────────────────────"
|
||||
bash "$CODEX_DIR/ingest_all.sh"
|
||||
|
||||
# ── 6. AFTER snapshot + live verification ────────────────────────────────────
|
||||
echo "── AFTER migration ───────────────────────────────────────────"
|
||||
echo " C-7 — url_form_ids should now be 0; sample ids should be bare:"
|
||||
run_sql "SELECT count(*) AS papers, count(*) FILTER (WHERE starts_with(id, 'https://')) AS url_form_ids FROM papers"
|
||||
run_sql "SELECT id FROM papers ORDER BY added_at LIMIT 3"
|
||||
|
||||
# C-1 must be verified through the REAL resolver-based discovery_leads(), not a
|
||||
# raw cited_id check: cited_id/openalex_id stay in OpenAlex form, so the raw
|
||||
# "cited_id NOT IN papers.id" count is non-zero by design — the resolver is what
|
||||
# excludes ingested papers. Check that no ingested paper leaks into the leads.
|
||||
echo " C-1 — real discovery_leads() must contain no already-ingested paper:"
|
||||
PYTHONPATH="$CODEX_DIR" "$PY" - <<'PYEOF'
|
||||
import os, psycopg
|
||||
from psycopg.rows import dict_row
|
||||
from codex.discover import discovery_leads
|
||||
try:
|
||||
leads = discovery_leads(limit=100000)
|
||||
with psycopg.connect(os.environ["DATABASE_URL"], row_factory=dict_row, connect_timeout=10) as c:
|
||||
ingested = {r["id"] for r in c.execute("SELECT id FROM papers").fetchall()}
|
||||
ingested |= {
|
||||
r["openalex_id"]
|
||||
for r in c.execute("SELECT openalex_id FROM papers WHERE openalex_id IS NOT NULL").fetchall()
|
||||
}
|
||||
leaked = sum(1 for lead in leads if lead["cited_id"] in ingested)
|
||||
print(f" leads={len(leads)} ingested-papers-leaked-into-leads={leaked}")
|
||||
except Exception as e:
|
||||
print(f" DB ERROR: {type(e).__name__} (details suppressed)")
|
||||
PYEOF
|
||||
|
||||
echo ""
|
||||
echo "✓ Migration complete."
|
||||
echo " Expected: url_form_ids=0 (C-7 verified) and leaked=0 (C-1 verified) above."
|
||||
echo " Next: re-run 'codex graph report' and refresh the ADR-F15 spike table (audit D-1)."
|
||||
@@ -13,7 +13,7 @@ CREATE EXTENSION IF NOT EXISTS vector;
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Layer 1: Papers + full-text chunks
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE papers (
|
||||
CREATE TABLE IF NOT EXISTS papers (
|
||||
id TEXT PRIMARY KEY, -- canonical ID: arXiv ID or DOI
|
||||
openalex_id TEXT UNIQUE, -- e.g. W2741809807
|
||||
bibkey TEXT UNIQUE, -- BibTeX key -> .bib + Doxygen @cite
|
||||
@@ -27,10 +27,10 @@ CREATE TABLE papers (
|
||||
);
|
||||
|
||||
-- HNSW index for fast approximate nearest-neighbour search on abstracts.
|
||||
CREATE INDEX papers_abstract_emb_idx
|
||||
CREATE INDEX IF NOT EXISTS papers_abstract_emb_idx
|
||||
ON papers USING hnsw (abstract_emb vector_cosine_ops);
|
||||
|
||||
CREATE TABLE chunks (
|
||||
CREATE TABLE IF NOT EXISTS chunks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
paper_id TEXT REFERENCES papers(id) ON DELETE CASCADE,
|
||||
ord INT NOT NULL, -- position within the paper
|
||||
@@ -38,13 +38,13 @@ CREATE TABLE chunks (
|
||||
embedding vector(1024)
|
||||
);
|
||||
|
||||
CREATE INDEX chunks_emb_idx
|
||||
CREATE INDEX IF NOT EXISTS chunks_emb_idx
|
||||
ON chunks USING hnsw (embedding vector_cosine_ops);
|
||||
CREATE INDEX chunks_paper_idx ON chunks (paper_id);
|
||||
CREATE INDEX IF NOT EXISTS chunks_paper_idx ON chunks (paper_id);
|
||||
|
||||
-- Sparse / keyword hits for exact mathematical terminology (hybrid search).
|
||||
-- Dense (above) + full-text (below) combined = robust against math terms.
|
||||
CREATE INDEX chunks_fts_idx
|
||||
CREATE INDEX IF NOT EXISTS chunks_fts_idx
|
||||
ON chunks USING gin (to_tsvector('english', content));
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
@@ -53,19 +53,19 @@ CREATE INDEX chunks_fts_idx
|
||||
-- edges to not-yet-ingested papers are intentionally preserved —
|
||||
-- those are your discovery leads.
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE citations (
|
||||
CREATE TABLE IF NOT EXISTS citations (
|
||||
citing_id TEXT REFERENCES papers(id) ON DELETE CASCADE,
|
||||
cited_id TEXT NOT NULL, -- arXiv/DOI/OpenAlex ID of the target
|
||||
context TEXT, -- optional: citation context (S2)
|
||||
PRIMARY KEY (citing_id, cited_id)
|
||||
);
|
||||
|
||||
CREATE INDEX citations_cited_idx ON citations (cited_id);
|
||||
CREATE INDEX IF NOT EXISTS citations_cited_idx ON citations (cited_id);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Layer 3: Provenance (the "cleanly couple" goal)
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE code_links (
|
||||
CREATE TABLE IF NOT EXISTS code_links (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
symbol TEXT NOT NULL, -- e.g. 'dec::hodge_star' or 'file.cpp:120'
|
||||
paper_id TEXT REFERENCES papers(id) ON DELETE SET NULL,
|
||||
@@ -74,8 +74,8 @@ CREATE TABLE code_links (
|
||||
added_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX code_links_symbol_idx ON code_links (symbol);
|
||||
CREATE INDEX code_links_paper_idx ON code_links (paper_id);
|
||||
CREATE INDEX IF NOT EXISTS code_links_symbol_idx ON code_links (symbol);
|
||||
CREATE INDEX IF NOT EXISTS code_links_paper_idx ON code_links (paper_id);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Example query: discovery leads
|
||||
@@ -88,3 +88,58 @@ CREATE INDEX code_links_paper_idx ON code_links (paper_id);
|
||||
-- GROUP BY cited_id
|
||||
-- ORDER BY pull DESC
|
||||
-- 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;
|
||||
|
||||
-- F-17 Paper identifier aliases: one paper → many OpenAlex IDs
|
||||
-- (arXiv preprint ID + journal published ID + any future version)
|
||||
-- Each OpenAlex ID maps to exactly one paper (UNIQUE on openalex_id).
|
||||
-- Populated automatically by ingest; add extra aliases manually.
|
||||
CREATE TABLE IF NOT EXISTS paper_identifiers (
|
||||
paper_id TEXT NOT NULL REFERENCES papers(id) ON DELETE CASCADE,
|
||||
openalex_id TEXT NOT NULL,
|
||||
PRIMARY KEY (paper_id, openalex_id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS paper_identifiers_oa_idx
|
||||
ON paper_identifiers (openalex_id);
|
||||
|
||||
-- Seed from existing papers.openalex_id
|
||||
INSERT INTO paper_identifiers (paper_id, openalex_id)
|
||||
SELECT id, openalex_id FROM papers WHERE openalex_id IS NOT NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
116
ingest_all.sh
Executable file
116
ingest_all.sh
Executable file
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
# Batch ingest — writes directly to Jetson PostgreSQL via SSH tunnel.
|
||||
#
|
||||
# Prerequisites:
|
||||
# ssh -f -N -L 5433:localhost:5432 alfred@jetson.local
|
||||
# uv sync (done once)
|
||||
#
|
||||
# Usage: bash ingest_all.sh 2>&1 | tee ingest_all.log
|
||||
set -euo pipefail
|
||||
|
||||
PAPERS_DIR="/Users/tarikmoussa/Desktop/ConformalLabpp/papers/txt"
|
||||
CODEX_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ENV_FILE="$CODEX_DIR/.env.jetson-ingest"
|
||||
TUNNEL_PORT=5433
|
||||
|
||||
# ── 1. Verify SSH tunnel is active ───────────────────────────────────────────
|
||||
if ! nc -z localhost "$TUNNEL_PORT" 2>/dev/null; then
|
||||
echo "ERROR: SSH tunnel not active on port $TUNNEL_PORT"
|
||||
echo "Run: ssh -f -N -L 5433:localhost:5432 alfred@jetson.local"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ SSH tunnel active on port $TUNNEL_PORT"
|
||||
|
||||
# ── 1b. Clear macOS UF_HIDDEN flag on editable-install .pth files ─────────────
|
||||
# Python 3.13 skips .pth files with this flag set (new in 3.13); uv sets it.
|
||||
# --no-sync below prevents uv from re-syncing (which would re-set the flag).
|
||||
SP="$CODEX_DIR/.venv/lib/python3.13/site-packages"
|
||||
for pth in "$SP"/_editable_impl_codex*.pth; do
|
||||
[[ -f "$pth" ]] && chflags nohidden "$pth" 2>/dev/null || true
|
||||
done
|
||||
echo "✓ Cleared hidden flags on editable .pth files"
|
||||
|
||||
# ── 2. Load env (DATABASE_URL → Jetson via tunnel) ───────────────────────────
|
||||
# pydantic-settings reads DATABASE_URL directly from the environment.
|
||||
# DOTENV_PATH is not supported; export each var explicitly.
|
||||
set -a; source "$ENV_FILE"; set +a
|
||||
export DATABASE_URL GROBID_URL OLLAMA_BASE_URL EMBEDDING_MODEL EMBEDDING_DIM OPENALEX_MAILTO
|
||||
echo "✓ DATABASE_URL: $DATABASE_URL"
|
||||
|
||||
# ── 3. Paper ID → source file mapping ────────────────────────────────────────
|
||||
# Format: "PAPER_ID|filename.txt"
|
||||
# SKIP: farkas-kra-1992-riemann-surfaces.txt (textbook, no OpenAlex entry)
|
||||
declare -a PAPERS=(
|
||||
# arXiv papers
|
||||
"math/0603097|springborn-2008-weighted-delaunay-hyperideal.txt"
|
||||
"1005.2698|bobenko-pinkall-springborn-2015-discrete-conformal-maps.txt"
|
||||
"math/0306167|luo-2004-combinatorial-yamabe-flow.txt"
|
||||
"math/0203250|bobenko-springborn-2004-circle-patterns.txt"
|
||||
"math/0503219|bobenko-springborn-2007-discrete-laplace-beltrami.txt"
|
||||
"2310.17529|bobenko-lutz-2025-non-euclidean-dce.txt"
|
||||
"2305.10988|bobenko-lutz-2024-decorated-conformal-maps.txt"
|
||||
"2206.13461|lutz-2023-canonical-tessellations.txt"
|
||||
"2601.22903|bowers-bowers-lutz-2026-koebe-rigidity.txt"
|
||||
"1911.00966|pinkall-springborn-2021-liouville.txt"
|
||||
"1505.01341|born-bucking-springborn-2015-quasiconformal.txt"
|
||||
"0906.1560|glickenstein-2011-discrete-conformal-variations.txt"
|
||||
"math/0001176|rivin-schlenker-2000-schlafli-formula-arxiv-preprint.txt"
|
||||
# DOI papers
|
||||
"10.1007/s00454-019-00132-8|springborn-2020-ideal-hyperbolic-polyhedra.txt"
|
||||
"10.1145/1964921.1964997|alexa-wardetzky-2011-discrete-laplacians-polygonal.txt"
|
||||
"10.1111/cgf.13931|bunge-herholz-kazhdan-botsch-2020-polygon-laplacian.txt"
|
||||
"10.1145/3450626.3459763|gillespie-springborn-crane-2021-discrete-conformal-equivalence.txt"
|
||||
"10.1145/3306346.3323042|sharp-soliman-crane-2019-navigating-intrinsic-triangulations.txt"
|
||||
"10.1145/3197517.3201367|soliman-slepcv-crane-2018-optimal-cone-singularities.txt"
|
||||
"10.1145/3132705|sawhney-crane-2017-boundary-first-flattening.txt"
|
||||
"10.1145/2767000|knoppel-crane-pinkall-schroder-2015-stripe-patterns.txt"
|
||||
"10.5555/1070432.1070581|erickson-whittlesey-2005-greedy-homotopy-homology.txt"
|
||||
"10.1145/1185657.1185665|desbrun-kanso-tong-2006-discrete-differential-forms.txt"
|
||||
"10.1080/10586458.1993.10504266|pinkall-polthier-1993-computing-discrete-minimal-surfaces.txt"
|
||||
"10.1007/s11040-021-09394-2|bobenko-bucking-2021-period-matrices.txt"
|
||||
"10.14279/depositonce-20357|lutz-2024-thesis.txt"
|
||||
"10.14279/depositonce-5415|sechelmann-2016-thesis.txt"
|
||||
# Book chapters (txt contains full book; ID = cited chapter)
|
||||
"10.1007/0-387-29555-0_13|precopa-molnar-eds-2006-non-euclidean-geometries-book.txt"
|
||||
"10.1007/978-3-642-17413-1_7|bobenko-klein-eds-2011-computational-approach-riemann-surfaces-book.txt"
|
||||
)
|
||||
|
||||
# ── 4. Ingest loop ────────────────────────────────────────────────────────────
|
||||
OK=0; FAIL=0; SKIP=0
|
||||
FAILED_IDS=()
|
||||
|
||||
cd "$CODEX_DIR"
|
||||
|
||||
for entry in "${PAPERS[@]}"; do
|
||||
PAPER_ID="${entry%%|*}"
|
||||
FILENAME="${entry##*|}"
|
||||
SOURCE="$PAPERS_DIR/$FILENAME"
|
||||
|
||||
if [[ ! -f "$SOURCE" ]]; then
|
||||
echo "SKIP (file missing): $FILENAME"
|
||||
(( SKIP++ )) || true
|
||||
continue
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "── Ingesting: $PAPER_ID"
|
||||
echo " source: $FILENAME"
|
||||
|
||||
if PYTHONPATH="$CODEX_DIR" "$CODEX_DIR/.venv/bin/codex" ingest "$PAPER_ID" --source "$SOURCE"; then
|
||||
(( OK++ )) || true
|
||||
else
|
||||
echo "FAILED: $PAPER_ID"
|
||||
FAILED_IDS+=("$PAPER_ID")
|
||||
(( FAIL++ )) || true
|
||||
fi
|
||||
done
|
||||
|
||||
# ── 5. Summary ────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "════════════════════════════════════════"
|
||||
echo "Ingest complete: $OK OK, $FAIL FAILED, $SKIP SKIPPED"
|
||||
if [[ ${#FAILED_IDS[@]} -gt 0 ]]; then
|
||||
echo "Failed IDs:"
|
||||
for id in "${FAILED_IDS[@]}"; do echo " $id"; done
|
||||
fi
|
||||
echo "════════════════════════════════════════"
|
||||
@@ -13,13 +13,18 @@ dependencies = [
|
||||
"pgvector>=0.3",
|
||||
"pydantic-settings>=2",
|
||||
"sentence-transformers>=3",
|
||||
"flagembedding>=1.2",
|
||||
"typer>=0.12",
|
||||
"httpx>=0.27",
|
||||
"tenacity>=8",
|
||||
"pymupdf>=1.24",
|
||||
"pix2tex>=0.1.4",
|
||||
"mcp[cli]>=1.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
codex = "codex.cli:app"
|
||||
codex-mcp = "codex.mcp_server:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
|
||||
1
scripts/__init__.py
Normal file
1
scripts/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Operational one-off scripts (importable for tests)."""
|
||||
227
scripts/ra_grobid_backfill.py
Normal file
227
scripts/ra_grobid_backfill.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""R-A — GROBID reference backfill (references-only, idempotent).
|
||||
|
||||
Adds GROBID-parsed citations to the live corpus **without** re-chunking the body,
|
||||
so the DQ-3-verified-clean ``.txt`` chunks are preserved. Complements the DQ-1
|
||||
Semantic-Scholar supplement with references the APIs lack (notably the two
|
||||
TU-Berlin ``depositonce`` theses, which neither OpenAlex nor S2 indexes).
|
||||
|
||||
Why references-only instead of ``ingest_paper(source_path=pdf)``
|
||||
----------------------------------------------------------------
|
||||
The PDF branch of :func:`codex.ingest.ingest_paper` runs Nougat OCR and then
|
||||
``DELETE FROM chunks`` + re-INSERT — it would replace the clean ``.txt`` chunks
|
||||
(DQ-3) with unmeasured PDF-OCR text and additionally requires the Nougat server.
|
||||
This backfill touches only the ``citations`` table (``ON CONFLICT DO NOTHING``),
|
||||
so it is safe, idempotent, and needs only a reachable GROBID server.
|
||||
|
||||
Prerequisite
|
||||
------------
|
||||
A reachable GROBID server. The corpus' ``GROBID_URL`` points at the Jetson
|
||||
(``http://jetson.local:8070``). GROBID now runs natively on that aarch64 host
|
||||
via the multi-arch CRF-only image (``grobid/grobid:0.9.0-crf``) — see
|
||||
``docs/infra/grobid-jetson.md`` for the deploy + docker-permission fix and the
|
||||
``GET /api/isalive`` check. (The old ``grobid/grobid:0.8.2`` pin was amd64-only,
|
||||
which is why GROBID never came up; pass ``--grobid-url`` to override the default.)
|
||||
Use the SSH DB tunnel for ``DATABASE_URL`` as documented in
|
||||
``docs/audit/DATA-QUALITY-2026-06-15.md``.
|
||||
|
||||
Usage
|
||||
-----
|
||||
# dry run (no writes) over the two zero-edge theses
|
||||
PYTHONPATH=. python scripts/ra_grobid_backfill.py --zero-edge-only
|
||||
|
||||
# write every paper's GROBID-parsed refs to the live DB
|
||||
PYTHONPATH=. python scripts/ra_grobid_backfill.py --write
|
||||
|
||||
Default is **dry-run**; pass ``--write`` to mutate the live DB.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg
|
||||
import psycopg.rows
|
||||
|
||||
from codex.config import get_settings
|
||||
from codex.db import get_conn
|
||||
from codex.models import Citation
|
||||
from codex.parsing.grobid import extract_references
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_PDF_DIR = "/Users/tarikmoussa/Desktop/ConformalLabpp/papers"
|
||||
|
||||
|
||||
def _normalize_cited_id(doi: str = "", arxiv_id: str = "") -> str | None:
|
||||
"""Normalize a GROBID-extracted reference id to the corpus' canonical form.
|
||||
|
||||
DOIs → bare, lower-cased (strip ``https://doi.org/`` / ``http://`` / ``doi:``),
|
||||
matching ``papers.id`` and ``codex.sources.openalex._normalize_doi`` (DQ-5).
|
||||
arXiv ids → strip a leading ``arXiv:`` prefix, leaving the bare id
|
||||
(``2305.10988``, ``math/0603097``). DOI takes precedence when both exist.
|
||||
Kept self-contained rather than reusing ``ingest._norm_cited_id`` because it
|
||||
additionally strips the ``arXiv:`` prefix (which GROBID can emit but that
|
||||
helper leaves untouched), and to avoid importing a private cross-module name.
|
||||
"""
|
||||
doi = (doi or "").strip()
|
||||
if doi:
|
||||
s = doi.lower()
|
||||
for prefix in ("https://doi.org/", "http://doi.org/", "doi:"):
|
||||
if s.startswith(prefix):
|
||||
return s[len(prefix) :]
|
||||
return s
|
||||
arx = (arxiv_id or "").strip()
|
||||
if arx:
|
||||
return arx[len("arxiv:") :].strip() if arx.lower().startswith("arxiv:") else arx
|
||||
return None
|
||||
|
||||
|
||||
def build_grobid_citations(
|
||||
paper_id: str,
|
||||
pdf_path: str | Path,
|
||||
grobid_url: str | None = None,
|
||||
timeout: float = 60.0,
|
||||
) -> list[Citation]:
|
||||
"""Extract a paper's references via GROBID and return normalized Citations.
|
||||
|
||||
``citing_id`` is the canonical ``paper_id``; ``cited_id`` is normalized.
|
||||
Self-citations and within-paper duplicates are dropped. Refs with neither a
|
||||
DOI nor an arXiv id are skipped (they cannot join the graph).
|
||||
"""
|
||||
refs = extract_references(str(pdf_path), grobid_url=grobid_url, timeout=timeout)
|
||||
seen: set[str] = set()
|
||||
citations: list[Citation] = []
|
||||
for ref in refs:
|
||||
cited = _normalize_cited_id(ref.get("doi", ""), ref.get("arxiv_id", ""))
|
||||
if not cited or cited == paper_id or cited in seen:
|
||||
continue
|
||||
seen.add(cited)
|
||||
citations.append(Citation(citing_id=paper_id, cited_id=cited))
|
||||
return citations
|
||||
|
||||
|
||||
def _coverage(conn: psycopg.Connection[psycopg.rows.DictRow]) -> tuple[int, int, int]:
|
||||
"""Return (n_papers, n_papers_with_out_edges, n_citations) from the live DB."""
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT (SELECT count(*) FROM papers) AS papers,
|
||||
(SELECT count(DISTINCT citing_id) FROM citations) AS citing,
|
||||
(SELECT count(*) FROM citations) AS edges
|
||||
"""
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
return int(row["papers"]), int(row["citing"]), int(row["edges"])
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--pdf-dir", default=DEFAULT_PDF_DIR, help="directory of source PDFs")
|
||||
parser.add_argument("--grobid-url", default=None, help="override Settings().grobid_url")
|
||||
parser.add_argument("--only", default=None, help="restrict to a single paper id")
|
||||
parser.add_argument(
|
||||
"--zero-edge-only",
|
||||
action="store_true",
|
||||
help="only papers with 0 out-edges (the R-A primary targets)",
|
||||
)
|
||||
parser.add_argument("--limit", type=int, default=None, help="cap number of papers")
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=float,
|
||||
default=300.0,
|
||||
help="GROBID HTTP timeout (s); theses can need >60s, esp. emulated",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--write", action="store_true", help="WRITE to the live DB (default: dry-run)"
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
grobid_url = args.grobid_url or get_settings().grobid_url
|
||||
pdf_dir = Path(args.pdf_dir)
|
||||
pdfs = {p.stem: p for p in pdf_dir.glob("*.pdf")}
|
||||
|
||||
# Read the target list in a short-lived connection, then release it: the GROBID
|
||||
# extraction below is a slow per-paper network loop and must NOT hold an
|
||||
# idle-in-transaction connection open across it (a mid-loop SSH-tunnel drop would
|
||||
# otherwise abort the whole batch — see docs/infra/jetson-ssh-stability.md).
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT p.id, p.source_path,
|
||||
(SELECT count(*) FROM citations ci WHERE ci.citing_id = p.id) AS out_edges
|
||||
FROM papers p
|
||||
ORDER BY out_edges, p.id
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
targets: list[tuple[str, Path]] = []
|
||||
for r in rows:
|
||||
if args.only and r["id"] != args.only:
|
||||
continue
|
||||
if args.zero_edge_only and r["out_edges"] != 0:
|
||||
continue
|
||||
stem = Path(r["source_path"]).stem if r["source_path"] else None
|
||||
pdf = pdfs.get(stem) if stem else None
|
||||
if pdf is None:
|
||||
logger.warning("no PDF for %s (stem=%s) — skipping", r["id"], stem)
|
||||
continue
|
||||
targets.append((r["id"], pdf))
|
||||
if args.limit is not None:
|
||||
targets = targets[: args.limit]
|
||||
|
||||
logger.info(
|
||||
"GROBID=%s | papers to process=%d | mode=%s",
|
||||
grobid_url,
|
||||
len(targets),
|
||||
"WRITE" if args.write else "DRY-RUN",
|
||||
)
|
||||
|
||||
# GROBID extraction — no DB connection held during this slow network loop.
|
||||
all_citations: list[Citation] = []
|
||||
for paper_id, pdf in targets:
|
||||
try:
|
||||
cits = build_grobid_citations(
|
||||
paper_id, pdf, grobid_url=grobid_url, timeout=args.timeout
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("GROBID extraction failed for %s (%s): %s", paper_id, pdf.name, exc)
|
||||
continue
|
||||
logger.info("%-38s %3d refs (%s)", paper_id, len(cits), pdf.name)
|
||||
all_citations.extend(cits)
|
||||
|
||||
logger.info("total candidate citations: %d", len(all_citations))
|
||||
|
||||
if not args.write:
|
||||
logger.info("DRY-RUN — no rows written. Re-run with --write to apply.")
|
||||
return 0
|
||||
|
||||
# Fresh connection for the write — the read connection was already released.
|
||||
with get_conn() as conn:
|
||||
before = _coverage(conn)
|
||||
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 all_citations],
|
||||
)
|
||||
conn.commit()
|
||||
after = _coverage(conn)
|
||||
logger.info(
|
||||
"coverage papers=%d citing %d->%d edges %d->%d (+%d)",
|
||||
after[0],
|
||||
before[1],
|
||||
after[1],
|
||||
before[2],
|
||||
after[2],
|
||||
after[2] - before[2],
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
131
scripts/rc_tex_reingest.py
Normal file
131
scripts/rc_tex_reingest.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""R-C — re-ingest arXiv papers from LaTeX source (section-aware, higher fidelity).
|
||||
|
||||
For each arXiv paper, download its source via :func:`codex.sources.arxiv.fetch_source`
|
||||
(multi-file ``\\input``/``\\include`` flattened), write it to a temp ``.tex``, and
|
||||
re-ingest through :func:`codex.ingest.ingest_paper`. The ``.tex`` ingest path is
|
||||
section-aware (:func:`codex.parsing.tex.chunk_sections`), so the stored ``section``
|
||||
column is labelled from real ``\\section`` headings instead of mostly ``body`` —
|
||||
serving DQ-3 (fidelity vs the OCR'd ``.txt``) and audit R-12 (weak ``section`` column).
|
||||
|
||||
Only arXiv papers have downloadable source; DOI/journal papers and books are skipped.
|
||||
Re-ingest REPLACES the paper's chunks (DELETE + re-INSERT) — that is the intended R-C
|
||||
improvement (flattened ``.txt`` → clean ``.tex`` chunks). It is idempotent and
|
||||
**dry-run by default**; pass ``--write`` to mutate the live corpus.
|
||||
|
||||
Usage
|
||||
-----
|
||||
# dry-run: list arXiv papers + source availability (no writes)
|
||||
PYTHONPATH=. python scripts/rc_tex_reingest.py
|
||||
|
||||
# re-ingest every arXiv paper from .tex
|
||||
PYTHONPATH=. python scripts/rc_tex_reingest.py --write
|
||||
|
||||
# one paper
|
||||
PYTHONPATH=. python scripts/rc_tex_reingest.py --only 2305.10988 --write
|
||||
|
||||
Needs ``DATABASE_URL`` (Jetson tunnel) as documented in
|
||||
``docs/audit/DATA-QUALITY-2026-06-15.md``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import tempfile
|
||||
|
||||
import psycopg
|
||||
import psycopg.rows
|
||||
|
||||
from codex.db import get_conn
|
||||
from codex.ingest import ingest_paper
|
||||
from codex.sources import arxiv
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_arxiv_id(paper_id: str) -> bool:
|
||||
"""Return True for arXiv ids (the only papers with downloadable LaTeX source).
|
||||
|
||||
arXiv ids are modern (``2305.10988``) or legacy (``math/0603097``). DOIs start
|
||||
with ``10.``; OpenAlex W-ids start with ``W`` or an ``https://openalex.org/`` URL.
|
||||
"""
|
||||
p = (paper_id or "").strip()
|
||||
# Exclude DOIs (incl. the arXiv DOI 10.48550/arXiv.*), OpenAlex W-ids, and any
|
||||
# http(s) URL form; everything else is treated as a bare arXiv id.
|
||||
return (
|
||||
bool(p)
|
||||
and not p.startswith(("10.", "W"))
|
||||
and not p.lower().startswith(("http://", "https://"))
|
||||
)
|
||||
|
||||
|
||||
def _section_dist(conn: psycopg.Connection[psycopg.rows.DictRow], paper_id: str) -> dict[str, int]:
|
||||
"""Return the section-label distribution for a paper's stored chunks."""
|
||||
rows = conn.execute(
|
||||
"SELECT coalesce(section, '(null)') AS s, count(*) AS n "
|
||||
"FROM chunks WHERE paper_id = %s GROUP BY 1 ORDER BY 2 DESC",
|
||||
(paper_id,),
|
||||
).fetchall()
|
||||
return {r["s"]: int(r["n"]) for r in rows}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--only", default=None, help="restrict to a single arXiv id")
|
||||
parser.add_argument("--limit", type=int, default=None, help="cap number of papers")
|
||||
parser.add_argument(
|
||||
"--write", action="store_true", help="re-ingest into the live DB (default: dry-run)"
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id FROM papers ORDER BY id").fetchall()
|
||||
targets = [
|
||||
r["id"]
|
||||
for r in rows
|
||||
if is_arxiv_id(r["id"]) and (args.only is None or r["id"] == args.only)
|
||||
]
|
||||
if args.limit is not None:
|
||||
targets = targets[: args.limit]
|
||||
|
||||
logger.info(
|
||||
"arXiv papers to process=%d | mode=%s",
|
||||
len(targets),
|
||||
"WRITE" if args.write else "DRY-RUN",
|
||||
)
|
||||
|
||||
for pid in targets:
|
||||
try:
|
||||
src = arxiv.fetch_source(pid)
|
||||
except Exception as exc:
|
||||
logger.warning("%s: source fetch failed: %s", pid, exc)
|
||||
continue
|
||||
if not src:
|
||||
logger.warning("%s: no LaTeX source available — skipping", pid)
|
||||
continue
|
||||
if not args.write:
|
||||
logger.info("%s: source OK (%d chars)", pid, len(src))
|
||||
continue
|
||||
|
||||
with get_conn() as conn:
|
||||
before = _section_dist(conn, pid)
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".tex", delete=True) as tf:
|
||||
tf.write(src)
|
||||
tf.flush()
|
||||
res = ingest_paper(pid, source_path=tf.name)
|
||||
with get_conn() as conn:
|
||||
after = _section_dist(conn, pid)
|
||||
logger.info(
|
||||
"%s: chunks %d->%d | section before=%s after=%s",
|
||||
pid,
|
||||
sum(before.values()),
|
||||
res.chunks_upserted,
|
||||
before,
|
||||
after,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
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://jetson.local: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://jetson.local: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
384
tests/cli/test_cli.py
Normal file
384
tests/cli/test_cli.py
Normal file
@@ -0,0 +1,384 @@
|
||||
"""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
|
||||
|
||||
# Regression guard (DQ-4): the pgvector `<->` operator requires the
|
||||
# query embedding cast to ::vector. Without it, real Postgres raises
|
||||
# "operator does not exist: vector <-> double precision[]". Mocked DBs
|
||||
# don't catch the type error, so assert the cast is in the SQL.
|
||||
executed_sql = mock_conn.execute.call_args_list[0][0][0]
|
||||
assert "<->" in executed_sql and "::vector" in executed_sql
|
||||
|
||||
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/graph/__init__.py
Normal file
0
tests/graph/__init__.py
Normal file
256
tests/graph/test_cli.py
Normal file
256
tests/graph/test_cli.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""CLI tests for `codex graph` sub-commands (F-15)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import networkx as nx
|
||||
import numpy as np
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from codex.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_conn_cm(conn: MagicMock):
|
||||
@contextmanager
|
||||
def _cm():
|
||||
yield conn
|
||||
|
||||
return _cm
|
||||
|
||||
|
||||
def _make_conn_with_paper_ids(*paper_ids: str) -> MagicMock:
|
||||
"""Mock conn that returns paper rows for SELECT id, title FROM papers."""
|
||||
conn = MagicMock()
|
||||
conn.execute.return_value.fetchall.return_value = [
|
||||
{"id": pid, "title": f"Title of {pid}"} for pid in paper_ids
|
||||
]
|
||||
return conn
|
||||
|
||||
|
||||
def _sample_graph() -> nx.DiGraph:
|
||||
"""A→B, A→C, B→C, B→D, C→D — 5 nodes, D is dangling (not in papers)."""
|
||||
g = nx.DiGraph()
|
||||
g.add_edges_from([("A", "B"), ("A", "C"), ("B", "C"), ("B", "D"), ("C", "D")])
|
||||
return g
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# codex graph report
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGraphReport:
|
||||
def _run(self, graph=None, known_ids=("A", "B", "C"), extra_args=()):
|
||||
if graph is None:
|
||||
graph = _sample_graph()
|
||||
conn = _make_conn_with_paper_ids(*known_ids)
|
||||
|
||||
with (
|
||||
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
|
||||
patch("codex.graph.build_citation_graph", return_value=graph),
|
||||
):
|
||||
return runner.invoke(app, ["graph", "report", *extra_args])
|
||||
|
||||
def test_exit_zero(self):
|
||||
assert self._run().exit_code == 0
|
||||
|
||||
def test_shows_hub_section(self):
|
||||
assert "HUB" in self._run().output.upper()
|
||||
|
||||
def test_shows_dangling_section(self):
|
||||
assert "DANGLING" in self._run().output.upper()
|
||||
|
||||
def test_dangling_node_in_output(self):
|
||||
# D is cited but not in known_ids
|
||||
assert "D" in self._run().output
|
||||
|
||||
def test_json_output_valid(self):
|
||||
import json
|
||||
|
||||
graph = _sample_graph()
|
||||
conn = _make_conn_with_paper_ids("A", "B", "C")
|
||||
with (
|
||||
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
|
||||
patch("codex.graph.build_citation_graph", return_value=graph),
|
||||
):
|
||||
result = runner.invoke(app, ["graph", "report", "--json"])
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert "hubs" in data
|
||||
assert "dangling" in data
|
||||
assert "nodes" in data
|
||||
assert data["dangling_count"] == 1
|
||||
|
||||
def test_empty_graph_message(self):
|
||||
conn = _make_conn_with_paper_ids()
|
||||
with (
|
||||
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
|
||||
patch("codex.graph.build_citation_graph", return_value=nx.DiGraph()),
|
||||
):
|
||||
result = runner.invoke(app, ["graph", "report"])
|
||||
assert result.exit_code == 0
|
||||
assert "empty" in result.output.lower()
|
||||
|
||||
def test_top_n_respected(self):
|
||||
# Build graph with 8 nodes, ask for top 3
|
||||
g = nx.DiGraph()
|
||||
for i in range(8):
|
||||
g.add_edge(f"P{i}", "HUB")
|
||||
conn = _make_conn_with_paper_ids(*[f"P{i}" for i in range(8)], "HUB")
|
||||
with (
|
||||
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
|
||||
patch("codex.graph.build_citation_graph", return_value=g),
|
||||
):
|
||||
result = runner.invoke(app, ["graph", "report", "--top-n", "3"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_small_corpus_warning_shown(self):
|
||||
# known_ids has only 3 papers (< graph_min_corpus_size default 15)
|
||||
result = self._run(known_ids=("A", "B", "C"))
|
||||
assert result.exit_code == 0
|
||||
assert "Warning" in result.output or "warning" in result.output
|
||||
|
||||
def test_shows_citing_coverage_line(self):
|
||||
# _sample_graph: A, B, C all cite → 3/3 = 100%, so the line shows but no warning.
|
||||
out = self._run(known_ids=("A", "B", "C")).output
|
||||
assert "Citing coverage" in out
|
||||
assert "weakens" not in out # 100% coverage → no R-D warning
|
||||
|
||||
def test_low_citing_coverage_warning(self):
|
||||
# Only A has an out-edge; B and C are ingested but cite nothing → 1/3 = 33%.
|
||||
g = nx.DiGraph()
|
||||
g.add_edge("A", "X")
|
||||
g.add_nodes_from(["B", "C"])
|
||||
result = self._run(graph=g, known_ids=("A", "B", "C"))
|
||||
assert result.exit_code == 0
|
||||
assert "weakens PageRank" in result.output # the R-D coverage warning
|
||||
|
||||
def test_json_includes_citing_coverage(self):
|
||||
import json
|
||||
|
||||
g = nx.DiGraph()
|
||||
g.add_edge("A", "X")
|
||||
g.add_nodes_from(["B", "C"])
|
||||
conn = _make_conn_with_paper_ids("A", "B", "C")
|
||||
with (
|
||||
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
|
||||
patch("codex.graph.build_citation_graph", return_value=g),
|
||||
):
|
||||
result = runner.invoke(app, ["graph", "report", "--json"])
|
||||
data = json.loads(result.output)
|
||||
assert data["citing_coverage"] == {"citing": 1, "papers": 3, "fraction": 0.3333}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# codex graph related
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGraphRelated:
|
||||
def _run(self, paper_id: str, graph=None, extra_args=()):
|
||||
if graph is None:
|
||||
graph = _sample_graph()
|
||||
conn = MagicMock()
|
||||
with (
|
||||
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
|
||||
patch("codex.graph.build_citation_graph", return_value=graph),
|
||||
):
|
||||
return runner.invoke(app, ["graph", "related", paper_id, *extra_args])
|
||||
|
||||
def test_exit_zero_known_paper(self):
|
||||
assert self._run("A").exit_code == 0
|
||||
|
||||
def test_unknown_paper_graceful(self):
|
||||
result = self._run("UNKNOWN_ID")
|
||||
assert result.exit_code == 0
|
||||
assert "No papers" in result.output
|
||||
|
||||
def test_no_results_message_when_min_shared_high(self):
|
||||
result = self._run("A", _sample_graph(), extra_args=("--min-shared", "99"))
|
||||
assert "No papers" in result.output
|
||||
|
||||
def test_related_papers_shown(self):
|
||||
# A cites {B,C}; B also cites C → shared=1 with A
|
||||
result = self._run("A", _sample_graph(), extra_args=("--min-shared", "1"))
|
||||
assert result.exit_code == 0
|
||||
# B shares C with A; should appear
|
||||
assert "B" in result.output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# codex search paper --cite-boost
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSearchCiteBoost:
|
||||
def _make_two_paper_conn(self, pr_winner: str) -> MagicMock:
|
||||
"""Two papers A and B at equal distance; pr_winner has a high PageRank score."""
|
||||
conn = MagicMock()
|
||||
conn.execute.return_value.fetchall.return_value = [
|
||||
{"id": "A", "title": "Paper A", "year": 2020, "distance": 0.5},
|
||||
{"id": "B", "title": "Paper B", "year": 2021, "distance": 0.5},
|
||||
]
|
||||
return conn
|
||||
|
||||
def test_cite_boost_exits_zero(self):
|
||||
conn = MagicMock()
|
||||
conn.execute.return_value.fetchall.return_value = [
|
||||
{"id": "A", "title": "Paper A", "year": 2020, "distance": 0.2},
|
||||
]
|
||||
graph = _sample_graph()
|
||||
|
||||
def _fake_encode(texts):
|
||||
return np.zeros((len(texts), 1024), dtype="float32")
|
||||
|
||||
with (
|
||||
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
|
||||
patch("codex.embed.get_embedder") as mock_emb,
|
||||
patch("codex.graph.build_citation_graph", return_value=graph),
|
||||
):
|
||||
mock_emb.return_value.encode_dense.side_effect = _fake_encode
|
||||
result = runner.invoke(app, ["search", "paper", "hodge star", "--cite-boost"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_cite_boost_promotes_high_pr_paper(self):
|
||||
"""High-PR paper should appear first (lower boosted distance) via divide formula."""
|
||||
import networkx as nx
|
||||
|
||||
# Build a graph where A has high PageRank (many papers cite A)
|
||||
g = nx.DiGraph()
|
||||
for i in range(10):
|
||||
g.add_edge(f"P{i}", "A") # A is heavily cited → high PR
|
||||
g.add_edge("P0", "B") # B is barely cited → low PR
|
||||
|
||||
conn = MagicMock()
|
||||
conn.execute.return_value.fetchall.return_value = [
|
||||
{"id": "A", "title": "Hub", "year": 2020, "distance": 0.5},
|
||||
{"id": "B", "title": "Niche", "year": 2021, "distance": 0.5},
|
||||
]
|
||||
|
||||
def _fake_encode(texts):
|
||||
return np.zeros((len(texts), 1024), dtype="float32")
|
||||
|
||||
with (
|
||||
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
|
||||
patch("codex.embed.get_embedder") as mock_emb,
|
||||
patch("codex.graph.build_citation_graph", return_value=g),
|
||||
):
|
||||
mock_emb.return_value.encode_dense.side_effect = _fake_encode
|
||||
result = runner.invoke(app, ["search", "paper", "query", "--cite-boost"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
lines = [ln for ln in result.output.strip().splitlines() if "Hub" in ln or "Niche" in ln]
|
||||
assert len(lines) == 2
|
||||
# A (Hub) should appear before B (Niche) — lower boosted distance
|
||||
hub_line = next(line for line in lines if "Hub" in line)
|
||||
niche_line = next(line for line in lines if "Niche" in line)
|
||||
assert lines.index(hub_line) < lines.index(niche_line)
|
||||
311
tests/graph/test_graph.py
Normal file
311
tests/graph/test_graph.py
Normal file
@@ -0,0 +1,311 @@
|
||||
"""Tests for codex.graph — citation graph analytics (F-15)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import networkx as nx
|
||||
import pytest
|
||||
|
||||
from codex.graph import (
|
||||
build_citation_graph,
|
||||
citation_pagerank,
|
||||
citing_coverage,
|
||||
dangling_citations,
|
||||
find_co_cited,
|
||||
find_related,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_conn(rows: list[dict]) -> MagicMock:
|
||||
conn = MagicMock()
|
||||
conn.execute.return_value.fetchall.return_value = rows
|
||||
return conn
|
||||
|
||||
|
||||
def _citation_rows(*pairs: tuple[str, str]) -> list[dict]:
|
||||
return [{"citing_id": a, "cited_id": b} for a, b in pairs]
|
||||
|
||||
|
||||
def _small_graph() -> nx.DiGraph:
|
||||
"""
|
||||
A→C, A→D, B→C, B→D, B→E, C→D
|
||||
Nodes: A B C D E (5 nodes ingested)
|
||||
"""
|
||||
rows = _citation_rows(
|
||||
("A", "C"),
|
||||
("A", "D"),
|
||||
("B", "C"),
|
||||
("B", "D"),
|
||||
("B", "E"),
|
||||
("C", "D"),
|
||||
)
|
||||
return build_citation_graph(_make_conn(rows))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_citation_graph
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildCitationGraph:
|
||||
def test_empty_db_returns_empty_graph(self):
|
||||
g = build_citation_graph(_make_conn([]))
|
||||
assert isinstance(g, nx.DiGraph)
|
||||
assert g.number_of_nodes() == 0
|
||||
assert g.number_of_edges() == 0
|
||||
|
||||
def test_nodes_and_edges_populated(self):
|
||||
rows = _citation_rows(("P1", "P2"), ("P1", "P3"), ("P2", "P3"))
|
||||
g = build_citation_graph(_make_conn(rows))
|
||||
assert set(g.nodes()) == {"P1", "P2", "P3"}
|
||||
assert g.number_of_edges() == 3
|
||||
|
||||
def test_edge_direction(self):
|
||||
rows = _citation_rows(("citing", "cited"))
|
||||
g = build_citation_graph(_make_conn(rows))
|
||||
assert g.has_edge("citing", "cited")
|
||||
assert not g.has_edge("cited", "citing")
|
||||
|
||||
def test_duplicate_edges_collapsed(self):
|
||||
rows = _citation_rows(("A", "B"), ("A", "B"))
|
||||
g = build_citation_graph(_make_conn(rows))
|
||||
assert g.number_of_edges() == 1
|
||||
|
||||
def test_dangling_nodes_included(self):
|
||||
rows = _citation_rows(("P1", "not_in_papers"))
|
||||
g = build_citation_graph(_make_conn(rows))
|
||||
assert "not_in_papers" in g.nodes()
|
||||
|
||||
def test_cross_citation_uses_doi_when_in_kb(self):
|
||||
# Simulate DB COALESCE: cited_id already resolved to papers.id (DOI)
|
||||
# for in-KB papers; dangling references keep their OpenAlex ID.
|
||||
rows = [
|
||||
{
|
||||
"citing_id": "https://doi.org/10.1234/paper-a",
|
||||
"cited_id": "https://doi.org/10.5678/paper-b",
|
||||
},
|
||||
{
|
||||
"citing_id": "https://doi.org/10.5678/paper-b",
|
||||
"cited_id": "https://openalex.org/W999",
|
||||
},
|
||||
]
|
||||
g = build_citation_graph(_make_conn(rows))
|
||||
assert g.has_edge("https://doi.org/10.1234/paper-a", "https://doi.org/10.5678/paper-b")
|
||||
assert g.has_edge("https://doi.org/10.5678/paper-b", "https://openalex.org/W999")
|
||||
assert g.number_of_nodes() == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# citation_pagerank
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCitationPagerank:
|
||||
def test_empty_graph_returns_empty_dict(self):
|
||||
g = nx.DiGraph()
|
||||
assert citation_pagerank(g) == {}
|
||||
|
||||
def test_scores_sum_to_one(self):
|
||||
g = _small_graph()
|
||||
pr = citation_pagerank(g)
|
||||
assert abs(sum(pr.values()) - 1.0) < 1e-6
|
||||
|
||||
def test_hub_node_has_highest_score(self):
|
||||
# D is pointed to by A, B, C → should rank highest
|
||||
g = _small_graph()
|
||||
pr = citation_pagerank(g)
|
||||
assert pr["D"] == max(pr.values())
|
||||
|
||||
def test_all_nodes_present(self):
|
||||
g = _small_graph()
|
||||
pr = citation_pagerank(g)
|
||||
assert set(pr.keys()) == set(g.nodes())
|
||||
|
||||
def test_small_graph_returns_uniform_scores(self):
|
||||
# 3 nodes < _MIN_PAGERANK_NODES (5) → uniform
|
||||
rows = _citation_rows(("A", "B"), ("B", "C"))
|
||||
g = build_citation_graph(_make_conn(rows))
|
||||
pr = citation_pagerank(g)
|
||||
assert len(pr) == 3
|
||||
scores = list(pr.values())
|
||||
assert all(abs(s - scores[0]) < 1e-9 for s in scores)
|
||||
|
||||
def test_small_graph_logs_warning(self, caplog: pytest.LogCaptureFixture):
|
||||
import logging
|
||||
|
||||
rows = _citation_rows(("A", "B"), ("B", "C"))
|
||||
g = build_citation_graph(_make_conn(rows))
|
||||
with caplog.at_level(logging.WARNING, logger="codex.graph"):
|
||||
citation_pagerank(g)
|
||||
assert any("corpus too small" in rec.message for rec in caplog.records)
|
||||
|
||||
def test_custom_damping(self):
|
||||
g = _small_graph()
|
||||
pr_85 = citation_pagerank(g, damping=0.85)
|
||||
pr_50 = citation_pagerank(g, damping=0.50)
|
||||
# Different damping → different scores (not all equal)
|
||||
assert pr_85 != pr_50
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find_related (bibliographic coupling)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindRelated:
|
||||
def _coupling_graph(self) -> nx.DiGraph:
|
||||
"""
|
||||
A cites: X Y Z
|
||||
B cites: X Y
|
||||
C cites: X
|
||||
D cites: W (no overlap with A)
|
||||
"""
|
||||
rows = _citation_rows(
|
||||
("A", "X"),
|
||||
("A", "Y"),
|
||||
("A", "Z"),
|
||||
("B", "X"),
|
||||
("B", "Y"),
|
||||
("C", "X"),
|
||||
("D", "W"),
|
||||
)
|
||||
return build_citation_graph(_make_conn(rows))
|
||||
|
||||
def test_unknown_paper_returns_empty(self):
|
||||
g = self._coupling_graph()
|
||||
assert find_related("UNKNOWN", g) == []
|
||||
|
||||
def test_paper_with_no_refs_returns_empty(self):
|
||||
# X has no outgoing edges → no references to couple on
|
||||
g = self._coupling_graph()
|
||||
assert find_related("X", g) == []
|
||||
|
||||
def test_related_by_two_shared_refs(self):
|
||||
g = self._coupling_graph()
|
||||
related = find_related("A", g, min_shared=2)
|
||||
assert "B" in related
|
||||
|
||||
def test_not_related_below_threshold(self):
|
||||
g = self._coupling_graph()
|
||||
related = find_related("A", g, min_shared=2)
|
||||
assert "C" not in related # only 1 shared ref
|
||||
|
||||
def test_ordered_by_shared_count_desc(self):
|
||||
g = self._coupling_graph()
|
||||
related = find_related("A", g, min_shared=1)
|
||||
# B shares 2 refs, C shares 1 → B before C
|
||||
assert related.index("B") < related.index("C")
|
||||
|
||||
def test_self_not_in_results(self):
|
||||
g = self._coupling_graph()
|
||||
related = find_related("A", g, min_shared=1)
|
||||
assert "A" not in related
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find_co_cited
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindCoCited:
|
||||
def _co_citation_graph(self) -> nx.DiGraph:
|
||||
"""
|
||||
P1 cites: A, B, C
|
||||
P2 cites: A, B
|
||||
P3 cites: A
|
||||
"""
|
||||
rows = _citation_rows(
|
||||
("P1", "A"),
|
||||
("P1", "B"),
|
||||
("P1", "C"),
|
||||
("P2", "A"),
|
||||
("P2", "B"),
|
||||
("P3", "A"),
|
||||
)
|
||||
return build_citation_graph(_make_conn(rows))
|
||||
|
||||
def test_unknown_paper_returns_empty(self):
|
||||
g = self._co_citation_graph()
|
||||
assert find_co_cited("UNKNOWN", g) == []
|
||||
|
||||
def test_paper_with_no_citers_returns_empty(self):
|
||||
# C has citers but let's test a node that has no predecessors
|
||||
rows = _citation_rows(("P1", "X"))
|
||||
g = build_citation_graph(_make_conn(rows))
|
||||
# "X" is cited once by P1, but P1 has no other citations
|
||||
# X is co-cited with nothing
|
||||
result = find_co_cited("X", g)
|
||||
assert result == []
|
||||
|
||||
def test_most_co_cited_first(self):
|
||||
g = self._co_citation_graph()
|
||||
result = find_co_cited("A", g)
|
||||
# B is co-cited twice (by P1 and P2); C once (by P1 only)
|
||||
ids = [r[0] for r in result]
|
||||
assert ids[0] == "B"
|
||||
|
||||
def test_self_not_included(self):
|
||||
g = self._co_citation_graph()
|
||||
result = find_co_cited("A", g)
|
||||
assert all(pid != "A" for pid, _ in result)
|
||||
|
||||
def test_counts_correct(self):
|
||||
g = self._co_citation_graph()
|
||||
result = dict(find_co_cited("A", g))
|
||||
assert result["B"] == 2 # P1 and P2 both cite A and B
|
||||
assert result["C"] == 1 # only P1 cites A and C
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dangling_citations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDanglingCitations:
|
||||
def test_empty_graph(self):
|
||||
assert dangling_citations(nx.DiGraph(), set()) == []
|
||||
|
||||
def test_all_known_returns_empty(self):
|
||||
rows = _citation_rows(("A", "B"), ("B", "C"))
|
||||
g = build_citation_graph(_make_conn(rows))
|
||||
assert dangling_citations(g, {"A", "B", "C"}) == []
|
||||
|
||||
def test_identifies_dangling(self):
|
||||
rows = _citation_rows(("A", "B"), ("A", "external_doi"))
|
||||
g = build_citation_graph(_make_conn(rows))
|
||||
dangling = dangling_citations(g, {"A", "B"})
|
||||
assert "external_doi" in dangling
|
||||
assert "A" not in dangling
|
||||
assert "B" not in dangling
|
||||
|
||||
def test_empty_known_set(self):
|
||||
rows = _citation_rows(("A", "B"))
|
||||
g = build_citation_graph(_make_conn(rows))
|
||||
dangling = dangling_citations(g, set())
|
||||
assert set(dangling) == {"A", "B"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# citing_coverage (R-D)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCitingCoverage:
|
||||
def test_counts_papers_with_out_edges(self):
|
||||
# _small_graph: A→.., B→.., C→D have out-edges; D, E have none.
|
||||
g = _small_graph()
|
||||
n_citing, n_papers = citing_coverage(g, {"A", "B", "C", "D", "E"})
|
||||
assert (n_citing, n_papers) == (3, 5)
|
||||
|
||||
def test_paper_absent_from_graph_counts_as_no_out_edges(self):
|
||||
# A cites; Z was ingested but has no chunks/edges, so it isn't a graph node.
|
||||
g = _small_graph()
|
||||
assert citing_coverage(g, {"A", "Z"}) == (1, 2)
|
||||
|
||||
def test_empty(self):
|
||||
assert citing_coverage(nx.DiGraph(), set()) == (0, 0)
|
||||
0
tests/ingest/__init__.py
Normal file
0
tests/ingest/__init__.py
Normal file
942
tests/ingest/test_ingest.py
Normal file
942
tests/ingest/test_ingest.py
Normal file
@@ -0,0 +1,942 @@
|
||||
"""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 httpx
|
||||
import numpy as np
|
||||
import psycopg
|
||||
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}'"
|
||||
)
|
||||
|
||||
|
||||
def test_ingest_citation_fallback_to_crossref() -> None:
|
||||
"""R-B: OpenAlex + S2 both empty → Crossref references supply the edges.
|
||||
|
||||
Crossref is the third leg of the fallback chain. Its cited DOIs are
|
||||
case-normalised and citing_id is rewritten to the canonical paper.id.
|
||||
"""
|
||||
paper = _make_paper(paper_id="10.1007/s00454-019-00132-8", openalex_id="W123")
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.execute = MagicMock()
|
||||
mock_conn.executemany = MagicMock()
|
||||
mock_conn.commit = MagicMock()
|
||||
|
||||
# Upper-case suffix proves the cited DOI is normalised to bare lower-case.
|
||||
crossref_refs = [Citation(citing_id=paper.id, cited_id="10.1090/S0002-9947-04-03545-7")]
|
||||
|
||||
with (
|
||||
patch("codex.ingest.openalex.fetch_paper", return_value=paper),
|
||||
patch("codex.ingest.openalex.fetch_citations", return_value=[]), # OpenAlex empty
|
||||
patch("codex.ingest.semanticscholar.fetch_references", return_value=[]), # S2 empty
|
||||
patch("codex.ingest.crossref.fetch_references", return_value=crossref_refs) as mock_cr,
|
||||
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)
|
||||
|
||||
# Crossref consulted with the bare DOI; one edge upserted.
|
||||
mock_cr.assert_called_once_with(paper.id)
|
||||
assert result.citations_upserted == 1
|
||||
mock_cursor = mock_conn.cursor.return_value.__enter__.return_value
|
||||
cit_rows = mock_cursor.executemany.call_args_list[0][0][1]
|
||||
assert cit_rows[0][0] == paper.id
|
||||
assert cit_rows[0][1] == "10.1090/s0002-9947-04-03545-7"
|
||||
|
||||
|
||||
def test_ingest_crossref_not_called_when_openalex_has_citations() -> None:
|
||||
"""R-B: the Crossref leg fires only when OpenAlex+S2 are empty (it's a fallback)."""
|
||||
paper = _make_paper(paper_id="10.1007/s00454-019-00132-8", openalex_id="W123")
|
||||
mock_conn = MagicMock()
|
||||
|
||||
with (
|
||||
patch("codex.ingest.openalex.fetch_paper", return_value=paper),
|
||||
patch(
|
||||
"codex.ingest.openalex.fetch_citations",
|
||||
return_value=[Citation(citing_id="W123", cited_id="https://openalex.org/W9")],
|
||||
),
|
||||
patch("codex.ingest.crossref.fetch_references") as mock_cr,
|
||||
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)
|
||||
|
||||
mock_cr.assert_not_called()
|
||||
|
||||
|
||||
def test_ingest_canonicalises_paper_id_to_caller_arg() -> None:
|
||||
"""papers.id is the caller-supplied id, not OpenAlex's URL-form id (audit C-7).
|
||||
|
||||
Real OpenAlex returns the doi/id as a full URL; ingest must key the paper on
|
||||
the bare id the caller passed (matching ingest scripts and CLI lookups), and
|
||||
keep the OpenAlex work id only as openalex_id.
|
||||
"""
|
||||
oa_paper = Paper(
|
||||
id="https://doi.org/10.48550/arxiv.math/0603097", # URL form, as OpenAlex emits
|
||||
title="A Test Paper",
|
||||
openalex_id="https://openalex.org/W4301005794",
|
||||
authors=["Alice", "Bob"],
|
||||
year=2008,
|
||||
abstract="Test abstract.",
|
||||
)
|
||||
mock_conn = MagicMock()
|
||||
|
||||
with (
|
||||
patch("codex.ingest.openalex.fetch_paper", return_value=oa_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)),
|
||||
):
|
||||
result = ingest_paper("math/0603097")
|
||||
|
||||
papers_params = mock_conn.execute.call_args_list[0][0][1]
|
||||
assert papers_params["id"] == "math/0603097", "papers.id must be the caller's bare id"
|
||||
assert papers_params["openalex_id"] == "https://openalex.org/W4301005794"
|
||||
assert result.paper_id == "math/0603097"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. test_ingest_paper_source_tex
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ingest_paper_source_tex(tmp_path: Any) -> None:
|
||||
"""``.tex`` source → section-aware chunking; the stored ``section`` column is
|
||||
labelled from the real ``\\section`` heading, not the chunk body (R-C / R-12)."""
|
||||
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()
|
||||
|
||||
# (section title, chunk content) pairs from the section-aware chunker.
|
||||
section_pairs = [
|
||||
("Introduction", "Body of the introduction goes here."),
|
||||
("Proof of the Main Theorem", "Body of the proof goes here."),
|
||||
("3.2 Discrete Conformal Maps", "Body about discrete conformal maps."),
|
||||
]
|
||||
|
||||
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.chunk_sections", return_value=section_pairs),
|
||||
patch("codex.ingest.is_quality_chunk", return_value=True),
|
||||
):
|
||||
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
|
||||
|
||||
# R-F: a bare canonical heading → bucket ("Introduction"→intro); any other
|
||||
# heading is stored as its cleaned real title ("proof of the main theorem",
|
||||
# "discrete conformal maps") instead of being collapsed/mislabelled.
|
||||
chunk_rows = chunk_insert_call[0][1]
|
||||
assert [row[4] for row in chunk_rows] == [
|
||||
"intro",
|
||||
"proof of the main theorem",
|
||||
"discrete conformal maps",
|
||||
]
|
||||
assert result.chunks_upserted == len(section_pairs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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=[]),
|
||||
# OpenAlex empty → ingest now consults the S2 reference supplement (DQ-1);
|
||||
# mock it empty so only the GROBID refs contribute to the count.
|
||||
patch("codex.ingest.semanticscholar.fetch_references", 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.is_quality_chunk", return_value=True),
|
||||
):
|
||||
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
|
||||
|
||||
|
||||
def test_ingest_pdf_grobid_citations_normalize_doi(tmp_path: Any) -> None:
|
||||
"""R-A guard: GROBID extracts the DOI verbatim from the PDF reference text, so
|
||||
it may be mixed-case, a ``https://doi.org/…`` URL, or ``doi:``-prefixed. Each
|
||||
must be normalized to the bare, lower-cased canonical form before the Citation
|
||||
is built — otherwise the same reference splits into case-/URL-variant graph
|
||||
nodes that never join the bare-lowercase ``papers.id``. arXiv-only refs (no
|
||||
DOI) are left untouched. Mirrors the S2-supplement normalization (DQ-1)."""
|
||||
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()
|
||||
|
||||
def _ref(doi: str = "", arxiv_id: str = "") -> dict[str, str]:
|
||||
# Faithful to GROBID's output shape: all five keys always present.
|
||||
return {"title": "", "authors": "", "year": "", "doi": doi, "arxiv_id": arxiv_id}
|
||||
|
||||
grobid_refs = [
|
||||
_ref(doi="10.1007/S00454-019-00132-8"), # mixed-case bare DOI
|
||||
_ref(doi="https://doi.org/10.1145/AbC.123"), # URL-form DOI
|
||||
_ref(doi="doi:10.1/MixedCase"), # doi:-prefixed DOI
|
||||
_ref(arxiv_id="2101.00001"), # arXiv-only ref → untouched
|
||||
]
|
||||
|
||||
with (
|
||||
patch("codex.ingest.openalex.fetch_paper", return_value=paper),
|
||||
patch("codex.ingest.openalex.fetch_citations", return_value=[]),
|
||||
# OpenAlex empty → S2 supplement consulted (DQ-1); empty so only GROBID
|
||||
# refs contribute to the citation rows under inspection.
|
||||
patch("codex.ingest.semanticscholar.fetch_references", 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=["pdf chunk one."]),
|
||||
patch("codex.parsing.grobid.extract_references", return_value=grobid_refs),
|
||||
patch("codex.ingest.is_quality_chunk", return_value=True),
|
||||
):
|
||||
result = ingest_paper(paper.id, source_path=str(pdf_file))
|
||||
|
||||
assert result.citations_upserted == 4
|
||||
|
||||
# Locate the citations INSERT among the cursor's executemany calls (chunks
|
||||
# are also inserted via executemany, so filter by SQL rather than by index).
|
||||
mock_cursor = mock_conn.cursor.return_value.__enter__.return_value
|
||||
citation_calls = [
|
||||
c for c in mock_cursor.executemany.call_args_list if "INSERT INTO citations" in c[0][0]
|
||||
]
|
||||
assert len(citation_calls) == 1
|
||||
inserted_rows: list[tuple[str, str, Any]] = citation_calls[0][0][1]
|
||||
|
||||
# Every edge hangs off the canonical paper.id (citing side).
|
||||
assert all(row[0] == paper.id for row in inserted_rows)
|
||||
|
||||
cited_ids = {row[1] for row in inserted_rows}
|
||||
assert cited_ids == {
|
||||
"10.1007/s00454-019-00132-8", # mixed-case → lower-cased
|
||||
"10.1145/abc.123", # URL prefix stripped + lower-cased
|
||||
"10.1/mixedcase", # doi: prefix stripped + lower-cased
|
||||
"2101.00001", # arXiv id untouched
|
||||
}
|
||||
# No raw URL-form / prefixed DOI leaked into the citation graph.
|
||||
assert not any(cid.startswith(("http", "doi:")) for cid in cited_ids)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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:
|
||||
"""OpenAlex None + arXiv has nothing → stub Paper; S2 references still consulted."""
|
||||
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),
|
||||
# arXiv + abstract sources also empty → genuine stub fallback path.
|
||||
patch("codex.ingest.arxiv.fetch_metadata", return_value=None) as mock_arxiv,
|
||||
patch("codex.ingest.semanticscholar.fetch_abstract", return_value=None),
|
||||
patch("codex.ingest.crossref.fetch_abstract", 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)
|
||||
|
||||
# arXiv metadata recovery attempted; S2 references consulted as fallback.
|
||||
mock_arxiv.assert_called_once_with(paper_id)
|
||||
mock_s2.assert_called()
|
||||
assert result.paper_id == paper_id
|
||||
|
||||
|
||||
def test_ingest_paper_openalex_404_recovers_from_arxiv() -> None:
|
||||
"""DQ-2: OpenAlex 404 on an arXiv id → authoritative metadata from the arXiv API,
|
||||
bibkey auto-generated, abstract embedded (no zero vector)."""
|
||||
recovered = Paper(
|
||||
id="1911.00966",
|
||||
title="A discrete version of Liouville's theorem on conformal maps",
|
||||
authors=["Ulrich Pinkall", "Boris Springborn"],
|
||||
year=2019,
|
||||
abstract="Liouville's theorem says that in dimension greater than two...",
|
||||
)
|
||||
fake_emb = _fake_embedder()
|
||||
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.arxiv.fetch_metadata", return_value=recovered) as mock_arxiv,
|
||||
patch("codex.ingest.semanticscholar.fetch_references", return_value=[]),
|
||||
patch("codex.ingest.get_embedder", return_value=fake_emb),
|
||||
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
|
||||
):
|
||||
result = ingest_paper("1911.00966")
|
||||
|
||||
mock_arxiv.assert_called_once_with("1911.00966")
|
||||
assert result.paper_id == "1911.00966"
|
||||
params: dict[str, Any] = mock_conn.execute.call_args_list[0][0][1]
|
||||
assert params["title"].startswith("A discrete version")
|
||||
assert params["bibkey"] == "PinkallSpringborn2019" # generated from recovered authors+year
|
||||
# abstract present → real embedding computed, not a zero vector
|
||||
fake_emb.encode_dense.assert_called_once()
|
||||
|
||||
|
||||
def test_ingest_paper_empty_abstract_recovered_from_s2() -> None:
|
||||
"""DQ-2: OpenAlex has the paper but no abstract → supplemented from S2 before embedding."""
|
||||
paper = _make_paper(abstract=None) # has openalex_id, no abstract
|
||||
fake_emb = _fake_embedder()
|
||||
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.semanticscholar.fetch_references", return_value=[]),
|
||||
patch(
|
||||
"codex.ingest.semanticscholar.fetch_abstract",
|
||||
return_value="Recovered abstract text.",
|
||||
) as mock_abs,
|
||||
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)
|
||||
|
||||
mock_abs.assert_called_once()
|
||||
params: dict[str, Any] = mock_conn.execute.call_args_list[0][0][1]
|
||||
assert params["abstract"] == "Recovered abstract text."
|
||||
fake_emb.encode_dense.assert_called_once_with(["Recovered abstract text."])
|
||||
|
||||
|
||||
def test_ingest_paper_openalex_empty_uses_s2_supplement() -> None:
|
||||
"""DQ-1: OpenAlex returns an empty reference list → ingest supplements from S2,
|
||||
rewriting citing_id to paper.id and lowercasing DOI cited-ids."""
|
||||
paper = _make_paper() # openalex_id="W123", id="2301.07041"
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.execute = MagicMock()
|
||||
mock_conn.executemany = MagicMock()
|
||||
mock_conn.commit = MagicMock()
|
||||
|
||||
# S2 keys citing_id by the id form passed in; ingest must canonicalise it.
|
||||
s2_refs = [
|
||||
Citation(citing_id="arXiv:2301.07041", cited_id="10.1/UPPER", context="ctx"),
|
||||
Citation(citing_id="arXiv:2301.07041", cited_id="2202.00002"),
|
||||
]
|
||||
|
||||
with (
|
||||
patch("codex.ingest.openalex.fetch_paper", return_value=paper),
|
||||
patch("codex.ingest.openalex.fetch_citations", return_value=[]),
|
||||
patch("codex.ingest.semanticscholar.fetch_references", return_value=s2_refs) 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 consulted with a namespaced id (a bare arXiv id 404s on S2).
|
||||
mock_s2.assert_called_once_with("arXiv:2301.07041")
|
||||
assert result.citations_upserted == 2
|
||||
|
||||
mock_cursor = mock_conn.cursor.return_value.__enter__.return_value
|
||||
inserted_rows: list[tuple[str, str, Any]] = mock_cursor.executemany.call_args_list[0][0][1]
|
||||
# citing_id rewritten to paper.id, not the S2 id form.
|
||||
assert all(row[0] == paper.id for row in inserted_rows)
|
||||
cited = {row[1] for row in inserted_rows}
|
||||
assert "10.1/upper" in cited # DOI lowercased
|
||||
assert "2202.00002" in cited # arXiv id untouched
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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=[]),
|
||||
# No abstract recoverable from any source (DQ-2) → genuine zero-vector case.
|
||||
patch("codex.ingest.semanticscholar.fetch_abstract", return_value=None),
|
||||
patch("codex.ingest.crossref.fetch_abstract", return_value=None),
|
||||
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()
|
||||
|
||||
# Extractors derive paper_id from the PDF filename stem ("paper"), which differs
|
||||
# from papers.id — ingest must insert papers.id, not this stem (audit C-10).
|
||||
fake_formula = FormulaChunk(paper_id="paper", page=1, raw_latex=r"\alpha", context="test")
|
||||
fake_figure = FigureChunk(
|
||||
paper_id="paper", 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
|
||||
|
||||
# C-10: formula/figure rows must be keyed on papers.id, not the extractor's
|
||||
# filename-stem paper_id, or the FK to papers(id) is violated.
|
||||
cursor = mock_conn.cursor.return_value.__enter__.return_value
|
||||
inserted_paper_ids = {
|
||||
row[0] for call in cursor.executemany.call_args_list for row in call[0][1]
|
||||
}
|
||||
assert inserted_paper_ids == {paper.id}, (
|
||||
f"formula/figure inserts must use papers.id, got {inserted_paper_ids}"
|
||||
)
|
||||
|
||||
|
||||
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_doi_paper_upserts_bare_canonical_id() -> None:
|
||||
"""DQ-5 regression: re-ingesting an existing DOI paper must be idempotent.
|
||||
|
||||
OpenAlex returns the ``doi`` as a full URL (and possibly mixed-case), but the
|
||||
stored canonical ``papers.id`` is the BARE, lower-cased DOI (M-1 migration).
|
||||
The papers upsert must therefore carry the BARE id into ``%(id)s`` so
|
||||
``ON CONFLICT (id)`` matches the existing row and UPDATEs it — rather than
|
||||
attempting a fresh INSERT that trips the separate ``papers_openalex_id_key``
|
||||
unique constraint (the UniqueViolation crash).
|
||||
|
||||
The real ``_map_paper``/``fetch_paper`` run here (only the HTTP ``_get``, the
|
||||
embedder and the DB are mocked), so this exercises the actual normalization
|
||||
through the ingest path. The DB is a MagicMock, so it cannot raise the real
|
||||
constraint error; the live re-ingest of 10.1007/s00454-019-00132-8 is the
|
||||
end-to-end UniqueViolation proof (see DATA-QUALITY-2026-06-15.md, DQ-5).
|
||||
"""
|
||||
bare = "10.1007/s00454-019-00132-8"
|
||||
work = {
|
||||
"id": "https://openalex.org/W2899262408",
|
||||
# URL form + an upper-case suffix char — the worst case for ON CONFLICT.
|
||||
"doi": "https://doi.org/10.1007/S00454-019-00132-8",
|
||||
"title": "A Test Paper",
|
||||
"publication_year": 2019,
|
||||
"authorships": [{"author": {"display_name": "Alice Smith"}}],
|
||||
"abstract_inverted_index": {"Hello": [0], "world": [1]},
|
||||
# Non-empty so fetch_citations yields edges and the S2 fallback is skipped.
|
||||
"referenced_works": ["https://openalex.org/W1"],
|
||||
}
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.execute = MagicMock()
|
||||
mock_conn.executemany = MagicMock()
|
||||
mock_conn.commit = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"codex.ingest.openalex._get",
|
||||
return_value=httpx.Response(200, json=work),
|
||||
),
|
||||
patch("codex.ingest.get_embedder", return_value=_fake_embedder()),
|
||||
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
|
||||
):
|
||||
result = ingest_paper(bare)
|
||||
|
||||
# The IngestResult and the upsert must use the bare canonical id, not the URL.
|
||||
assert result.paper_id == bare
|
||||
|
||||
upsert_sql: str = mock_conn.execute.call_args_list[0][0][0]
|
||||
upsert_params: dict[str, Any] = mock_conn.execute.call_args_list[0][0][1]
|
||||
assert "INSERT INTO papers" in upsert_sql
|
||||
assert "ON CONFLICT (id)" in upsert_sql
|
||||
assert upsert_params["id"] == bare, (
|
||||
f"upsert id must be the bare canonical DOI '{bare}', "
|
||||
f"not the OpenAlex URL form '{upsert_params['id']}'"
|
||||
)
|
||||
|
||||
|
||||
def test_ingest_normalizes_non_bare_caller_id() -> None:
|
||||
"""Review #1: a non-bare CALLER id (URL-form / mixed-case DOI) is normalized to the
|
||||
bare canonical form before being pinned to papers.id (the C-7 pin runs it through
|
||||
_norm_cited_id), so a sloppy caller cannot store a URL-form papers.id that would
|
||||
defeat ON CONFLICT (id) idempotency or the ``paper.id.startswith('10.')`` gates."""
|
||||
paper = _make_paper(paper_id="10.1007/s00454-019-00132-8", openalex_id="W1")
|
||||
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.semanticscholar.fetch_references", return_value=[]),
|
||||
patch("codex.ingest.crossref.fetch_references", return_value=[]),
|
||||
patch("codex.ingest.get_embedder", return_value=_fake_embedder()),
|
||||
patch("codex.ingest.get_conn", side_effect=_make_conn_cm(mock_conn)),
|
||||
):
|
||||
# Caller passes the URL form with an upper-case suffix char (worst case).
|
||||
result = ingest_paper("https://doi.org/10.1007/S00454-019-00132-8")
|
||||
|
||||
bare = "10.1007/s00454-019-00132-8"
|
||||
assert result.paper_id == bare
|
||||
upsert_params: dict[str, Any] = mock_conn.execute.call_args_list[0][0][1]
|
||||
assert upsert_params["id"] == bare, (
|
||||
f"non-bare caller must be normalized to '{bare}', got '{upsert_params['id']}'"
|
||||
)
|
||||
|
||||
|
||||
def test_ingest_disambiguates_bibkey_on_unique_violation() -> None:
|
||||
"""A papers.bibkey UNIQUE collision is retried with an a/b/c suffix (audit C-15)."""
|
||||
paper = _make_paper() # bibkey auto-generates to "AliceBob2023"
|
||||
mock_conn = MagicMock()
|
||||
state = {"first": True}
|
||||
|
||||
def execute_side_effect(sql: str, params: Any = None) -> MagicMock:
|
||||
# The first papers INSERT collides on the bibkey; the retry must succeed.
|
||||
if "INSERT INTO papers" in sql and state["first"]:
|
||||
state["first"] = False
|
||||
raise psycopg.errors.UniqueViolation(
|
||||
'duplicate key value violates unique constraint "papers_bibkey_key"'
|
||||
)
|
||||
return MagicMock()
|
||||
|
||||
mock_conn.execute.side_effect = execute_side_effect
|
||||
|
||||
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)
|
||||
|
||||
papers_inserts = [
|
||||
c for c in mock_conn.execute.call_args_list if "INSERT INTO papers" in c[0][0]
|
||||
]
|
||||
assert len(papers_inserts) == 2, "expected one retry after the bibkey collision"
|
||||
assert papers_inserts[0][0][1]["bibkey"] == "AliceBob2023"
|
||||
assert papers_inserts[1][0][1]["bibkey"] == "AliceBob2023a"
|
||||
mock_conn.rollback.assert_called_once()
|
||||
|
||||
|
||||
def test_ingest_openalex_collision_raises_clear_error() -> None:
|
||||
"""An openalex_id UNIQUE collision raises a clear, actionable error (audit C-15).
|
||||
|
||||
Unlike a bibkey clash (auto-suffixed), a shared openalex_id means two papers
|
||||
claim the same OpenAlex work — a real data conflict that must surface, not the
|
||||
raw psycopg error.
|
||||
"""
|
||||
paper = _make_paper()
|
||||
mock_conn = MagicMock()
|
||||
|
||||
def execute_side_effect(sql: str, params: Any = None) -> MagicMock:
|
||||
if "INSERT INTO papers" in sql:
|
||||
raise psycopg.errors.UniqueViolation(
|
||||
'duplicate key value violates unique constraint "papers_openalex_id_key"'
|
||||
)
|
||||
return MagicMock()
|
||||
|
||||
mock_conn.execute.side_effect = execute_side_effect
|
||||
|
||||
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)),
|
||||
pytest.raises(ValueError, match="openalex_id"),
|
||||
):
|
||||
ingest_paper(paper.id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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]
|
||||
122
tests/mcp/test_http_auth.py
Normal file
122
tests/mcp/test_http_auth.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""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
|
||||
from pydantic import SecretStr
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _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 = SecretStr("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 = SecretStr("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]
|
||||
387
tests/parsing/test_mathpix.py
Normal file
387
tests/parsing/test_mathpix.py
Normal file
@@ -0,0 +1,387 @@
|
||||
"""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 pydantic import SecretStr
|
||||
|
||||
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 = SecretStr("my_id")
|
||||
mock_settings.mathpix_app_key = SecretStr("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
|
||||
177
tests/parsing/test_tex.py
Normal file
177
tests/parsing/test_tex.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""Tests for codex.parsing.tex."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from codex.parsing.tex import (
|
||||
chunk_sections,
|
||||
chunk_text,
|
||||
extract_sections,
|
||||
flatten_inputs,
|
||||
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
|
||||
|
||||
|
||||
class TestFlattenInputs:
|
||||
def test_inlines_input_and_include(self) -> None:
|
||||
files = {
|
||||
"main.tex": r"Start \input{sec/intro} mid \include{proof} end",
|
||||
"sec/intro.tex": "INTRO BODY",
|
||||
"proof.tex": "PROOF BODY",
|
||||
}
|
||||
out = flatten_inputs(files["main.tex"], files)
|
||||
assert "INTRO BODY" in out
|
||||
assert "PROOF BODY" in out
|
||||
assert r"\input" not in out and r"\include" not in out
|
||||
|
||||
def test_resolves_without_tex_suffix(self) -> None:
|
||||
# \input{intro} must resolve the archive member "intro.tex".
|
||||
files = {"main.tex": r"\input{intro}", "intro.tex": "BODY"}
|
||||
assert "BODY" in flatten_inputs(files["main.tex"], files)
|
||||
|
||||
def test_recursive_includes(self) -> None:
|
||||
files = {"a.tex": r"X \input{b}", "b.tex": r"Y \input{c}", "c.tex": "Z"}
|
||||
out = flatten_inputs(files["a.tex"], files)
|
||||
assert "X" in out and "Y" in out and "Z" in out
|
||||
|
||||
def test_unresolved_input_dropped(self) -> None:
|
||||
# A reference with no matching file is removed, not left as a directive.
|
||||
out = flatten_inputs(r"A \input{missing} B", {})
|
||||
assert r"\input" not in out
|
||||
assert "A" in out and "B" in out
|
||||
|
||||
def test_input_cycle_does_not_leak_directive(self) -> None:
|
||||
# a -> b -> a: the depth cap must strip the unresolved directive, not leak it.
|
||||
files = {"a.tex": r"AA \input{b}", "b.tex": r"BB \input{a}"}
|
||||
out = flatten_inputs(files["a.tex"], files)
|
||||
assert r"\input" not in out
|
||||
assert "AA" in out and "BB" in out
|
||||
|
||||
def test_dot_slash_prefix_resolves_without_overstrip(self) -> None:
|
||||
# "./intro" resolves intro.tex; lstrip must not eat a real leading dot/run.
|
||||
files = {"main.tex": r"\input{./intro}", "intro.tex": "BODY"}
|
||||
assert "BODY" in flatten_inputs(files["main.tex"], files)
|
||||
|
||||
|
||||
class TestChunkSections:
|
||||
def test_pairs_carry_section_titles(self) -> None:
|
||||
latex = (
|
||||
r"\section{Introduction}" + "\nIntro body text here.\n"
|
||||
r"\section{Proof}" + "\nProof body text here.\n"
|
||||
)
|
||||
pairs = chunk_sections(latex)
|
||||
titles = [t for t, _ in pairs]
|
||||
assert "Introduction" in titles
|
||||
assert "Proof" in titles
|
||||
# No chunk spans a section boundary: each chunk belongs to one title.
|
||||
assert all(isinstance(c, str) and c for _, c in pairs)
|
||||
|
||||
def test_fallback_titleless_when_no_sections(self) -> None:
|
||||
pairs = chunk_sections("Plain text with no section commands at all here.")
|
||||
assert pairs and all(title is None for title, _ in pairs)
|
||||
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
302
tests/quality/test_quality.py
Normal file
302
tests/quality/test_quality.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""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,
|
||||
_clean_title,
|
||||
classify_section,
|
||||
filter_chunks,
|
||||
is_quality_chunk,
|
||||
run_quality_pass,
|
||||
section_label,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
|
||||
class TestCleanTitle:
|
||||
def test_strips_leading_numbering(self):
|
||||
assert _clean_title("3.2 Variational Principles") == "variational principles"
|
||||
|
||||
def test_strips_latex_and_braces(self):
|
||||
assert _clean_title(r"The \emph{Discrete} Laplacian") == "the discrete laplacian"
|
||||
|
||||
def test_collapses_and_lowercases(self):
|
||||
assert _clean_title(" Rigidity Results ") == "rigidity results"
|
||||
|
||||
def test_strips_accent_macros(self):
|
||||
assert _clean_title(r"M\"obius Invariance") == "mobius invariance"
|
||||
|
||||
def test_strips_ties_and_accents(self):
|
||||
assert _clean_title(r"Colin~de~Verdi\`ere") == "colin de verdiere"
|
||||
|
||||
def test_truncates_at_word_boundary(self):
|
||||
long = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu omega"
|
||||
out = _clean_title(long)
|
||||
assert len(out) <= 60
|
||||
assert not out.endswith(" ")
|
||||
assert long.lower().startswith(out) # whole words from the start, no partial tail
|
||||
|
||||
|
||||
class TestSectionLabel:
|
||||
def test_bare_canonical_heading_maps_to_bucket(self):
|
||||
# Only an EXACT canonical heading collapses to its controlled bucket.
|
||||
assert section_label("Introduction", "body text") == "intro"
|
||||
assert section_label("Proof", "body text") == "proof"
|
||||
assert section_label("References", "body text") == "bibliography"
|
||||
assert section_label("Abstract", "body text") == "abstract"
|
||||
|
||||
def test_descriptive_title_stored_verbatim(self):
|
||||
# R-F: a heading that merely STARTS with a keyword keeps its real title,
|
||||
# not the bucket — fixes classify_section prefix-match over-bucketing.
|
||||
assert section_label("Preliminaries", "body text") == "preliminaries"
|
||||
assert section_label("3.1 Discrete Conformal Maps", "x") == "discrete conformal maps"
|
||||
assert section_label("Proof of Theorem 3", "x") == "proof of theorem 3"
|
||||
assert section_label("Abstract Nonsense and Categories", "x") == (
|
||||
"abstract nonsense and categories"
|
||||
)
|
||||
assert section_label("Introduction to Operator Algebras", "x") == (
|
||||
"introduction to operator algebras"
|
||||
)
|
||||
|
||||
def test_none_or_blank_title_falls_back_to_content(self):
|
||||
# .pdf/.txt path: unchanged content-based classification.
|
||||
assert section_label(None, "Theorem 1. Let X be ...") == "theorem"
|
||||
assert section_label("", "ordinary body prose here") == "body"
|
||||
|
||||
def test_title_cleaning_to_empty_falls_back_to_content(self):
|
||||
# A pure-numbering heading cleans to "" → classify by content instead.
|
||||
assert section_label("3.2", "Lemma 2. Suppose ...") == "theorem"
|
||||
0
tests/scaffold/__init__.py
Normal file
0
tests/scaffold/__init__.py
Normal file
38
tests/scaffold/test_config.py
Normal file
38
tests/scaffold/test_config.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""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.get_secret_value()
|
||||
== "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.get_secret_value() == "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
|
||||
72
tests/scaffold/test_db.py
Normal file
72
tests/scaffold/test_db.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""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()
|
||||
|
||||
|
||||
def test_schema_sql_is_idempotent() -> None:
|
||||
"""Every CREATE TABLE/INDEX in infra/schema.sql uses IF NOT EXISTS (audit M-1, T-2).
|
||||
|
||||
Re-applying schema.sql must be a safe no-op so the migration can run
|
||||
repeatedly against an existing database.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
schema = (Path(__file__).resolve().parents[2] / "infra" / "schema.sql").read_text()
|
||||
# Drop line comments so commented-out example queries don't count.
|
||||
code = "\n".join(line.split("--", 1)[0] for line in schema.splitlines())
|
||||
non_idempotent = re.findall(
|
||||
r"CREATE\s+(?:UNIQUE\s+)?(?:TABLE|INDEX)\s+(?!IF\s+NOT\s+EXISTS)(\w+)",
|
||||
code,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
assert not non_idempotent, f"schema.sql has non-idempotent CREATE statements: {non_idempotent}"
|
||||
|
||||
|
||||
def test_migrate_command_reports_privilege_error() -> None:
|
||||
"""`codex migrate` surfaces InsufficientPrivilege with guidance and exits 1 (M-1)."""
|
||||
import psycopg
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from codex.cli import app
|
||||
|
||||
def boom(*args: object, **kwargs: object) -> object:
|
||||
raise psycopg.errors.InsufficientPrivilege("must be owner of table chunks")
|
||||
|
||||
with patch("psycopg.connect", boom):
|
||||
result = CliRunner().invoke(app, ["migrate"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "MIGRATION_DATABASE_URL" in result.output
|
||||
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/scripts/__init__.py
Normal file
0
tests/scripts/__init__.py
Normal file
112
tests/scripts/test_ra_grobid_backfill.py
Normal file
112
tests/scripts/test_ra_grobid_backfill.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Tests for scripts.ra_grobid_backfill — the R-A GROBID references backfill.
|
||||
|
||||
Only the pure, offline logic is covered (id normalization + Citation assembly);
|
||||
``extract_references`` is mocked so no GROBID server or DB is required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from codex.models import Citation
|
||||
from scripts.ra_grobid_backfill import _normalize_cited_id, build_grobid_citations, main
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _normalize_cited_id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_normalize_doi_url_to_bare_lowercase() -> None:
|
||||
assert _normalize_cited_id(doi="https://doi.org/10.1007/S00454-019-00132-8") == (
|
||||
"10.1007/s00454-019-00132-8"
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_doi_prefixes() -> None:
|
||||
assert _normalize_cited_id(doi="http://doi.org/10.1/X") == "10.1/x"
|
||||
assert _normalize_cited_id(doi="doi:10.1/X") == "10.1/x"
|
||||
assert _normalize_cited_id(doi="10.1/x") == "10.1/x"
|
||||
|
||||
|
||||
def test_normalize_arxiv_prefix_stripped() -> None:
|
||||
assert _normalize_cited_id(arxiv_id="arXiv:2305.10988") == "2305.10988"
|
||||
assert _normalize_cited_id(arxiv_id="2305.10988") == "2305.10988"
|
||||
# Legacy ids keep their slash.
|
||||
assert _normalize_cited_id(arxiv_id="math/0603097") == "math/0603097"
|
||||
|
||||
|
||||
def test_normalize_doi_takes_precedence_over_arxiv() -> None:
|
||||
assert _normalize_cited_id(doi="10.5/y", arxiv_id="2305.10988") == "10.5/y"
|
||||
|
||||
|
||||
def test_normalize_empty_returns_none() -> None:
|
||||
assert _normalize_cited_id() is None
|
||||
assert _normalize_cited_id(doi=" ", arxiv_id="") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_grobid_citations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ref(doi: str = "", arxiv_id: str = "") -> dict[str, str]:
|
||||
return {"title": "t", "authors": "a", "year": "2020", "doi": doi, "arxiv_id": arxiv_id}
|
||||
|
||||
|
||||
def test_build_citations_normalizes_dedups_and_drops_self() -> None:
|
||||
paper_id = "10.1007/s00454-019-00132-8"
|
||||
refs = [
|
||||
_ref(doi="https://doi.org/10.1/A"), # → 10.1/a
|
||||
_ref(doi="DOI:10.1/a"), # duplicate of the above after normalization
|
||||
_ref(arxiv_id="arXiv:2305.10988"), # → 2305.10988
|
||||
_ref(doi="", arxiv_id=""), # no id → skipped
|
||||
_ref(doi="https://doi.org/10.1007/S00454-019-00132-8"), # self-cite → dropped
|
||||
]
|
||||
with patch("scripts.ra_grobid_backfill.extract_references", return_value=refs) as mock_ex:
|
||||
cits = build_grobid_citations(paper_id, "some.pdf", grobid_url="http://grobid")
|
||||
|
||||
mock_ex.assert_called_once_with("some.pdf", grobid_url="http://grobid", timeout=60.0)
|
||||
assert all(isinstance(c, Citation) for c in cits)
|
||||
assert all(c.citing_id == paper_id for c in cits)
|
||||
assert [c.cited_id for c in cits] == ["10.1/a", "2305.10988"]
|
||||
|
||||
|
||||
def test_build_citations_empty_when_no_refs() -> None:
|
||||
with patch("scripts.ra_grobid_backfill.extract_references", return_value=[]):
|
||||
assert build_grobid_citations("10.1/x", "x.pdf") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# main — connection scope (review finding #4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_main_uses_separate_connections_for_read_and_write(tmp_path: Any) -> None:
|
||||
"""Review #4: the read connection is released BEFORE the slow GROBID loop, and a
|
||||
fresh connection is opened for the write — so no idle-in-transaction connection
|
||||
spans the per-paper network loop (which would abort on an SSH-tunnel drop)."""
|
||||
(tmp_path / "paper.pdf").write_bytes(b"%PDF-1.4")
|
||||
conns: list[MagicMock] = []
|
||||
|
||||
@contextmanager
|
||||
def fake_get_conn() -> Any:
|
||||
c = MagicMock()
|
||||
c.execute.return_value.fetchall.return_value = [
|
||||
{"id": "10.1/x", "source_path": "/d/paper.txt", "out_edges": 0}
|
||||
]
|
||||
c.execute.return_value.fetchone.return_value = {"papers": 1, "citing": 1, "edges": 1}
|
||||
conns.append(c)
|
||||
yield c
|
||||
|
||||
with (
|
||||
patch("scripts.ra_grobid_backfill.get_conn", side_effect=fake_get_conn),
|
||||
patch("scripts.ra_grobid_backfill.build_grobid_citations", return_value=[]),
|
||||
):
|
||||
rc = main(["--pdf-dir", str(tmp_path), "--write"])
|
||||
|
||||
assert rc == 0
|
||||
# Two DISTINCT get_conn() context managers (read, then write) — not one held
|
||||
# open across the GROBID loop.
|
||||
assert len(conns) == 2
|
||||
21
tests/scripts/test_rc_tex_reingest.py
Normal file
21
tests/scripts/test_rc_tex_reingest.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Tests for scripts.rc_tex_reingest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from scripts.rc_tex_reingest import is_arxiv_id
|
||||
|
||||
|
||||
def test_is_arxiv_id_accepts_modern_and_legacy() -> None:
|
||||
assert is_arxiv_id("2305.10988")
|
||||
assert is_arxiv_id("math/0603097")
|
||||
assert is_arxiv_id("1005.2698")
|
||||
|
||||
|
||||
def test_is_arxiv_id_rejects_dois_and_wids() -> None:
|
||||
assert not is_arxiv_id("10.1007/s00454-019-00132-8")
|
||||
assert not is_arxiv_id("10.14279/depositonce-20357")
|
||||
assert not is_arxiv_id("W2971636899")
|
||||
assert not is_arxiv_id("https://openalex.org/W2971636899")
|
||||
assert not is_arxiv_id("http://openalex.org/W2971636899")
|
||||
assert not is_arxiv_id("10.48550/arXiv.2305.10988") # arXiv DOI form, not bare id
|
||||
assert not is_arxiv_id("")
|
||||
0
tests/sources/__init__.py
Normal file
0
tests/sources/__init__.py
Normal file
288
tests/sources/test_arxiv.py
Normal file
288
tests/sources/test_arxiv.py
Normal file
@@ -0,0 +1,288 @@
|
||||
"""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
|
||||
|
||||
|
||||
def test_fetch_source_flattens_multifile(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Multi-file projects: \\input/\\include directives are inlined so the body
|
||||
from the included files is returned, not just the primary skeleton (R-C)."""
|
||||
main = (
|
||||
b"\\documentclass{article}\\begin{document}"
|
||||
b"\\input{sections/intro}\\include{proof}\\end{document}"
|
||||
)
|
||||
tar_bytes = _make_tar_gz(
|
||||
{
|
||||
"main.tex": main,
|
||||
"sections/intro.tex": b"INTRODUCTION BODY TEXT",
|
||||
"proof.tex": b"PROOF BODY TEXT",
|
||||
}
|
||||
)
|
||||
|
||||
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 "INTRODUCTION BODY TEXT" in result # \input was inlined
|
||||
assert "PROOF BODY TEXT" in result # \include was inlined
|
||||
assert "\\input" not in result and "\\include" not in result
|
||||
|
||||
|
||||
def test_fetch_source_bare_gzip_single_file(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Legacy arXiv submissions are served as a single bare gzipped .tex (no tar
|
||||
wrapper); fetch_source must gunzip the body directly rather than fail."""
|
||||
import gzip
|
||||
|
||||
latex = b"\\documentstyle[a4]{article}\n\\section{Intro}\nOld single-file paper body."
|
||||
gz_bytes = gzip.compress(latex)
|
||||
|
||||
def mock_get(
|
||||
url: str,
|
||||
*,
|
||||
timeout: int = 60,
|
||||
follow_redirects: bool = True,
|
||||
) -> httpx.Response:
|
||||
return httpx.Response(200, content=gz_bytes)
|
||||
|
||||
monkeypatch.setattr(httpx, "get", mock_get)
|
||||
|
||||
result = arxiv.fetch_source("math/0001176")
|
||||
|
||||
assert result is not None
|
||||
assert "Old single-file paper body." in result
|
||||
|
||||
|
||||
def test_fetch_source_resolves_non_tex_include(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""\\input of a non-.tex body member (e.g. a .def file) is inlined, not dropped (R-C)."""
|
||||
main = b"\\documentclass{article}\\begin{document}\\input{body.def}\\end{document}"
|
||||
tar_bytes = _make_tar_gz({"main.tex": main, "body.def": b"NON TEX BODY TEXT"})
|
||||
|
||||
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 "NON TEX BODY TEXT" in result
|
||||
|
||||
|
||||
def test_fetch_source_ignores_commented_documentclass(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A commented %\\documentclass must not select a file as the primary document."""
|
||||
decoy = b"% \\documentclass{article}\n\\section{Stub}\nDECOY ONLY"
|
||||
real = b"\\documentclass{book}\\begin{document}REAL BODY HERE\\end{document}"
|
||||
# decoy sorts/inserts first; without the fix it would win on the substring match.
|
||||
tar_bytes = _make_tar_gz({"aaa_decoy.tex": decoy, "real.tex": real})
|
||||
|
||||
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 "REAL BODY HERE" in result
|
||||
assert "DECOY ONLY" not 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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fetch_metadata — parse the Atom feed into a Paper (DQ-2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ATOM_FEED = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<entry>
|
||||
<title>A discrete version of Liouville's theorem on conformal maps</title>
|
||||
<summary>Liouville's theorem says that in dimension greater than two,
|
||||
all conformal maps are Moebius transformations.</summary>
|
||||
<published>2019-11-03T00:00:00Z</published>
|
||||
<author><name>Ulrich Pinkall</name></author>
|
||||
<author><name>Boris Springborn</name></author>
|
||||
</entry>
|
||||
</feed>"""
|
||||
|
||||
_ATOM_EMPTY = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom"><title>ArXiv Query</title></feed>"""
|
||||
|
||||
|
||||
def test_fetch_metadata_parses_entry(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A valid Atom feed yields a populated Paper."""
|
||||
|
||||
def mock_get(url: str, **kwargs: object) -> httpx.Response:
|
||||
return httpx.Response(200, text=_ATOM_FEED, request=httpx.Request("GET", url))
|
||||
|
||||
monkeypatch.setattr(httpx, "get", mock_get)
|
||||
|
||||
paper = arxiv.fetch_metadata("1911.00966")
|
||||
assert paper is not None
|
||||
assert paper.id == "1911.00966"
|
||||
assert paper.title.startswith("A discrete version of Liouville")
|
||||
assert paper.authors == ["Ulrich Pinkall", "Boris Springborn"]
|
||||
assert paper.year == 2019
|
||||
assert paper.abstract and "conformal maps" in paper.abstract
|
||||
assert paper.openalex_id is None
|
||||
|
||||
|
||||
def test_fetch_metadata_no_entry_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A feed with no <entry> (id not found) returns None."""
|
||||
|
||||
def mock_get(url: str, **kwargs: object) -> httpx.Response:
|
||||
return httpx.Response(200, text=_ATOM_EMPTY, request=httpx.Request("GET", url))
|
||||
|
||||
monkeypatch.setattr(httpx, "get", mock_get)
|
||||
assert arxiv.fetch_metadata("0000.00000") is None
|
||||
|
||||
|
||||
def test_fetch_metadata_strips_arxiv_prefix(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""An ``arXiv:`` prefix is stripped so Paper.id is the bare id."""
|
||||
|
||||
def mock_get(url: str, **kwargs: object) -> httpx.Response:
|
||||
return httpx.Response(200, text=_ATOM_FEED, request=httpx.Request("GET", url))
|
||||
|
||||
monkeypatch.setattr(httpx, "get", mock_get)
|
||||
paper = arxiv.fetch_metadata("arXiv:1911.00966")
|
||||
assert paper is not None
|
||||
assert paper.id == "1911.00966"
|
||||
104
tests/sources/test_crossref.py
Normal file
104
tests/sources/test_crossref.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Tests for codex.sources.crossref."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from codex.models import Citation
|
||||
from codex.sources import crossref
|
||||
|
||||
|
||||
def test_fetch_abstract_strips_jats(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A JATS-tagged abstract is returned as plain text with tags removed."""
|
||||
body = {
|
||||
"message": {
|
||||
"abstract": "<jats:p>We prove a <jats:italic>theorem</jats:italic> about "
|
||||
"polyhedra.</jats:p>"
|
||||
}
|
||||
}
|
||||
|
||||
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
|
||||
return httpx.Response(200, json=body)
|
||||
|
||||
monkeypatch.setattr(crossref, "_get", mock_get)
|
||||
|
||||
result = crossref.fetch_abstract("10.1007/s00454-019-00132-8")
|
||||
assert result == "We prove a theorem about polyhedra."
|
||||
|
||||
|
||||
def test_fetch_abstract_absent_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A work with no abstract field (e.g. a book chapter) returns None."""
|
||||
|
||||
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
|
||||
return httpx.Response(200, json={"message": {"title": ["Some Chapter"]}})
|
||||
|
||||
monkeypatch.setattr(crossref, "_get", mock_get)
|
||||
assert crossref.fetch_abstract("10.1007/978-3-642-17413-1_7") is None
|
||||
|
||||
|
||||
def test_fetch_abstract_404_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A 404 returns None rather than raising."""
|
||||
|
||||
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
|
||||
raise httpx.HTTPStatusError(
|
||||
"404",
|
||||
request=httpx.Request("GET", url),
|
||||
response=httpx.Response(404, request=httpx.Request("GET", url)),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(crossref, "_get", mock_get)
|
||||
assert crossref.fetch_abstract("10.0000/nonexistent") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fetch_references (roadmap R-B)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fetch_references_returns_doi_edges(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Only references carrying a DOI become Citations; citing_id is the queried DOI."""
|
||||
body = {
|
||||
"message": {
|
||||
"reference": [
|
||||
{"key": "ref1", "DOI": "10.1090/s0002-9947-04-03545-7"},
|
||||
{"key": "ref2", "unstructured": "A book with no DOI, 1992."}, # skipped
|
||||
{"key": "ref3", "DOI": "10.1007/bf02392449", "article-title": "X"},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
|
||||
return httpx.Response(200, json=body)
|
||||
|
||||
monkeypatch.setattr(crossref, "_get", mock_get)
|
||||
refs = crossref.fetch_references("10.1007/s00454-019-00132-8")
|
||||
|
||||
assert refs == [
|
||||
Citation(citing_id="10.1007/s00454-019-00132-8", cited_id="10.1090/s0002-9947-04-03545-7"),
|
||||
Citation(citing_id="10.1007/s00454-019-00132-8", cited_id="10.1007/bf02392449"),
|
||||
]
|
||||
|
||||
|
||||
def test_fetch_references_no_reference_key_returns_empty(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A work with no deposited reference list returns []."""
|
||||
|
||||
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
|
||||
return httpx.Response(200, json={"message": {"title": ["No refs deposited"]}})
|
||||
|
||||
monkeypatch.setattr(crossref, "_get", mock_get)
|
||||
assert crossref.fetch_references("10.1007/978-3-642-17413-1_7") == []
|
||||
|
||||
|
||||
def test_fetch_references_404_returns_empty(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A 404 returns [] rather than raising."""
|
||||
|
||||
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
|
||||
raise httpx.HTTPStatusError(
|
||||
"404",
|
||||
request=httpx.Request("GET", url),
|
||||
response=httpx.Response(404, request=httpx.Request("GET", url)),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(crossref, "_get", mock_get)
|
||||
assert crossref.fetch_references("10.0000/nonexistent") == []
|
||||
289
tests/sources/test_openalex.py
Normal file
289
tests/sources/test_openalex.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""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 _canonical_id, _normalize_doi, _resolve_id
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SAMPLE_WORK = {
|
||||
"id": "https://openalex.org/W2741809807",
|
||||
# Real OpenAlex returns the DOI as a full URL, not a bare DOI (audit T-3 / DQ-5
|
||||
# — the old bare fixture hid the URL-form behaviour both fixes address).
|
||||
"doi": "https://doi.org/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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _normalize_doi — canonical bare, lower-cased form (DQ-5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_normalize_doi_strips_https_url() -> None:
|
||||
# The exact form OpenAlex returns in the `doi` field.
|
||||
assert _normalize_doi("https://doi.org/10.1007/s00454-019-00132-8") == (
|
||||
"10.1007/s00454-019-00132-8"
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_doi_strips_http_and_doi_prefix() -> None:
|
||||
assert _normalize_doi("http://doi.org/10.1145/3592430") == "10.1145/3592430"
|
||||
assert _normalize_doi("doi:10.1145/3592430") == "10.1145/3592430"
|
||||
|
||||
|
||||
def test_normalize_doi_lowercases() -> None:
|
||||
# DOIs are case-insensitive; the canonical id (and _norm_cited_id) is lower-case.
|
||||
assert _normalize_doi("https://doi.org/10.1007/S00454-019-00132-8") == (
|
||||
"10.1007/s00454-019-00132-8"
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_doi_already_bare_is_idempotent() -> None:
|
||||
assert _normalize_doi("10.1007/s00454-019-00132-8") == "10.1007/s00454-019-00132-8"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _canonical_id — arXiv DOI → bare arXiv id (arXiv half of DQ-5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_canonical_id_strips_arxiv_doi_modern() -> None:
|
||||
# arXiv works carry the arXiv DOI; the canonical id is the bare arXiv id.
|
||||
assert _canonical_id("https://doi.org/10.48550/arXiv.2305.10988") == "2305.10988"
|
||||
|
||||
|
||||
def test_canonical_id_strips_arxiv_doi_legacy() -> None:
|
||||
assert _canonical_id("https://doi.org/10.48550/arXiv.math/0603097") == "math/0603097"
|
||||
|
||||
|
||||
def test_canonical_id_leaves_regular_doi_bare() -> None:
|
||||
# A normal journal DOI is only normalized (bare + lower-case), not stripped.
|
||||
assert _canonical_id("https://doi.org/10.1007/S00454-019-00132-8") == (
|
||||
"10.1007/s00454-019-00132-8"
|
||||
)
|
||||
|
||||
|
||||
def test_fetch_paper_arxiv_doi_yields_bare_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""An arXiv work maps to the BARE arXiv id (not the arXiv DOI), so re-ingesting
|
||||
by arXiv id hits ON CONFLICT (id) instead of tripping papers_openalex_id_key."""
|
||||
work = {**_SAMPLE_WORK, "doi": "https://doi.org/10.48550/arXiv.2305.10988"}
|
||||
|
||||
def mock_get(url: str, params: dict[str, str] | None = None) -> httpx.Response:
|
||||
return httpx.Response(200, json=work)
|
||||
|
||||
monkeypatch.setattr(openalex, "_get", mock_get)
|
||||
paper = openalex.fetch_paper("2305.10988")
|
||||
|
||||
assert paper is not None
|
||||
assert paper.id == "2305.10988"
|
||||
|
||||
|
||||
def test_map_paper_falls_back_to_openalex_id_without_doi(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A work with no DOI keeps the OpenAlex W-id (URL form) as id, unchanged —
|
||||
normalization only rewrites the DOI branch."""
|
||||
work = {k: v for k, v in _SAMPLE_WORK.items() if k != "doi"}
|
||||
|
||||
def mock_get(url: str, params: dict[str, str] | None = None) -> httpx.Response:
|
||||
return httpx.Response(200, json=work)
|
||||
|
||||
monkeypatch.setattr(openalex, "_get", mock_get)
|
||||
paper = openalex.fetch_paper("W2741809807")
|
||||
|
||||
assert paper is not None
|
||||
assert paper.id == "https://openalex.org/W2741809807"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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)
|
||||
# DQ-5: openalex._canonical_id normalizes OpenAlex's URL-form doi to the BARE
|
||||
# lower-cased DOI at the source layer; ingest additionally pins papers.id to the
|
||||
# caller's id (audit C-7). Both converge on the bare canonical id — which is what
|
||||
# makes the ingest ON CONFLICT (id) upsert idempotent. (T-3: the fixture is
|
||||
# URL-form so this mapping can't silently regress.)
|
||||
assert paper.id == "10.1145/3592430"
|
||||
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") == []
|
||||
169
tests/sources/test_semanticscholar.py
Normal file
169
tests/sources/test_semanticscholar.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""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 == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fetch_references — {"data": null} guard (DQ-1 regression)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fetch_references_null_data_returns_empty(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""S2 returns HTTP 200 with ``{"data": null}`` for a known paper that has no
|
||||
parsed references; this must yield [] rather than raising TypeError."""
|
||||
|
||||
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
|
||||
return httpx.Response(200, json={"data": None, "citingPaperInfo": {}})
|
||||
|
||||
monkeypatch.setattr(semanticscholar, "_get", mock_get)
|
||||
assert semanticscholar.fetch_references("DOI:10.14279/depositonce-5415") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fetch_abstract (DQ-2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fetch_abstract_returns_text(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""fetch_abstract returns the abstract string when present."""
|
||||
|
||||
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
|
||||
return httpx.Response(200, json={"abstract": "We study ideal hyperbolic polyhedra."})
|
||||
|
||||
monkeypatch.setattr(semanticscholar, "_get", mock_get)
|
||||
assert semanticscholar.fetch_abstract("DOI:10.1007/s00454-019-00132-8") == (
|
||||
"We study ideal hyperbolic polyhedra."
|
||||
)
|
||||
|
||||
|
||||
def test_fetch_abstract_null_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A null/absent abstract yields None."""
|
||||
|
||||
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
|
||||
return httpx.Response(200, json={"abstract": None})
|
||||
|
||||
monkeypatch.setattr(semanticscholar, "_get", mock_get)
|
||||
assert semanticscholar.fetch_abstract("arXiv:1234.5678") is None
|
||||
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
|
||||
177
tests/synthesis/test_cli.py
Normal file
177
tests/synthesis/test_cli.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""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 == []
|
||||
161
tests/synthesis/test_gaps.py
Normal file
161
tests/synthesis/test_gaps.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""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"
|
||||
)
|
||||
# Coverage gaps are opt-in (audit R-8): pass the topic explicitly.
|
||||
leads = find_gaps(llm=StubLLM(grounded_body), topics=["hyperbolic volume formula"])
|
||||
assert any(lead.kind == "gap" for lead in leads)
|
||||
|
||||
|
||||
def test_find_gaps_no_coverage_gaps_without_explicit_topics(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
mock_paper_titles: None,
|
||||
mock_settings: Path,
|
||||
) -> None:
|
||||
"""Without explicit topics, paper titles do NOT auto-generate coverage gaps (R-8)."""
|
||||
sparse_chunks: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": 10,
|
||||
"paper_id": "springborn-2008",
|
||||
"ord": 16,
|
||||
"content": "the hyperbolic volume is V = L(gamma1) + L(gamma2) + L(gamma3)",
|
||||
"bibkey": "Springborn2008",
|
||||
},
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
"codex.synthesis._retrieve_chunks",
|
||||
lambda queries, top_k: [dict(c) for c in sparse_chunks],
|
||||
)
|
||||
# No lib_path, no topics → the coverage map must not run despite sparse coverage.
|
||||
leads = find_gaps(llm=StubLLM("a gap. [Springborn2008 #chunk 16]"))
|
||||
assert 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
|
||||
176
tests/synthesis/test_grounded.py
Normal file
176
tests/synthesis/test_grounded.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""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-C-0001" # kind-prefixed id (audit C-11)
|
||||
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: [])
|
||||
# Coverage gaps are opt-in now (audit R-8): pass an explicit topic.
|
||||
leads = find_gaps(llm=StubLLM(""), topics=["nonexistent topic"])
|
||||
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 == []
|
||||
112
tests/synthesis/test_model.py
Normal file
112
tests/synthesis/test_model.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""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:
|
||||
"""_make_lead_id produces a kind-prefixed, zero-padded L-<K>-XXXX string."""
|
||||
from codex.synthesis import _make_lead_id
|
||||
|
||||
assert _make_lead_id(1, "connection") == "L-C-0001"
|
||||
assert _make_lead_id(42, "gap") == "L-G-0042"
|
||||
assert _make_lead_id(9999, "improvement") == "L-I-9999"
|
||||
assert _make_lead_id(1, "conjecture") == "L-X-0001"
|
||||
|
||||
|
||||
def test_lead_id_namespaced_per_kind_no_collision() -> None:
|
||||
"""Regression for audit C-11: each stage numbers from 1, so without a kind
|
||||
prefix connection/gap/improvement all collide on L-0001 and overwrite one
|
||||
another in grounded/. The prefix must keep same-seq ids distinct.
|
||||
"""
|
||||
from codex.synthesis import _make_lead_id
|
||||
|
||||
ids = {_make_lead_id(1, k) for k in ("connection", "gap", "improvement", "conjecture")}
|
||||
assert len(ids) == 4, f"same-seq ids must stay distinct across kinds, got {ids}"
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user