Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,20 @@ jobs:
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U openlongevity"
--health-cmd "pg_isready -U openlongevity -d openlongevity_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgresql+asyncpg://openlongevity:openlongevity@localhost:5432/openlongevity_test
TEST_DATABASE_URL: postgresql+asyncpg://openlongevity:openlongevity@localhost:5432/openlongevity_test
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: {python-version: '3.12'}
- run: python -m pip install -e ".[dev,api,db]"
- run: alembic upgrade head
- run: python scripts/seed_test_db.py
- run: pytest --cov=openlongevity --cov-report=term-missing
- run: ruff check .
- run: python -m compileall src
- run: python -m compileall src
4 changes: 4 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ Evidence, evidence detail, and research-gap routes operate on synthetic fixtures

The citation export route should not be described as a complete publication export system. It does not yet produce bibliographic formats, persistent publication manifests, human-review certificates, or provider-backed evidence bundles. It establishes a narrow behavior that was previously documented only as a policy: synthetic fixtures are not observations and are excluded by default from citation-eligible evidence export. Future work can extend the same contract to persisted publication records once review status, source authenticity, and export manifests are implemented for that path.

`POST /api/v1/evidence/{record_id}/review` records a persistent review event when PostgreSQL is configured and the caller supplies `X-Review-Key` matching `OPENLONGEVITY_REVIEW_KEY`. The request body includes `status`, `reviewer`, `reviewed_at`, and `notes`. The server rejects machine-only statuses as human review actions and requires the same metadata that citation export later expects from verified records. Without a configured review key, the route returns `REVIEW_DISABLED`; without a configured and migrated database, it returns `DATABASE_NOT_CONFIGURED`. This keeps the preview from pretending that review events are persistent when the audit table is not available.

`GET /api/v1/evidence/{record_id}/review-events` lists stored review events for a fixture evidence record when the review repository is configured. The route returns audit events, not a full reviewer user interface. It is the persistence boundary for the human-review workflow: reviewer actions can be stored, inspected, and connected to citation-export eligibility, while user management and role delegation remain future work.

Evidence grades and scores require their methodological labels. The A–G mapping is a project taxonomy, and the numerical navigation score uses heuristic constants. Neither is a calibrated scientific certainty estimate. The score also depends on execution time when a publication date is present. The API's ability to serialize a number does not justify describing it as a treatment effect, probability of truth, or measure of human longevity benefit.

