diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d08e5eb..aea6273 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ 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 @@ -23,12 +23,12 @@ jobs: 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 \ No newline at end of file diff --git a/docs/API.md b/docs/API.md index 5560924..955d5e0 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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. diff --git a/migrations/versions/0002_evidence_review_events.py b/migrations/versions/0002_evidence_review_events.py new file mode 100644 index 0000000..da97812 --- /dev/null +++ b/migrations/versions/0002_evidence_review_events.py @@ -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") diff --git a/sql/schema.sql b/sql/schema.sql index 811d859..99172ab 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -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); diff --git a/src/openlongevity/api.py b/src/openlongevity/api.py index 9eb4e29..b735304 100644 --- a/src/openlongevity/api.py +++ b/src/openlongevity/api.py @@ -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): @@ -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 @@ -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]: @@ -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__, @@ -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) diff --git a/src/openlongevity/db.py b/src/openlongevity/db.py index ab092b9..034c1df 100644 --- a/src/openlongevity/db.py +++ b/src/openlongevity/db.py @@ -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) diff --git a/src/openlongevity/repository.py b/src/openlongevity/repository.py index eb832a8..f891c55 100644 --- a/src/openlongevity/repository.py +++ b/src/openlongevity/repository.py @@ -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 @@ -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, + } + diff --git a/tests/test_api.py b/tests/test_api.py index a7a56f0..7e2b4f0 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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" \ No newline at end of file