carepath-api / scribe /tests /test_documents.py
tranth3truong's picture
Deploy CP-UX-17: public demo hub, same-origin API, one design world
aacc29a
Raw
History Blame Contribute Delete
11.1 kB
"""Reading a photographed Vietnamese medical document into confirmable turns.
The point of this path: harm for limited-English-proficiency patients
concentrates at medication reconciliation and discharge, which happen on paper.
The vision model only transcribes -- the risk engine, not the model, decides
whether a line is dangerous.
"""
from __future__ import annotations
import io
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO_ROOT / "scribe"))
sys.path.insert(0, str(REPO_ROOT / "interpreter"))
os.environ.setdefault("ASR_PROVIDER", "mock")
os.environ.setdefault("ALLOW_MOCK_ASR", "true")
os.environ.setdefault("LLM_PROVIDER", "offline")
os.environ.setdefault("MEDICAL_LEXICON_PATH", str(REPO_ROOT / "data" / "medical_lexicon.json"))
os.environ.setdefault("CAREPATH_ENV_FILE", "__tests_no_env__")
# setdefault, never assignment: these modules share a process, and pinning a
# mode here leaks into every other test module that imports the app.
os.environ.setdefault("PROVIDER_MODE", "mock")
_TMP_DB = Path(tempfile.mkdtemp(prefix="carepath_docs_")) / "docs.db"
os.environ["DATABASE_URL"] = f"sqlite:///{_TMP_DB.as_posix()}"
from fastapi.testclient import TestClient
import app.config as interpreter_config
import app.db as interpreter_db
from app.providers.base import ProviderOutputError
from app.providers.ckey import CKeyChatClient, parse_document_lines, read_document_lines
from app.providers.registry import read_document
from app.config import Settings
from carepath.main import app, get_pipeline, get_settings
PNG_1PX = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06"
b"\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05"
b"\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
)
class FakeResponse:
def __init__(self, content: str) -> None:
self._body = json.dumps({"choices": [{"message": {"content": content}}]}).encode("utf-8")
def read(self) -> bytes:
return self._body
def __enter__(self) -> "FakeResponse":
return self
def __exit__(self, *exc: object) -> None:
return None
class RecordingOpener:
def __init__(self, content: str) -> None:
self.content = content
self.requests: list[dict] = []
def __call__(self, request, timeout=None):
self.requests.append(json.loads(request.data.decode("utf-8")))
return FakeResponse(self.content)
class ParseTests(unittest.TestCase):
def test_parses_lines(self) -> None:
lines = parse_document_lines('{"lines": ["Amoxicillin 500mg", " ngày 2 lần "]}')
self.assertEqual(lines, ["Amoxicillin 500mg", "ngày 2 lần"])
def test_unreadable_document_yields_nothing(self) -> None:
"""Fail closed: better zero lines than a guess at what a prescription says."""
self.assertEqual(parse_document_lines('{"lines": []}'), [])
def test_accepts_fenced_json(self) -> None:
self.assertEqual(parse_document_lines('```json\n{"lines": ["a"]}\n```'), ["a"])
def test_rejects_prose_or_extra_keys(self) -> None:
for bad in [
'{"lines": ["a"], "summary": "a prescription"}',
'{"text": "Amoxicillin"}',
'{"lines": "Amoxicillin"}',
'{"lines": [{"text": "a"}]}',
"The document says Amoxicillin 500mg",
]:
with self.assertRaises(ProviderOutputError, msg=bad):
parse_document_lines(bad)
class VisionRequestTests(unittest.TestCase):
def test_image_is_sent_as_a_data_uri_alongside_the_prompt(self) -> None:
opener = RecordingOpener('{"lines": ["Amoxicillin 500mg"]}')
client = CKeyChatClient(
api_key="sk-test",
base_url="https://example.test/v1",
model="gpt-5.4",
opener=opener,
backoff_seconds=0,
)
lines = read_document_lines(client, PNG_1PX, "image/png")
self.assertEqual(lines, ["Amoxicillin 500mg"])
content = opener.requests[0]["messages"][1]["content"]
self.assertEqual(content[0]["type"], "text")
self.assertEqual(content[1]["type"], "image_url")
self.assertTrue(content[1]["image_url"]["url"].startswith("data:image/png;base64,"))
system = opener.requests[0]["messages"][0]["content"]
self.assertIn("Do not translate", system)
class ModeTests(unittest.TestCase):
def test_modes_without_vision_fail_closed(self) -> None:
"""Silence would read as 'the document was blank'. Raise instead."""
for mode in ("mock", "cloud"):
with self.assertRaises(RuntimeError, msg=mode):
read_document(Settings(provider_mode=mode), PNG_1PX, "image/png")
def test_demo_mode_replays_a_scripted_prescription(self) -> None:
lines = read_document(Settings(provider_mode="demo"), PNG_1PX, "image/png")
self.assertTrue(any("Amoxicillin" in line for line in lines))
class EndpointTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
get_settings.cache_clear()
get_pipeline.cache_clear()
interpreter_config.get_settings.cache_clear()
interpreter_db.set_engine(None)
def _visit(self, client) -> str:
return client.post("/api/sessions", json={"consent": {"ok": True}}).json()["session_id"]
def _upload(self, client, visit_id, data=PNG_1PX, filename="donthuoc.png", ctype="image/png"):
return client.post(
f"/api/v1/visits/{visit_id}/documents",
files={"image": (filename, io.BytesIO(data), ctype)},
)
def test_document_lines_become_gated_turns(self) -> None:
prescription = read_document(Settings(provider_mode="demo"), PNG_1PX, "image/png")
with TestClient(app) as client:
visit_id = self._visit(client)
# Patch the reader rather than pinning PROVIDER_MODE for the process.
with patch("carepath.main.read_document", return_value=prescription):
response = self._upload(client, visit_id)
self.assertEqual(response.status_code, 200, response.text)
turns = response.json()
self.assertTrue(turns)
self.assertTrue(all(turn["speaker"] == "document" for turn in turns))
# The risk engine, not the vision model, decides this is risky.
dosed = [
turn
for turn in turns
if any(span["kind"] == "dose_number" for span in turn["risk_spans"])
]
self.assertTrue(dosed, "a prescription line with a dose should be detected")
self.assertEqual(dosed[0]["status"], "awaiting_confirm")
# They are ordinary turns, so they appear in the visit transcript.
transcript = client.get(f"/api/sessions/{visit_id}/transcript").json()
self.assertEqual(len(transcript), len(turns))
def test_sample_header_reads_the_scripted_document_whatever_was_uploaded(self) -> None:
"""The public demo's sample path, which the page labels as scripted.
`demo` mode never looks at the bytes. That is honest for a sample and
would be a fabrication for a visitor's own prescription, so the header
exists to make the distinction explicit at the call site.
"""
with TestClient(app) as client:
visit_id = self._visit(client)
response = client.post(
f"/api/v1/visits/{visit_id}/documents",
files={"image": ("anything.png", io.BytesIO(PNG_1PX), "image/png")},
headers={"X-CarePath-Sample": "1"},
)
self.assertEqual(response.status_code, 200, response.text)
turns = response.json()
self.assertTrue(any("Amoxicillin" in turn["source_text"] for turn in turns))
def test_sample_mode_still_gates_the_risky_lines(self) -> None:
"""Scripted translation, real gate. If the demo showed a dose straight
to the patient pane it would be advertising the opposite of the product.
"""
with TestClient(app) as client:
visit_id = self._visit(client)
turns = client.post(
f"/api/v1/visits/{visit_id}/documents",
files={"image": ("anything.png", io.BytesIO(PNG_1PX), "image/png")},
headers={"X-CarePath-Sample": "1"},
).json()
dosed = [
turn
for turn in turns
if any(span["kind"] == "dose_number" for span in turn["risk_spans"])
]
self.assertTrue(dosed, "the scripted prescription must carry a dose line")
for turn in dosed:
self.assertEqual(turn["status"], "awaiting_confirm")
def test_without_the_header_the_configured_mode_still_applies(self) -> None:
"""The header opts in. It must not become the default by accident: this
process runs PROVIDER_MODE=mock, which has no vision and fails closed.
"""
with TestClient(app) as client:
visit_id = self._visit(client)
self.assertEqual(self._upload(client, visit_id).status_code, 502)
def test_rejects_a_non_image_upload(self) -> None:
with TestClient(app) as client:
visit_id = self._visit(client)
response = self._upload(
client, visit_id, data=b"not an image", filename="notes.txt", ctype="text/plain"
)
self.assertEqual(response.status_code, 400)
def test_rejects_an_empty_upload(self) -> None:
with TestClient(app) as client:
visit_id = self._visit(client)
self.assertEqual(self._upload(client, visit_id, data=b"").status_code, 400)
def test_unknown_visit_is_404(self) -> None:
with TestClient(app) as client:
self.assertEqual(self._upload(client, "nope").status_code, 404)
def test_reader_failure_is_502_and_creates_no_turns(self) -> None:
with TestClient(app) as client:
visit_id = self._visit(client)
with patch("carepath.main.read_document", side_effect=RuntimeError("gateway down")):
response = self._upload(client, visit_id)
self.assertEqual(response.status_code, 502)
self.assertEqual(client.get(f"/api/sessions/{visit_id}/transcript").json(), [])
def test_failure_message_does_not_leak_internals(self) -> None:
with TestClient(app) as client:
visit_id = self._visit(client)
with patch(
"carepath.main.read_document",
side_effect=RuntimeError("sk-secret leaked and Tôi bị dị ứng"),
):
response = self._upload(client, visit_id)
self.assertNotIn("sk-secret", response.text)
self.assertNotIn("dị ứng", response.text)
if __name__ == "__main__":
unittest.main()