carepath-api / scribe /carepath /services /scribe_local.py
tranth3truong's picture
Deploy CP-UX-17: public demo hub, same-origin API, one design world
aacc29a
Raw
History Blame Contribute Delete
16.2 kB
"""Private research staging provider for a dual-adapter Scribe bundle.
The provider loads one 4-bit base model, switches between the transcript GEC
and grounded SOAP adapters, and keeps the public ``ClinicalLLM`` interface
unchanged. It is opt-in with ``LLM_PROVIDER=scribe_local`` and is never a
production default.
"""
from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from typing import Any, Callable
from carepath.config import Settings
from carepath.schemas import SoapNote
from carepath.services.gec_local import _passes_safety
from carepath.services.llm import ClinicalLLM, CorrectionResult, LLMError, SoapResult
from carepath.services.retrieval import RetrievedTerm
from carepath_shared.normalize import contains_folded, normalize_for_match
logger = logging.getLogger("carepath.scribe_local")
Generator = Callable[[str, str], str]
_NUMBER_UNIT_RE = re.compile(
r"\b\d+(?:[.,]\d+)?\s*(?:mg|mcg|µg|g|kg|ml|l|mmhg|%|bpm|cm|mm)?\b",
re.IGNORECASE,
)
_MISSING_PREFIXES = ("chua", "khong co thong tin", "khong ghi nhan")
_FACT_TYPES = {"symptom", "history", "observation", "assessment", "medication", "dose", "plan"}
_NEGATION_CUES = ("khong", "chua", "am tinh", "no ", "not ", "denies")
_SECTION_FACT_TYPES = {
"subjective": {"symptom", "history", "medication", "dose"},
"objective": {"observation"},
"assessment": {"assessment"},
"plan": {"plan", "medication", "dose"},
}
class LocalScribeLLM:
"""One base model with GEC and SOAP PEFT adapters for Colab staging."""
provider_name = "scribe_local"
def __init__(self, bundle_path: Path, generate_fn: Generator | None = None):
self.bundle = Path(bundle_path)
manifest_path = self.bundle / "scribe_manifest.json"
try:
self.manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(f"Invalid Scribe bundle manifest: {manifest_path}") from exc
_validate_manifest(self.manifest)
self._generate_fn = generate_fn
self._model = None
self._tokenizer = None
def readiness(self) -> tuple[bool, dict[str, object]]:
adapters = self.manifest["adapters"]
missing = [name for name, rel in adapters.items() if not (self.bundle / rel).exists()]
return not missing, {
"provider": self.provider_name,
"bundle": str(self.bundle),
"base_model": self.manifest["base_model"],
"adapters": sorted(adapters),
"missing_adapters": missing,
"usage_scope": self.manifest["usage_scope"],
"promotion_status": self.manifest["promotion_status"],
"fallback": "disabled",
}
def correct_transcript(
self,
raw_text: str,
retrieved_terms: list[RetrievedTerm],
encounter_context: str | None = None,
) -> CorrectionResult:
if self.manifest.get("correction_mode", "adapter") == "identity":
return CorrectionResult(raw_text, "scribe_local_identity")
prompt = json.dumps(
{
"task": "correct_asr_transcript",
"raw_transcript": raw_text,
"retrieved_terms": [term.term for term in retrieved_terms],
"encounter_context": encounter_context,
},
ensure_ascii=False,
)
corrected = _strip_generation(self._generate("gec", prompt))
if not _passes_safety(raw_text, corrected):
raise LLMError("scribe_local correction failed safety gate")
if not _numbers_supported(corrected, raw_text):
raise LLMError("scribe_local correction introduced a number or unit")
return CorrectionResult(corrected, self.provider_name)
def generate_soap(
self,
corrected_text: str,
retrieved_terms: list[RetrievedTerm],
encounter_context: str | None = None,
) -> SoapResult:
extraction_prompt = json.dumps(
{
"task": "extract_grounded_clinical_facts",
"transcript": corrected_text,
"encounter_context": encounter_context,
"schema": {
"facts": [
{
"type": "string",
"value": "string",
"negated": False,
"uncertain": False,
"source_span": {
"start": 0,
"end": 1,
"text": "exact transcript substring",
},
}
]
},
},
ensure_ascii=False,
)
extracted = _json_object(self._generate("soap", extraction_prompt), "SOAP extraction")
facts = _ground_facts(corrected_text, extracted.get("facts"))
writing_prompt = json.dumps(
{
"task": "write_grounded_soap_note",
"facts": facts,
"rules": [
"Use only supplied facts.",
"Each non-missing section is semicolon-separated exact fact values.",
"Leave assessment or plan missing when no matching fact exists.",
"Always set review_required to true.",
],
"schema": {
"subjective": "string",
"objective": "string",
"assessment": "string",
"plan": "string",
"missing_information": ["string"],
"review_required": True,
},
},
ensure_ascii=False,
)
payload = _json_object(self._generate("soap", writing_prompt), "SOAP writing")
payload["review_required"] = True
payload.setdefault("missing_information", [])
try:
soap = SoapNote(**payload)
except Exception as exc:
raise LLMError(f"scribe_local SOAP response failed schema validation: {exc}") from exc
_validate_soap(soap, facts, corrected_text, retrieved_terms)
return SoapResult(soap=soap, provider=self.provider_name)
def generate_patient_summary(
self,
transcript_en: str,
retrieved_terms: list[RetrievedTerm],
encounter_context: str | None = None,
):
"""Refuse: this bundle ships gec and soap adapters only.
Consistent with the rest of this provider, an unsupported task fails
closed rather than being answered by an adapter not trained for it. A
configured offline fallback will serve the summary instead.
"""
del transcript_en, retrieved_terms, encounter_context
raise LLMError("scribe_local has no patient-summary adapter")
def _generate(self, adapter: str, user_prompt: str) -> str:
if self._generate_fn is not None:
return self._generate_fn(adapter, user_prompt)
self._ensure_model()
import torch # type: ignore
self._model.set_adapter(adapter)
system = self.manifest.get("prompts", {}).get(
adapter, "Return only the requested JSON without unsupported clinical facts."
)
rendered = self._tokenizer.apply_chat_template(
[
{"role": "system", "content": system},
{"role": "user", "content": user_prompt},
],
tokenize=False,
add_generation_prompt=True,
)
inputs = self._tokenizer(rendered, return_tensors="pt").to(self._model.device)
limit = int(self.manifest.get("max_new_tokens", {}).get(adapter, 512))
with torch.no_grad():
output = self._model.generate(
**inputs,
max_new_tokens=limit,
do_sample=False,
num_beams=1,
pad_token_id=self._tokenizer.pad_token_id,
)
generated = output[0][inputs["input_ids"].shape[-1] :]
return self._tokenizer.decode(generated, skip_special_tokens=True)
def _ensure_model(self) -> None:
if self._model is not None:
return
import torch # type: ignore
from peft import PeftModel # type: ignore
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig # type: ignore
if not torch.cuda.is_available():
raise RuntimeError("scribe_local requires a CUDA GPU; use private Colab staging")
base_name = self.manifest["base_model"]
revision = self.manifest.get("base_revision")
kwargs = {"revision": revision} if revision else {}
tokenizer = AutoTokenizer.from_pretrained(base_name, trust_remote_code=True, **kwargs)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
compute_dtype = (
torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
)
quantization = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=compute_dtype,
bnb_4bit_use_double_quant=True,
)
base = AutoModelForCausalLM.from_pretrained(
base_name,
quantization_config=quantization,
device_map="auto",
trust_remote_code=True,
**kwargs,
)
adapters = self.manifest["adapters"]
if self.manifest.get("correction_mode", "adapter") == "adapter":
model = PeftModel.from_pretrained(
base, str(self.bundle / adapters["gec"]), adapter_name="gec"
)
model.load_adapter(str(self.bundle / adapters["soap"]), adapter_name="soap")
else:
model = PeftModel.from_pretrained(
base, str(self.bundle / adapters["soap"]), adapter_name="soap"
)
model.eval()
self._model, self._tokenizer = model, tokenizer
logger.info("Loaded research Scribe adapters from %s", self.bundle)
def _validate_manifest(manifest: dict[str, Any]) -> None:
if manifest.get("schema") != "carepath.scribe.bundle/1":
raise ValueError("Unsupported Scribe bundle schema")
if manifest.get("usage_scope") != "research_only":
raise ValueError("Scribe bundle must be research_only")
if manifest.get("promotion_status") != "blocked_research_only":
raise ValueError("Scribe bundle promotion must remain blocked_research_only")
if not manifest.get("base_model"):
raise ValueError("Scribe bundle requires base_model")
adapters = manifest.get("adapters")
if not isinstance(adapters, dict) or "soap" not in adapters:
raise ValueError("Scribe bundle requires a SOAP adapter")
if manifest.get("correction_mode", "adapter") == "adapter" and "gec" not in adapters:
raise ValueError("Adapter correction mode requires a GEC adapter")
def _json_object(text: str, label: str) -> dict[str, Any]:
start, end = text.find("{"), text.rfind("}")
if start < 0 or end < start:
raise LLMError(f"{label} did not return JSON")
try:
payload = json.loads(text[start : end + 1])
except json.JSONDecodeError as exc:
raise LLMError(f"{label} returned invalid JSON") from exc
if not isinstance(payload, dict):
raise LLMError(f"{label} must return one JSON object")
return payload
def _strip_generation(text: str) -> str:
return text.split("<|im_end|>", 1)[0].strip()
def _numbers(text: str) -> set[str]:
return {normalize_for_match(match.group(0)) for match in _NUMBER_UNIT_RE.finditer(text)}
def _numbers_supported(output: str, source: str) -> bool:
return _numbers(output).issubset(_numbers(source))
def _ground_facts(transcript: str, rows: Any) -> list[dict[str, Any]]:
if not isinstance(rows, list):
raise LLMError("SOAP extraction must return a facts list")
accepted: list[dict[str, Any]] = []
for row in rows:
if not isinstance(row, dict):
raise LLMError("SOAP fact must be an object")
fact_type = str(row.get("type", "")).strip().lower()
value = str(row.get("value", "")).strip()
span_value = row.get("source_span")
if not isinstance(span_value, dict):
raise LLMError("SOAP fact source_span must include start, end, and text")
start, end = span_value.get("start"), span_value.get("end")
span = str(span_value.get("text", ""))
if (
not fact_type
or fact_type not in _FACT_TYPES
or not value
or not span
or not isinstance(start, int)
or isinstance(start, bool)
or not isinstance(end, int)
or isinstance(end, bool)
or start < 0
or end <= start
or end > len(transcript)
or transcript[start:end] != span
):
raise LLMError("SOAP fact is not grounded to an exact transcript span")
if not contains_folded(span, value):
raise LLMError("SOAP fact value is not present in its exact source span")
if not _numbers_supported(value, span):
raise LLMError("SOAP fact number/unit is not grounded to its source span")
negated = bool(row.get("negated", False))
has_negation_cue = any(cue in normalize_for_match(span) for cue in _NEGATION_CUES)
if negated != has_negation_cue:
raise LLMError("SOAP fact negation does not match its exact source span")
accepted.append(
{
"type": fact_type,
"value": value,
"negated": negated,
"uncertain": bool(row.get("uncertain", False)),
"source_span": span,
}
)
return accepted
def _missing_section(text: str) -> bool:
folded = normalize_for_match(text)
return not folded or folded.startswith(_MISSING_PREFIXES)
def _validate_soap(
soap: SoapNote,
facts: list[dict[str, Any]],
transcript: str,
retrieved_terms: list[RetrievedTerm],
) -> None:
rendered = " ".join((soap.subjective, soap.objective, soap.assessment, soap.plan))
evidence = " ".join(str(fact["source_span"]) for fact in facts)
if not _numbers_supported(rendered, evidence):
raise LLMError("SOAP output introduced an unsupported number or unit")
for term in retrieved_terms:
if contains_folded(rendered, term.term) and not contains_folded(transcript, term.term):
raise LLMError(f"SOAP output introduced unsupported retrieved term: {term.term}")
fact_types = {str(fact["type"]) for fact in facts}
for section, allowed_types in _SECTION_FACT_TYPES.items():
text = str(getattr(soap, section))
if _missing_section(text):
continue
allowed_values = {
normalize_for_match(str(fact["value"])).strip(" .")
for fact in facts
if fact["type"] in allowed_types
}
fragments = {
normalize_for_match(fragment).strip(" .")
for fragment in text.split(";")
if fragment.strip()
}
if not fragments or not fragments.issubset(allowed_values):
raise LLMError(f"SOAP {section} contains text outside grounded fact values")
if not _missing_section(soap.assessment) and not fact_types.intersection(
{"assessment", "diagnosis"}
):
raise LLMError("SOAP assessment lacks a grounded assessment/diagnosis fact")
if not _missing_section(soap.plan) and not fact_types.intersection(
{"plan", "treatment", "medication", "follow_up"}
):
raise LLMError("SOAP plan lacks a grounded plan fact")
def build_scribe_local(settings: Settings) -> ClinicalLLM:
if not settings.scribe_bundle_path:
raise ValueError("SCRIBE_BUNDLE_PATH is required for the scribe_local provider")
return LocalScribeLLM(Path(settings.scribe_bundle_path))