diff --git a/Makefile b/Makefile index b1eb3ce..3578aec 100755 --- a/Makefile +++ b/Makefile @@ -120,6 +120,7 @@ deploy-prod: check-db all $(BINDIR)/decrypt $(BINDIR)/fips-check rsync -avz --delete portal/templates/ $(PROD_HOST):$(DEPLOY_DIR)/templates/ rsync -avz portal/static/ $(PROD_HOST):$(DEPLOY_DIR)/static/ rsync -avz portal/lang/ $(PROD_HOST):$(DEPLOY_DIR)/lang/ + rsync -avz api/tracker_prompts/ $(PROD_HOST):$(DEPLOY_DIR)/tracker_prompts/ ssh $(PROD_HOST) "$(DEPLOY_DIR)/start.sh" @echo "" ssh $(PROD_HOST) "$(DEPLOY_DIR)/status.sh" diff --git a/api/tracker_prompts/extract_assessment.md b/api/tracker_prompts/extract_assessment.md new file mode 100644 index 0000000..dd5ed64 --- /dev/null +++ b/api/tracker_prompts/extract_assessment.md @@ -0,0 +1,13 @@ +Extract clinical assessments and examination findings from this medical document. Return a JSON array or null. + +Each entry: +- type: "screening", "examination", "developmental" +- value: (empty) +- summary: assessment name or description, e.g. "Neurological examination" +- timestamp: "YYYY-MM-DD" if date mentioned +- data: {"instrument": "...", "findings": "...", "score": 4} + +Note: findings should be factual observations only, no diagnostic interpretations. + +Document: +{{MARKDOWN}} diff --git a/api/tracker_prompts/extract_birth.md b/api/tracker_prompts/extract_birth.md new file mode 100644 index 0000000..5aa5adb --- /dev/null +++ b/api/tracker_prompts/extract_birth.md @@ -0,0 +1,13 @@ +Extract birth and perinatal details from this medical document. Return a JSON array or null. + +Each entry: +- type: "delivery" +- value: (empty) +- summary: date + type, e.g. "Feb 26, 2020, premature 26 SSW" +- timestamp: "YYYY-MM-DD" birth date +- data: {"type": "premature/vaginal/cesarean", "gestational_weeks": 26, "weight_g": 3200, "apgar_1": 8, "apgar_5": 9, "complications": "..."} + +Include only fields present in the document. + +Document: +{{MARKDOWN}} diff --git a/api/tracker_prompts/extract_consultation.md b/api/tracker_prompts/extract_consultation.md new file mode 100644 index 0000000..fbc6ade --- /dev/null +++ b/api/tracker_prompts/extract_consultation.md @@ -0,0 +1,11 @@ +Extract consultation/visit records from this medical document. Return a JSON array or null. + +Each entry: +- type: visit subtype ("visit", "referral", "follow_up", "letter") +- value: (empty) +- summary: provider + date, e.g. "Prof. Dr. Péraud, Aug 2022" +- timestamp: "YYYY-MM-DD" if date mentioned +- data: {"provider": "...", "specialty": "...", "location": "...", "reason": "..."} + +Document: +{{MARKDOWN}} diff --git a/api/tracker_prompts/extract_device.md b/api/tracker_prompts/extract_device.md new file mode 100644 index 0000000..c21bccb --- /dev/null +++ b/api/tracker_prompts/extract_device.md @@ -0,0 +1,13 @@ +Extract medical devices and implants from this medical document. Return a JSON array or null. + +Each entry: +- type: "implant", "external", "prosthetic" +- value: (empty) +- summary: device name, e.g. "Medtronic Micra AV" +- timestamp: "YYYY-MM-DD" implant/start date if mentioned +- data: {"device": "...", "manufacturer": "...", "model": "...", "serial": "...", "settings": {"pressure": "5 cmH₂O"}, "location": "..."} + +Extract each distinct device as a separate entry. Include current settings if documented. + +Document: +{{MARKDOWN}} diff --git a/api/tracker_prompts/extract_diagnosis.md b/api/tracker_prompts/extract_diagnosis.md new file mode 100644 index 0000000..22f9545 --- /dev/null +++ b/api/tracker_prompts/extract_diagnosis.md @@ -0,0 +1,16 @@ +Extract diagnoses and medical conditions from this medical document. Return a JSON array or null. + +Each entry: +- type: status ("active", "resolved", "historical") +- value: (empty) +- summary: condition name, e.g. "Posthämorrhagischer Hydrocephalus" +- search_key: ICD-10 code lowercase if available, e.g. "g91.1" +- timestamp: "YYYY-MM-DD" if diagnosis date mentioned +- data: {"icd10": "...", "status": "active/resolved", "source": "document"} + +Only extract DISEASES and CONDITIONS — not procedures. +"Z. n. [procedure]" (status post procedure) belongs in surgical history, not here. +Keep the original language of the condition name. + +Document: +{{MARKDOWN}} diff --git a/api/tracker_prompts/extract_exercise.md b/api/tracker_prompts/extract_exercise.md new file mode 100644 index 0000000..f76832f --- /dev/null +++ b/api/tracker_prompts/extract_exercise.md @@ -0,0 +1,16 @@ +Extract PHYSICAL EXERCISE and SPORTS activities from this medical document. Return a JSON array or null. + +This is ONLY for voluntary physical activity: gym, running, swimming, cycling, hiking, yoga, team sports, etc. +Do NOT extract therapeutic treatments here (physical therapy, Feldenkrais, occupational therapy, speech therapy) — those belong in therapies. + +If the document does not mention any exercise or sports activities, return null. + +Each entry: +- type: "activity" +- value: measurement if available, e.g. "5.2" +- summary: activity + details, e.g. "5.2 km run, 30 min" +- timestamp: "YYYY-MM-DD" if date mentioned +- data: {"activity": "...", "distance_km": 5.2, "duration_min": 30} + +Document: +{{MARKDOWN}} diff --git a/api/tracker_prompts/extract_family_history.md b/api/tracker_prompts/extract_family_history.md new file mode 100644 index 0000000..e5287c9 --- /dev/null +++ b/api/tracker_prompts/extract_family_history.md @@ -0,0 +1,16 @@ +Extract FAMILY medical history from this medical document. Return a JSON array or null. + +This is ONLY for hereditary/genetic conditions in BLOOD RELATIVES (parents, siblings, grandparents, aunts, uncles, cousins). NOT the patient's own conditions — those belong in other categories. + +Look for phrases like: "family history of...", "mother/father had...", "runs in the family", "familial", "hereditary". + +If the document does not mention any family member's health conditions, return null. + +Each entry: +- type: relation ("mother", "father", "sibling", "grandparent", "aunt", "uncle", "cousin", "other") +- value: (empty) +- summary: relation + condition, e.g. "Father: Type 2 Diabetes" +- data: {"relation": "father", "condition": "Type 2 Diabetes", "age_onset": 55} + +Document: +{{MARKDOWN}} diff --git a/api/tracker_prompts/extract_fertility.md b/api/tracker_prompts/extract_fertility.md new file mode 100644 index 0000000..ddde5a5 --- /dev/null +++ b/api/tracker_prompts/extract_fertility.md @@ -0,0 +1,17 @@ +Extract FERTILITY and REPRODUCTIVE HEALTH data from this medical document. Return a JSON array or null. + +This is ONLY for: menstrual cycles, ovulation tracking, IVF/IUI treatments, sperm analysis, hormone levels related to reproduction, fertility medications, miscarriages, pregnancy complications. +Do NOT extract birth/delivery here — that belongs in birth category. +Do NOT extract general pregnancy mentions unless they involve fertility treatment or complications. + +If the document does not mention any fertility or reproductive health data, return null. + +Each entry: +- type: "cycle", "treatment", "observation" +- value: (empty) +- summary: description, e.g. "IVF cycle 2, Feb 2025" +- timestamp: "YYYY-MM-DD" if date mentioned +- data: {"description": "...", "details": "..."} + +Document: +{{MARKDOWN}} diff --git a/api/tracker_prompts/extract_history.md b/api/tracker_prompts/extract_history.md new file mode 100644 index 0000000..28de10c --- /dev/null +++ b/api/tracker_prompts/extract_history.md @@ -0,0 +1,18 @@ +Extract LIFE and MEDICAL HISTORY events from this medical document. Return a JSON array or null. + +This is for biographical and perinatal events: premature birth, NICU stays, developmental milestones, accidents, major life events that affected health. +Do NOT extract diseases/conditions here — those belong in diagnoses. +Do NOT extract surgeries here — those belong in procedures. +Do NOT extract hospital admissions — those belong in hospitalizations. + +If the document does not mention any historical life events, return null. + +Each entry: +- type: "perinatal", "developmental", "accident", "event" +- value: (empty) +- summary: event + time, e.g. "Premature birth at 26 weeks, Feb 2020" +- timestamp: "YYYY-MM-DD" if date mentioned +- data: {"event": "...", "age_at_event": "...", "details": "..."} + +Document: +{{MARKDOWN}} diff --git a/api/tracker_prompts/extract_hospitalization.md b/api/tracker_prompts/extract_hospitalization.md new file mode 100644 index 0000000..12ca373 --- /dev/null +++ b/api/tracker_prompts/extract_hospitalization.md @@ -0,0 +1,11 @@ +Extract hospitalizations from this medical document. Return a JSON array or null. + +Each entry: +- type: "admission", "emergency", "day_case" +- value: (empty) +- summary: reason + facility, e.g. "Shunt revision, Universitätsklinikum Ulm" +- timestamp: "YYYY-MM-DD" admission date if mentioned +- data: {"reason": "...", "facility": "...", "discharge": "YYYY-MM-DD", "duration_days": 5} + +Document: +{{MARKDOWN}} diff --git a/api/tracker_prompts/extract_imaging.md b/api/tracker_prompts/extract_imaging.md new file mode 100644 index 0000000..a16c41f --- /dev/null +++ b/api/tracker_prompts/extract_imaging.md @@ -0,0 +1,13 @@ +Extract imaging study references from this medical document. Return a JSON array or null. + +Each entry: +- type: modality lowercase ("mri", "ct", "xray", "ultrasound") +- value: (empty) +- summary: body part + modality + date, e.g. "Brain MRI (Aug 2022)" +- timestamp: "YYYY-MM-DD" if date mentioned +- data: {"body_part": "...", "modality": "...", "institution": "...", "findings_summary": "brief structural description, no interpretation"} + +Note: findings_summary is factual anatomy only ("enlarged ventricles", "3cm mass in left lobe"). NO diagnostic opinions. + +Document: +{{MARKDOWN}} diff --git a/api/tracker_prompts/extract_lab.md b/api/tracker_prompts/extract_lab.md new file mode 100644 index 0000000..fd85234 --- /dev/null +++ b/api/tracker_prompts/extract_lab.md @@ -0,0 +1,15 @@ +Extract laboratory test results from this medical document. Return a JSON array or null. + +Each entry: +- type: "result" +- value: numeric value as string, e.g. "14.2" +- summary: name: value unit, e.g. "Hemoglobin: 14.2 g/dL" +- search_key: test name lowercase, e.g. "hemoglobin" +- timestamp: "YYYY-MM-DD" if collection date mentioned +- data: {"test_name": "...", "numeric_value": 14.2, "unit": "g/dL"} + +Do NOT include reference ranges, flags (H/L), or interpretations. +Extract every individual test result as a separate entry. + +Document: +{{MARKDOWN}} diff --git a/api/tracker_prompts/extract_medication.md b/api/tracker_prompts/extract_medication.md new file mode 100644 index 0000000..d710e37 --- /dev/null +++ b/api/tracker_prompts/extract_medication.md @@ -0,0 +1,13 @@ +Extract medications from this medical document. Return a JSON array or null. + +Each entry: +- type: "prescription" +- value: (empty) +- summary: med name + dose, e.g. "Metformin 500mg" +- timestamp: "YYYY-MM-DD" if start date mentioned +- data: {"medication": "...", "dosage": "...", "frequency": "...", "prescriber": "..."} + +Extract each distinct medication as a separate entry. + +Document: +{{MARKDOWN}} diff --git a/api/tracker_prompts/extract_note.md b/api/tracker_prompts/extract_note.md new file mode 100644 index 0000000..a936452 --- /dev/null +++ b/api/tracker_prompts/extract_note.md @@ -0,0 +1,13 @@ +Extract clinical notes and free-text observations from this medical document. Return a JSON array or null. + +Each entry: +- type: "general", "progress", "clinical" +- value: (empty) +- summary: note title or first line, e.g. "Follow-up assessment" +- timestamp: "YYYY-MM-DD" if date mentioned +- data: {"text": "full note text..."} + +Only extract distinct notes that don't fit other categories (not diagnoses, not procedures, not vitals). + +Document: +{{MARKDOWN}} diff --git a/api/tracker_prompts/extract_nutrition.md b/api/tracker_prompts/extract_nutrition.md new file mode 100644 index 0000000..637c0d0 --- /dev/null +++ b/api/tracker_prompts/extract_nutrition.md @@ -0,0 +1,11 @@ +Extract nutrition and diet information from this medical document. Return a JSON array or null. + +Each entry: +- type: "observation", "restriction", "tolerance" +- value: (empty) +- summary: brief description, e.g. "Tolerating solid foods well" +- timestamp: "YYYY-MM-DD" if date mentioned +- data: {"description": "...", "details": "..."} + +Document: +{{MARKDOWN}} diff --git a/api/tracker_prompts/extract_provider.md b/api/tracker_prompts/extract_provider.md new file mode 100644 index 0000000..8571019 --- /dev/null +++ b/api/tracker_prompts/extract_provider.md @@ -0,0 +1,13 @@ +Extract healthcare providers from this medical document. Return a JSON array or null. + +Each entry: +- type: "physician", "clinic", "hospital", "therapist" +- value: (empty) +- summary: name + specialty, e.g. "Prof. Dr. Péraud, Neurosurgery" +- data: {"name": "...", "role": "...", "specialty": "...", "institution": "...", "phone": "...", "address": "..."} + +Only extract providers who TREATED or REFERRED the patient. +Ignore names from letterheads, board members, administrative staff, or signatories who didn't provide care. + +Document: +{{MARKDOWN}} diff --git a/api/tracker_prompts/extract_supplement.md b/api/tracker_prompts/extract_supplement.md new file mode 100644 index 0000000..27d0e54 --- /dev/null +++ b/api/tracker_prompts/extract_supplement.md @@ -0,0 +1,11 @@ +Extract supplements and vitamins from this medical document. Return a JSON array or null. + +Each entry: +- type: "vitamin", "mineral", "herbal", "other" +- value: (empty) +- summary: supplement name + dosage, e.g. "Vitamin D3 2000 IU" +- timestamp: "YYYY-MM-DD" if start date mentioned +- data: {"supplement": "...", "dosage": "...", "frequency": "..."} + +Document: +{{MARKDOWN}} diff --git a/api/tracker_prompts/extract_surgery.md b/api/tracker_prompts/extract_surgery.md new file mode 100644 index 0000000..1ce02f1 --- /dev/null +++ b/api/tracker_prompts/extract_surgery.md @@ -0,0 +1,17 @@ +Extract SURGICAL PROCEDURES from this medical document. Return a JSON array or null. + +Only extract actual operations/procedures that were PERFORMED — cutting, implanting, removing, scoping. +Do NOT extract diagnoses, conditions, or diseases here — those belong elsewhere. +Do NOT extract hospital admissions — those belong elsewhere. + +Each entry: +- type: "inpatient", "outpatient", "endoscopic" +- value: (empty) +- summary: procedure + date, e.g. "VP-Shunt Implantation, Jul 2020" +- timestamp: "YYYY-MM-DD" if date mentioned +- data: {"procedure": "...", "surgeon": "...", "facility": "...", "details": "..."} + +Extract each distinct procedure as a separate entry. Include technique details in data. + +Document: +{{MARKDOWN}} diff --git a/api/tracker_prompts/extract_symptom.md b/api/tracker_prompts/extract_symptom.md new file mode 100644 index 0000000..87e9e38 --- /dev/null +++ b/api/tracker_prompts/extract_symptom.md @@ -0,0 +1,15 @@ +Extract symptoms and complaints from this medical document. Return a JSON array or null. + +Each entry: +- type: "chronic", "acute", "observation" +- value: (empty) +- summary: symptom description, e.g. "Head tilt to the right" +- timestamp: "YYYY-MM-DD" if date mentioned +- data: {"symptom": "...", "severity": "...", "details": "..."} + +Only extract SYMPTOMS — things the patient experiences or displays. +NOT diagnoses (those go elsewhere), NOT imaging findings, NOT test results. +A symptom is something observable: pain, difficulty walking, head tilt, irritability, fever. + +Document: +{{MARKDOWN}} diff --git a/api/tracker_prompts/extract_therapy.md b/api/tracker_prompts/extract_therapy.md new file mode 100644 index 0000000..9cd4bb1 --- /dev/null +++ b/api/tracker_prompts/extract_therapy.md @@ -0,0 +1,11 @@ +Extract therapies and rehabilitation from this medical document. Return a JSON array or null. + +Each entry: +- type: "physical", "occupational", "speech", "behavioral", "other" +- value: (empty) +- summary: therapy + provider, e.g. "Feldenkrais therapy, 3 months" +- timestamp: "YYYY-MM-DD" start date if mentioned +- data: {"therapy": "...", "provider": "...", "frequency": "...", "duration": "...", "goal": "..."} + +Document: +{{MARKDOWN}} diff --git a/api/tracker_prompts/extract_vital.md b/api/tracker_prompts/extract_vital.md new file mode 100644 index 0000000..a040899 --- /dev/null +++ b/api/tracker_prompts/extract_vital.md @@ -0,0 +1,13 @@ +Extract vital sign measurements from this medical document. Return a JSON array or null. + +Each entry: +- type: vital type ("weight", "blood_pressure", "heart_rate", "temperature", "oxygen_saturation", "height", "head_circumference") +- value: measurement as string, e.g. "75.5" +- summary: value + unit, e.g. "75.5 kg" +- timestamp: "YYYY-MM-DD" if date mentioned +- data: {"unit": "...", "numeric_value": 75.5} + +For blood pressure: value "120/80", data: {"systolic": 120, "diastolic": 80, "unit": "mmHg"} + +Document: +{{MARKDOWN}} diff --git a/cmd/populate-search-key/main.go b/cmd/populate-search-key/main.go index a900491..8afcc52 100644 --- a/cmd/populate-search-key/main.go +++ b/cmd/populate-search-key/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" "log" + "os" "inou/lib" ) @@ -11,34 +12,22 @@ func main() { log.Fatal("lib.Init failed:", err) } lib.ConfigInit() - - // Get all dossiers - allDossiers, err := lib.DossierList(nil, nil) // nil ctx = system, nil filter = all - if err != nil { - log.Fatal("List dossiers:", err) - } - type dossierRow struct { - DossierID string - Count int - } - var dossiers []dossierRow - for _, d := range allDossiers { - count, _ := lib.EntryCount(nil, d.DossierID, lib.CategoryLab, "") - if count > 0 { - dossiers = append(dossiers, dossierRow{DossierID: d.DossierID, Count: count}) - } + if err := lib.RefDBInit("/tank/inou/data/reference.db"); err != nil { + log.Fatal("RefDBInit:", err) } - fmt.Printf("Found %d dossiers with lab data\n", len(dossiers)) + dossierID := "d31ec40f59c30cf1" + if len(os.Args) > 1 { + dossierID = os.Args[1] + } - for i, d := range dossiers { - fmt.Printf("[%d/%d] Normalizing labs for dossier %s (%d entries)...\n", - i+1, len(dossiers), d.DossierID, d.Count) + count, _ := lib.EntryCount(nil, dossierID, lib.CategoryLab, "") + fmt.Printf("Normalizing %d lab entries for %s...\n", count, dossierID) - if err := lib.Normalize(d.DossierID, lib.CategoryLab); err != nil { - log.Printf("Warning: normalize failed for %s: %v", d.DossierID, err) - continue - } + if err := lib.Normalize(dossierID, lib.CategoryLab, func(p, t int) { + fmt.Printf(" batch %d/%d\n", p, t) + }); err != nil { + log.Fatal("Normalize failed:", err) } fmt.Println("Done!") diff --git a/docs/examples/20250806 - 1462926_JONGSMA_OATV4_ENG.PDF b/docs/examples/20250806 - 1462926_JONGSMA_OATV4_ENG.PDF new file mode 100644 index 0000000..6dffe2a Binary files /dev/null and b/docs/examples/20250806 - 1462926_JONGSMA_OATV4_ENG.PDF differ diff --git a/docs/examples/20251105 Oxalic acid - Creatine.pdf b/docs/examples/20251105 Oxalic acid - Creatine.pdf new file mode 100644 index 0000000..401e024 Binary files /dev/null and b/docs/examples/20251105 Oxalic acid - Creatine.pdf differ diff --git a/docs/examples/20251118 - Labs - Eko - Sophia.pdf b/docs/examples/20251118 - Labs - Eko - Sophia.pdf new file mode 100644 index 0000000..4f7659d Binary files /dev/null and b/docs/examples/20251118 - Labs - Eko - Sophia.pdf differ diff --git a/docs/examples/20251229 Metals Toxic Nutrient Elements.PDF b/docs/examples/20251229 Metals Toxic Nutrient Elements.PDF new file mode 100644 index 0000000..9f837c5 Binary files /dev/null and b/docs/examples/20251229 Metals Toxic Nutrient Elements.PDF differ diff --git a/docs/examples/431df34c-9659-4eaf-8f39-cb2f8b71b44a 4.pdf b/docs/examples/431df34c-9659-4eaf-8f39-cb2f8b71b44a 4.pdf new file mode 100644 index 0000000..057de9e Binary files /dev/null and b/docs/examples/431df34c-9659-4eaf-8f39-cb2f8b71b44a 4.pdf differ diff --git a/docs/examples/8bf6b044-3aac-447b-b4af-9157da366108.pdf b/docs/examples/8bf6b044-3aac-447b-b4af-9157da366108.pdf new file mode 100644 index 0000000..377dcee Binary files /dev/null and b/docs/examples/8bf6b044-3aac-447b-b4af-9157da366108.pdf differ diff --git a/docs/examples/Anastasia Elizarova_16.08.2022.pdf b/docs/examples/Anastasia Elizarova_16.08.2022.pdf new file mode 100644 index 0000000..bc752fd Binary files /dev/null and b/docs/examples/Anastasia Elizarova_16.08.2022.pdf differ diff --git a/docs/examples/a8ea33ce-1068-4936-b3e4-e4bda6d9858b 3.pdf b/docs/examples/a8ea33ce-1068-4936-b3e4-e4bda6d9858b 3.pdf new file mode 100644 index 0000000..bc16794 Binary files /dev/null and b/docs/examples/a8ea33ce-1068-4936-b3e4-e4bda6d9858b 3.pdf differ diff --git a/docs/examples/№12610214 _ ELIZAROVA A.D. - Протокол МРТ - 22.04.25.pdf b/docs/examples/№12610214 _ ELIZAROVA A.D. - Протокол МРТ - 22.04.25.pdf new file mode 100644 index 0000000..f215a28 Binary files /dev/null and b/docs/examples/№12610214 _ ELIZAROVA A.D. - Протокол МРТ - 22.04.25.pdf differ diff --git a/lib/config.go b/lib/config.go index 5ca447a..b23fa45 100644 --- a/lib/config.go +++ b/lib/config.go @@ -24,6 +24,7 @@ func Init() error { var ( GeminiKey string = "" AnthropicKey string = "" + FireworksKey string = "" SystemAccessorID string = "7b3a3ee1c2776dcd" // Default fallback ) @@ -49,6 +50,8 @@ func ConfigInit() { GeminiKey = value case "ANTHROPIC_API_KEY": AnthropicKey = value + case "FIREWORKS_API_KEY": + FireworksKey = value case "SYSTEM_ACCESSOR_ID": SystemAccessorID = value } @@ -62,6 +65,9 @@ func ConfigInit() { if AnthropicKey == "" { AnthropicKey = os.Getenv("ANTHROPIC_API_KEY") } + if FireworksKey == "" { + FireworksKey = os.Getenv("FIREWORKS_API_KEY") + } if SystemAccessorID == "" { if envID := os.Getenv("SYSTEM_ACCESSOR_ID"); envID != "" { SystemAccessorID = envID diff --git a/lib/llm.go b/lib/llm.go index 14fddf2..5d8eaa7 100644 --- a/lib/llm.go +++ b/lib/llm.go @@ -1,6 +1,7 @@ package lib import ( + "bufio" "bytes" "encoding/json" "fmt" @@ -148,3 +149,101 @@ func CallGeminiMultimodal(parts []GeminiPart, config *GeminiConfig) (string, err return finalText, nil } + +// CallFireworks sends a request to the Fireworks AI API (OpenAI-compatible). +// messages should be OpenAI-format: []map[string]interface{} with "role" and "content" keys. +// For vision, content can be an array of {type: "text"/"image_url", ...} parts. +func CallFireworks(model string, messages []map[string]interface{}, maxTokens int) (string, error) { + if FireworksKey == "" { + return "", fmt.Errorf("Fireworks API key not configured") + } + + stream := maxTokens > 4096 + reqBody := map[string]interface{}{ + "model": model, + "messages": messages, + "max_tokens": maxTokens, + "temperature": 0.1, + "stream": stream, + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("marshal request: %w", err) + } + + req, err := http.NewRequest("POST", "https://api.fireworks.ai/inference/v1/chat/completions", bytes.NewReader(jsonBody)) + if err != nil { + return "", fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+FireworksKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("API request: %w", err) + } + defer resp.Body.Close() + + if !stream { + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read response: %w", err) + } + if resp.StatusCode != 200 { + return "", fmt.Errorf("Fireworks API error %d: %s", resp.StatusCode, string(body)) + } + var oaiResp struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := json.Unmarshal(body, &oaiResp); err != nil { + return "", fmt.Errorf("parse response: %w", err) + } + if len(oaiResp.Choices) == 0 { + return "", fmt.Errorf("empty response from Fireworks") + } + text := strings.TrimSpace(oaiResp.Choices[0].Message.Content) + text = strings.TrimPrefix(text, "```json") + text = strings.TrimPrefix(text, "```") + text = strings.TrimSuffix(text, "```") + return strings.TrimSpace(text), nil + } + + // Streaming: read SSE chunks and accumulate content + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("Fireworks API error %d: %s", resp.StatusCode, string(body)) + } + var sb strings.Builder + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 256*1024), 256*1024) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + data := line[6:] + if data == "[DONE]" { + break + } + var chunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` + } + if json.Unmarshal([]byte(data), &chunk) == nil && len(chunk.Choices) > 0 { + sb.WriteString(chunk.Choices[0].Delta.Content) + } + } + text := strings.TrimSpace(sb.String()) + text = strings.TrimPrefix(text, "```json") + text = strings.TrimPrefix(text, "```") + text = strings.TrimSuffix(text, "```") + return strings.TrimSpace(text), nil +} diff --git a/lib/normalize.go b/lib/normalize.go index ee6381d..2d8e018 100644 --- a/lib/normalize.go +++ b/lib/normalize.go @@ -67,7 +67,7 @@ func Normalize(dossierID string, category int, progress ...func(processed, total // 3. Call LLM with group keys (batched to stay within token limits) mapping := make(map[string]normMapping) - batchSize := 100 + batchSize := 50 for i := 0; i < len(keys); i += batchSize { end := i + batchSize if end > len(keys) { @@ -240,11 +240,13 @@ Return a JSON object where each key is the EXACT input name, value is {"name":"C Test names: %s`, nameList) - maxTokens := 8192 + maxTokens := 32768 temp := 0.0 + model := "gemini-3.1-pro-preview" config := &GeminiConfig{ Temperature: &temp, MaxOutputTokens: &maxTokens, + Model: &model, } resp, err := CallGeminiMultimodal([]GeminiPart{{Text: prompt}}, config) diff --git a/portal/dossier_sections.go b/portal/dossier_sections.go index ffce05a..6a1dca7 100644 --- a/portal/dossier_sections.go +++ b/portal/dossier_sections.go @@ -3,13 +3,11 @@ package main import ( "encoding/json" "fmt" - "html/template" "net/http" + "sort" "strings" "time" "inou/lib" - "path/filepath" - "os" ) // DossierSection represents a unified section block on the dossier page @@ -44,18 +42,19 @@ type TrackerButton struct { // 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 + 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 @@ -79,7 +78,6 @@ var sectionConfigs = []SectionConfig{ {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: "uploads", Color: "6366f1", HeadingKey: "section_uploads", HideEmpty: false}, {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}, @@ -167,23 +165,50 @@ func BuildDossierSections(targetID, targetHex string, target *lib.Dossier, p *li } case "labs": - section.Items, section.Summary = buildLabItems(targetID, lang, T) - section.Searchable = len(section.Items) > 5 + 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 = true + if len(orders) == 0 { + section.Summary = T("no_lab_data") + } else { + section.Summary = fmt.Sprintf("%d orders", len(orders)) + for _, order := range orders { + item := SectionItem{ + ID: order.EntryID, + Label: order.Value, + Expandable: true, + } + 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 { + 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 item.Date == "" && order.Timestamp > 0 { + item.Date = time.Unix(order.Timestamp, 0).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 = entriesToSectionItems(entries) - section.Summary = fmt.Sprintf("%d documents", len(entries)) - + section.Items = docEntriesToSectionItems(entries) + section.Summary = fmt.Sprintf("%d", len(entries)) + case "procedures": entries, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategorySurgery, &lib.EntryFilter{DossierID: targetID, Limit: 50}) section.Items = entriesToSectionItems(entries) - section.Summary = fmt.Sprintf("%d procedures", len(entries)) - + section.Summary = fmt.Sprintf("%d", len(entries)) + case "assessments": entries, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryAssessment, &lib.EntryFilter{DossierID: targetID, Limit: 50}) section.Items = entriesToSectionItems(entries) - section.Summary = fmt.Sprintf("%d assessments", len(entries)) + section.Summary = fmt.Sprintf("%d", len(entries)) case "genetics": genomeEntries, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryGenome, &lib.EntryFilter{DossierID: targetID, Limit: 1}) @@ -194,24 +219,6 @@ func BuildDossierSections(targetID, targetHex string, target *lib.Dossier, p *li } // Items loaded dynamically via JS - case "uploads": - uploadDir := filepath.Join(uploadsDir, targetHex) - var uploadCount int - var uploadSize int64 - filepath.Walk(uploadDir, func(path string, info os.FileInfo, err error) error { - if err == nil && !info.IsDir() { uploadCount++; uploadSize += info.Size() } - return nil - }) - if uploadCount > 0 { - section.Summary = fmt.Sprintf("%d files, %s", uploadCount, formatSize(uploadSize)) - } else { - section.Summary = T("no_files") - } - if canEdit { - section.ActionURL = fmt.Sprintf("/dossier/%s/upload", targetHex) - section.ActionLabel = T("manage") - } - case "vitals": section.Summary = T("vitals_desc") @@ -224,13 +231,12 @@ func BuildDossierSections(targetID, targetHex string, target *lib.Dossier, p *li if cfg.Category > 0 { entries, _ := lib.EntryList(lib.SystemAccessorID, "", cfg.Category, &lib.EntryFilter{DossierID: targetID, Limit: 50}) section.Items = entriesToSectionItems(entries) - // Use section ID for summary (e.g., "2 medications" not "2 items") - section.Summary = fmt.Sprintf("%d %s", len(entries), cfg.ID) + section.Summary = fmt.Sprintf("%d", len(entries)) } } // Skip empty sections if configured to hide - if section.HideEmpty && len(section.Items) == 0 && !section.Dynamic && !section.ComingSoon && section.ID != "checkin" && section.ID != "uploads" { + if section.HideEmpty && len(section.Items) == 0 && !section.Dynamic && !section.ComingSoon && section.ID != "checkin" { continue } @@ -469,28 +475,158 @@ func buildLabItems(dossierID, lang string, T func(string) string) ([]SectionItem return items, summary } -// entriesToSectionItems converts Entry slice to SectionItem slice -func entriesToSectionItems(entries []*lib.Entry) []SectionItem { +// 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, - Meta: e.Summary, - Type: e.Type, + ID: e.EntryID, + Label: e.Value, + Type: e.Type, + LinkURL: e.EntryID, + LinkTitle: "source", } if e.Timestamp > 0 { - // Convert Unix timestamp to YYYYMMDD - // item.Date = time.Unix(e.Timestamp, 0).Format("20060102") + item.Date = time.Unix(e.Timestamp, 0).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, + } + + 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).Format("20060102") + } + + // Parse Data to build expandable children + if e.Data != "" { + var dataMap map[string]interface{} + if json.Unmarshal([]byte(e.Data), &dataMap) == nil { + // 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 + } + item.Children = append(item.Children, SectionItem{ + Label: k, + Value: val, + }) + } + 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 { @@ -676,6 +812,91 @@ func formatSize(bytes int64) string { 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) @@ -723,11 +944,6 @@ func handleDossierV2(w http.ResponseWriter, r *http.Request) { // Build sections sections := BuildDossierSections(targetID, targetHex, target, p, lang, canEdit) - // Build lab reference data for charts - labRefJSON := template.JS(buildLabRefData(targetID, target.DOB, target.Sex)) - labSearchJSON := template.JS(buildLabSearchIndex()) - loincNameJSON := template.JS(buildLoincNameMap()) - render(w, r, PageData{ Page: "dossier", Lang: lang, @@ -740,8 +956,197 @@ func handleDossierV2(w http.ResponseWriter, r *http.Request) { AccessList: accessList, HasGenome: hasGenome, Sections: sections, - LabRefJSON: labRefJSON, - LabSearchJSON: labSearchJSON, - LoincNameJSON: loincNameJSON, }) } + +// 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 tests []lib.LabTest + lib.RefQuery("SELECT loinc_id, name FROM lab_test", nil, &tests) + searchIndex := make(map[string][]string) + for _, test := range tests { + name := strings.ToLower(test.Name) + if !contains(searchIndex[name], test.LoincID) { + searchIndex[name] = append(searchIndex[name], test.LoincID) + } + for _, word := range strings.Fields(name) { + word = strings.Trim(word, "()") + if len(word) >= 3 && !contains(searchIndex[word], test.LoincID) { + searchIndex[word] = append(searchIndex[word], test.LoincID) + } + } + } + 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).Format("20060102") + } + matchedOrders = append(matchedOrders, oj) + } + + // LOINC name map + loincNameMap := make(map[string]string) + for _, t := range tests { + if matchLoincs[t.LoincID] { loincNameMap[t.LoincID] = t.Name } + } + 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}) +} diff --git a/portal/lang/en.yaml b/portal/lang/en.yaml index 368a2d0..f9f702d 100644 --- a/portal/lang/en.yaml +++ b/portal/lang/en.yaml @@ -77,6 +77,26 @@ section_medications: "Medications" section_records: "Records" section_journal: "Journal" section_checkin: "Daily Check-in" +section_procedures: "Procedures" +section_assessments: "Assessments" +section_genetics: "Genetics" +section_supplements: "Supplements" +section_symptoms: "Symptoms" +section_hospitalizations: "Hospitalizations" +section_therapies: "Therapies" +section_consultations: "Consultations" +section_diagnoses: "Diagnoses" +section_exercise: "Exercise" +section_nutrition: "Nutrition" +section_fertility: "Fertility" +section_notes: "Notes" +section_history: "Medical History" +section_family_history: "Family History" +section_birth: "Birth" +section_devices: "Devices" +section_providers: "Providers" +section_questions: "Questions" +section_privacy: "Privacy" # Daily Check-in checkin_summary: "Track vitals, medications, symptoms" diff --git a/portal/main.go b/portal/main.go index c3f9b81..bc55ba7 100644 --- a/portal/main.go +++ b/portal/main.go @@ -137,9 +137,6 @@ type PageData struct { SelectedRole string // Dossier: unified sections Sections []DossierSection - LabRefJSON template.JS // JSON: abbreviation → {direction, refLow, refHigh} - LabSearchJSON template.JS // JSON: search term → []loinc - LoincNameJSON template.JS // JSON: loinc → full test name } type CategoryAccess struct { @@ -1875,6 +1872,8 @@ func setupMux() http.Handler { } else if strings.Contains(path, "/files/") && strings.HasSuffix(path, "/undo") { handleUndoImport(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") { handleLabSearch(w, r) + } else if strings.Contains(path, "/document/") { handleDocumentView(w, r) } else { handleDossierV2(w, r) } }) mux.HandleFunc("/viewer/", func(w http.ResponseWriter, r *http.Request) { @@ -1932,6 +1931,15 @@ func main() { fmt.Println("lib.DBInit successful") lib.ConfigInit() + // Initialize prompts directory (shared with API for extraction prompts) + // Deployed layout: /tank/inou/bin/portal, prompts at /tank/inou/tracker_prompts/ + exe, _ := os.Executable() + promptsDir := filepath.Join(filepath.Dir(exe), "..", "tracker_prompts") + if _, err := os.Stat(promptsDir); os.IsNotExist(err) { + promptsDir = "api/tracker_prompts" // Dev fallback + } + lib.InitPrompts(promptsDir) + loadTranslations() lib.TranslateInit("lang") // also init lib translations for CategoryTranslate loadTemplates() diff --git a/portal/static/style.css b/portal/static/style.css index 3b81808..8e09c52 100644 --- a/portal/static/style.css +++ b/portal/static/style.css @@ -1156,6 +1156,9 @@ a:hover { .data-label { font-weight: 500; color: var(--text); + overflow-wrap: break-word; + word-break: break-word; + min-width: 0; } .data-meta { @@ -1194,16 +1197,35 @@ a:hover { color: var(--text-muted); text-align: right; } +.search-wrap { + position: relative; + margin-left: auto; + width: 220px; +} .search-input { - padding: 0.3rem 0.5rem; + padding: 0.3rem 1.5rem 0.3rem 0.5rem; border: 1px solid var(--border); border-radius: 6px; font-size: 0.8rem; background: var(--bg); color: var(--text); outline: none; - width: 220px; - margin-left: auto; + width: 100%; + box-sizing: border-box; +} +.search-clear { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + cursor: pointer; + color: var(--text-muted); + font-size: 1rem; + line-height: 1; + display: none; +} +.search-input:not(:placeholder-shown) + .search-clear { + display: block; } .search-input:focus { border-color: var(--primary); @@ -1539,6 +1561,9 @@ a:hover { .sg-card-content-sm { padding: 24px; max-width: 480px; } .sg-profile-card { padding: 20px; min-height: 140px; display: flex; flex-direction: column; } .sg-profile-card h3 { font-size: 1.25rem; margin-bottom: 4px; } +.card-actions { position: absolute; top: 14px; right: 14px; display: flex; gap: 4px; } +.card-actions a { color: var(--text-muted); text-decoration: none; padding: 2px 5px; font-size: 1.1rem; line-height: 1; border-radius: 4px; } +.card-actions a:hover { color: var(--accent); background: var(--accent-light); } .sg-profile-card .card-meta { margin-bottom: 8px; } .sg-profile-dob { font-size: 0.85rem; color: var(--text-muted); margin-bottom: 12px; } .sg-profile-stats { display: flex; gap: 16px; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 12px; } @@ -2010,3 +2035,74 @@ a:hover { .build-profile-btn-icon { font-size: 1rem; } + +/* Detail key labels in expanded data rows */ +.detail-key { + color: var(--text-muted); + text-transform: capitalize; + min-width: 100px; + flex: 0 0 auto !important; +} +.data-row.child .data-value { + color: var(--text); + text-align: right; +} + +/* Document side pane */ +.doc-pane-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.3); + z-index: 999; +} +.doc-pane { + position: fixed; + top: 0; + right: 0; + width: min(600px, 90vw); + height: 100vh; + background: var(--bg-card); + box-shadow: -4px 0 24px rgba(0,0,0,0.12); + z-index: 1000; + display: flex; + flex-direction: column; +} +.doc-pane-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.doc-pane-title { + font-weight: 600; + font-size: 1rem; +} +.doc-pane-body { + flex: 1; + overflow-y: auto; + padding: 20px; + font-size: 0.9rem; + line-height: 1.7; +} +.doc-pane-body h2, .doc-pane-body h3, .doc-pane-body h4 { + margin: 16px 0 8px; + font-weight: 600; +} +.doc-pane-body p { margin-bottom: 8px; } +.doc-pane-body li { margin-left: 20px; margin-bottom: 4px; } +.doc-pane-body hr { border: none; border-top: 1px solid var(--border); margin: 16px 0; } +.doc-pane-body table.doc-table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 0.85rem; } +.doc-pane-body table.doc-table th, .doc-pane-body table.doc-table td { border: 1px solid var(--border); padding: 6px 10px; text-align: left; } +.doc-pane-body table.doc-table th { background: var(--bg-muted); font-weight: 600; } +.doc-pane-body strong { font-weight: 600; } +.doc-pane-tabs { display: flex; gap: 0; margin-left: 16px; } +.doc-pane-tab { background: none; border: none; padding: 4px 12px; cursor: pointer; font-size: 0.8rem; color: var(--text-muted); border-bottom: 2px solid transparent; } +.doc-pane-tab.active { color: var(--text); border-bottom-color: var(--accent); font-weight: 500; } +.doc-highlight { + background: var(--accent-light); + border-left: 3px solid var(--accent); + padding: 4px 8px; + border-radius: 2px; +} diff --git a/portal/templates/dashboard.tmpl b/portal/templates/dashboard.tmpl index fa61703..8c755aa 100644 --- a/portal/templates/dashboard.tmpl +++ b/portal/templates/dashboard.tmpl @@ -6,7 +6,10 @@
- +
+ + +

{{.Dossier.Name}}

{{.T.you}}

@@ -27,12 +30,15 @@ {{.T.view}}
- + {{range .AccessibleDossiers}}
- {{if .CanEdit}}{{end}} - {{if eq .RelationInt 99}}
{{end}} + {{if .CanEdit}}
+ + +
{{end}} + {{if eq .RelationInt 99}}
{{end}}

{{.Name}}

{{if eq .RelationInt 99}}{{$.T.role}}: {{.Relation}}{{else}}{{$.T.my_role}}: {{.Relation}}{{if .IsCareReceiver}} · {{$.T.care}}{{end}}{{end}}

diff --git a/portal/templates/dossier.tmpl b/portal/templates/dossier.tmpl index 5ba23b5..8e3772b 100644 --- a/portal/templates/dossier.tmpl +++ b/portal/templates/dossier.tmpl @@ -67,6 +67,21 @@ {{template "footer"}}
+{{/* Document Side Pane */}} + + + {{/* Genetics Warning Modal */}}