1380 lines
44 KiB
Go
1380 lines
44 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
"inou/lib"
|
||
)
|
||
|
||
// DossierSection represents a unified section block on the dossier page
|
||
type DossierSection struct {
|
||
ID string // "imaging", "labs", "genetics", etc.
|
||
Icon string // emoji or icon identifier
|
||
Color string // hex color for indicator (without #)
|
||
HeadingKey string // translation key for heading
|
||
Heading string // resolved heading text
|
||
Summary string // summary line
|
||
ActionURL string // optional button URL
|
||
ActionLabel string // optional button label (translation key resolved)
|
||
Items []SectionItem // the actual data rows
|
||
HideEmpty bool // hide entire section when no items
|
||
ComingSoon bool // show as coming soon
|
||
Dynamic bool // loaded via JS (like genetics)
|
||
DynamicType string // "genetics" for special handling
|
||
CustomHTML string // for completely custom sections (privacy)
|
||
Searchable bool // show search/filter box in header
|
||
ChartData string // JSON chart data (vitals)
|
||
// Checkin-specific: show "build your profile" prompt
|
||
ShowBuildTracker bool // true if trackable categories are empty
|
||
TrackableStats map[string]int // counts for trackable categories
|
||
TrackerButtons []TrackerButton // buttons for empty trackable categories
|
||
}
|
||
|
||
// TrackerButton for the "build your profile" section
|
||
type TrackerButton struct {
|
||
Label string
|
||
Icon string
|
||
URL string
|
||
}
|
||
|
||
// SectionItem represents a row in a section
|
||
type SectionItem struct {
|
||
ID string
|
||
Label string
|
||
Meta string // secondary text below label
|
||
Date string // YYYYMMDD format for JS formatting
|
||
Time string // "20:06 -0400" — shown alongside Date when present
|
||
Type string
|
||
Value string
|
||
LinkURL string
|
||
LinkTitle string
|
||
Expandable bool
|
||
Expanded bool
|
||
Children []SectionItem
|
||
SourceSpansJSON string // JSON-encoded source spans for doc pane highlighting
|
||
}
|
||
|
||
// SectionConfig defines how to build a section for a category
|
||
type SectionConfig struct {
|
||
ID string
|
||
Category int
|
||
Color string
|
||
HeadingKey string
|
||
HideEmpty bool
|
||
ComingSoon bool
|
||
Dynamic bool
|
||
DynamicType string
|
||
}
|
||
|
||
// Standard section configurations
|
||
var sectionConfigs = []SectionConfig{
|
||
{ID: "checkin", HeadingKey: "section_checkin", Color: "B45309"},
|
||
{ID: "imaging", Category: lib.CategoryImaging, Color: "B45309", HeadingKey: "section_imaging", HideEmpty: false},
|
||
{ID: "labs", Category: lib.CategoryLab, Color: "059669", HeadingKey: "section_labs", HideEmpty: false},
|
||
{ID: "documents", Category: lib.CategoryDocument, Color: "06b6d4", HeadingKey: "section_records", HideEmpty: true},
|
||
{ID: "procedures", Category: lib.CategorySurgery, Color: "DC2626", HeadingKey: "section_procedures", HideEmpty: true},
|
||
{ID: "assessments", Category: lib.CategoryAssessment, Color: "7C3AED", HeadingKey: "section_assessments", HideEmpty: true},
|
||
{ID: "genetics", Category: lib.CategoryGenome, Color: "8B5CF6", HeadingKey: "section_genetics", HideEmpty: true, Dynamic: true, DynamicType: "genetics"},
|
||
{ID: "medications", Category: lib.CategoryMedication, Color: "8b5cf6", HeadingKey: "section_medications", HideEmpty: true},
|
||
{ID: "supplements", Category: lib.CategorySupplement, Color: "8b5cf6", HeadingKey: "section_supplements", HideEmpty: true},
|
||
{ID: "symptoms", Category: lib.CategorySymptom, Color: "F59E0B", HeadingKey: "section_symptoms", HideEmpty: true},
|
||
{ID: "hospitalizations", Category: lib.CategoryHospital, Color: "EF4444", HeadingKey: "section_hospitalizations", HideEmpty: true},
|
||
{ID: "therapies", Category: lib.CategoryTherapy, Color: "10B981", HeadingKey: "section_therapies", HideEmpty: true},
|
||
{ID: "consultations", Category: lib.CategoryConsultation, Color: "3B82F6", HeadingKey: "section_consultations", HideEmpty: true},
|
||
{ID: "diagnoses", Category: lib.CategoryDiagnosis, Color: "EF4444", HeadingKey: "section_diagnoses", HideEmpty: true},
|
||
{ID: "exercise", Category: lib.CategoryExercise, Color: "22C55E", HeadingKey: "section_exercise", HideEmpty: true},
|
||
{ID: "nutrition", Category: lib.CategoryNutrition, Color: "F97316", HeadingKey: "section_nutrition", HideEmpty: true},
|
||
{ID: "fertility", Category: lib.CategoryFertility, Color: "EC4899", HeadingKey: "section_fertility", HideEmpty: true},
|
||
{ID: "notes", Category: lib.CategoryNote, Color: "6B7280", HeadingKey: "section_notes", HideEmpty: true},
|
||
{ID: "history", Category: lib.CategoryHistory, Color: "6B7280", HeadingKey: "section_history", HideEmpty: true},
|
||
{ID: "family_history", Category: lib.CategoryFamilyHistory, Color: "6B7280", HeadingKey: "section_family_history", HideEmpty: true},
|
||
{ID: "birth", Category: lib.CategoryBirth, Color: "EC4899", HeadingKey: "section_birth", HideEmpty: true},
|
||
{ID: "devices", Category: lib.CategoryDevice, Color: "6366F1", HeadingKey: "section_devices", HideEmpty: true},
|
||
{ID: "providers", Category: lib.CategoryProvider, Color: "0EA5E9", HeadingKey: "section_providers", HideEmpty: true},
|
||
{ID: "questions", Category: lib.CategoryQuestion, Color: "8B5CF6", HeadingKey: "section_questions", HideEmpty: true},
|
||
{ID: "vitals", Category: lib.CategoryVital, Color: "ec4899", HeadingKey: "section_vitals", HideEmpty: true},
|
||
{ID: "privacy", HeadingKey: "section_privacy", Color: "64748b"},
|
||
}
|
||
|
||
type chartRef struct {
|
||
RefLow float64 `json:"refLow"`
|
||
RefHigh float64 `json:"refHigh"`
|
||
Direction string `json:"direction,omitempty"`
|
||
}
|
||
|
||
// vitalRef returns US reference range for a body composition metric by sex.
|
||
// Sex: 1=male, 2=female (ISO 5218). Returns nil if no reference data.
|
||
// Direction: "higher_better" = only lower bound matters, "lower_better" = only upper bound, "" = both.
|
||
func vitalRef(metricType string, sex int) *chartRef {
|
||
type ref struct{ low, high float64; dir string }
|
||
// US reference ranges: [male, female]
|
||
// Sources: WHO (BMI), ACE/ACSM (body fat), Tanita (visceral fat)
|
||
ranges := map[string][2]ref{
|
||
"bmi": {{18.5, 24.9, ""}, {18.5, 24.9, ""}},
|
||
"body_fat": {{10, 22, ""}, {20, 33, ""}},
|
||
"visceral_fat": {{0, 12, "lower_better"}, {0, 12, "lower_better"}},
|
||
"subcutaneous_fat": {{0, 19, "lower_better"}, {0, 28, "lower_better"}},
|
||
"water": {{50, 0, "higher_better"}, {45, 0, "higher_better"}},
|
||
"muscle": {{33, 0, "higher_better"}, {24, 0, "higher_better"}},
|
||
"skeletal_muscle": {{33, 0, "higher_better"}, {24, 0, "higher_better"}},
|
||
"bone": {{2.5, 0, "higher_better"}, {1.8, 0, "higher_better"}},
|
||
"protein": {{16, 0, "higher_better"}, {16, 0, "higher_better"}},
|
||
}
|
||
r, ok := ranges[metricType]
|
||
if !ok {
|
||
return nil
|
||
}
|
||
idx := 0
|
||
if sex == 2 {
|
||
idx = 1
|
||
}
|
||
return &chartRef{RefLow: r[idx].low, RefHigh: r[idx].high, Direction: r[idx].dir}
|
||
}
|
||
|
||
// BuildDossierSections builds all sections for a dossier
|
||
func BuildDossierSections(targetID, targetHex string, target *lib.Dossier, p *lib.Dossier, lang string, canEdit bool) []DossierSection {
|
||
T := func(key string) string { return translations[lang][key] }
|
||
|
||
var sections []DossierSection
|
||
|
||
for _, cfg := range sectionConfigs {
|
||
section := DossierSection{
|
||
ID: cfg.ID,
|
||
Color: cfg.Color,
|
||
HeadingKey: cfg.HeadingKey,
|
||
Heading: T(cfg.HeadingKey),
|
||
HideEmpty: cfg.HideEmpty,
|
||
ComingSoon: cfg.ComingSoon,
|
||
Dynamic: cfg.Dynamic,
|
||
DynamicType: cfg.DynamicType,
|
||
}
|
||
|
||
switch cfg.ID {
|
||
case "checkin":
|
||
section.ActionURL = fmt.Sprintf("/dossier/%s/trackers", targetHex)
|
||
section.ActionLabel = T("open")
|
||
|
||
// Count trackable categories
|
||
stats := make(map[string]int)
|
||
vitals, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryVital, &lib.EntryFilter{DossierID: targetID, Limit: 1})
|
||
meds, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryMedication, &lib.EntryFilter{DossierID: targetID, Limit: 1})
|
||
supps, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategorySupplement, &lib.EntryFilter{DossierID: targetID, Limit: 1})
|
||
exercise, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryExercise, &lib.EntryFilter{DossierID: targetID, Limit: 1})
|
||
symptoms, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategorySymptom, &lib.EntryFilter{DossierID: targetID, Limit: 1})
|
||
|
||
stats["vitals"] = len(vitals)
|
||
stats["medications"] = len(meds)
|
||
stats["supplements"] = len(supps)
|
||
stats["exercise"] = len(exercise)
|
||
stats["symptoms"] = len(symptoms)
|
||
section.TrackableStats = stats
|
||
|
||
// Check if any trackable data exists
|
||
hasData := len(vitals) > 0 || len(meds) > 0 || len(supps) > 0 || len(exercise) > 0 || len(symptoms) > 0
|
||
|
||
if hasData {
|
||
// Compact summary showing what's tracked
|
||
section.Summary = T("checkin_summary")
|
||
} else {
|
||
// Show build profile prompt
|
||
section.ShowBuildTracker = true
|
||
section.Summary = T("checkin_build_profile")
|
||
promptsURL := fmt.Sprintf("/dossier/%s/trackers", targetHex)
|
||
section.TrackerButtons = []TrackerButton{
|
||
{Label: T("btn_vitals"), URL: promptsURL + "?add=vital"},
|
||
{Label: T("btn_medications"), URL: promptsURL + "?add=medication"},
|
||
{Label: T("btn_supplements"), URL: promptsURL + "?add=supplement"},
|
||
{Label: T("btn_exercise"), URL: promptsURL + "?add=exercise"},
|
||
}
|
||
}
|
||
|
||
case "imaging":
|
||
studies, _ := fetchStudiesWithSeries(targetHex)
|
||
section.Items, section.Summary = buildImagingItems(studies, targetHex, target.DossierID, lang, T)
|
||
if len(studies) > 0 {
|
||
section.ActionURL = fmt.Sprintf("/viewer/?token=%s", target.DossierID)
|
||
section.ActionLabel = T("open_viewer")
|
||
}
|
||
|
||
case "labs":
|
||
orders, _ := lib.EntryQueryOld(targetID, lib.CategoryLab, "lab_order")
|
||
sort.Slice(orders, func(i, j int) bool { return orders[i].Timestamp > orders[j].Timestamp })
|
||
section.Searchable = len(orders) > 0
|
||
if len(orders) == 0 {
|
||
section.Summary = T("no_lab_data")
|
||
} else {
|
||
section.Heading = pluralT(len(orders), cfg.HeadingKey, lang)
|
||
for _, order := range orders {
|
||
item := SectionItem{
|
||
ID: order.EntryID,
|
||
Label: order.Value,
|
||
Expandable: true,
|
||
}
|
||
var odata struct {
|
||
LocalTime string `json:"local_time"`
|
||
SummaryTranslated string `json:"summary_translated"`
|
||
}
|
||
if json.Unmarshal([]byte(order.Data), &odata) == nil {
|
||
if odata.LocalTime != "" {
|
||
if t, err := time.Parse(time.RFC3339, odata.LocalTime); err == nil {
|
||
item.Date = t.Format("20060102")
|
||
if t.Hour() != 0 || t.Minute() != 0 {
|
||
_, offset := t.Zone()
|
||
item.Time = fmt.Sprintf("%02d:%02d %s", t.Hour(), t.Minute(), offsetToTZName(offset))
|
||
}
|
||
}
|
||
}
|
||
if odata.SummaryTranslated != "" {
|
||
item.Meta = odata.SummaryTranslated
|
||
}
|
||
}
|
||
if item.Date == "" && order.Timestamp > 0 {
|
||
item.Date = time.Unix(order.Timestamp, 0).UTC().Format("20060102")
|
||
}
|
||
section.Items = append(section.Items, item)
|
||
}
|
||
}
|
||
|
||
case "documents":
|
||
entries, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryDocument, &lib.EntryFilter{DossierID: targetID, Limit: 50})
|
||
section.Items = docEntriesToSectionItems(entries)
|
||
section.Heading = pluralT(len(entries), cfg.HeadingKey, lang)
|
||
|
||
case "procedures":
|
||
entries, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategorySurgery, &lib.EntryFilter{DossierID: targetID, Limit: 50})
|
||
section.Items = entriesToSectionItems(entries)
|
||
section.Heading = pluralT(len(entries), cfg.HeadingKey, lang)
|
||
|
||
case "assessments":
|
||
entries, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryAssessment, &lib.EntryFilter{DossierID: targetID, Limit: 50})
|
||
section.Items = entriesToSectionItems(entries)
|
||
section.Heading = pluralT(len(entries), cfg.HeadingKey, lang)
|
||
|
||
case "genetics":
|
||
genomeEntries, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryGenome, &lib.EntryFilter{DossierID: targetID, Limit: 1})
|
||
if len(genomeEntries) > 0 {
|
||
section.Summary = "Loading..."
|
||
} else {
|
||
section.Dynamic = false // no data: let HideEmpty hide the section
|
||
}
|
||
// Items loaded dynamically via JS
|
||
|
||
case "vitals":
|
||
// Load group containers (depth 2) — each is a metric type
|
||
groups, _ := lib.EntryRead(lib.SystemAccessorID, targetID, &lib.Filter{Category: lib.CategoryVital, Type: "root"})
|
||
if len(groups) > 0 {
|
||
metrics, _ := lib.EntryRead(lib.SystemAccessorID, targetID, &lib.Filter{Category: lib.CategoryVital, ParentID: groups[0].EntryID})
|
||
type chartPoint struct {
|
||
Date int64 `json:"date"` // unix seconds
|
||
Val float64 `json:"val"`
|
||
}
|
||
type chartMetric struct {
|
||
Name string `json:"name"`
|
||
Type string `json:"type"`
|
||
Unit string `json:"unit"`
|
||
Points []chartPoint `json:"points"`
|
||
Ref *chartRef `json:"ref,omitempty"`
|
||
}
|
||
var chartMetrics []chartMetric
|
||
for _, g := range metrics {
|
||
readings, _ := lib.EntryRead(lib.SystemAccessorID, targetID, &lib.Filter{
|
||
Category: lib.CategoryVital,
|
||
Type: "reading",
|
||
ParentID: g.EntryID,
|
||
})
|
||
latest := ""
|
||
latestDate := ""
|
||
var points []chartPoint
|
||
unit := ""
|
||
for _, r := range readings {
|
||
if r.Timestamp > 0 {
|
||
// Parse numeric value from summary like "94.5 kg"
|
||
parts := strings.SplitN(r.Summary, " ", 2)
|
||
if v, err := strconv.ParseFloat(parts[0], 64); err == nil {
|
||
points = append(points, chartPoint{Date: r.Timestamp, Val: v})
|
||
if unit == "" && len(parts) > 1 {
|
||
unit = parts[1]
|
||
}
|
||
}
|
||
}
|
||
latest = r.Summary
|
||
if r.Timestamp > 0 {
|
||
latestDate = time.Unix(r.Timestamp, 0).UTC().Format("2006-01-02")
|
||
}
|
||
}
|
||
section.Items = append(section.Items, SectionItem{
|
||
ID: g.EntryID,
|
||
Label: g.Summary,
|
||
Value: latest,
|
||
Date: latestDate,
|
||
})
|
||
if len(points) > 0 {
|
||
cm := chartMetric{Name: g.Summary, Type: g.Type, Unit: unit, Points: points}
|
||
cm.Ref = vitalRef(g.Type, target.Sex)
|
||
chartMetrics = append(chartMetrics, cm)
|
||
}
|
||
}
|
||
section.Summary = fmt.Sprintf("%d metrics", len(metrics))
|
||
if len(chartMetrics) > 0 {
|
||
if b, err := json.Marshal(chartMetrics); err == nil {
|
||
section.ChartData = string(b)
|
||
}
|
||
}
|
||
}
|
||
|
||
case "privacy":
|
||
// Handled separately - needs access list, not entries
|
||
continue
|
||
|
||
default:
|
||
// Generic handler for any category with a Category set
|
||
if cfg.Category > 0 {
|
||
entries, _ := lib.EntryList(lib.SystemAccessorID, "", cfg.Category, &lib.EntryFilter{DossierID: targetID, Limit: 50})
|
||
section.Items = entriesToSectionItems(entries)
|
||
section.Heading = pluralT(len(entries), cfg.HeadingKey, lang)
|
||
}
|
||
}
|
||
|
||
// Skip empty sections if configured to hide
|
||
if section.HideEmpty && len(section.Items) == 0 && !section.Dynamic && !section.ComingSoon && section.ID != "checkin" {
|
||
continue
|
||
}
|
||
|
||
sections = append(sections, section)
|
||
}
|
||
|
||
return sections
|
||
}
|
||
|
||
// pluralT returns a translated plural string like "3 slices" using translation keys.
|
||
// Keys: "{key}_one", "{key}_few" (Russian 2-4), "{key}_other".
|
||
// Falls back: few->other, missing key->English.
|
||
func pluralT(n int, key, lang string) string {
|
||
T := func(k string) string {
|
||
if t, ok := translations[lang]; ok {
|
||
if s, ok := t[k]; ok { return s }
|
||
}
|
||
if t, ok := translations["en"]; ok {
|
||
if s, ok := t[k]; ok { return s }
|
||
}
|
||
return k
|
||
}
|
||
|
||
form := pluralForm(n, lang)
|
||
s := T(key + "_" + form)
|
||
if !strings.Contains(s, "%d") {
|
||
if n > 1 {
|
||
return fmt.Sprintf("%d %s", n, s)
|
||
}
|
||
return s
|
||
}
|
||
return fmt.Sprintf(s, n)
|
||
}
|
||
|
||
func pluralForm(n int, lang string) string {
|
||
switch lang {
|
||
case "ru":
|
||
mod10, mod100 := n%10, n%100
|
||
if mod10 == 1 && mod100 != 11 {
|
||
return "one"
|
||
}
|
||
if mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) {
|
||
return "few"
|
||
}
|
||
return "other"
|
||
default:
|
||
if n == 1 {
|
||
return "one"
|
||
}
|
||
return "other"
|
||
}
|
||
}
|
||
|
||
func offsetToTZName(offset int) string {
|
||
switch offset {
|
||
case -4 * 3600:
|
||
return "EDT"
|
||
case -5 * 3600:
|
||
return "EST"
|
||
case -6 * 3600:
|
||
return "CST"
|
||
case -7 * 3600:
|
||
return "MST"
|
||
case -8 * 3600:
|
||
return "PST"
|
||
case 0:
|
||
return "UTC"
|
||
case 1 * 3600:
|
||
return "CET"
|
||
case 2 * 3600:
|
||
return "CEST"
|
||
case 3 * 3600:
|
||
return "MSK"
|
||
default:
|
||
h := offset / 3600
|
||
m := (offset % 3600) / 60
|
||
if m < 0 {
|
||
m = -m
|
||
}
|
||
return fmt.Sprintf("%+03d%02d", h, m)
|
||
}
|
||
}
|
||
|
||
// buildImagingItems converts studies to section items
|
||
func buildImagingItems(studies []Study, targetHex, dossierID, lang string, T func(string) string) ([]SectionItem, string) {
|
||
var items []SectionItem
|
||
var totalSlices int
|
||
|
||
for _, s := range studies {
|
||
totalSlices += s.SliceCount
|
||
|
||
item := SectionItem{
|
||
ID: s.ID,
|
||
Label: s.Description,
|
||
Date: s.Date,
|
||
LinkURL: fmt.Sprintf("/viewer/?token=%s&study=%s", dossierID, s.ID),
|
||
LinkTitle: T("open_viewer"),
|
||
Expandable: s.SeriesCount > 1,
|
||
}
|
||
|
||
// Add series as children
|
||
for _, series := range s.Series {
|
||
if series.SliceCount > 0 {
|
||
label := series.Description
|
||
if label == "" {
|
||
label = series.Modality
|
||
}
|
||
child := SectionItem{
|
||
ID: series.ID,
|
||
Label: label,
|
||
Value: pluralT(series.SliceCount, "slice", lang),
|
||
LinkURL: fmt.Sprintf("/viewer/?token=%s&study=%s&series=%s", dossierID, s.ID, series.ID),
|
||
LinkTitle: T("open_viewer"),
|
||
}
|
||
item.Children = append(item.Children, child)
|
||
}
|
||
}
|
||
|
||
if !item.Expandable {
|
||
// Single series - show slice count, no expand
|
||
item.Children = nil
|
||
item.Value = pluralT(s.SliceCount, "slice", lang)
|
||
} else {
|
||
item.Value = pluralT(s.SeriesCount, "series", lang)
|
||
}
|
||
|
||
items = append(items, item)
|
||
}
|
||
|
||
var summary string
|
||
if len(studies) > 0 {
|
||
summary = fmt.Sprintf("%d studies, %d slices", len(studies), totalSlices)
|
||
}
|
||
|
||
return items, summary
|
||
}
|
||
|
||
// buildLabItems creates parent/child lab section items
|
||
func buildLabItems(dossierID, lang string, T func(string) string) ([]SectionItem, string) {
|
||
// Get lab orders (parents)
|
||
orders, _ := lib.EntryQueryOld(dossierID, lib.CategoryLab, "lab_order")
|
||
|
||
// Also get standalone lab results (no parent)
|
||
allLabs, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryLab, &lib.EntryFilter{DossierID: dossierID, Limit: 5000})
|
||
var standalones []*lib.Entry
|
||
for _, e := range allLabs {
|
||
if e.ParentID == "" && e.Type != "lab_order" {
|
||
standalones = append(standalones, e)
|
||
}
|
||
}
|
||
|
||
if len(orders) == 0 && len(standalones) == 0 {
|
||
return nil, T("no_lab_data")
|
||
}
|
||
|
||
var items []SectionItem
|
||
var totalTests int
|
||
|
||
// Process lab orders with children
|
||
for _, order := range orders {
|
||
// Get children for this order
|
||
children, _ := lib.EntryChildren(dossierID, order.EntryID)
|
||
totalTests += len(children)
|
||
|
||
item := SectionItem{
|
||
ID: order.EntryID,
|
||
Label: order.Value,
|
||
Expandable: len(children) > 0,
|
||
}
|
||
|
||
// Use original local_time from Data JSON if available
|
||
var data struct {
|
||
LocalTime string `json:"local_time"`
|
||
SummaryTranslated string `json:"summary_translated"`
|
||
}
|
||
if json.Unmarshal([]byte(order.Data), &data) == nil {
|
||
if data.LocalTime != "" {
|
||
if t, err := time.Parse(time.RFC3339, data.LocalTime); err == nil {
|
||
item.Date = t.Format("20060102")
|
||
if t.Hour() != 0 || t.Minute() != 0 {
|
||
_, offset := t.Zone()
|
||
item.Time = fmt.Sprintf("%02d:%02d %s", t.Hour(), t.Minute(), offsetToTZName(offset))
|
||
}
|
||
} else {
|
||
fmt.Printf("[DEBUG] Failed to parse local_time for %s: %s (err: %v)\n", order.EntryID, data.LocalTime, err)
|
||
}
|
||
}
|
||
if data.SummaryTranslated != "" {
|
||
item.Meta = data.SummaryTranslated
|
||
}
|
||
}
|
||
|
||
// Fallback: if date still not set, use timestamp
|
||
if item.Date == "" && order.Timestamp > 0 {
|
||
t := time.Unix(order.Timestamp, 0).UTC()
|
||
item.Date = t.Format("20060102")
|
||
fmt.Printf("[DEBUG] Set date from timestamp for %s: %s -> %s\n", order.EntryID, order.Value, item.Date)
|
||
}
|
||
|
||
if item.Date == "" {
|
||
fmt.Printf("[DEBUG] No date set for order %s: %s (timestamp: %d, local_time: %s)\n", order.EntryID, order.Value, order.Timestamp, data.LocalTime)
|
||
}
|
||
|
||
if len(children) > 0 {
|
||
item.Value = pluralT(len(children), "result", lang)
|
||
for _, c := range children {
|
||
var childData struct {
|
||
Loinc string `json:"loinc"`
|
||
SummaryTranslated string `json:"summary_translated"`
|
||
}
|
||
json.Unmarshal([]byte(c.Data), &childData)
|
||
|
||
child := SectionItem{
|
||
Label: c.Summary,
|
||
Type: childData.Loinc,
|
||
Meta: childData.SummaryTranslated,
|
||
}
|
||
item.Children = append(item.Children, child)
|
||
}
|
||
}
|
||
|
||
items = append(items, item)
|
||
}
|
||
|
||
// Add standalone lab results
|
||
for _, standalone := range standalones {
|
||
var data struct {
|
||
Loinc string `json:"loinc"`
|
||
}
|
||
json.Unmarshal([]byte(standalone.Data), &data)
|
||
|
||
item := SectionItem{
|
||
ID: standalone.EntryID,
|
||
Label: standalone.Summary,
|
||
Type: data.Loinc, // Store LOINC for search matching
|
||
}
|
||
|
||
// Set date from timestamp
|
||
if standalone.Timestamp > 0 {
|
||
t := time.Unix(standalone.Timestamp, 0).UTC()
|
||
item.Date = t.Format("20060102")
|
||
}
|
||
|
||
items = append(items, item)
|
||
totalTests++
|
||
}
|
||
|
||
summary := fmt.Sprintf("%s, %s", pluralT(len(orders), "order", lang), pluralT(totalTests, "result", lang))
|
||
return items, summary
|
||
}
|
||
|
||
// docEntriesToSectionItems converts document entries to section items with preview links.
|
||
func docEntriesToSectionItems(entries []*lib.Entry) []SectionItem {
|
||
var items []SectionItem
|
||
for _, e := range entries {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
item := SectionItem{
|
||
ID: e.EntryID,
|
||
Label: e.Value,
|
||
Type: e.Type,
|
||
LinkURL: e.EntryID,
|
||
LinkTitle: "source",
|
||
}
|
||
if e.Timestamp > 0 {
|
||
item.Date = time.Unix(e.Timestamp, 0).UTC().Format("20060102")
|
||
}
|
||
items = append(items, item)
|
||
}
|
||
return items
|
||
}
|
||
|
||
// entriesToSectionItems converts Entry slice to SectionItem slice.
|
||
// Entries with Data fields become expandable with details as children.
|
||
func entriesToSectionItems(entries []*lib.Entry) []SectionItem {
|
||
// Internal Data keys that shouldn't be shown to the user
|
||
skipKeys := map[string]bool{
|
||
"source_doc_id": true,
|
||
"source": true,
|
||
"source_spans": true,
|
||
"summary_translated": true,
|
||
"data_translated": true,
|
||
}
|
||
|
||
var items []SectionItem
|
||
for _, e := range entries {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
// Use Summary as label when Value is empty (common for doc extracts)
|
||
label := e.Value
|
||
if label == "" {
|
||
label = e.Summary
|
||
}
|
||
item := SectionItem{
|
||
ID: e.EntryID,
|
||
Label: label,
|
||
Type: e.Type,
|
||
}
|
||
if e.Timestamp > 0 {
|
||
item.Date = time.Unix(e.Timestamp, 0).UTC().Format("20060102")
|
||
}
|
||
|
||
// Parse Data to build expandable children
|
||
if e.Data != "" {
|
||
var dataMap map[string]interface{}
|
||
if json.Unmarshal([]byte(e.Data), &dataMap) == nil {
|
||
// Use translated data values when available
|
||
translated, _ := dataMap["data_translated"].(map[string]interface{})
|
||
|
||
// Collect keys in deterministic order: preferred fields first, then alphabetical
|
||
var keys []string
|
||
for k := range dataMap {
|
||
if !skipKeys[k] {
|
||
keys = append(keys, k)
|
||
}
|
||
}
|
||
sort.Slice(keys, func(i, j int) bool {
|
||
pi, pj := dataFieldPriority(keys[i]), dataFieldPriority(keys[j])
|
||
if pi != pj {
|
||
return pi < pj
|
||
}
|
||
return keys[i] < keys[j]
|
||
})
|
||
for _, k := range keys {
|
||
val := formatDataValue(k, dataMap[k])
|
||
if val == "" {
|
||
continue
|
||
}
|
||
child := SectionItem{Label: k, Value: val}
|
||
if translated != nil {
|
||
if tv := formatDataValue(k, translated[k]); tv != "" && tv != val {
|
||
child.Meta = tv
|
||
}
|
||
}
|
||
item.Children = append(item.Children, child)
|
||
}
|
||
if len(item.Children) > 0 {
|
||
item.Expandable = true
|
||
}
|
||
// Link to source document
|
||
if docID, ok := dataMap["source_doc_id"].(string); ok && docID != "" {
|
||
item.LinkURL = docID
|
||
item.LinkTitle = "source"
|
||
}
|
||
// Source spans for doc pane highlighting
|
||
if spans, ok := dataMap["source_spans"]; ok {
|
||
if b, err := json.Marshal(spans); err == nil {
|
||
item.SourceSpansJSON = string(b)
|
||
}
|
||
}
|
||
// Show translation as secondary text
|
||
if tr, ok := dataMap["summary_translated"].(string); ok && tr != "" {
|
||
item.Meta = tr
|
||
}
|
||
}
|
||
}
|
||
|
||
items = append(items, item)
|
||
}
|
||
return items
|
||
}
|
||
|
||
// dataFieldPriority returns sort priority for data field keys (lower = first).
|
||
func dataFieldPriority(key string) int {
|
||
order := map[string]int{
|
||
"name": 1, "role": 2, "specialty": 3, "institution": 4,
|
||
"procedure": 5, "diagnosis": 5, "condition": 5, "therapy": 5,
|
||
"facility": 6, "surgeon": 6, "provider": 6,
|
||
"date": 7, "frequency": 8, "duration": 8,
|
||
"details": 10, "description": 10, "notes": 10,
|
||
"phone": 11, "address": 12,
|
||
}
|
||
if p, ok := order[key]; ok {
|
||
return p
|
||
}
|
||
return 9 // unlisted keys go before details/address
|
||
}
|
||
|
||
// formatDataValue renders a Data field value as a display string.
|
||
func formatDataValue(key string, v interface{}) string {
|
||
switch val := v.(type) {
|
||
case string:
|
||
return val
|
||
case float64:
|
||
if val == float64(int(val)) {
|
||
return fmt.Sprintf("%d", int(val))
|
||
}
|
||
return fmt.Sprintf("%g", val)
|
||
case bool:
|
||
if val {
|
||
return "yes"
|
||
}
|
||
return "no"
|
||
case map[string]interface{}:
|
||
// Flatten nested objects (e.g. settings: {pressure: "5 cmH₂O"})
|
||
var parts []string
|
||
for k, sv := range val {
|
||
s := formatDataValue(k, sv)
|
||
if s != "" {
|
||
parts = append(parts, k+": "+s)
|
||
}
|
||
}
|
||
return strings.Join(parts, ", ")
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
// buildLoincNameMap builds a JSON map of LOINC code → full test name
|
||
// for displaying full names in charts.
|
||
func buildLoincNameMap() string {
|
||
var tests []lib.LabTest
|
||
if err := lib.RefQuery("SELECT loinc_id, name FROM lab_test", nil, &tests); err != nil {
|
||
return "{}"
|
||
}
|
||
|
||
// Map: loinc → name
|
||
nameMap := make(map[string]string)
|
||
for _, test := range tests {
|
||
nameMap[test.LoincID] = test.Name
|
||
}
|
||
|
||
b, _ := json.Marshal(nameMap)
|
||
return string(b)
|
||
}
|
||
|
||
// buildLabSearchIndex builds a JSON map of search terms → LOINC codes
|
||
// for client-side lab result filtering. Keys are lowercase test names and abbreviations.
|
||
func buildLabSearchIndex() string {
|
||
var tests []lib.LabTest
|
||
if err := lib.RefQuery("SELECT loinc_id, name FROM lab_test", nil, &tests); err != nil {
|
||
return "{}"
|
||
}
|
||
|
||
// Map: lowercase name → []loinc
|
||
index := make(map[string][]string)
|
||
|
||
// Index by test names from lab_test table
|
||
for _, test := range tests {
|
||
name := strings.ToLower(test.Name)
|
||
words := strings.Fields(name)
|
||
|
||
// Index by full name
|
||
if !contains(index[name], test.LoincID) {
|
||
index[name] = append(index[name], test.LoincID)
|
||
}
|
||
|
||
// Index by individual words (for partial matching)
|
||
for _, word := range words {
|
||
// Strip parentheses from words like "(MCV)" -> "mcv"
|
||
word = strings.Trim(word, "()")
|
||
if len(word) >= 3 { // Skip short words
|
||
if !contains(index[word], test.LoincID) {
|
||
index[word] = append(index[word], test.LoincID)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Also index by abbreviations from actual lab entries
|
||
// Get unique LOINC+abbreviation pairs from all lab entries with parent
|
||
entries, err2 := lib.LabEntryListForIndex()
|
||
if err2 == nil {
|
||
seen := make(map[string]bool) // Track LOINC+abbr pairs to avoid duplicates
|
||
for _, e := range entries {
|
||
var data struct {
|
||
Loinc string `json:"loinc"`
|
||
Abbreviation string `json:"abbreviation"`
|
||
}
|
||
if json.Unmarshal([]byte(e.Data), &data) == nil && data.Loinc != "" && data.Abbreviation != "" {
|
||
key := data.Loinc + "|" + data.Abbreviation
|
||
if !seen[key] {
|
||
// Strip parentheses and convert to lowercase
|
||
abbr := strings.ToLower(strings.Trim(data.Abbreviation, "()"))
|
||
if abbr != "" && !contains(index[abbr], data.Loinc) {
|
||
index[abbr] = append(index[abbr], data.Loinc)
|
||
}
|
||
seen[key] = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
b, _ := json.Marshal(index)
|
||
return string(b)
|
||
}
|
||
|
||
func contains(slice []string, item string) bool {
|
||
for _, s := range slice {
|
||
if s == item {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// buildLabRefData builds a JSON map of test abbreviation → reference data
|
||
// for the chart JS to use. Keys are abbreviations (e.g. "WBC", "Hgb").
|
||
func buildLabRefData(dossierID string, dob time.Time, sex int) string {
|
||
type refInfo struct {
|
||
Direction string `json:"direction"`
|
||
RefLow float64 `json:"refLow"`
|
||
RefHigh float64 `json:"refHigh"`
|
||
}
|
||
|
||
result := make(map[string]refInfo)
|
||
|
||
// Load all lab child entries to get unique loinc → abbreviation mappings
|
||
entries, err := lib.EntryQueryOld(dossierID, lib.CategoryLab, "")
|
||
if err != nil {
|
||
return "{}"
|
||
}
|
||
|
||
// Build loinc → abbreviation + si_factor map from entries
|
||
type testInfo struct {
|
||
abbr string
|
||
siFactor float64
|
||
}
|
||
loincMap := make(map[string]testInfo) // loinc → {abbr, siFactor}
|
||
|
||
for _, e := range entries {
|
||
if e.ParentID == "" {
|
||
continue
|
||
}
|
||
var data struct {
|
||
Loinc string `json:"loinc"`
|
||
Abbr string `json:"abbreviation"`
|
||
SIFactor float64 `json:"si_factor"`
|
||
}
|
||
if json.Unmarshal([]byte(e.Data), &data) != nil || data.Loinc == "" || data.Abbr == "" {
|
||
continue
|
||
}
|
||
if _, exists := loincMap[data.Loinc]; !exists {
|
||
factor := data.SIFactor
|
||
if factor == 0 {
|
||
factor = 1.0
|
||
}
|
||
loincMap[data.Loinc] = testInfo{abbr: data.Abbr, siFactor: factor}
|
||
}
|
||
}
|
||
|
||
// Patient's current age in days (for reference range lookup)
|
||
ageDays := int64(0)
|
||
if !dob.IsZero() {
|
||
ageDays = lib.AgeDays(dob.Unix(), time.Now().Unix())
|
||
}
|
||
|
||
// Convert sex int to CALIPER format
|
||
sexStr := ""
|
||
switch sex {
|
||
case 1:
|
||
sexStr = "M"
|
||
case 2:
|
||
sexStr = "F"
|
||
}
|
||
|
||
// For each unique loinc, look up lab_test (direction) and lab_reference (ranges)
|
||
for loinc, info := range loincMap {
|
||
test, err := lib.LabTestGet(loinc)
|
||
if err != nil || test == nil {
|
||
continue
|
||
}
|
||
|
||
ref, err := lib.LabRefLookup(loinc, sexStr, ageDays)
|
||
if err != nil || ref == nil {
|
||
continue
|
||
}
|
||
|
||
// Convert SI reference values back to original unit for chart display
|
||
low := lib.FromLabScale(ref.RefLow) / info.siFactor
|
||
high := lib.FromLabScale(ref.RefHigh) / info.siFactor
|
||
|
||
result[info.abbr] = refInfo{
|
||
Direction: test.Direction,
|
||
RefLow: low,
|
||
RefHigh: high,
|
||
}
|
||
}
|
||
|
||
b, _ := json.Marshal(result)
|
||
return string(b)
|
||
}
|
||
|
||
// formatSize formats bytes to human readable
|
||
func formatSize(bytes int64) string {
|
||
if bytes < 1024 {
|
||
return fmt.Sprintf("%d B", bytes)
|
||
} else if bytes < 1024*1024 {
|
||
return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
|
||
}
|
||
return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024))
|
||
}
|
||
|
||
// handleDocumentView returns the markdown content of a document entry.
|
||
// GET /dossier/{dossierID}/document/{docID}
|
||
func handleDocumentView(w http.ResponseWriter, r *http.Request) {
|
||
p := getLoggedInDossier(r)
|
||
if p == nil {
|
||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
parts := strings.Split(r.URL.Path, "/")
|
||
// /dossier/{id}/document/{docID} → parts[2]=id, parts[4]=docID
|
||
if len(parts) < 5 {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
targetID := parts[2]
|
||
docID := parts[4]
|
||
|
||
// RBAC check
|
||
if _, err := lib.DossierGet(p.DossierID, targetID); err != nil {
|
||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
entry, err := lib.EntryGet(nil, docID)
|
||
if err != nil || entry.DossierID != targetID || entry.Category != lib.CategoryDocument {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
// Serve original PDF if ?pdf=1
|
||
if r.URL.Query().Get("pdf") == "1" {
|
||
var docData struct {
|
||
SourceUpload string `json:"source_upload"`
|
||
}
|
||
json.Unmarshal([]byte(entry.Data), &docData)
|
||
if docData.SourceUpload == "" {
|
||
http.Error(w, "No PDF available", http.StatusNotFound)
|
||
return
|
||
}
|
||
uploadEntry, err := lib.EntryGet(nil, docData.SourceUpload)
|
||
if err != nil {
|
||
http.Error(w, "Upload not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
var uploadData struct {
|
||
Path string `json:"path"`
|
||
}
|
||
json.Unmarshal([]byte(uploadEntry.Data), &uploadData)
|
||
if uploadData.Path == "" {
|
||
http.Error(w, "No file path", http.StatusNotFound)
|
||
return
|
||
}
|
||
pdfBytes, err := lib.DecryptFile(uploadData.Path)
|
||
if err != nil {
|
||
http.Error(w, "Decrypt failed", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/pdf")
|
||
w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, entry.Value))
|
||
w.Write(pdfBytes)
|
||
return
|
||
}
|
||
|
||
var data struct {
|
||
Markdown string `json:"markdown"`
|
||
MarkdownTranslated string `json:"markdown_translated"`
|
||
TranslatedTo string `json:"translated_to"`
|
||
SourceUpload string `json:"source_upload"`
|
||
}
|
||
json.Unmarshal([]byte(entry.Data), &data)
|
||
|
||
resp := map[string]interface{}{
|
||
"markdown": data.Markdown,
|
||
"title": entry.Value,
|
||
"has_pdf": data.SourceUpload != "",
|
||
}
|
||
if data.MarkdownTranslated != "" {
|
||
resp["markdown_translated"] = data.MarkdownTranslated
|
||
resp["translated_to"] = data.TranslatedTo
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(resp)
|
||
}
|
||
|
||
// handleDossierV2 renders the new unified dossier page
|
||
func handleDossierV2(w http.ResponseWriter, r *http.Request) {
|
||
p := getLoggedInDossier(r)
|
||
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
|
||
|
||
// Parse path: /dossier/{id}/v2
|
||
parts := strings.Split(r.URL.Path, "/")
|
||
if len(parts) < 3 || parts[2] == "" { http.NotFound(w, r); return }
|
||
|
||
targetID := parts[2]
|
||
targetHex := formatHexID(targetID)
|
||
|
||
// RBAC-checked read
|
||
target, err := lib.DossierGet(p.DossierID, targetID)
|
||
if err != nil { http.Error(w, "Forbidden", http.StatusForbidden); return }
|
||
isSelf := targetID == p.DossierID
|
||
canEdit := isSelf
|
||
if !isSelf {
|
||
if access, found := getAccess(formatHexID(p.DossierID), targetHex); found {
|
||
canEdit = access.CanEdit
|
||
}
|
||
}
|
||
|
||
lang := getLang(r)
|
||
showDetails := isSelf
|
||
canManageAccess := isSelf
|
||
|
||
// Build access list (for privacy section)
|
||
accessRecords, _ := listAccessors(targetHex)
|
||
var accessList []AccessEntry
|
||
for _, ar := range accessRecords {
|
||
accessList = append(accessList, AccessEntry{
|
||
DossierID: ar.Accessor,
|
||
Name: ar.Name,
|
||
Relation: T(lang, "rel_" + fmt.Sprintf("%d", ar.Relation)),
|
||
CanEdit: ar.CanEdit,
|
||
IsSelf: ar.Accessor == p.DossierID,
|
||
})
|
||
}
|
||
|
||
// Check for genome
|
||
genomeEntries, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryGenome, &lib.EntryFilter{DossierID: targetID, Limit: 1})
|
||
hasGenome := len(genomeEntries) > 0
|
||
|
||
// Build sections
|
||
sections := BuildDossierSections(targetID, targetHex, target, p, lang, canEdit)
|
||
|
||
render(w, r, PageData{
|
||
Page: "dossier",
|
||
Lang: lang,
|
||
Embed: isEmbed(r),
|
||
Dossier: p,
|
||
TargetDossier: target,
|
||
ShowDetails: showDetails,
|
||
CanManageAccess: canManageAccess,
|
||
CanEdit: canEdit,
|
||
AccessList: accessList,
|
||
HasGenome: hasGenome,
|
||
Sections: sections,
|
||
})
|
||
}
|
||
|
||
// 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
|
||
func handleLabSearch(w http.ResponseWriter, r *http.Request) {
|
||
p := getLoggedInDossier(r)
|
||
if p == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized); return }
|
||
|
||
parts := strings.Split(r.URL.Path, "/")
|
||
if len(parts) < 3 { http.NotFound(w, r); return }
|
||
targetID := parts[2]
|
||
|
||
target, err := lib.DossierGet(p.DossierID, targetID)
|
||
if err != nil { http.Error(w, "Forbidden", http.StatusForbidden); return }
|
||
|
||
type childJSON struct {
|
||
Label string `json:"label"`
|
||
Loinc string `json:"loinc"`
|
||
}
|
||
type refInfo struct {
|
||
Direction string `json:"direction"`
|
||
RefLow float64 `json:"refLow"`
|
||
RefHigh float64 `json:"refHigh"`
|
||
}
|
||
type abbrInfo struct {
|
||
abbr string
|
||
siFactor float64
|
||
}
|
||
|
||
// Shared: build ref data from loinc→abbr map
|
||
buildRefs := func(loincAbbrs map[string]abbrInfo) map[string]refInfo {
|
||
refs := make(map[string]refInfo)
|
||
ageDays := int64(0)
|
||
if !target.DOB.IsZero() {
|
||
ageDays = lib.AgeDays(target.DOB.Unix(), time.Now().Unix())
|
||
}
|
||
sexStr := ""
|
||
switch target.Sex {
|
||
case 1: sexStr = "M"
|
||
case 2: sexStr = "F"
|
||
}
|
||
for loinc, info := range loincAbbrs {
|
||
test, err := lib.LabTestGet(loinc)
|
||
if err != nil || test == nil { continue }
|
||
ref, err := lib.LabRefLookup(loinc, sexStr, ageDays)
|
||
if err != nil || ref == nil { continue }
|
||
low := lib.FromLabScale(ref.RefLow) / info.siFactor
|
||
high := lib.FromLabScale(ref.RefHigh) / info.siFactor
|
||
refs[info.abbr] = refInfo{Direction: test.Direction, RefLow: low, RefHigh: high}
|
||
}
|
||
return refs
|
||
}
|
||
|
||
// Shared: extract child JSON + track loinc abbreviations
|
||
childToJSON := func(entries []*lib.Entry, loincAbbrs map[string]abbrInfo) []childJSON {
|
||
var out []childJSON
|
||
for _, c := range entries {
|
||
var data struct {
|
||
Loinc string `json:"loinc"`
|
||
Abbr string `json:"abbreviation"`
|
||
SIF float64 `json:"si_factor"`
|
||
}
|
||
json.Unmarshal([]byte(c.Data), &data)
|
||
out = append(out, childJSON{Label: c.Summary, Loinc: data.Loinc})
|
||
if data.Loinc != "" && data.Abbr != "" {
|
||
if _, exists := loincAbbrs[data.Loinc]; !exists {
|
||
f := data.SIF
|
||
if f == 0 { f = 1.0 }
|
||
loincAbbrs[data.Loinc] = abbrInfo{abbr: data.Abbr, siFactor: f}
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
|
||
// Mode 1: expand a single order
|
||
if orderID := r.URL.Query().Get("order"); orderID != "" {
|
||
children, _ := lib.EntryChildren(targetID, orderID)
|
||
loincAbbrs := make(map[string]abbrInfo)
|
||
childrenOut := childToJSON(children, loincAbbrs)
|
||
if childrenOut == nil { childrenOut = []childJSON{} }
|
||
|
||
json.NewEncoder(w).Encode(struct {
|
||
Children []childJSON `json:"children"`
|
||
Refs map[string]refInfo `json:"refs"`
|
||
}{Children: childrenOut, Refs: buildRefs(loincAbbrs)})
|
||
return
|
||
}
|
||
|
||
// Mode 2: search
|
||
q := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q")))
|
||
if len(q) < 2 {
|
||
w.Write([]byte(`{"orders":[],"refs":{}}`))
|
||
return
|
||
}
|
||
|
||
// Build search index: term → []loinc
|
||
var loincEntries []lib.LoincInfo
|
||
lib.RefQuery("SELECT loinc_num, long_name, short_name, component, system, property FROM loinc_lab", nil, &loincEntries)
|
||
searchIndex := make(map[string][]string)
|
||
for _, l := range loincEntries {
|
||
// Index by long_name words and component words
|
||
for _, src := range []string{l.LongName, l.Component} {
|
||
for _, word := range strings.Fields(strings.ToLower(src)) {
|
||
word = strings.Trim(word, "()[].,/")
|
||
if len(word) >= 3 && !contains(searchIndex[word], l.Code) {
|
||
searchIndex[word] = append(searchIndex[word], l.Code)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
matchLoincs := make(map[string]bool)
|
||
for term, loincs := range searchIndex {
|
||
if strings.Contains(term, q) {
|
||
for _, l := range loincs { matchLoincs[l] = true }
|
||
}
|
||
}
|
||
|
||
type orderJSON struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Date string `json:"date"`
|
||
Time string `json:"time"`
|
||
Count int `json:"count"`
|
||
Children []childJSON `json:"children"`
|
||
}
|
||
|
||
orders, _ := lib.EntryQueryOld(targetID, lib.CategoryLab, "lab_order")
|
||
var matchedOrders []orderJSON
|
||
loincAbbrs := make(map[string]abbrInfo)
|
||
|
||
for _, order := range orders {
|
||
children, _ := lib.EntryChildren(targetID, order.EntryID)
|
||
orderNameMatch := strings.Contains(strings.ToLower(order.Value), q)
|
||
|
||
var matched []childJSON
|
||
for _, c := range children {
|
||
var data struct {
|
||
Loinc string `json:"loinc"`
|
||
Abbr string `json:"abbreviation"`
|
||
SIF float64 `json:"si_factor"`
|
||
}
|
||
json.Unmarshal([]byte(c.Data), &data)
|
||
|
||
textMatch := strings.Contains(strings.ToLower(c.Summary), q)
|
||
loincMatch := data.Loinc != "" && matchLoincs[data.Loinc]
|
||
if orderNameMatch || textMatch || loincMatch {
|
||
matched = append(matched, childJSON{Label: c.Summary, Loinc: data.Loinc})
|
||
if data.Loinc != "" && data.Abbr != "" {
|
||
if _, exists := loincAbbrs[data.Loinc]; !exists {
|
||
f := data.SIF
|
||
if f == 0 { f = 1.0 }
|
||
loincAbbrs[data.Loinc] = abbrInfo{abbr: data.Abbr, siFactor: f}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if len(matched) == 0 { continue }
|
||
|
||
oj := orderJSON{ID: order.EntryID, Name: order.Value, Count: len(matched), Children: matched}
|
||
var odata struct{ LocalTime string `json:"local_time"` }
|
||
if json.Unmarshal([]byte(order.Data), &odata) == nil && odata.LocalTime != "" {
|
||
if t, err := time.Parse(time.RFC3339, odata.LocalTime); err == nil {
|
||
oj.Date = t.Format("20060102")
|
||
if t.Hour() != 0 || t.Minute() != 0 {
|
||
_, offset := t.Zone()
|
||
oj.Time = fmt.Sprintf("%02d:%02d %s", t.Hour(), t.Minute(), offsetToTZName(offset))
|
||
}
|
||
}
|
||
}
|
||
if oj.Date == "" && order.Timestamp > 0 {
|
||
oj.Date = time.Unix(order.Timestamp, 0).UTC().Format("20060102")
|
||
}
|
||
matchedOrders = append(matchedOrders, oj)
|
||
}
|
||
|
||
// LOINC name map — use official long_name from loinc_lab
|
||
loincNameMap := make(map[string]string)
|
||
for _, l := range loincEntries {
|
||
if matchLoincs[l.Code] {
|
||
loincNameMap[l.Code] = l.LongName
|
||
}
|
||
}
|
||
if matchedOrders == nil { matchedOrders = []orderJSON{} }
|
||
|
||
json.NewEncoder(w).Encode(struct {
|
||
Orders []orderJSON `json:"orders"`
|
||
Refs map[string]refInfo `json:"refs"`
|
||
LoincNames map[string]string `json:"loincNames"`
|
||
}{Orders: matchedOrders, Refs: buildRefs(loincAbbrs), LoincNames: loincNameMap})
|
||
}
|