feat: initial project scaffold
pyproject.toml (Python 3.12, uv), codex/ package (config, db, models), infra/ (docker-compose + schema), .env.example, .gitignore, README. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
25
.env.example
Normal file
25
.env.example
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Copy this file to .env and fill in your values.
|
||||||
|
# Never commit .env to version control.
|
||||||
|
|
||||||
|
# PostgreSQL connection string (psycopg / asyncpg format)
|
||||||
|
# Example: postgresql://researcher:change_me@localhost:5432/papers
|
||||||
|
DATABASE_URL=postgresql://researcher:change_me@localhost:5432/papers
|
||||||
|
|
||||||
|
# GROBID service base URL (containerised — see infra/docker-compose.yml)
|
||||||
|
GROBID_URL=http://localhost:8070
|
||||||
|
|
||||||
|
# Ollama base URL for optional local LLM Q&A layer
|
||||||
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
|
|
||||||
|
# Sentence-transformers model for dense embeddings.
|
||||||
|
# BGE-M3 (BAAI/bge-m3) produces 1024-dimensional vectors and supports
|
||||||
|
# dense + sparse (hybrid) retrieval. Change together with EMBEDDING_DIM.
|
||||||
|
EMBEDDING_MODEL=BAAI/bge-m3
|
||||||
|
|
||||||
|
# Dimension of the embedding vectors. Must match EMBEDDING_MODEL output.
|
||||||
|
# BGE-M3 = 1024 | Jina v4 = 2048 | Qwen3-Embedding-0.6B = 1024
|
||||||
|
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
|
||||||
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Environment secrets — NEVER commit
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Type-checker and linter caches
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Test caches
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Editor / OS artefacts
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
113
README.md
Normal file
113
README.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# codex — Personal Paper Knowledge Base
|
||||||
|
|
||||||
|
A self-hostable tool for managing scientific papers: ingest PDFs and arXiv
|
||||||
|
sources, build a citation graph, and link C++ implementations back to the
|
||||||
|
papers that describe them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Start Postgres (pgvector) + GROBID
|
||||||
|
docker compose -f infra/docker-compose.yml up -d
|
||||||
|
|
||||||
|
# 2. Copy and edit environment variables
|
||||||
|
cp .env.example .env
|
||||||
|
$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.')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `DATABASE_URL` | `postgresql://researcher:change_me@localhost:5432/papers` | libpq connection string |
|
||||||
|
| `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 |
|
||||||
|
| `EMBEDDING_DIM` | `1024` | Embedding vector dimension (must match model) |
|
||||||
|
| `OPENALEX_MAILTO` | *(empty)* | E-mail for OpenAlex Polite Pool (required for automated use) |
|
||||||
|
|
||||||
|
See `.env.example` for a documented template.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Three-layer data model
|
||||||
|
|
||||||
|
All data lives in a single Postgres instance with the pgvector extension.
|
||||||
|
|
||||||
|
### Layer 1 — Semantics (`papers`, `chunks`)
|
||||||
|
|
||||||
|
Papers are stored with metadata and an abstract-level dense embedding
|
||||||
|
(BGE-M3, 1024 dimensions). Full-text is split into `chunks`, each with its
|
||||||
|
own dense embedding and a Postgres full-text (GIN) index.
|
||||||
|
Hybrid search combines nearest-neighbour vector search with keyword (FTS)
|
||||||
|
retrieval for robust handling of exact mathematical terminology.
|
||||||
|
|
||||||
|
### Layer 2 — Citations (`citations`)
|
||||||
|
|
||||||
|
Directed edges in the citation graph. The `cited_id` column has **no
|
||||||
|
foreign-key constraint** on purpose: edges pointing to papers that have not
|
||||||
|
yet been ingested are kept as-is. Those "dangling" targets are your
|
||||||
|
**discovery leads** — papers frequently cited by your collection that you
|
||||||
|
have not yet read.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Top discovery leads
|
||||||
|
SELECT cited_id, count(*) AS pull
|
||||||
|
FROM citations
|
||||||
|
WHERE cited_id NOT IN (SELECT id FROM papers)
|
||||||
|
GROUP BY cited_id
|
||||||
|
ORDER BY pull DESC
|
||||||
|
LIMIT 20;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layer 3 — Provenance (`code_links`)
|
||||||
|
|
||||||
|
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
|
||||||
|
containing **only the cited subset** of your collection.
|
||||||
|
|
||||||
|
The exported `.bib` is a derived view of the master catalogue — regenerate
|
||||||
|
it at any time; it is not the source of truth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI reference (coming in F-07)
|
||||||
|
|
||||||
|
```
|
||||||
|
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 ask "<question>" # optional LLM Q&A via Ollama
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run ruff check . && uv run ruff format --check . # lint
|
||||||
|
uv run mypy codex/ # type-check
|
||||||
|
uv run pytest # tests
|
||||||
|
```
|
||||||
0
codex/__init__.py
Normal file
0
codex/__init__.py
Normal file
85
codex/config.py
Normal file
85
codex/config.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""Application configuration via environment variables / .env file.
|
||||||
|
|
||||||
|
All settings are read from the environment (or a .env file in the project
|
||||||
|
root). Import :func:`get_settings` wherever you need configuration; the
|
||||||
|
returned object is cached after the first call.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Centralised, env-driven configuration for the codex application."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
case_sensitive=False,
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Database
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
database_url: str = Field(
|
||||||
|
default="postgresql://researcher:change_me@localhost:5432/papers",
|
||||||
|
description=(
|
||||||
|
"libpq-compatible connection string consumed by psycopg. "
|
||||||
|
"Example: postgresql://user:pass@host:5432/dbname"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# External services
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
grobid_url: str = Field(
|
||||||
|
default="http://localhost:8070",
|
||||||
|
description="Base URL of the GROBID HTTP API (containerised).",
|
||||||
|
)
|
||||||
|
|
||||||
|
ollama_base_url: str = Field(
|
||||||
|
default="http://localhost:11434",
|
||||||
|
description="Base URL of the local Ollama endpoint (optional Q&A layer).",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Embeddings
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
embedding_model: str = Field(
|
||||||
|
default="BAAI/bge-m3",
|
||||||
|
description=(
|
||||||
|
"sentence-transformers model identifier. "
|
||||||
|
"Must match EMBEDDING_DIM. Default: BAAI/bge-m3 (1024 dims)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
embedding_dim: int = Field(
|
||||||
|
default=1024,
|
||||||
|
gt=0,
|
||||||
|
description=(
|
||||||
|
"Dimension of the dense embedding vectors. "
|
||||||
|
"Must match the output dimension of EMBEDDING_MODEL."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# API etiquette
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
openalex_mailto: str = Field(
|
||||||
|
default="",
|
||||||
|
description=(
|
||||||
|
"E-mail address for the OpenAlex Polite Pool (faster rate limits). "
|
||||||
|
"Required by OpenAlex ToS for automated access."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
"""Return the cached application settings singleton."""
|
||||||
|
return Settings()
|
||||||
57
codex/db.py
Normal file
57
codex/db.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Database connection helpers.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- :func:`get_conn` — a context manager that yields a psycopg connection.
|
||||||
|
- :func:`apply_schema` — idempotently applies ``infra/schema.sql``.
|
||||||
|
|
||||||
|
The connection pool (psycopg_pool) is intentionally deferred to F-05
|
||||||
|
(ingest); here we expose a simple per-call ``psycopg.connect`` wrapper
|
||||||
|
that is sufficient for schema migration and unit-testing without requiring
|
||||||
|
an additional ``psycopg[pool]`` dependency at scaffold stage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
import psycopg.rows
|
||||||
|
|
||||||
|
from codex.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_conn() -> Generator[psycopg.Connection[psycopg.rows.DictRow], None, None]:
|
||||||
|
"""Yield a psycopg connection with dict-row factory.
|
||||||
|
|
||||||
|
The connection is opened from :func:`codex.config.get_settings`
|
||||||
|
``database_url`` and closed automatically when the context exits.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("SELECT 1")
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
with psycopg.connect(
|
||||||
|
settings.database_url,
|
||||||
|
row_factory=psycopg.rows.dict_row,
|
||||||
|
) as conn:
|
||||||
|
yield conn
|
||||||
|
|
||||||
|
|
||||||
|
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``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: An open psycopg connection (obtained via :func:`get_conn`).
|
||||||
|
"""
|
||||||
|
schema_path = Path(__file__).parent.parent / "infra" / "schema.sql"
|
||||||
|
sql = schema_path.read_text(encoding="utf-8")
|
||||||
|
conn.execute(sql)
|
||||||
|
conn.commit()
|
||||||
80
codex/models.py
Normal file
80
codex/models.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Domain dataclasses.
|
||||||
|
|
||||||
|
Each class maps directly to a table in ``infra/schema.sql``.
|
||||||
|
Field names and types are kept in sync with the DDL.
|
||||||
|
|
||||||
|
All fields that are nullable in the schema are typed as ``… | None``
|
||||||
|
with a default of ``None`` so instances can be constructed incrementally.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Paper:
|
||||||
|
"""Maps to the ``papers`` table (Layer 1 — semantic search).
|
||||||
|
|
||||||
|
``id`` is the canonical identifier: an arXiv ID (e.g. ``2301.00001``)
|
||||||
|
or a DOI (e.g. ``10.1145/3592430``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
openalex_id: str | None = None
|
||||||
|
bibkey: str | None = None
|
||||||
|
authors: list[str] = field(default_factory=list)
|
||||||
|
year: int | None = None
|
||||||
|
abstract: str | None = None
|
||||||
|
source_path: str | None = None
|
||||||
|
# abstract_emb is a list of floats representing the pgvector column;
|
||||||
|
# pgvector's Python driver accepts list[float] for vector columns.
|
||||||
|
abstract_emb: list[float] | None = None
|
||||||
|
added_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Chunk:
|
||||||
|
"""Maps to the ``chunks`` table (Layer 1 — full-text chunks).
|
||||||
|
|
||||||
|
``id`` is set by the database (BIGSERIAL); leave it as ``None``
|
||||||
|
when constructing a new chunk before insertion.
|
||||||
|
"""
|
||||||
|
|
||||||
|
paper_id: str
|
||||||
|
ord: int
|
||||||
|
content: str
|
||||||
|
id: int | None = None
|
||||||
|
embedding: list[float] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Citation:
|
||||||
|
"""Maps to the ``citations`` table (Layer 2 — citation graph).
|
||||||
|
|
||||||
|
``cited_id`` intentionally has no foreign-key constraint in the schema;
|
||||||
|
citations to not-yet-ingested papers are preserved as discovery leads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
citing_id: str
|
||||||
|
cited_id: str
|
||||||
|
context: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CodeLink:
|
||||||
|
"""Maps to the ``code_links`` table (Layer 3 — provenance).
|
||||||
|
|
||||||
|
``symbol`` is a C++ qualified name or ``file.cpp:line`` reference.
|
||||||
|
``role`` is a free-text description such as 'implements Thm 3.2'.
|
||||||
|
``id`` is set by the database (BIGSERIAL).
|
||||||
|
"""
|
||||||
|
|
||||||
|
symbol: str
|
||||||
|
paper_id: str | None = None
|
||||||
|
role: str | None = None
|
||||||
|
note: str | None = None
|
||||||
|
id: int | None = None
|
||||||
|
added_at: datetime | None = None
|
||||||
0
codex/parsing/__init__.py
Normal file
0
codex/parsing/__init__.py
Normal file
0
codex/sources/__init__.py
Normal file
0
codex/sources/__init__.py
Normal file
47
infra/docker-compose.yml
Normal file
47
infra/docker-compose.yml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Paper knowledge base: Provenance + Citation graph + Semantic search
|
||||||
|
# A single Postgres instance (pgvector) covers all three layers.
|
||||||
|
# GROBID parses references / structure from PDFs without an arXiv source.
|
||||||
|
#
|
||||||
|
# Quick start:
|
||||||
|
# docker compose up -d
|
||||||
|
# psql "${DATABASE_URL}" # see .env.example for default value
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: pgvector/pgvector:pg17 # official pgvector image
|
||||||
|
container_name: papers-db
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: papers
|
||||||
|
POSTGRES_USER: researcher
|
||||||
|
POSTGRES_PASSWORD: change_me # change before production use
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
# schema.sql is executed automatically on first start:
|
||||||
|
- ./schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U researcher -d papers"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
90
infra/schema.sql
Normal file
90
infra/schema.sql
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- Schema: Paper knowledge base
|
||||||
|
-- Layer 1 papers, chunks -> semantic search (pgvector)
|
||||||
|
-- Layer 2 citations -> citation graph / discovery
|
||||||
|
-- Layer 3 code_links -> provenance (C++ symbol <-> paper)
|
||||||
|
--
|
||||||
|
-- Adjust EMBEDDING_DIM to match your model (see .env.example):
|
||||||
|
-- BGE-M3 = 1024 | Qwen3-Embedding-0.6B = 1024 | Jina v4 = 2048
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Layer 1: Papers + full-text chunks
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
CREATE TABLE 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
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
authors TEXT[],
|
||||||
|
year INT,
|
||||||
|
abstract TEXT,
|
||||||
|
source_path TEXT, -- path to parsed .tex / .mmd file
|
||||||
|
abstract_emb vector(1024), -- paper-level embedding (similarity)
|
||||||
|
added_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- HNSW index for fast approximate nearest-neighbour search on abstracts.
|
||||||
|
CREATE INDEX papers_abstract_emb_idx
|
||||||
|
ON papers USING hnsw (abstract_emb vector_cosine_ops);
|
||||||
|
|
||||||
|
CREATE TABLE chunks (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
paper_id TEXT REFERENCES papers(id) ON DELETE CASCADE,
|
||||||
|
ord INT NOT NULL, -- position within the paper
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
embedding vector(1024)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX chunks_emb_idx
|
||||||
|
ON chunks USING hnsw (embedding vector_cosine_ops);
|
||||||
|
CREATE INDEX 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
|
||||||
|
ON chunks USING gin (to_tsvector('english', content));
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Layer 2: Citation graph
|
||||||
|
-- cited_id has NO foreign key ON PURPOSE:
|
||||||
|
-- edges to not-yet-ingested papers are intentionally preserved —
|
||||||
|
-- those are your discovery leads.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
CREATE TABLE 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);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Layer 3: Provenance (the "cleanly couple" goal)
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
CREATE TABLE 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,
|
||||||
|
role TEXT, -- e.g. 'implements Thm 3.2', 'uses Eq 5'
|
||||||
|
note TEXT,
|
||||||
|
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);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Example query: discovery leads
|
||||||
|
-- Papers referenced by multiple already-ingested papers but not yet
|
||||||
|
-- collected themselves — ranked by how often they are cited locally.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- SELECT cited_id, count(*) AS pull
|
||||||
|
-- FROM citations
|
||||||
|
-- WHERE cited_id NOT IN (SELECT id FROM papers)
|
||||||
|
-- GROUP BY cited_id
|
||||||
|
-- ORDER BY pull DESC
|
||||||
|
-- LIMIT 20;
|
||||||
46
pyproject.toml
Normal file
46
pyproject.toml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "codex"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Personal knowledge base for scientific papers — provenance, citation graph, semantic search."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"psycopg[binary]>=3.1",
|
||||||
|
"pgvector>=0.3",
|
||||||
|
"pydantic-settings>=2",
|
||||||
|
"sentence-transformers>=3",
|
||||||
|
"typer>=0.12",
|
||||||
|
"httpx>=0.27",
|
||||||
|
"tenacity>=8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
codex = "codex.cli:app"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"ruff>=0.4",
|
||||||
|
"mypy>=1.10",
|
||||||
|
"pytest>=8",
|
||||||
|
"pytest-mock>=3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py312"
|
||||||
|
line-length = 100
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "UP", "B", "SIM"]
|
||||||
|
ignore = []
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.12"
|
||||||
|
strict = true
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
Reference in New Issue
Block a user