feat: add AI commentary to lab trend charts

After lab trend charts render, a POST to /dossier/{id}/labs/commentary
sends compact series summaries (first→last value, % change, dates,
reference range) to Claude Haiku, which returns one sentence per metric.
Commentary appears below each chart as an italic insight, e.g.:
  'Cholesterol dropped 15% from 210→178 mg/dL since March — now within range.'

Implementation:
- New handleLabCommentary() in dossier_sections.go — auth-gated, POST only,
  gracefully skips if no Anthropic key configured
- Route: POST /dossier/{id}/labs/commentary (registered before /labs in mux)
- renderFilterChart() now wraps each SVG in .lab-chart-wrap with a
  .lab-commentary placeholder showing a pulsing '…' while loading
- fetchLabCommentary() fires async after chart render, patches commentary
  divs by data-abbr attribute when response arrives
- CSS: .lab-chart-wrap, .lab-commentary, .lab-commentary-text,
  .lab-commentary-loading with pulse animation
This commit is contained in:
James 2026-03-23 12:27:20 -04:00
parent 831ab61445
commit f58a4f804e
4 changed files with 169 additions and 2 deletions

View File

@ -1084,6 +1084,107 @@ func handleDossierV2(w http.ResponseWriter, r *http.Request) {
})
}
// handleLabCommentary generates AI commentary for lab trend charts.
// POST /dossier/{id}/labs/commentary
// Body: {"series":[{"name":"Cholesterol","abbr":"CHOL","unit":"mg/dL","points":[{"date":"2024-01-15","val":210},...],"refLow":0,"refHigh":200,"direction":"lower_better"}]}
// Returns: {"commentary":{"CHOL":"Cholesterol dropped 15% from 210 to 180 mg/dL since January — now within normal range.",...}}
func handleLabCommentary(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
if p == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized); return }
if r.Method != "POST" { http.Error(w, "Method not allowed", 405); return }
if lib.AnthropicKey == "" {
http.Error(w, "AI commentary unavailable", 503)
return
}
var body struct {
Series []struct {
Name string `json:"name"`
Abbr string `json:"abbr"`
Unit string `json:"unit"`
Direction string `json:"direction"`
RefLow float64 `json:"refLow"`
RefHigh float64 `json:"refHigh"`
Points []struct {
Date string `json:"date"`
Val float64 `json:"val"`
} `json:"points"`
} `json:"series"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.Series) == 0 {
http.Error(w, "Invalid request", 400)
return
}
// Build compact prompt
var sb strings.Builder
sb.WriteString("You are a medical data analyst. For each lab metric below, write ONE concise sentence (max 20 words) describing the trend for a patient. Focus on: direction (up/down/stable), magnitude (% or absolute change), and whether it's moving toward or away from the normal range. Use plain language, no jargon. Do NOT give medical advice or diagnoses.\n\n")
sb.WriteString("Format your response as JSON: {\"ABBR\": \"sentence\", ...}\n\n")
sb.WriteString("Metrics:\n")
for _, s := range body.Series {
if len(s.Points) < 2 { continue }
first := s.Points[0]
last := s.Points[len(s.Points)-1]
pct := 0.0
if first.Val != 0 { pct = (last.Val - first.Val) / first.Val * 100 }
var refStr string
if s.RefHigh > 0 || s.RefLow > 0 {
refStr = fmt.Sprintf(", normal range: %.1f%.1f %s", s.RefLow, s.RefHigh, s.Unit)
}
sb.WriteString(fmt.Sprintf("- %s (%s): %.1f→%.1f %s (%+.0f%%) from %s to %s%s\n",
s.Name, s.Abbr, first.Val, last.Val, s.Unit, pct, first.Date, last.Date, refStr))
}
reqBody, _ := json.Marshal(map[string]interface{}{
"model": "claude-haiku-4-5",
"max_tokens": 512,
"messages": []map[string]interface{}{
{"role": "user", "content": sb.String()},
},
})
req, err := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", strings.NewReader(string(reqBody)))
if err != nil { http.Error(w, "Request error", 500); return }
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", lib.AnthropicKey)
req.Header.Set("anthropic-version", "2023-06-01")
resp, err := http.DefaultClient.Do(req)
if err != nil { http.Error(w, "API error", 502); return }
defer resp.Body.Close()
var result struct {
Content []struct{ Text string `json:"text"` } `json:"content"`
Error struct{ Message string `json:"message"` } `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || len(result.Content) == 0 {
http.Error(w, "Bad API response", 502)
return
}
if resp.StatusCode != 200 {
http.Error(w, "API error: "+result.Error.Message, 502)
return
}
// Parse the JSON response from Claude (it may be wrapped in markdown code fence)
raw := strings.TrimSpace(result.Content[0].Text)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var commentary map[string]string
if err := json.Unmarshal([]byte(raw), &commentary); err != nil {
// Return the raw text as a fallback under "_raw"
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"commentary": map[string]string{"_raw": raw}})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"commentary": commentary})
}
// handleLabSearch serves lab data as JSON.
// GET /dossier/{id}/labs?order={entryID} — children for one order (expand)
// GET /dossier/{id}/labs?q=sodium — search across all orders