Publication responses currently enforce a false synthetic flag without deriving authenticity from a verified origin model. This is a known limitation for test-seeded or otherwise manually inserted records. Clients and operators must not use that flag alone as proof that an item came from a real provider request. Correcting this requires code and schema decisions plus a regression test; documenting the limitation does not repair it.
Expand Down
38 changes: 38 additions & 0 deletions migrations/versions/0002_evidence_review_events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Evidence review event audit table.
Revision ID: 0002_review_events
Revises: 0001_publications
"""

import sqlalchemy as sa
from alembic import op

revision = "0002_review_events"
down_revision = "0001_publications"
branch_labels = None
depends_on = None


def upgrade():
op.create_table(
"evidence_review_events",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("record_identifier", sa.String(160), nullable=False),
sa.Column("status", sa.String(40), nullable=False),
sa.Column("reviewer", sa.String(120), nullable=False),
sa.Column("reviewed_at", sa.String(40), nullable=False),
sa.Column("notes", sa.Text(), nullable=False),
sa.Column("payload", sa.JSON(), nullable=False),
)
op.create_index(
"ix_evidence_review_events_record_identifier",
"evidence_review_events",
["record_identifier"],
)


def downgrade():
op.drop_index(
"ix_evidence_review_events_record_identifier",
table_name="evidence_review_events",
)
op.drop_table("evidence_review_events")
2 changes: 2 additions & 0 deletions sql/schema.sql
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
CREATE TABLE IF NOT EXISTS publications (identifier TEXT PRIMARY KEY, title TEXT NOT NULL, source TEXT NOT NULL, publication_date DATE, retraction_status TEXT NOT NULL DEFAULT 'unknown' CHECK (retraction_status IN ('active','corrected','expression_of_concern','retracted','unknown')));
CREATE TABLE IF NOT EXISTS evidence_records (identifier TEXT PRIMARY KEY, publication_identifier TEXT REFERENCES publications(identifier), study_type TEXT NOT NULL, species TEXT NOT NULL, endpoint TEXT NOT NULL, confidence DOUBLE PRECISION NOT NULL CHECK (confidence >= 0 AND confidence <= 1), replication_status TEXT NOT NULL DEFAULT 'unknown', limitations JSONB NOT NULL DEFAULT '[]'::jsonb, provenance JSONB NOT NULL DEFAULT '{}'::jsonb);
CREATE TABLE IF NOT EXISTS evidence_review_events (id SERIAL PRIMARY KEY, record_identifier TEXT NOT NULL, status TEXT NOT NULL, reviewer TEXT NOT NULL, reviewed_at TEXT NOT NULL, notes TEXT NOT NULL, payload JSONB NOT NULL DEFAULT '{}'::jsonb);
CREATE INDEX IF NOT EXISTS ix_evidence_review_events_record_identifier ON evidence_review_events(record_identifier);
73 changes: 70 additions & 3 deletions src/openlongevity/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@
from .evidence import EvidenceEngine
from .exports import build_citation_export, evidence_record_payload
from .gaps import ResearchGapDetector
from .models import EvidenceRecord, StudyType
from .models import EvidenceRecord, ReviewStatus, StudyType
from .providers import PubMedProvider, SearchQuery
from .providers.base import ProviderError, Publication
from .review import apply_human_review


class ProvenanceResponse(BaseModel):
Expand Down Expand Up @@ -72,6 +73,14 @@ class IngestionRequest(BaseModel):
limit: int = Field(default=5, ge=1, le=25)


class EvidenceReviewRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
status: ReviewStatus
reviewer: str = Field(min_length=1, max_length=120)
reviewed_at: str = Field(min_length=1, max_length=40)
notes: str = Field(min_length=1, max_length=2000)


class RevisionResponse(BaseModel):
revision: int
payload: Publication
Expand All @@ -81,15 +90,19 @@ class RevisionResponse(BaseModel):

def create_app(
database_url: str | None = None, provider: PubMedProvider | None = None,
ingestion_key: str | None = None,
ingestion_key: str | None = None, review_key: str | None = None,
) -> FastAPI:
from .repository import PublicationRepository
from .repository import EvidenceReviewRepository, PublicationRepository

database_url = database_url or getenv("DATABASE_URL")
database = Database(database_url) if database_url else None
repository = PublicationRepository(database) if database else None
review_repository = EvidenceReviewRepository(database) if database else None
source = provider or PubMedProvider()
key = ingestion_key if ingestion_key is not None else getenv("OPENLONGEVITY_INGESTION_KEY")
reviewer_key = (
review_key if review_key is not None else getenv("OPENLONGEVITY_REVIEW_KEY")
)

@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
Expand Down Expand Up @@ -129,6 +142,12 @@ def require_repository() -> PublicationRepository:
"message": "Configure and migrate PostgreSQL first"})
return repository

def require_review_repository() -> EvidenceReviewRepository:
if review_repository is None:
raise HTTPException(503, {"code": "DATABASE_NOT_CONFIGURED",
"message": "Configure and migrate PostgreSQL first"})
return review_repository

@app.get("/api/v1/health")
async def health() -> dict[str, str]:
return {"status": "ok", "version": __version__,
Expand Down Expand Up @@ -225,6 +244,54 @@ def evidence_record(identifier: str) -> dict[str, Any]:
return {"item": record, "mode": "fixture-only", "disclaimer": DISCLAIMER}
raise HTTPException(404, {"code": "NOT_FOUND", "message": "Evidence fixture not found"})

def fixture_by_identifier(identifier: str) -> EvidenceRecord:
for record in fixtures:
if record.identifier == identifier:
return record
raise HTTPException(404, {"code": "NOT_FOUND", "message": "Evidence fixture not found"})

@app.post("/api/v1/evidence/{identifier}/review")
async def review_evidence_record(
identifier: str,
body: EvidenceReviewRequest,
x_review_key: str | None = Header(default=None),
) -> dict[str, Any]:
if not reviewer_key:
raise HTTPException(503, {"code": "REVIEW_DISABLED",
"message": "Server-side review key is not configured"})
if not x_review_key or not secrets.compare_digest(x_review_key, reviewer_key):
raise HTTPException(401, {"code": "UNAUTHORIZED",
"message": "An operator review key is required"})
record = fixture_by_identifier(identifier)
try:
reviewed = apply_human_review(
record,
status=body.status,
reviewer=body.reviewer,
reviewed_at=body.reviewed_at,
notes=body.notes,
)
except ValueError as exc:
raise HTTPException(422, {"code": "INVALID_REVIEW", "message": str(exc)}) from exc
payload = evidence_record_payload(
reviewed, synthetic=True, level=engine.grade(reviewed).value
)
event = await require_review_repository().record_event(
record_identifier=reviewed.identifier,
status=reviewed.review_status.value,
reviewer=reviewed.reviewed_by or "",
reviewed_at=reviewed.reviewed_at or "",
notes=reviewed.review_notes or "",
payload=payload,
)
return {"mode": "review-event", "item": event, "disclaimer": DISCLAIMER}

@app.get("/api/v1/evidence/{identifier}/review-events")
async def review_events(identifier: str) -> dict[str, Any]:
fixture_by_identifier(identifier)
events = await require_review_repository().list_for_record(identifier)
return {"mode": "review-events", "items": events, "disclaimer": DISCLAIMER}

@app.get("/api/v1/research-gaps")
def gaps(topic: str = Query(min_length=1, max_length=120)) -> dict[str, Any]:
gaps_found = ResearchGapDetector(engine).detect(topic, fixtures)
Expand Down
11 changes: 11 additions & 0 deletions src/openlongevity/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ class PublicationRevisionRow(Base):
retrieved_at: Mapped[str] = mapped_column(String(40))


class EvidenceReviewEventRow(Base):
__tablename__ = "evidence_review_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
record_identifier: Mapped[str] = mapped_column(String(160), index=True)
status: Mapped[str] = mapped_column(String(40))
reviewer: Mapped[str] = mapped_column(String(120))
reviewed_at: Mapped[str] = mapped_column(String(40))
notes: Mapped[str] = mapped_column(Text)
payload: Mapped[dict[str, Any]] = mapped_column(JSON)


class Database:
def __init__(self, url: str) -> None:
self.engine = create_async_engine(url, pool_pre_ping=True)
Expand Down
49 changes: 48 additions & 1 deletion src/openlongevity/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert

from .db import Database, PublicationRevisionRow, PublicationRow
from .db import Database, EvidenceReviewEventRow, PublicationRevisionRow, PublicationRow
from .providers.base import Publication


Expand Down Expand Up @@ -91,3 +91,50 @@ async def history(self, identifier: str) -> list[dict[str, Any]]:
"content_hash": row.content_hash, "retrieved_at": row.retrieved_at}
for row in rows]


class EvidenceReviewRepository:
def __init__(self, database: Database) -> None:
self.database = database

async def record_event(
self,
*,
record_identifier: str,
status: str,
reviewer: str,
reviewed_at: str,
notes: str,
payload: dict[str, Any],
) -> dict[str, Any]:
async with self.database.sessions.begin() as session:
row = EvidenceReviewEventRow(
record_identifier=record_identifier,
status=status,
reviewer=reviewer,
reviewed_at=reviewed_at,
notes=notes,
payload=payload,
)
session.add(row)
await session.flush()
return self.serialize(row)

async def list_for_record(self, record_identifier: str) -> list[dict[str, Any]]:
async with self.database.sessions() as session:
rows = await session.scalars(select(EvidenceReviewEventRow).where(
EvidenceReviewEventRow.record_identifier == record_identifier
).order_by(EvidenceReviewEventRow.id))
return [self.serialize(row) for row in rows]

@staticmethod
def serialize(row: EvidenceReviewEventRow) -> dict[str, Any]:
return {
"id": row.id,
"record_identifier": row.record_identifier,
"status": row.status,
"reviewer": row.reviewer,
"reviewed_at": row.reviewed_at,
"notes": row.notes,
"payload": row.payload,
}

53 changes: 53 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,56 @@ def test_citation_export_excludes_synthetic_fixtures() -> None:
assert payload["excluded_total"] == 1
assert payload["excluded"][0]["identifier"] == "SYN-001"
assert payload["excluded"][0]["reason"] == "synthetic_fixture"


def test_review_endpoint_is_disabled_without_review_key() -> None:
client = TestClient(create_app())
response = client.post(
"/api/v1/evidence/SYN-001/review",
json={
"status": "verified",
"reviewer": "Reviewer",
"reviewed_at": "2026-09-21T10:00:00+03:00",
"notes": "Checked against fixture source.",
},
)
assert response.status_code == 503
assert response.json()["error"]["code"] == "REVIEW_DISABLED"

def test_review_endpoint_requires_database_for_persistence(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Ascundem variabilele de mediu doar pentru acest test,
# astfel incat baza de date sa para neconfigurata
monkeypatch.delenv("DATABASE_URL", raising=False)
monkeypatch.delenv("TEST_DATABASE_URL", raising=False)

client = TestClient(create_app(review_key="review-secret"))
response = client.post(
"/api/v1/evidence/SYN-001/review",
headers={"X-Review-Key": "review-secret"},
json={
"status": "verified",
"reviewer": "Reviewer",
"reviewed_at": "2026-09-21T10:00:00+03:00",
"notes": "Checked against fixture source.",
},
)
assert response.status_code == 503
assert response.json()["error"]["code"] == "DATABASE_NOT_CONFIGURED"


def test_review_endpoint_rejects_machine_status_as_human_review() -> None:
client = TestClient(create_app(review_key="review-secret"))
response = client.post(
"/api/v1/evidence/SYN-001/review",
headers={"X-Review-Key": "review-secret"},
json={
"status": "machine_extracted",
"reviewer": "Reviewer",
"reviewed_at": "2026-09-21T10:00:00+03:00",
"notes": "Machine extraction is not verification.",
},
)
assert response.status_code == 422
assert response.json()["error"]["code"] == "INVALID_REVIEW"
Loading