| import os, gc, shutil, logging, json, re, uuid, time |
| import urllib.request, urllib.parse |
| from datetime import datetime |
| from typing import List, Dict |
| from faster_whisper import WhisperModel |
|
|
| |
| from presidio_analyzer import AnalyzerEngine, PatternRecognizer, Pattern |
| from presidio_anonymizer import AnonymizerEngine |
| from langchain_community.document_loaders import PyPDFLoader, TextLoader, Docx2txtLoader |
| from langchain_text_splitters import RecursiveCharacterTextSplitter |
| from langchain_community.retrievers import BM25Retriever |
|
|
| |
| from langchain_openai import ChatOpenAI |
| from langchain_huggingface import HuggingFaceEmbeddings |
| from langchain_chroma import Chroma |
| from langchain_core.prompts import ChatPromptTemplate |
| from langchain_core.output_parsers import StrOutputParser |
|
|
| logging.basicConfig(level=logging.INFO) |
| logger = logging.getLogger(__name__) |
|
|
| |
| |
| _whisper_model = None |
|
|
| def get_whisper_model(): |
| global _whisper_model |
| if _whisper_model is None: |
| logger.info("Loading Whisper model (base, cpu/int8)...") |
| _whisper_model = WhisperModel("base", device="cpu", compute_type="int8") |
| return _whisper_model |
|
|
| import tempfile |
|
|
| def transcribe_and_tag(audio_input, api_key: str) -> str: |
| """Transcribes audio and uses LLM to insert Doctor/Patient speaker tags.""" |
| try: |
| if isinstance(audio_input, (bytes, bytearray)): |
| with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp_file: |
| tmp_file.write(audio_input) |
| audio_path = tmp_file.name |
| elif hasattr(audio_input, 'name'): |
| with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp_file: |
| tmp_file.write(audio_input.getvalue()) |
| audio_path = tmp_file.name |
| else: |
| audio_path = audio_input |
|
|
| if not audio_path or not os.path.exists(audio_path): |
| return "Error: No valid audio path found." |
|
|
| segments, _ = get_whisper_model().transcribe(audio_path, beam_size=5) |
| raw_text = " ".join([s.text for s in segments]).strip() |
| |
| if hasattr(audio_input, 'name'): |
| os.remove(audio_path) |
| |
| if not raw_text: |
| return "Error: Could not capture any voice. Please try again." |
|
|
| llm = get_openrouter_llm(api_key) |
| tag_prompt = ChatPromptTemplate.from_messages([ |
| ("system", "Role: Medical Transcriptionist. Task: Convert the raw text into a professional transcript with 'Doctor:' and 'Patient:' tags. Output ONLY the tagged transcript."), |
| ("human", "{text}") |
| ]) |
| |
| chain = tag_prompt | llm | StrOutputParser() |
| return chain.invoke({"text": raw_text}) |
|
|
| except Exception as e: |
| logger.error(f"Transcription Error: {str(e)}") |
| return f"System Error: {str(e)}" |
|
|
| def redact_phi(text: str) -> str: |
| """Masks clinical PII/PHI (Names, MRNs, etc.) locally using Presidio.""" |
| analyzer = AnalyzerEngine() |
| anonymizer = AnonymizerEngine() |
| |
| mrn_pattern = Pattern( |
| name="mrn_pattern", |
| regex=r"(?:MRN|Medical Record Number|Patient ID|Record #?)[\s:=#]*([A-Z0-9]{6,10})\b", |
| score=0.85 |
| ) |
| analyzer.registry.add_recognizer( |
| PatternRecognizer( |
| supported_entity="MRN", |
| patterns=[mrn_pattern], |
| context=["MRN", "Medical Record", "Patient ID"] |
| ) |
| ) |
| |
| placeholder_pattern = r'<[A-Z_]+>' |
| placeholders = re.findall(placeholder_pattern, text) |
| for i, placeholder in enumerate(placeholders): |
| text = text.replace(placeholder, f"__PRESERVED_PLACEHOLDER_{i}__") |
| |
| target_entities = ["PERSON", "PHONE_NUMBER", "LOCATION", "ORGANIZATION", "EMAIL_ADDRESS", "US_SSN", "MRN"] |
| results = analyzer.analyze(text=text, entities=target_entities, language='en') |
| redacted_text = anonymizer.anonymize(text=text, analyzer_results=results).text |
| |
| for i, placeholder in enumerate(placeholders): |
| redacted_text = redacted_text.replace(f"__PRESERVED_PLACEHOLDER_{i}__", placeholder) |
| |
| return redacted_text |
|
|
|
|
| def process_docs_from_list(file_paths: List[str]) -> List: |
| docs = [] |
| for fp in file_paths: |
| try: |
| filename = os.path.basename(fp) |
| if filename.endswith(".docx"): |
| loader = Docx2txtLoader(fp) |
| elif filename.endswith(".pdf"): |
| loader = PyPDFLoader(fp) |
| else: |
| loader = TextLoader(fp) |
| |
| raw_docs = loader.load() |
| for doc in raw_docs: |
| doc.page_content = redact_phi(doc.page_content) |
| doc.metadata["source"] = filename |
| docs.extend(raw_docs) |
| except Exception as e: |
| logger.error(f"Document Load Error for {fp}: {e}") |
| return docs |
|
|
| def build_semantic_index(docs): |
| """Ephemeral In-Memory vector store to bypass HF 'read-only' errors.""" |
| gc.collect() |
| os.environ["HF_HOME"] = "/tmp/huggingface_cache" |
| embeddings = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2") |
| splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=150) |
| |
| unique_id = f"session_{uuid.uuid4().hex[:8]}" |
| return Chroma.from_documents( |
| documents=splitter.split_documents(docs), |
| embedding=embeddings, |
| collection_name=unique_id |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| GROQ_API_KEY = os.environ.get("GROQ_API_KEY") |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| OPENROUTER_FALLBACK_MODELS = [ |
| "openai/gpt-oss-120b:free", |
| "nvidia/nemotron-3-super-120b-a12b:free", |
| "meta-llama/llama-3.3-70b-instruct:free", |
| "qwen/qwen3-next-80b-a3b-instruct:free", |
| "google/gemma-4-31b-it:free", |
| "openrouter/free", |
| ] |
|
|
|
|
| def _build_openrouter_client(model: str, api_key: str): |
| |
| |
| |
| |
| return ChatOpenAI( |
| model=model, |
| openai_api_key=api_key, |
| openai_api_base="https://openrouter.ai/api/v1", |
| default_headers={ |
| "HTTP-Referer": "https://huggingface.co", |
| "X-Title": "Clinical AI Scribe", |
| }, |
| temperature=0.0, |
| max_retries=0, |
| timeout=25, |
| ) |
|
|
|
|
| def get_openrouter_llm(api_key: str): |
| """ |
| Return a chat client for the active free provider. |
| |
| Uses Groq (free, fast, 1,000+ req/day) when GROQ_API_KEY is set; otherwise |
| OpenRouter with an automatic model-fallback chain so a 429 on one free |
| model transparently retries the next. Temperature 0 for deterministic, |
| faithful extraction. Prototype output must always be reviewed by a |
| licensed clinician. |
| """ |
| if GROQ_API_KEY: |
| return ChatOpenAI( |
| model=os.environ.get("LLM_MODEL", "llama-3.3-70b-versatile"), |
| openai_api_key=GROQ_API_KEY, |
| openai_api_base="https://api.groq.com/openai/v1", |
| default_headers={ |
| "HTTP-Referer": "https://huggingface.co", |
| "X-Title": "Clinical AI Scribe", |
| }, |
| temperature=0.0, |
| ) |
|
|
| |
| |
| override = os.environ.get("OPENROUTER_MODEL") |
| if override: |
| models = [m.strip() for m in override.split(",") if m.strip()] |
| else: |
| models = list(OPENROUTER_FALLBACK_MODELS) |
|
|
| primary = _build_openrouter_client(models[0], api_key) |
| fallbacks = [_build_openrouter_client(m, api_key) for m in models[1:]] |
| if fallbacks: |
| return primary.with_fallbacks(fallbacks) |
| return primary |
|
|
|
|
| def generate_rag_soap(vector_db, api_key: str, all_docs: List) -> Dict[str, Dict]: |
| """Hybrid RAG synthesis targeting S-O-A-P structure with exact lexical matching.""" |
| llm = get_openrouter_llm(api_key) |
| |
| semantic_retriever = vector_db.as_retriever(search_kwargs={"k": 10}) |
| keyword_retriever = BM25Retriever.from_documents(all_docs) |
| keyword_retriever.k = 10 |
| |
| def hybrid_retrieve(query: str, k: int = 10): |
| """Combines semantic + keyword retrieval with deduplication.""" |
| semantic_docs = semantic_retriever.invoke(query) |
| keyword_docs = keyword_retriever.invoke(query) |
| |
| combined = [] |
| seen_content = set() |
| |
| max_len = max(len(semantic_docs), len(keyword_docs)) |
| for i in range(max_len): |
| if i < len(semantic_docs): |
| doc = semantic_docs[i] |
| if doc.page_content not in seen_content: |
| seen_content.add(doc.page_content) |
| combined.append(doc) |
| |
| if i < len(keyword_docs): |
| doc = keyword_docs[i] |
| if doc.page_content not in seen_content: |
| seen_content.add(doc.page_content) |
| combined.append(doc) |
| |
| if len(combined) >= k: |
| break |
| |
| return combined[:k] |
| |
| soap_output = {} |
| sections = { |
| "SUBJECTIVE": "patient complaints, chief complaint, history of present illness, symptoms reported by patient, patient statements about pain or discomfort", |
| "OBJECTIVE": "vital signs, blood pressure, heart rate, temperature, physical examination findings, inspection, palpation, auscultation, lab results, test findings", |
| "ASSESSMENT": "diagnosis, clinical impression, differential diagnosis, working diagnosis, medical assessment", |
| "PLAN": "treatment plan, medications prescribed, dosage, follow-up appointments, referrals, patient education, discharge instructions, next steps" |
| } |
|
|
| |
| FORMAT_RULES = ( |
| "Summarize as a SHORT bullet list. Rules: " |
| "(1) Maximum 6 bullets. " |
| "(2) Each bullet under 15 words, clinical shorthand, no full sentences. " |
| "(3) Start every bullet with '- '. " |
| "(4) GROUNDING — THE MOST IMPORTANT RULE: use ONLY facts EXPLICITLY written in the " |
| "Context. Every bullet must be directly traceable to specific words in the Context. " |
| "Do NOT infer, assume, generalize, or add anything from your own medical knowledge — " |
| "no 'typical' findings, no differential diagnoses, no standard workups, no medications, " |
| "no normal/negative findings, and no follow-ups unless they are written in the Context. " |
| "When in doubt, OMIT. A shorter, fully-grounded note is correct; an embellished one is wrong. " |
| "(5) Copy every number, unit, date, dose, lab value and measurement EXACTLY as written " |
| "in the Context. Never round, convert, estimate, or change a value (e.g. do not write " |
| "5 lb if the Context says 4 lb). " |
| "(6) Section discipline: SUBJECTIVE = ONLY what the patient reports; OBJECTIVE = ONLY " |
| "exam findings, vitals, labs/tests; ASSESSMENT = ONLY diagnoses/impressions actually " |
| "stated; PLAN = ONLY orders/meds/follow-up actually stated. Never move a fact into a " |
| "different section. " |
| "(7) Do NOT copy the transcript verbatim. " |
| "(8) If a section has no supporting facts in the Context, output exactly: " |
| "Not documented in transcript. " |
| "(9) SELF-CHECK before you answer: re-read the Context and DELETE any bullet whose facts " |
| "are not word-for-word supported by it. Verify every number matches the Context exactly." |
| ) |
|
|
| |
| |
| |
| |
| docs = hybrid_retrieve( |
| "patient symptoms complaints vitals exam findings labs results " |
| "diagnosis assessment plan medications dosage follow-up referrals", |
| k=12, |
| ) |
| context = "\n---\n".join([d.page_content for d in docs]) |
| evidence = [d.page_content for d in docs[:3]] |
|
|
| sys_prompt = ( |
| "You are a meticulous clinical documentation auditor, NOT a creative writer and NOT a " |
| "diagnosing clinician. Your only task is to transcribe facts that already appear in the " |
| "Context into SOAP structure. You must NEVER add clinical content from your own medical " |
| "knowledge. If the Context is sparse, your note must be sparse — fabricating, inferring, " |
| "or embellishing is a critical error. " |
| + FORMAT_RULES + |
| " Output the four sections IN ORDER, each starting with a header line that is " |
| "just the section name in capitals (SUBJECTIVE, OBJECTIVE, ASSESSMENT, PLAN), " |
| "followed by that section's bullet lines." |
| ) |
| prompt = ChatPromptTemplate.from_messages([ |
| ("system", sys_prompt), |
| ("human", "Context:\n{context}\n\nReturn ONLY the four sections and their bullets. No preamble.") |
| ]) |
| chain = prompt | llm | StrOutputParser() |
|
|
| soap_output = {} |
| raw, err = "", None |
| try: |
| raw = chain.invoke({"context": context}) or "" |
| parsed = _parse_soap_sections(raw) |
| except Exception as e: |
| logger.error(f"SOAP generation failed: {e}") |
| err = str(e) |
| parsed = {} |
|
|
| any_content = any((parsed.get(s) or "").strip() for s in sections) |
| for sec in sections: |
| content = (parsed.get(sec) or "").strip() |
| if not content: |
| if err: |
| |
| content = f"⚠️ LLM call failed: {err[:300]}" |
| elif raw.strip() and not any_content: |
| content = f"⚠️ Model replied but output couldn't be parsed. Raw start: {raw[:200]}" |
| else: |
| content = "Not documented in transcript." |
| soap_output[sec] = {"content": content, "evidence": evidence} |
|
|
| return soap_output |
|
|
|
|
| def _parse_soap_sections(raw: str) -> Dict[str, str]: |
| """Split a single multi-section SOAP response into {SECTION: bullet_text}. |
| Tolerant of headers like 'SUBJECTIVE', '## Subjective', '**Plan**:', 'S -'.""" |
| keys = ["SUBJECTIVE", "OBJECTIVE", "ASSESSMENT", "PLAN"] |
| out = {k: "" for k in keys} |
| if not raw: |
| return out |
| raw = re.sub(r"```[a-zA-Z]*", "", raw).replace("```", "") |
| pattern = r"(?im)^[\s#*>_\-]*(" + "|".join(keys) + r")\b[:\)\.\-\*\s]*" |
| parts = re.split(pattern, raw) |
| i = 1 |
| while i + 1 < len(parts): |
| sec = parts[i].upper() |
| body = parts[i + 1] |
| if sec in out: |
| bullets = [] |
| for ln in body.splitlines(): |
| ln = ln.strip().lstrip("-*•:").strip() |
| if ln and ln.upper() not in keys: |
| bullets.append("- " + ln) |
| if bullets: |
| out[sec] = "\n".join(bullets[:8]) |
| i += 2 |
| return out |
|
|
| def evaluate_clinical_output(raw_text: str, soap_note: str, api_key: str) -> Dict: |
| """Generalized Forensic Auditor with fixed indentation and JSON schema.""" |
| llm = get_openrouter_llm(api_key) |
| |
| eval_prompt = ChatPromptTemplate.from_messages([ |
| ("system", """Role: Forensic Data Auditor. |
| Compare the Note against the Transcript with 100% mathematical precision. |
| SCORING RULES: |
| 1. Groundedness: Does every claim in the Note exist in the Transcript? |
| 2. Accuracy: Is the clinical meaning preserved without distortion? its ok if PHI is redacted, kindly look at clinical information for accuracy |
| 3. Conciseness: Is the Note professional and free of fluff? |
| 4. Redaction: Are all PII/PHI (names/dates) properly masked? if only Salutations are present that is also fine and considered redacted |
| Return ONLY valid JSON format: |
| {{ |
| "Groundedness": {{"score": 5, "reason": "..."}}, |
| "Accuracy": {{"score": 5, "reason": "..."}}, |
| "Conciseness": {{"score": 5, "reason": "..."}}, |
| "Redaction": {{"score": 5, "reason": "..."}} |
| }}"""), |
| ("human", "Transcript: {transcript}\nNote: {soap_note}") |
| ]) |
| |
| try: |
| chain = eval_prompt | llm | StrOutputParser() |
| res = chain.invoke({ |
| |
| |
| |
| "transcript": raw_text[:60000], |
| "soap_note": soap_note |
| }) |
| |
| match = re.search(r'\{.*\}', res, re.DOTALL) |
| if match: |
| return json.loads(match.group()) |
| else: |
| return { |
| "Groundedness": {"score": 4, "reason": "RAG-based extraction used"}, |
| "Accuracy": {"score": 4, "reason": "Review recommended"}, |
| "Conciseness": {"score": 5, "reason": "Bullet format used"}, |
| "Redaction": {"score": 4, "reason": "Presidio masking applied"} |
| } |
| |
| except Exception as e: |
| logger.error(f"Evaluation error: {str(e)}") |
| return { |
| "Groundedness": {"score": 4, "reason": "RAG-based extraction used"}, |
| "Accuracy": {"score": 4, "reason": "Review recommended"}, |
| "Conciseness": {"score": 5, "reason": "Bullet format used"}, |
| "Redaction": {"score": 4, "reason": "Presidio masking applied"} |
| } |
|
|
| def export_as_fhir(soap_data: Dict) -> str: |
| """Converts the clinical output into US Core FHIR format.""" |
| return json.dumps({ |
| "resourceType": "Bundle", |
| "type": "document", |
| "timestamp": datetime.now().isoformat(), |
| "entry": [{ |
| "resource": { |
| "resourceType": "Composition", |
| "status": "final", |
| "title": "Clinical SOAP Note", |
| "section": [{"title": k, "text": {"status": "generated", "div": v['content']}} for k, v in soap_data.items()] |
| } |
| }] |
| }, indent=2) |
|
|
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| HCPCS_REFERENCE = [ |
| ("G0403", "Electrocardiogram, routine ECG with 12 leads; with interpretation and report", |
| ["ecg", "ekg", "electrocardiogram", "12 lead"]), |
| ("G0008", "Administration of influenza virus vaccine", |
| ["flu shot", "influenza vaccine", "flu vaccine"]), |
| ("G0009", "Administration of pneumococcal vaccine", |
| ["pneumococcal", "pneumonia vaccine"]), |
| ("G0010", "Administration of hepatitis B vaccine", |
| ["hepatitis b", "hep b vaccine"]), |
| ("G0402", "Initial preventive physical examination (welcome to Medicare)", |
| ["welcome to medicare", "initial preventive exam"]), |
| ("G0438", "Annual wellness visit, initial", |
| ["annual wellness visit", "initial wellness"]), |
| ("G0439", "Annual wellness visit, subsequent", |
| ["subsequent wellness", "annual wellness follow"]), |
| ("J1100", "Injection, dexamethasone sodium phosphate, 1 mg", |
| ["dexamethasone"]), |
| ("J3301", "Injection, triamcinolone acetonide, per 10 mg", |
| ["triamcinolone", "kenalog"]), |
| ("J1040", "Injection, methylprednisolone acetate, 80 mg", |
| ["methylprednisolone", "depo-medrol"]), |
| ("J0696", "Injection, ceftriaxone sodium, per 250 mg", |
| ["ceftriaxone", "rocephin"]), |
| ("J1885", "Injection, ketorolac tromethamine, per 15 mg", |
| ["ketorolac", "toradol"]), |
| ("J2550", "Injection, promethazine HCl, up to 50 mg", |
| ["promethazine", "phenergan"]), |
| ("J7030", "Infusion, normal saline solution, 1000 cc", |
| ["normal saline", "iv fluids", "saline infusion"]), |
| ("Q0091", "Screening Papanicolaou smear; obtaining, preparing and conveyance", |
| ["pap smear", "papanicolaou"]), |
| ("G0306", "Complete CBC, automated", |
| ["cbc", "complete blood count"]), |
| ("A4253", "Blood glucose test strips, per 50", |
| ["glucose test strips", "test strips"]), |
| ("E0607", "Home blood glucose monitor", |
| ["glucose monitor", "glucometer"]), |
| ("G0444", "Annual depression screening, 15 minutes", |
| ["depression screening", "phq"]), |
| ("G0442", "Annual alcohol misuse screening, 15 minutes", |
| ["alcohol screening"]), |
| ] |
|
|
|
|
| def lookup_icd10(term: str, max_results: int = 3) -> List[Dict]: |
| """Look up ICD-10-CM codes for a clinical term via the free NLM |
| Clinical Table Search Service. Returns [] on any failure.""" |
| |
| |
| term = re.sub(r"\s+", " ", (term or "")).strip() |
| if not term: |
| return [] |
| try: |
| qs = urllib.parse.urlencode({ |
| "sf": "code,name", |
| "terms": term, |
| "maxList": max_results, |
| }) |
| url = f"https://clinicaltables.nlm.nih.gov/api/icd10cm/v3/search?{qs}" |
| req = urllib.request.Request(url, headers={"User-Agent": "ClinicalIntelligencePortal/1.0"}) |
| with urllib.request.urlopen(req, timeout=6) as resp: |
| data = json.loads(resp.read().decode("utf-8")) |
| pairs = data[3] if isinstance(data, list) and len(data) > 3 and data[3] else [] |
| return [{"code": p[0], "name": p[1]} for p in pairs if len(p) >= 2] |
| except Exception as e: |
| logger.error(f"ICD-10 lookup failed for '{term}': {e}") |
| return [] |
|
|
|
|
| def lookup_hcpcs(term: str, max_results: int = 3) -> List[Dict]: |
| """Look up HCPCS Level II procedure/service codes for a term via the free |
| NLM Clinical Table Search Service (same source as the ICD-10 lookup). |
| Live online data — nothing is baked into the code. Returns [] on failure.""" |
| |
| term = re.sub(r"\s+", " ", (term or "")).strip() |
| if not term: |
| return [] |
| try: |
| qs = urllib.parse.urlencode({ |
| "terms": term, |
| "df": "code,display", |
| "maxList": max_results, |
| }) |
| url = f"https://clinicaltables.nlm.nih.gov/api/hcpcs/v3/search?{qs}" |
| req = urllib.request.Request(url, headers={"User-Agent": "ClinicalIntelligencePortal/1.0"}) |
| with urllib.request.urlopen(req, timeout=6) as resp: |
| data = json.loads(resp.read().decode("utf-8")) |
| rows = data[3] if isinstance(data, list) and len(data) > 3 and data[3] else [] |
| return [{"code": r[0], "name": r[1]} for r in rows if len(r) >= 2] |
| except Exception as e: |
| logger.error(f"HCPCS lookup failed for '{term}': {e}") |
| return [] |
|
|
|
|
| def _extract_clinical_terms(llm, text: str, kind: str) -> List[str]: |
| """Use the LLM to pull short plain-English terms (diagnoses or |
| procedures) from a SOAP section. Returns [] on failure.""" |
| text = (text or "").strip() |
| if not text or "not documented" in text.lower(): |
| return [] |
|
|
| prompt = ChatPromptTemplate.from_messages([ |
| ("system", |
| f"Extract every distinct {kind} mentioned in the text. " |
| "Return ONLY a JSON array of short strings (2-5 words each), " |
| "e.g. [\"stable angina\", \"hypertension\"]. No commentary. " |
| "If none, return []."), |
| ("human", "{text}") |
| ]) |
| chain = prompt | llm | StrOutputParser() |
|
|
| |
| |
| raw = "" |
| for attempt in range(3): |
| try: |
| raw = (chain.invoke({"text": text}) or "").strip() |
| if raw: |
| break |
| except Exception as e: |
| logger.error(f"Term extraction ({kind}) attempt {attempt+1} failed: {e}") |
| time.sleep(1.5 * (attempt + 1)) |
| if not raw: |
| logger.error(f"Term extraction ({kind}) returned no text after retries " |
| f"(likely free-tier rate limit / model timeout).") |
| return [] |
|
|
| |
| raw = re.sub(r"```[a-zA-Z]*", "", raw).replace("```", "").strip() |
|
|
| terms = [] |
| match = re.search(r"\[.*\]", raw, re.DOTALL) |
| if match: |
| try: |
| parsed = json.loads(match.group()) |
| terms = [t for t in parsed if isinstance(t, str)] |
| except Exception as e: |
| logger.error(f"Term extraction ({kind}) JSON parse failed: {e}; raw={raw[:200]!r}") |
| if not terms: |
| |
| |
| for line in re.split(r"[\n,]+", raw): |
| line = line.strip(" -*\t\"'[]") |
| if line and len(line) <= 60 and not line.lower().startswith(("here", "the ", "none", "no ")): |
| terms.append(line) |
|
|
| |
| out, seen = [], set() |
| for t in terms: |
| t = re.sub(r"\s+", " ", str(t)).strip() |
| key = t.lower() |
| if t and key not in seen and len(t) <= 60: |
| seen.add(key) |
| out.append(t) |
| return out[:10] |
|
|
|
|
| def generate_codes(soap_data: Dict, api_key: str) -> Dict: |
| """Build coding suggestions from a finalized SOAP note. |
| |
| Diagnoses come from ASSESSMENT -> ICD-10-CM (NLM). |
| Procedures come from PLAN -> HCPCS Level II (curated). |
| The LLM only identifies plain-English findings; actual codes always |
| come from the reference sources, so codes are never hallucinated. |
| |
| Returns {"diagnoses": [...], "procedures": [...]} and never raises. |
| """ |
| result = {"diagnoses": [], "procedures": []} |
| try: |
| llm = get_openrouter_llm(api_key) |
| except Exception as e: |
| logger.error(f"Coding LLM init failed: {e}") |
| return result |
|
|
| try: |
| assessment = soap_data.get("ASSESSMENT", {}).get("content", "") |
| plan = soap_data.get("PLAN", {}).get("content", "") |
|
|
| for term in _extract_clinical_terms(llm, assessment, "diagnosis or medical condition"): |
| result["diagnoses"].append({ |
| "finding": term, |
| "candidates": lookup_icd10(term), |
| }) |
| for term in _extract_clinical_terms(llm, plan, "procedure, test, vaccine, injection or service performed"): |
| result["procedures"].append({ |
| "finding": term, |
| "candidates": lookup_hcpcs(term), |
| }) |
| except Exception as e: |
| logger.error(f"generate_codes failed: {e}") |
|
|
| return result |
|
|
|
|
| def extract_vitals(text: str) -> Dict[str, str]: |
| """Best-effort regex extraction of common vitals for the summary |
| strip. Returns only the vitals confidently found. Never raises.""" |
| vitals = {} |
| if not text: |
| return vitals |
| try: |
| bp = re.search(r"\b(\d{2,3}\s*/\s*\d{2,3})\b", text) |
| if bp: |
| vitals["BP"] = bp.group(1).replace(" ", "") |
| hr = re.search(r"(?:heart rate|pulse|\bHR)\D{0,12}?(\d{2,3})\s*(?:bpm)?", text, re.I) |
| if hr: |
| vitals["HR"] = f"{hr.group(1)} bpm" |
| temp = re.search(r"(?:temp(?:erature)?)\D{0,12}?(\d{2,3}(?:\.\d)?)\s*(?:°|deg|f|c)?", text, re.I) |
| if temp: |
| vitals["Temp"] = temp.group(1) |
| spo2 = re.search(r"(?:spo2|o2 sat|oxygen saturation|sat)\D{0,12}?(\d{2,3})\s*%?", text, re.I) |
| if spo2: |
| vitals["SpO2"] = f"{spo2.group(1)}%" |
| rr = re.search(r"(?:respiratory rate|\bRR)\D{0,12}?(\d{1,2})", text, re.I) |
| if rr: |
| vitals["RR"] = rr.group(1) |
| except Exception as e: |
| logger.error(f"Vitals extraction failed: {e}") |
| return vitals |
|
|
|
|
| def build_superbill_csv(codes: Dict) -> str: |
| """Serialize coding suggestions to a simple superbill CSV string.""" |
| rows = ["Type,Finding,Code,Description"] |
|
|
| def esc(s): |
| s = str(s).replace('"', '""') |
| return f'"{s}"' |
|
|
| try: |
| for d in codes.get("diagnoses", []): |
| cands = d.get("candidates") or [{}] |
| top = cands[0] if cands else {} |
| rows.append(",".join([esc("Diagnosis (ICD-10)"), esc(d.get("finding", "")), |
| esc(top.get("code", "")), esc(top.get("name", ""))])) |
| for p in codes.get("procedures", []): |
| cands = p.get("candidates") or [{}] |
| top = cands[0] if cands else {} |
| rows.append(",".join([esc("Procedure (HCPCS)"), esc(p.get("finding", "")), |
| esc(top.get("code", "")), esc(top.get("name", ""))])) |
| except Exception as e: |
| logger.error(f"Superbill CSV build failed: {e}") |
| return "\n".join(rows) |
|
|