diff --git a/portal/dossier_sections.go b/portal/dossier_sections.go index 331759e..0ae401b 100644 --- a/portal/dossier_sections.go +++ b/portal/dossier_sections.go @@ -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 diff --git a/portal/main.go b/portal/main.go index 1fe917e..8470809 100644 --- a/portal/main.go +++ b/portal/main.go @@ -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) } diff --git a/portal/static/style.css b/portal/static/style.css index 3202517..19d5ffa 100644 --- a/portal/static/style.css +++ b/portal/static/style.css @@ -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 { diff --git a/portal/templates/dossier.tmpl b/portal/templates/dossier.tmpl index 5c60196..b2f8696 100644 --- a/portal/templates/dossier.tmpl +++ b/portal/templates/dossier.tmpl @@ -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 += `
`; html += buildSVGChart(displayName, s.unit, s.points, s.abbr, globalTMin, globalTMax); + html += `
`; + 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 = `💡 ${esc(text)}`; + } + // 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) {