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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

## Unreleased — documentation and integrity audit

Review-event history now uses bounded cursor pagination with a default page size of fifty and a maximum of one hundred. Clients follow `next_after_id` to retrieve subsequent pages. PostgreSQL integration coverage verifies persisted review payloads, chronological insertion order, record isolation, and continued exclusion of reviewed fixtures from citation export. Review events do not yet update the evidence read model.
Evidence list, detail, and citation export now reconstruct current review metadata from the latest persisted event by ID. Historical events remain intact, synthetic origin remains authoritative, and configured storage failures return an error instead of stale fixture review metadata. Integration coverage exercises successive review states, deliberately backdated timestamps, and recovery through a fresh application instance.

Review-event history now uses bounded cursor pagination with a default page size of fifty and a maximum of one hundred. Clients follow `next_after_id` to retrieve subsequent pages. PostgreSQL integration coverage verifies persisted review payloads, event-ID ordering, record isolation, and continued exclusion of reviewed fixtures from citation export.

The documentation work begun from commit `9fddcbb` expands the academic corpus, corrects implementation claims, and standardizes attribution to CIPRIAN ȘTEFAN PLEȘCA — cercetător român independent. It preserves the author's removal of the wiki and the additional academic topics. This section describes development work, not a published v0.3.0 release. Mandatory release gates remain a separate decision under the project request and governance policy.