View File

@ -2232,6 +2232,7 @@ func setupMux() http.Handler {
} else if strings.Contains(path, "/files/") && strings.HasSuffix(path, "/reprocess") { handleReprocess(w, r)
} else if strings.HasSuffix(path, "/process-imaging") { handleProcessImaging(w, r)
} else if strings.HasSuffix(path, "/process-status") { handleProcessStatus(w, r)
} else if strings.HasSuffix(path, "/labs/commentary") { handleLabCommentary(w, r)
} else if strings.HasSuffix(path, "/labs") { handleLabSearch(w, r)
} else if strings.Contains(path, "/document/") { handleDocumentView(w, r)
} else { handleDossierV2(w, r) }

View File

@ -1263,9 +1263,27 @@ a:hover {
.lab-chart {
width: 100%;
height: auto;
margin-bottom: 12px;
margin-bottom: 4px;
}
.lab-chart:last-child { margin-bottom: 0; }
.lab-chart-wrap {
margin-bottom: 16px;
}
.lab-chart-wrap:last-child { margin-bottom: 0; }
.lab-commentary {
min-height: 20px;
padding: 0 4px 8px 4px;
}
.lab-commentary-loading {
color: var(--text-muted, #9ca3af);
font-size: 12px;
animation: pulse 1.5s ease-in-out infinite;
}
.lab-commentary-text {
font-size: 13px;
color: var(--text-secondary, #6b7280);
font-style: italic;
}
@keyframes pulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1; } }
/* Expandable children */
.data-row-children {

View File

@ -585,9 +585,56 @@ function renderFilterChart(card, table, q) {
// Build display name: "Full Name (Abbr)" or fallback to abbreviation
const fullName = loincNames[loinc] || s.abbr;
const displayName = fullName !== s.abbr ? `${fullName} (${s.abbr})` : s.abbr;
html += `<div class="lab-chart-wrap" data-abbr="${esc(s.abbr)}">`;
html += buildSVGChart(displayName, s.unit, s.points, s.abbr, globalTMin, globalTMax);
html += `<div class="lab-commentary" data-abbr="${esc(s.abbr)}"><span class="lab-commentary-loading">…</span></div>`;
html += `</div>`;
}
body.innerHTML = html;
// Fetch AI commentary asynchronously — don't block chart render
fetchLabCommentary(chartable, body);
}
async function fetchLabCommentary(chartable, body) {
const series = [];
for (const [loinc, s] of chartable) {
if (s.points.length < 2) continue;
const ref = labRefData[s.abbr] || {};
series.push({
name: loincNames[loinc] || s.abbr,
abbr: s.abbr,
unit: s.unit,
direction: ref.direction || '',
refLow: ref.refLow || 0,
refHigh: ref.refHigh || 0,
points: s.points.map(p => ({
date: p.date.toISOString().slice(0,10),
val: p.val,
})),
});
}
if (series.length === 0) return;
try {
const resp = await fetch(`/dossier/${dossierGUID}/labs/commentary`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({series}),
});
if (!resp.ok) return;
const data = await resp.json();
const commentary = data.commentary || {};
for (const [abbr, text] of Object.entries(commentary)) {
if (abbr === '_raw') continue;
const el = body.querySelector(`.lab-commentary[data-abbr="${abbr}"]`);
if (el) el.innerHTML = `<span class="lab-commentary-text">💡 ${esc(text)}</span>`;
}
// Clear any remaining loading spinners
body.querySelectorAll('.lab-commentary-loading').forEach(el => el.remove());
} catch (_) {
body.querySelectorAll('.lab-commentary-loading').forEach(el => el.remove());
}
}
function buildSVGChart(name, unit, points, abbr, globalTMin, globalTMax) {