From 9b8409a6370247ac88c441c4f25f54b95f1cf13e Mon Sep 17 00:00:00 2001 From: Ciprian-LocalPulse Date: Tue, 22 Sep 2026 05:52:28 +0300 Subject: [PATCH] feat: project persisted reviews into current evidence views --- CHANGELOG.md | 4 +++- docs/API.md | 19 +++++++++++++++- src/openlongevity/api.py | 39 ++++++++++++++++++++------------- src/openlongevity/repository.py | 13 +++++++++++ tests/test_api.py | 32 +++++++++++++++++++++++++++ tests/test_review_history.py | 24 +++++++++++++++++++- 6 files changed, 113 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92b0113..7f1ad6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/API.md b/docs/API.md index 5d3a0ad..0d24b5f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 diff --git a/src/openlongevity/api.py b/src/openlongevity/api.py index 2734ce0..dc0c280 100644 --- a/src/openlongevity/api.py +++ b/src/openlongevity/api.py @@ -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: diff --git a/src/openlongevity/repository.py b/src/openlongevity/repository.py index 84203a9..2162d25 100644 --- a/src/openlongevity/repository.py +++ b/src/openlongevity/repository.py @@ -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, *, diff --git a/tests/test_api.py b/tests/test_api.py index 544f3f1..86969ab 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,4 +1,5 @@ import os +from unittest.mock import AsyncMock import pytest @@ -6,10 +7,41 @@ 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"}, ]) diff --git a/tests/test_review_history.py b/tests/test_review_history.py index 78f4c84..51da73b 100644 --- a/tests/test_review_history.py +++ b/tests/test_review_history.py @@ -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, @@ -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(