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:
parent
831ab61445
commit
f58a4f804e
|
|
@ -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.
|
// handleLabSearch serves lab data as JSON.
|
||||||
// GET /dossier/{id}/labs?order={entryID} — children for one order (expand)
|
// GET /dossier/{id}/labs?order={entryID} — children for one order (expand)
|
||||||
// GET /dossier/{id}/labs?q=sodium — search across all orders
|
// GET /dossier/{id}/labs?q=sodium — search across all orders
|
||||||
|
|
|
||||||
|
|
@ -2232,6 +2232,7 @@ func setupMux() http.Handler {
|
||||||
} else if strings.Contains(path, "/files/") && strings.HasSuffix(path, "/reprocess") { handleReprocess(w, r)
|
} 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-imaging") { handleProcessImaging(w, r)
|
||||||
} else if strings.HasSuffix(path, "/process-status") { handleProcessStatus(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.HasSuffix(path, "/labs") { handleLabSearch(w, r)
|
||||||
} else if strings.Contains(path, "/document/") { handleDocumentView(w, r)
|
} else if strings.Contains(path, "/document/") { handleDocumentView(w, r)
|
||||||
} else { handleDossierV2(w, r) }
|
} else { handleDossierV2(w, r) }
|
||||||
|
|
|
||||||
|
|
@ -1263,9 +1263,27 @@ a:hover {
|
||||||
.lab-chart {
|
.lab-chart {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
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 */
|
/* Expandable children */
|
||||||
.data-row-children {
|
.data-row-children {
|
||||||
|
|
|
||||||
|
|
@ -585,9 +585,56 @@ function renderFilterChart(card, table, q) {
|
||||||
// Build display name: "Full Name (Abbr)" or fallback to abbreviation
|
// Build display name: "Full Name (Abbr)" or fallback to abbreviation
|
||||||
const fullName = loincNames[loinc] || s.abbr;
|
const fullName = loincNames[loinc] || s.abbr;
|
||||||
const displayName = fullName !== s.abbr ? `${fullName} (${s.abbr})` : 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 += 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;
|
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) {
|
function buildSVGChart(name, unit, points, abbr, globalTMin, globalTMax) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue