"""Documentation Agent — public API. OWNER: Person A. Pipeline (Day 3): audio | transcript -> retrieve patient wiki + guideline pages + ICD-10 candidates -> render soap_v1 prompt -> router.complete(task="soap_note", json_mode=True) # Qwen3-32B on Groq -> parse + validate into SOAPNoteDraft Every guideline page and patient page we retrieve is attached to the draft's ``sources`` — that citation guarantee is the clinical-safety contract, so it is enforced here rather than trusted to the model. ``approve_draft`` (Day 4) then persists an (optionally edited) draft to the patient's wiki page. """ from __future__ import annotations import json import re from datetime import datetime, timezone from pathlib import Path from uuid import uuid4 import structlog from app.agents.documentation.retrieval import RetrievalContext, retrieve_context from app.agents.wiki import api as wiki_api from app.contracts import ( Citation, ICD10Suggestion, PatientWikiPageRef, SOAPNoteDraft, SOAPSection, ) from app.integrations import whisper from app.llm.router import complete log = structlog.get_logger(__name__) _PROMPT_PATH = Path(__file__).resolve().parent / "prompts" / "soap_v1.md" _TASK = "soap_note" _JSON_FENCE_RE = re.compile(r"```(?:json)?\s*(\{.*\})\s*```", re.DOTALL) # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def draft_soap( *, patient_id: str, audio_path: Path | None = None, transcript: str | None = None, ) -> SOAPNoteDraft: """Build a SOAP note draft from audio or a transcript. Exactly one of ``audio_path`` / ``transcript`` is required; if both are given the transcript wins (audio is not re-transcribed). """ if transcript is None: if audio_path is None: raise ValueError("draft_soap needs either audio_path or transcript") transcript = whisper.transcribe(audio_path) transcript = transcript.strip() if not transcript: raise ValueError("transcript is empty") ctx = retrieve_context(transcript, patient_id) messages = _build_messages(transcript, ctx) result = complete(task=_TASK, messages=messages, json_mode=True, max_tokens=2048) payload = _parse_json(result.text) draft = _assemble_draft( patient_id=patient_id, transcript=transcript, ctx=ctx, payload=payload, model=result.model, ) log.info( "documentation.draft_ready", patient_id=patient_id, draft_id=draft.draft_id, n_sources=len(draft.sources), latency_ms=round(result.latency_ms, 1), ) return draft def approve_draft(draft: SOAPNoteDraft, edits: dict | None = None) -> PatientWikiPageRef: """Persist a SOAP draft (with optional doctor edits) to the patient wiki page. ``edits`` may carry doctor-edited SOAP fields, e.g. ``{"plan": "...", "assessment": "..."}`` — they overwrite the matching fields before the draft is written to the patient's Encounter History. """ if edits: soap = draft.soap.model_copy( update={k: v for k, v in edits.items() if k in SOAPSection.model_fields} ) draft = draft.model_copy(update={"soap": soap}) return wiki_api.update_patient_page(draft.patient_id, draft) # --------------------------------------------------------------------------- # Prompt rendering # --------------------------------------------------------------------------- def _build_messages(transcript: str, ctx: RetrievalContext) -> list[dict[str, str]]: """Render soap_v1.md into a system + user message pair.""" template = _PROMPT_PATH.read_text(encoding="utf-8") system, user = _split_prompt(template) filled = ( user.replace("{{transcript}}", transcript) .replace("{{patient_wiki_excerpt}}", ctx.patient_wiki or "No prior wiki page on file.") .replace("{{retrieved_guideline_excerpts}}", _format_guidelines(ctx)) .replace("{{icd10_candidates}}", _format_icd(ctx)) ) return [ {"role": "system", "content": system}, {"role": "user", "content": filled}, ] def _split_prompt(template: str) -> tuple[str, str]: """Split soap_v1.md into the System block and the Inputs+Output block.""" sys_marker, inputs_marker = "## System", "## Inputs" if sys_marker in template and inputs_marker in template: system = template.split(sys_marker, 1)[1].split(inputs_marker, 1)[0].strip() user = inputs_marker + template.split(inputs_marker, 1)[1] return system, user.strip() # Fallback: whole template as the user message. return "You are CliniqAI's Documentation Agent.", template.strip() def _format_guidelines(ctx: RetrievalContext) -> str: if not ctx.guidelines: return "No matching guideline pages found." return "\n\n".join( f"[{g.page_id}] {g.title}\n{g.excerpt}" for g in ctx.guidelines ) def _format_icd(ctx: RetrievalContext) -> str: if not ctx.icd_candidates: return "No ICD-10 candidates retrieved." return "\n".join(f"- {h.doc_id}: {h.text}" for h in ctx.icd_candidates) # --------------------------------------------------------------------------- # Response parsing # --------------------------------------------------------------------------- def _parse_json(text: str) -> dict: """Parse the model's JSON, tolerating ```json fences and leading prose.""" text = text.strip() try: return json.loads(text) except json.JSONDecodeError: pass m = _JSON_FENCE_RE.search(text) if not m: # Last resort: grab the outermost {...} span. start, end = text.find("{"), text.rfind("}") if start == -1 or end <= start: raise ValueError(f"model did not return JSON: {text[:200]!r}") m_text = text[start : end + 1] else: m_text = m.group(1) return json.loads(m_text) def _find_soap(payload: dict) -> dict: """Locate the SOAP object regardless of how the model wrapped it. Models inconsistently nest the note under "soap", "soap_note", etc., or emit the fields flat at the top level — tolerate all of them. """ for key in ("soap", "soap_note", "SOAP", "soapNote", "note"): value = payload.get(key) if isinstance(value, dict): return value return payload # flat shape def _coerce_field(value: object) -> str: """Render a SOAP field as clean text, whatever shape the model returned. Strings pass through; dicts become "key: value" joined by " · "; lists become one bullet per item — so a nested object/array never leaks into the UI as an ugly Python ``repr``. """ if value is None: return "" if isinstance(value, str): return value.strip() if isinstance(value, bool): return str(value) if isinstance(value, (int, float)): return str(value) if isinstance(value, list): return "\n".join(f"- {item}" for item in map(_coerce_field, value) if item) if isinstance(value, dict): parts = [ f"{str(k).replace('_', ' ')}: {text}" for k, text in ((k, _coerce_field(v)) for k, v in value.items()) if text ] return " · ".join(parts) return str(value).strip() def _pick(soap_in: dict, payload: dict, key: str, default: object) -> object: """Read a top-level field from the SOAP object or the payload root. Models sometimes nest referral_needed / confidence_overall / suggestions inside the SOAP wrapper instead of at the top level — check both. """ if isinstance(soap_in, dict) and key in soap_in: return soap_in[key] return payload.get(key, default) def _assemble_draft( *, patient_id: str, transcript: str, ctx: RetrievalContext, payload: dict, model: str, ) -> SOAPNoteDraft: """Build a validated SOAPNoteDraft from the model payload + retrieved context. Deterministic fields (ids, timestamps, model) and the citation list are set here so the audit trail never depends on the model getting them right. """ soap_in = _find_soap(payload) soap = SOAPSection( subjective=_coerce_field(soap_in.get("subjective")), objective=_coerce_field(soap_in.get("objective")), assessment=_coerce_field(soap_in.get("assessment")), plan=_coerce_field(soap_in.get("plan")), ) icd = _coerce_icd(_pick(soap_in, payload, "icd10_suggestions", None), ctx) sources = _build_sources(ctx) return SOAPNoteDraft( draft_id=f"soap-{uuid4().hex[:8]}", patient_id=patient_id, encounter_ts=datetime.now(timezone.utc), soap=soap, icd10_suggestions=icd, guideline_suggestions=[ str(g) for g in (_pick(soap_in, payload, "guideline_suggestions", []) or []) if str(g).strip() ], sources=sources, referral_needed=bool(_pick(soap_in, payload, "referral_needed", False)), confidence_overall=_clamp(_pick(soap_in, payload, "confidence_overall", 0.0)), transcript_excerpt=transcript[:280], model=model, ) def _coerce_icd(raw: object, ctx: RetrievalContext) -> list[ICD10Suggestion]: """Prefer the model's ICD picks; fall back to top index candidates.""" out: list[ICD10Suggestion] = [] if isinstance(raw, list): for item in raw: if not isinstance(item, dict) or not item.get("code"): continue out.append( ICD10Suggestion( code=str(item["code"]), label=str(item.get("label", "")), confidence=_clamp(item.get("confidence", 0.5)), ) ) if out: return out # Fallback: surface the top index hits so the doctor still sees candidates. for hit in ctx.icd_candidates[:3]: label = hit.text.split(maxsplit=1)[1] if " " in hit.text else hit.text out.append(ICD10Suggestion(code=hit.doc_id, label=label, confidence=0.4)) return out def _build_sources(ctx: RetrievalContext) -> list[Citation]: """Citation for every retrieved guideline + the patient page (audit contract).""" sources: list[Citation] = [] for g in ctx.guidelines: sources.append( Citation( source_type="wiki", source_id=g.page_id, title=g.title, snippet=g.excerpt[:200] if g.excerpt else None, ) ) if ctx.patient_wiki is not None: sources.append( Citation(source_type="wiki", source_id="patients", title="Patient history") ) return sources def _clamp(value: object, lo: float = 0.0, hi: float = 1.0) -> float: try: return max(lo, min(hi, float(value))) # type: ignore[arg-type] except (TypeError, ValueError): return lo