| from __future__ import annotations |
| import json |
| import re |
| from typing import Protocol |
| from whatchanged.models import DOMAINS, EVENTS, Finding |
|
|
|
|
| class LLMBackend(Protocol): |
| def complete(self, prompt: str, max_tokens: int = 256, |
| grammar: str | None = None, temperature: float = 0.2) -> str: ... |
|
|
|
|
| class FakeBackend: |
| """Test/dev backend that returns a fixed string and records the last prompt.""" |
| def __init__(self, response: str): |
| self.response = response |
| self.last_prompt: str | None = None |
|
|
| def complete(self, prompt: str, max_tokens: int = 256, |
| grammar: str | None = None, temperature: float = 0.2) -> str: |
| self.last_prompt = prompt |
| return self.response |
|
|
|
|
| def parse_extraction(raw: str) -> dict: |
| """Robustly pull the first JSON object out of model output and coerce it to the |
| schema: domains clamped 1..5, events clamped >=0 ints, unknown keys dropped.""" |
| match = re.search(r"\{.*\}", raw, re.DOTALL) |
| if not match: |
| return {} |
| try: |
| data = json.loads(match.group(0)) |
| except (json.JSONDecodeError, ValueError): |
| return {} |
| out: dict = {} |
| for k, v in data.items(): |
| if k in DOMAINS: |
| try: |
| out[k] = max(1, min(5, int(v))) |
| except (TypeError, ValueError): |
| continue |
| elif k in EVENTS: |
| try: |
| out[k] = max(0, int(v)) |
| except (TypeError, ValueError): |
| continue |
| return out |
|
|
|
|
| def build_extract_prompt(note: str) -> str: |
| return ( |
| "You convert a caregiver's free-text note about an elderly person's day into " |
| "JSON. Only output a JSON object. Rate each domain 1-5 (5 = best) ONLY if the " |
| "note mentions it; omit domains not mentioned. Count events as non-negative " |
| "integers.\n" |
| f"Domains: {', '.join(DOMAINS)}\n" |
| f"Events: {', '.join(EVENTS)}\n" |
| "Do not diagnose. Output JSON only.\n\n" |
| f'Note: "{note}"\n' |
| "JSON:" |
| ) |
|
|
|
|
| def build_extract_grammar() -> str: |
| """GBNF that constrains decoding to a JSON object whose keys are EXACTLY the schema |
| fields, each mapped to an integer. This guarantees valid, parseable output and makes |
| out-of-schema keys (e.g. a slangy 'tumble' or 'freezes') impossible at decode time.""" |
| keys = " | ".join(r'"\"' + k + r'\""' for k in (*DOMAINS, *EVENTS)) |
| return ( |
| 'root ::= "{" ws ( pair ( ws "," ws pair )* )? ws "}"\n' |
| 'pair ::= key ws ":" ws [0-9]+\n' |
| "key ::= " + keys + "\n" |
| "ws ::= [ \\t\\n]*\n" |
| ) |
|
|
|
|
| EXTRACT_GRAMMAR = build_extract_grammar() |
|
|
|
|
| def extract_note(note: str, backend: LLMBackend) -> dict: |
| if not note.strip() or backend is None: |
| return {} |
| |
| raw = backend.complete(build_extract_prompt(note), max_tokens=128, |
| grammar=EXTRACT_GRAMMAR, temperature=0.0) |
| return parse_extraction(raw) |
|
|
|
|
| def build_narrate_prompt(findings: list[Finding]) -> str: |
| bullet = "\n".join(f"- {f.summary}" for f in findings) |
| return ( |
| "You are writing a short, plain-language paragraph for a caregiver to bring to " |
| "a doctor. Summarize ONLY the findings below. Be factual and descriptive. Do " |
| "NOT diagnose, predict, or recommend treatment.\n\n" |
| f"Findings:\n{bullet}\n\nParagraph:" |
| ) |
|
|
|
|
| def narrate_findings(findings: list[Finding], backend: LLMBackend) -> str: |
| if not findings: |
| return "No notable changes were detected in the logged period." |
| fallback = "Over the logged period: " + "; ".join(f.summary for f in findings) + "." |
| if backend is None: |
| return fallback |
| try: |
| text = backend.complete(build_narrate_prompt(findings), max_tokens=140).strip() |
| return text or fallback |
| except Exception: |
| return fallback |
|
|
|
|
| class LlamaCppBackend: |
| """Wraps a local GGUF model via llama-cpp-python. Model loads lazily on first |
| use so importing this module never requires the model or the native lib.""" |
| def __init__(self, model_path: str, n_ctx: int = 1024, n_threads: int | None = None): |
| self.model_path = model_path |
| self.n_ctx = n_ctx |
| self.n_threads = n_threads |
| self._llm = None |
|
|
| def _ensure(self): |
| if self._llm is None: |
| from llama_cpp import Llama |
| self._llm = Llama( |
| model_path=self.model_path, n_ctx=self.n_ctx, |
| n_threads=self.n_threads, verbose=False, |
| ) |
| return self._llm |
|
|
| def complete(self, prompt: str, max_tokens: int = 256, |
| grammar: str | None = None, temperature: float = 0.2) -> str: |
| llm = self._ensure() |
| g = None |
| if grammar is not None: |
| from llama_cpp import LlamaGrammar |
| g = LlamaGrammar.from_string(grammar, verbose=False) |
| out = llm.create_completion( |
| prompt=prompt, max_tokens=max_tokens, temperature=temperature, |
| stop=["\n\n\n"], grammar=g, |
| ) |
| return out["choices"][0]["text"] |
|
|