Expand Down
19 changes: 18 additions & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,24 @@ Review history accepts `after_id` (a nonnegative event identifier, default zero)
curl 'http://localhost:8000/api/v1/evidence/SYN-001/review-events?after_id=0&limit=20'
```

The repository fetches at most the requested limit plus one row, using the extra row to detect continuation. Cursors remain scoped to the requested evidence identifier. Concurrent writes can become visible in subsequent requests; this interface is not a frozen export snapshot. Existing clients that previously expected the entire history in one response must follow the cursor. Review events remain separate from the fixture read model and do not make synthetic records citation eligible. PostgreSQL integration tests exercise authenticated writes, persisted payloads, ordered pagination, record isolation, and fixture exclusion from citation export.
The repository fetches at most the requested limit plus one row, using the extra row to detect continuation. Cursors remain scoped to the requested evidence identifier. Concurrent writes can become visible in subsequent requests; this interface is not a frozen export snapshot. Existing clients that previously expected the entire history in one response must follow the cursor. Review events supply current review metadata without making synthetic records citation eligible. PostgreSQL integration tests exercise authenticated writes, persisted payloads, ordered pagination, record isolation, and fixture exclusion from citation export.

### Current review state

The evidence list, evidence detail, and citation-export routes resolve current review metadata from the persisted event with the greatest database ID for each selected record. This ordering reflects allocated event identifiers, not reviewer-supplied timestamps or transaction commit timestamps. A later event can mark previously verified evidence as disputed. The earlier event remains available in the audit history. Reads use one grouped database query for the selected records rather than retrieving each record's full history.

Only review status, reviewer, review timestamp, and review notes are projected onto the underlying evidence record. Stored event payloads cannot replace the record's source, identifier, study type, or synthetic classification. The preview continues to expose fixture evidence, and reviewing a fixture never makes it eligible for citation export. Persisted publication metadata is still a separate resource; this feature does not introduce scientific evidence extraction from those publications.

Without a configured database, the fixture retains its original unreviewed state. With a configured database, storage errors return HTTP 503 instead of silently reverting to an unreviewed fixture. Events survive application restarts because the current state is reconstructed on each request. A fresh request sees the events visible to that query; cross-request snapshot isolation is not provided. The research-gap route remains a fixture demonstration and does not consume this review projection.

```mermaid
flowchart LR
A[Evidence fixture] --> C[Current evidence view]
B[Latest persisted review by event ID] --> C
C --> D[List and detail]
C --> E[Citation eligibility filter]
E --> F[Synthetic evidence excluded]
```

## Failure and health interpretation

Expand Down
39 changes: 24 additions & 15 deletions src/openlongevity/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,32 +217,41 @@ async def history(identifier: str) -> list[RevisionResponse]:
"illustrative marker", "synthetic fixture", confidence=0.55, tags=("senescence",)
)]

async def current_records(records: list[EvidenceRecord]) -> list[EvidenceRecord]:
if review_repository is None:
return records
events = await review_repository.latest_for_records([r.identifier for r in records])
return [apply_human_review(
record, status=ReviewStatus(events[record.identifier]["status"]),
reviewer=events[record.identifier]["reviewer"],
reviewed_at=events[record.identifier]["reviewed_at"],
notes=events[record.identifier]["notes"],
) if record.identifier in events else record for record in records]

@app.get("/api/v1/evidence")
def evidence(topic: str = Query(default="", max_length=120)) -> dict[str, Any]:
records = [r for r in fixtures if topic.casefold() in r.title.casefold()]
async def evidence(topic: str = Query(default="", max_length=120)) -> dict[str, Any]:
records = await current_records(
[r for r in fixtures if topic.casefold() in r.title.casefold()]
)
return {"items": [evidence_record_payload(r, synthetic=True, level=engine.grade(r).value)
for r in records], "mode": "fixture-only",
"summary": engine.summarize(records), "disclaimer": DISCLAIMER}

@app.get("/api/v1/evidence/export/citation")
def citation_export(topic: str = Query(default="", max_length=120)) -> dict[str, Any]:
records = [r for r in fixtures if topic.casefold() in r.title.casefold()]
async def citation_export(topic: str = Query(default="", max_length=120)) -> dict[str, Any]:
records = await current_records(
[r for r in fixtures if topic.casefold() in r.title.casefold()]
)
payloads = [evidence_record_payload(r, synthetic=True, level=engine.grade(r).value)
for r in records]
return {**build_citation_export(payloads), "source_mode": "fixture-only"}

@app.get("/api/v1/evidence/{identifier}")
def evidence_record(identifier: str) -> dict[str, Any]:
# FIX: call evidence() with an explicit topic="" instead of relying on the
# default value. The default is a fastapi.Query(...) sentinel object, which
# only gets resolved to a real string during an actual HTTP request. Calling
# evidence() directly as a plain Python function (as we do here) left `topic`
# as that Query object, causing: AttributeError: 'Query' object has no
# attribute 'casefold'.
for record in evidence(topic="")["items"]:
if record["identifier"] == identifier:
return {"item": record, "mode": "fixture-only", "disclaimer": DISCLAIMER}
raise HTTPException(404, {"code": "NOT_FOUND", "message": "Evidence fixture not found"})
async def evidence_record(identifier: str) -> dict[str, Any]:
record, = await current_records([fixture_by_identifier(identifier)])
return {"item": evidence_record_payload(
record, synthetic=True, level=engine.grade(record).value,
), "mode": "fixture-only", "disclaimer": DISCLAIMER}

def fixture_by_identifier(identifier: str) -> EvidenceRecord:
for record in fixtures:
Expand Down
13 changes: 13 additions & 0 deletions src/openlongevity/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,19 @@ class EvidenceReviewRepository:
def __init__(self, database: Database) -> None:
self.database = database

async def latest_for_records(self, identifiers: list[str]) -> dict[str, dict[str, Any]]:
"""Read the greatest persisted event ID per record in one query."""
if not identifiers:
return {}
latest_ids = select(func.max(EvidenceReviewEventRow.id)).where(
EvidenceReviewEventRow.record_identifier.in_(identifiers),
).group_by(EvidenceReviewEventRow.record_identifier)
async with self.database.sessions() as session:
rows = await session.scalars(select(EvidenceReviewEventRow).where(
EvidenceReviewEventRow.id.in_(latest_ids),
))
return {row.record_identifier: self.serialize(row) for row in rows}

async def record_event(
self,
*,
Expand Down
32 changes: 32 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,47 @@
import os
from unittest.mock import AsyncMock

import pytest

fastapi = pytest.importorskip("fastapi")
from fastapi.testclient import TestClient # noqa: E402

from openlongevity.api import create_app # noqa: E402
from openlongevity.repository import EvidenceReviewRepository # noqa: E402

TEST_DATABASE_URL = os.getenv("TEST_DATABASE_URL")


def test_evidence_without_database_retains_unreviewed_fixture(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("DATABASE_URL", raising=False)
with TestClient(create_app()) as client:
detail = client.get("/api/v1/evidence/SYN-001").json()["item"]
listed = client.get("/api/v1/evidence").json()["items"][0]
assert detail == listed
assert detail["review_status"] == "unreviewed"
assert detail["synthetic"] is True


@pytest.mark.parametrize("path", [
"/api/v1/evidence", "/api/v1/evidence/SYN-001", "/api/v1/evidence/export/citation",
])
def test_review_storage_failure_does_not_return_stale_evidence(
monkeypatch: pytest.MonkeyPatch, path: str,
) -> None:
from sqlalchemy.exc import SQLAlchemyError

monkeypatch.setattr(
EvidenceReviewRepository, "latest_for_records",
AsyncMock(side_effect=SQLAlchemyError("storage unavailable")),
)
with TestClient(create_app(database_url="postgresql+asyncpg://localhost/unused")) as client:
response = client.get(path)
assert response.status_code == 503
assert response.json()["error"]["code"] == "DATABASE_UNAVAILABLE"


@pytest.mark.parametrize("params", [
{"limit": 0}, {"limit": 101}, {"after_id": -1}, {"after_id": "invalid"},
])
Expand Down
24 changes: 23 additions & 1 deletion tests/test_review_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,17 @@ async def test_review_history_round_trip_and_cursor() -> None:
created = []
for status in ("verified", "disputed", "human_reviewed"):
response = await client.post(
"/api/v1/evidence/SYN-001/review", json={**body, "status": status},
"/api/v1/evidence/SYN-001/review",
json={**body, "status": status,
"reviewed_at": f"2026-09-{22 - len(created)}T10:00:00+03:00"},
headers={"X-Review-Key": "test-review-key"},
)
assert response.status_code == 200
created.append(response.json()["item"])
detail = (await client.get("/api/v1/evidence/SYN-001")).json()["item"]
assert detail["review_status"] == status
assert detail["reviewed_by"] == reviewer
assert detail["synthetic"] is True
# Another record must never leak into this record's cursor page.
await repository.record_event(
record_identifier=f"OTHER-{reviewer}", status="verified", reviewer=reviewer,
Expand All @@ -66,6 +72,22 @@ async def test_review_history_round_trip_and_cursor() -> None:
exported = (await client.get("/api/v1/evidence/export/citation")).json()
assert exported["items"] == []
assert exported["excluded"][0]["reason"] == "synthetic_fixture"
latest = await repository.latest_for_records(["SYN-001", "MISSING"])
assert latest == {"SYN-001": created[-1]}
assert await repository.latest_for_records([]) == {}
# A fresh application instance must recover the same persisted review.
restarted = create_app(database_url=TEST_DATABASE_URL)
async with restarted.router.lifespan_context(restarted):
async with AsyncClient(
transport=ASGITransport(app=restarted), base_url="http://test",
) as client:
response = await client.get("/api/v1/evidence", params={"topic": "senescence"})
assert response.status_code == 200
current = response.json()["items"][0]
assert current["review_status"] == "human_reviewed"
assert current["reviewed_by"] == reviewer
assert current["reviewed_at"] == created[-1]["reviewed_at"]
assert current["review_notes"] == body["notes"]
finally:
async with database.sessions.begin() as session:
await session.execute(delete(EvidenceReviewEventRow).where(
Expand Down
Loading