tranth3truong's picture
Deploy public Scribe-only CarePath Space
cc678b9
Raw
History Blame Contribute Delete
18.6 kB
from __future__ import annotations
import json
import logging
import re
import urllib.error
import urllib.request
from dataclasses import dataclass, replace
from typing import Any, Protocol
from carepath.config import Settings
from carepath.schemas import SoapNote
from carepath.services.retrieval import RetrievedTerm
logger = logging.getLogger("carepath.llm")
class LLMError(RuntimeError):
"""Raised when the configured LLM provider fails."""
@dataclass(frozen=True)
class CorrectionResult:
corrected_text: str
provider: str
raw_response: str | None = None
@dataclass(frozen=True)
class SoapResult:
soap: SoapNote
provider: str
class ClinicalLLM(Protocol):
def readiness(self) -> tuple[bool, dict[str, object]]:
...
def correct_transcript(
self,
raw_text: str,
retrieved_terms: list[RetrievedTerm],
encounter_context: str | None = None,
) -> CorrectionResult:
...
def generate_soap(
self,
corrected_text: str,
retrieved_terms: list[RetrievedTerm],
encounter_context: str | None = None,
) -> "SoapResult":
...
class OpenAICompatibleLLM:
def __init__(self, settings: Settings):
self.settings = settings
self.provider_name = "ckey" if settings.llm_provider == "ckey" else "openai_compatible"
def readiness(self) -> tuple[bool, dict[str, object]]:
return (
bool(self.settings.llm_api_key),
{
"provider": self.provider_name,
"base_url": self.settings.llm_base_url,
"model": self.settings.llm_model,
"missing_api_key": not bool(self.settings.llm_api_key),
},
)
def correct_transcript(
self,
raw_text: str,
retrieved_terms: list[RetrievedTerm],
encounter_context: str | None = None,
) -> CorrectionResult:
content = self._chat_json(
system=CORRECTION_SYSTEM_PROMPT,
user=build_correction_prompt(raw_text, retrieved_terms, encounter_context),
)
parsed = extract_json_object(content)
corrected = str(parsed.get("corrected_transcript", "")).strip()
if not corrected:
raise LLMError("LLM correction response did not include corrected_transcript")
return CorrectionResult(
corrected_text=corrected,
provider=self.provider_name,
raw_response=content,
)
def generate_soap(
self,
corrected_text: str,
retrieved_terms: list[RetrievedTerm],
encounter_context: str | None = None,
) -> SoapResult:
content = self._chat_json(
system=SOAP_SYSTEM_PROMPT,
user=build_soap_prompt(corrected_text, retrieved_terms, encounter_context),
)
parsed = extract_json_object(content)
parsed["review_required"] = True
parsed.setdefault("missing_information", [])
return SoapResult(soap=SoapNote(**parsed), provider=self.provider_name)
def _chat_json(self, system: str, user: str) -> str:
if not self.settings.llm_api_key:
raise LLMError(f"LLM_API_KEY is required for {self.provider_name} provider")
try:
return self._request_chat(system, user, use_response_format=True)
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
if _can_retry_without_response_format(exc.code, body):
return self._request_chat(system, user, use_response_format=False)
raise LLMError(f"LLM HTTP {exc.code}: {body}") from exc
except Exception as exc:
raise LLMError(f"LLM request failed: {exc}") from exc
def _request_chat(self, system: str, user: str, use_response_format: bool) -> str:
payload = {
"model": self.settings.llm_model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": 0.0,
}
if use_response_format:
payload["response_format"] = {"type": "json_object"}
data = json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
url=f"{self.settings.llm_base_url}/chat/completions",
data=data,
headers={
"Authorization": f"Bearer {self.settings.llm_api_key}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(
request, timeout=self.settings.llm_timeout_seconds
) as response:
response_payload = json.loads(response.read().decode("utf-8"))
try:
return str(response_payload["choices"][0]["message"]["content"])
except (KeyError, IndexError, TypeError) as exc:
raise LLMError("LLM response did not match chat completions shape") from exc
class OfflineClinicalLLM:
"""Deterministic fallback for local demos without sending clinical text out."""
def readiness(self) -> tuple[bool, dict[str, object]]:
return True, {
"warning": "offline fallback does not replace a validated clinical LLM"
}
def correct_transcript(
self,
raw_text: str,
retrieved_terms: list[RetrievedTerm],
encounter_context: str | None = None,
) -> CorrectionResult:
corrected = normalize_transcript_spacing(raw_text)
corrected = _restore_common_units(corrected)
for item in retrieved_terms:
corrected = _restore_term_case(corrected, item.term)
return CorrectionResult(corrected_text=corrected, provider="offline")
def generate_soap(
self,
corrected_text: str,
retrieved_terms: list[RetrievedTerm],
encounter_context: str | None = None,
) -> SoapResult:
sections = _bucket_clauses(corrected_text)
term_text = ", ".join(item.term for item in retrieved_terms) or "không có"
subjective = sections["subjective"]
objective = sections["objective"]
assessment = sections["assessment"] or (
"Chưa có đánh giá rõ ràng trong bản ghi. "
f"Thuật ngữ liên quan được phát hiện: {term_text}. Cần bác sĩ xác nhận."
)
plan_reminder = (
"Bác sĩ rà soát lại bản ghi âm, xác nhận triệu chứng, dấu hiệu sinh tồn, "
"chẩn đoán và kế hoạch điều trị trước khi lưu hồ sơ."
)
plan = f"{sections['plan']}. {plan_reminder}" if sections["plan"] else plan_reminder
missing = []
if not objective:
missing.append("Dấu hiệu sinh tồn hoặc kết quả cận lâm sàng chưa rõ.")
if not subjective:
missing.append("Triệu chứng/chủ quan của bệnh nhân chưa rõ.")
soap = SoapNote(
subjective=subjective or "Chưa đủ thông tin chủ quan trong transcript.",
objective=objective or "Chưa đủ thông tin khách quan trong transcript.",
assessment=assessment,
plan=plan,
missing_information=missing,
review_required=True,
)
return SoapResult(soap=soap, provider="offline")
class FallbackClinicalLLM:
"""Wrap a network LLM so a provider failure never breaks the demo.
If the primary provider raises ``LLMError`` (timeout, auth, bad gateway,
malformed JSON), we transparently serve the deterministic offline generator
and tag the provider as ``*_offline_fallback`` so the response metadata makes
the degraded path visible to clinicians and operators.
"""
def __init__(self, primary: ClinicalLLM, fallback: "OfflineClinicalLLM"):
self.primary = primary
self.fallback = fallback
def readiness(self) -> tuple[bool, dict[str, object]]:
ready, details = self.primary.readiness()
return ready, {**details, "fallback": "offline", "fallback_enabled": True}
def correct_transcript(
self,
raw_text: str,
retrieved_terms: list[RetrievedTerm],
encounter_context: str | None = None,
) -> CorrectionResult:
try:
return self.primary.correct_transcript(
raw_text, retrieved_terms, encounter_context=encounter_context
)
except LLMError as exc:
logger.warning("LLM correction failed; using offline fallback: %s", exc)
result = self.fallback.correct_transcript(
raw_text, retrieved_terms, encounter_context=encounter_context
)
return replace(result, provider="offline_fallback")
def generate_soap(
self,
corrected_text: str,
retrieved_terms: list[RetrievedTerm],
encounter_context: str | None = None,
) -> SoapResult:
try:
return self.primary.generate_soap(
corrected_text, retrieved_terms, encounter_context=encounter_context
)
except LLMError as exc:
logger.warning("LLM SOAP generation failed; using offline fallback: %s", exc)
result = self.fallback.generate_soap(
corrected_text, retrieved_terms, encounter_context=encounter_context
)
return replace(result, provider="offline_fallback")
def build_llm(settings: Settings) -> ClinicalLLM:
if settings.llm_provider in {"offline", "mock"}:
return OfflineClinicalLLM()
if settings.llm_provider == "gec_local":
# Lazy import: gec_local imports from this module.
from carepath.services.gec_local import build_gec_local
primary = build_gec_local(settings)
if settings.llm_fallback_offline:
return FallbackClinicalLLM(primary, OfflineClinicalLLM())
return primary
if settings.llm_provider in {"openai", "openai_compatible", "ckey"}:
primary = OpenAICompatibleLLM(settings)
if settings.llm_fallback_offline:
return FallbackClinicalLLM(primary, OfflineClinicalLLM())
return primary
raise ValueError(
"LLM_PROVIDER must be 'offline', 'openai_compatible', 'ckey', or 'gec_local'"
)
CORRECTION_SYSTEM_PROMPT = """
You correct Vietnamese medical ASR transcripts for a clinical scribe MVP.
Rules:
- Preserve Vietnamese meaning and code-switched medical terms.
- Preserve numbers, units, medication names, biomarkers, and acronyms.
- Use retrieved terms only when they fit the transcript.
- Do not add diagnoses, medications, or facts not present in the transcript.
- Return one JSON object with key corrected_transcript.
""".strip()
SOAP_SYSTEM_PROMPT = """
You create draft SOAP notes for Vietnamese clinicians.
Rules:
- Write Vietnamese SOAP sections.
- Preserve English medical terms, acronyms, biomarkers, numbers, and units.
- Do not invent clinical facts.
- If information is missing, state that it is missing.
- Always set review_required to true.
- Return one JSON object with subjective, objective, assessment, plan,
missing_information, and review_required.
""".strip()
def build_correction_prompt(
raw_text: str,
retrieved_terms: list[RetrievedTerm],
encounter_context: str | None,
) -> str:
terms = [
{
"term": item.term,
"vietnamese": item.vietnamese,
"category": item.category,
"score": round(item.score, 3),
}
for item in retrieved_terms
]
return json.dumps(
{
"task": "correct_asr_transcript",
"encounter_context": encounter_context,
"raw_transcript": raw_text,
"retrieved_terms": terms,
"output_schema": {"corrected_transcript": "string"},
},
ensure_ascii=False,
)
def build_soap_prompt(
corrected_text: str,
retrieved_terms: list[RetrievedTerm],
encounter_context: str | None,
) -> str:
return json.dumps(
{
"task": "draft_vietnamese_soap_note",
"encounter_context": encounter_context,
"corrected_transcript": corrected_text,
"retrieved_terms": [item.term for item in retrieved_terms],
"output_schema": {
"subjective": "string",
"objective": "string",
"assessment": "string",
"plan": "string",
"missing_information": ["string"],
"review_required": True,
},
},
ensure_ascii=False,
)
def extract_json_object(text: str) -> dict[str, Any]:
stripped = text.strip()
if stripped.startswith("```"):
stripped = re.sub(r"^```(?:json)?", "", stripped).strip()
stripped = re.sub(r"```$", "", stripped).strip()
try:
parsed = json.loads(stripped)
except json.JSONDecodeError:
start = stripped.find("{")
end = stripped.rfind("}")
if start == -1 or end == -1 or end <= start:
raise LLMError("No JSON object found in LLM response")
parsed = json.loads(stripped[start : end + 1])
if not isinstance(parsed, dict):
raise LLMError("LLM response JSON must be an object")
return parsed
def _can_retry_without_response_format(status_code: int, body: str) -> bool:
if status_code not in {400, 422}:
return False
lowered = body.lower()
return "response_format" in lowered or "json_object" in lowered
def normalize_transcript_spacing(text: str) -> str:
text = re.sub(r"\s+", " ", text).strip()
text = re.sub(r"\s+([,.:%])", r"\1", text)
text = re.sub(r"([,.:%])([^\s])", r"\1 \2", text)
return text
def _restore_common_units(text: str) -> str:
replacements = {
"spo2": "SpO2",
"ecg": "ECG",
"hba1c": "HbA1c",
"bmi": "BMI",
"mmhg": "mmHg",
"mg/dl": "mg/dL",
"mg dl": "mg/dL",
}
for needle, replacement in replacements.items():
text = re.sub(rf"\b{re.escape(needle)}\b", replacement, text, flags=re.I)
return text
def _restore_term_case(text: str, term: str) -> str:
if len(term) < 3 or term.islower():
return text
return re.sub(rf"\b{re.escape(term)}\b", term, text, flags=re.I)
# Offline SOAP bucketing. Each clause is assigned to exactly one section, in
# priority order, so a run-on dictation is split into S/O/A/P instead of being
# dumped wholesale into Subjective. This is a heuristic safety net, not a
# replacement for the validated clinical LLM.
_ASSESSMENT_KEYWORDS = (
"chẩn đoán",
"nghĩ đến",
"nghĩ tới",
"nghi ngờ",
"hội chứng",
"ấn tượng",
"đánh giá",
)
_HISTORY_KEYWORDS = ("tiền sử", "tiền căn", "bệnh sử")
_PLAN_KEYWORDS = (
"cho làm",
"chỉ định",
"kê đơn",
"kê toa",
"cho thuốc",
"cho uống",
"theo dõi",
"tái khám",
"chuyển",
"nhập viện",
"điều trị",
"dặn",
"hẹn",
"truyền",
"tiêm",
"bù dịch",
"hội chẩn",
)
_OBJECTIVE_KEYWORDS = (
"huyết áp",
"mạch",
"nhiệt độ",
"spo2",
"mmhg",
"mg/dl",
"bpm",
"lần/phút",
"xét nghiệm",
"siêu âm",
"x-quang",
"ct",
"mri",
"ecg",
"điện tim",
"khám",
"nghe phổi",
"phù",
"vàng da",
"troponin",
"công thức máu",
)
_SUBJECTIVE_KEYWORDS = (
"đau",
"mệt",
"ho",
"sốt",
"chóng mặt",
"buồn nôn",
"khó thở",
"tê",
"rát",
"ngứa",
"mỏi",
"nôn",
"tiêu chảy",
"hồi hộp",
"mất ngủ",
"sụt cân",
"chán ăn",
"than",
)
# A number directly followed by a clinical unit is an objective measurement.
_MEASUREMENT = re.compile(
r"\d+(?:[.,]\d+)?\s*(?:%|mmhg|mg/dl|bpm|lần/phút|mmol|°c)",
flags=re.IGNORECASE,
)
# Raw ASR is often unpunctuated, so punctuation alone cannot segment it. We
# insert a break before where a new clinical statement begins:
# - a vital sign immediately followed by a number ("huyết áp 150"), so a real
# measurement splits but a history phrase ("tăng huyết áp") does not; and
# - statement-starter verbs/markers (orders, impressions, history), curated to
# avoid substrings of one another so multi-word phrases stay intact.
_VITAL_MEASUREMENT_CUE = r"(?:huyết áp|mạch|nhiệt độ|nhịp thở|spo2)\s*\d"
_STARTER_CUES = (
"cho làm",
"chỉ định",
"kê đơn",
"kê toa",
"theo dõi",
"tái khám",
"nhập viện",
"hội chẩn",
"chẩn đoán",
"nghĩ đến",
"nghĩ tới",
"nghi ngờ",
"tiền sử",
"tiền căn",
"bệnh sử",
)
_BOUNDARY_RE = re.compile(
r"\s+(?=(?:"
+ "|".join(re.escape(c) for c in sorted(_STARTER_CUES, key=len, reverse=True))
+ r"|"
+ _VITAL_MEASUREMENT_CUE
+ r"))",
flags=re.IGNORECASE,
)
def _split_clauses(text: str) -> list[str]:
# Insert breaks before clinical cue words (handles unpunctuated ASR), then
# split on commas, semicolons, newlines, and sentence punctuation -- but not
# on a period/comma between digits (e.g. 37.5, 1,5) so numbers stay intact.
text = _BOUNDARY_RE.sub("\n", text)
parts = re.split(r"[;\n]+|,(?!\d)|(?<!\d)[.!?]+(?!\d)", text)
return [part.strip() for part in parts if part and part.strip()]
def _classify_clause(clause: str) -> str:
lowered = clause.lower()
if any(kw in lowered for kw in _ASSESSMENT_KEYWORDS):
return "assessment"
if any(kw in lowered for kw in _HISTORY_KEYWORDS):
return "subjective"
if any(kw in lowered for kw in _PLAN_KEYWORDS):
return "plan"
if _MEASUREMENT.search(lowered) or any(kw in lowered for kw in _OBJECTIVE_KEYWORDS):
return "objective"
if any(kw in lowered for kw in _SUBJECTIVE_KEYWORDS):
return "subjective"
return "subjective"
def _bucket_clauses(text: str) -> dict[str, str]:
buckets: dict[str, list[str]] = {
"subjective": [],
"objective": [],
"assessment": [],
"plan": [],
}
for clause in _split_clauses(text):
buckets[_classify_clause(clause)].append(clause)
return {
section: ". ".join(c[:1].upper() + c[1:] for c in clauses)
for section, clauses in buckets.items()
}