feat: fix year interpretation, new schedule format, exclude today from backfill

This commit is contained in:
James 2026-02-09 02:00:18 -05:00
parent 3014f21d72
commit 9781b31c7d
27 changed files with 1960 additions and 324 deletions

1
anthropic.env Normal file
View File

@ -0,0 +1 @@
GEMINI_API_KEY=AIzaSyAsSUSCVs3SPXL7ugsbXa-chzcOKKJJrbA

3
api/anthropic.env Normal file
View File

@ -0,0 +1,3 @@
ANTHROPIC_API_KEY=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
GEMINI_API_KEY=AIzaSyAsSUSCVs3SPXL7ugsbXa-chzcOKKJJrbA
SYSTEM_ACCESSOR_ID=7b3a3ee1c2776dcd

View File

@ -63,7 +63,7 @@ func loadLLMConfig() {
// callLLMForPrompt is the main entry point for turning user text into a structured prompt.
func callLLMForPrompt(userInput string, dossierID string) (*ExtractionResult, error) {
triage, err := runTriage(userInput)
triage, err := runTriage(userInput, dossierID)
if err != nil {
return nil, err
}
@ -72,7 +72,7 @@ func callLLMForPrompt(userInput string, dossierID string) (*ExtractionResult, er
}
existingTypes := getExistingPromptTypes(dossierID) // Assuming db is accessible in api/main
return runExtraction(userInput, triage.Category, triage.Language, existingTypes)
return runExtraction(userInput, triage.Category, triage.Language, dossierID, existingTypes)
}
@ -87,12 +87,26 @@ func loadPrompt(name string) (string, error) {
return string(data), nil
}
func runTriage(userInput string) (*TriageResponse, error) {
func runTriage(userInput string, dossierID string) (*TriageResponse, error) {
tmpl, err := loadPrompt("triage")
if err != nil {
return nil, fmt.Errorf("failed to load triage prompt: %v", err)
}
// Get dossier context
dossier, _ := lib.DossierGet(nil, dossierID)
dossierName := "Patient"
dossierDOB := ""
if dossier != nil && dossier.Name != "" {
dossierName = dossier.Name
}
if dossier != nil && dossier.DateOfBirth != "" {
dossierDOB = dossier.DateOfBirth
}
prompt := strings.ReplaceAll(tmpl, "{{INPUT}}", userInput)
prompt = strings.ReplaceAll(prompt, "{{DOSSIER_NAME}}", dossierName)
prompt = strings.ReplaceAll(prompt, "{{DOSSIER_DOB}}", dossierDOB)
respText, err := lib.CallGemini(prompt)
if err != nil {
@ -117,7 +131,7 @@ func runTriage(userInput string) (*TriageResponse, error) {
return &result, nil
}
func runExtraction(userInput, category, language string, existingTypes map[string][]string) (*ExtractionResult, error) {
func runExtraction(userInput, category, language, dossierID string, existingTypes map[string][]string) (*ExtractionResult, error) {
tmpl, err := loadPrompt(category)
if err != nil {
tmpl, err = loadPrompt("default")
@ -126,6 +140,22 @@ func runExtraction(userInput, category, language string, existingTypes map[strin
}
}
// Get dossier name and DOB
dossier, _ := lib.DossierGet(nil, dossierID)
dossierName := "Patient"
dossierDOB := ""
if dossier != nil && dossier.Name != "" {
dossierName = dossier.Name
}
if dossier != nil && dossier.DateOfBirth != "" {
dossierDOB = dossier.DateOfBirth
}
// Get current date and year
now := time.Now()
currentDate := now.Format("2006-01-02")
currentYear := now.Format("2006")
var existingStr string
for cat, types := range existingTypes {
if len(types) > 0 {
@ -141,6 +171,10 @@ func runExtraction(userInput, category, language string, existingTypes map[strin
prompt = strings.ReplaceAll(prompt, "{{LANGUAGE}}", language)
prompt = strings.ReplaceAll(prompt, "{{CATEGORY}}", category)
prompt = strings.ReplaceAll(prompt, "{{EXISTING_TYPES}}", existingStr)
prompt = strings.ReplaceAll(prompt, "{{DOSSIER_NAME}}", dossierName)
prompt = strings.ReplaceAll(prompt, "{{DOSSIER_DOB}}", dossierDOB)
prompt = strings.ReplaceAll(prompt, "{{CURRENT_DATE}}", currentDate)
prompt = strings.ReplaceAll(prompt, "{{CURRENT_YEAR}}", currentYear)
respText, err := lib.CallGemini(prompt)
if err != nil {
@ -149,7 +183,22 @@ func runExtraction(userInput, category, language string, existingTypes map[strin
log.Printf("Gemini raw response for %s: %s", category, respText)
// First try to parse as singular "entry" (what the prompts actually generate)
// First try plural "entries" format (used by exercise.md and others)
var result ExtractionResult
if err := json.Unmarshal([]byte(respText), &result); err == nil {
log.Printf("Parsed as plural format: entries=%d", len(result.Entries))
if result.Category == "" {
result.Category = category
}
// If we got entries in plural format, return it
if len(result.Entries) > 0 || result.Error != "" {
return &result, nil
}
} else {
log.Printf("Plural parse failed: %v", err)
}
// Fallback: try singular "entry" format
var singleEntryResult struct {
Question string `json:"question"`
Category string `json:"category"`
@ -161,6 +210,7 @@ func runExtraction(userInput, category, language string, existingTypes map[strin
Error string `json:"error,omitempty"`
}
if err := json.Unmarshal([]byte(respText), &singleEntryResult); err == nil {
log.Printf("Parsed as singular format: entry=%v", singleEntryResult.Entry != nil)
result := ExtractionResult{
Question: singleEntryResult.Question,
Category: singleEntryResult.Category,
@ -179,16 +229,7 @@ func runExtraction(userInput, category, language string, existingTypes map[strin
return &result, nil
}
// Fallback: try plural "entries" format
var result ExtractionResult
if err := json.Unmarshal([]byte(respText), &result); err != nil {
return nil, fmt.Errorf("failed to parse extraction JSON: %v (raw: %s)", err, respText)
}
if result.Category == "" {
result.Category = category
}
return &result, nil
return nil, fmt.Errorf("failed to parse extraction JSON in either format (raw: %s)", respText)
}

View File

@ -215,13 +215,25 @@ func tryGeneratePromptFromFreeform(promptID string, userInput string) (*lib.Prom
primaryEntryValue = generated.Entries[0].Value
for _, entryData := range generated.Entries {
entryJSON, _ := json.Marshal(entryData.Data)
// Use timestamp from LLM: prefer Date (ISO string), fallback to Timestamp (Unix), then now
timestamp := time.Now().Unix()
if entryData.Date != "" {
// Parse ISO date (YYYY-MM-DD) and convert to Unix timestamp
if t, err := time.Parse("2006-01-02", entryData.Date); err == nil {
timestamp = t.Unix()
} else {
log.Printf("Failed to parse date %s: %v", entryData.Date, err)
}
} else if entryData.Timestamp > 0 {
timestamp = entryData.Timestamp
}
entry := &lib.Entry{
DossierID: p.DossierID,
Category: lib.CategoryFromString[generated.Category],
Type: generated.Type, // All entries share the same type for now
Value: entryData.Value,
Data: string(entryJSON), // This needs to be consistent
Timestamp: time.Now().Unix(),
Timestamp: timestamp,
}
if err := lib.EntryAdd(entry); err != nil {
log.Printf("Failed to create entry from freeform: %v", err)
@ -255,13 +267,8 @@ func tryGeneratePromptFromFreeform(promptID string, userInput string) (*lib.Prom
Active: true,
}
// Set LastResponse from the initial generated entry data
if len(generated.Entries) > 0 && generated.Entries[0].Data != nil {
initialResponseData, _ := json.Marshal(generated.Entries[0].Data)
newPrompt.LastResponse = string(initialResponseData)
newPrompt.LastResponseRaw = generated.Entries[0].Value
newPrompt.LastResponseAt = time.Now().Unix()
}
// NOTE: Don't set LastResponse here - backfilled entries are historical.
// LastResponse will be set when user actually submits a response for "today".
if err := lib.PromptAdd(newPrompt); err != nil {
log.Printf("Failed to create prompt: %v", err)
@ -334,37 +341,48 @@ func calculateNextAskFromSchedule(schedule []ScheduleSlot, now time.Time) int64
var earliest time.Time
for _, slot := range schedule {
// Parse time
hour, min := 8, 0 // default to 08:00
if slot.Time != "" {
fmt.Sscanf(slot.Time, "%d:%d", &hour, &min)
// Build list of times to check (support both old Time and new Times)
var times []string
if len(slot.Times) > 0 {
times = slot.Times
} else if slot.Time != "" {
times = []string{slot.Time}
} else {
times = []string{"08:00"} // default
}
// For each day in the slot, find next occurrence
for _, dayName := range slot.Days {
targetWeekday, ok := dayMap[dayName]
if !ok {
continue
}
// For each time in the slot
for _, timeStr := range times {
// Parse time
hour, min := 8, 0
fmt.Sscanf(timeStr, "%d:%d", &hour, &min)
// Calculate days until target weekday
currentWeekday := now.Weekday()
daysUntil := int(targetWeekday) - int(currentWeekday)
if daysUntil < 0 {
daysUntil += 7
}
// For each day in the slot, find next occurrence
for _, dayName := range slot.Days {
targetWeekday, ok := dayMap[dayName]
if !ok {
continue
}
// Build candidate time
candidate := time.Date(now.Year(), now.Month(), now.Day()+daysUntil, hour, min, 0, 0, now.Location())
// Calculate days until target weekday
currentWeekday := now.Weekday()
daysUntil := int(targetWeekday) - int(currentWeekday)
if daysUntil < 0 {
daysUntil += 7
}
// If it's today but the time has passed, move to next week
if daysUntil == 0 && candidate.Before(now) {
candidate = candidate.AddDate(0, 0, 7)
}
// Build candidate time
candidate := time.Date(now.Year(), now.Month(), now.Day()+daysUntil, hour, min, 0, 0, now.Location())
// Track earliest
if earliest.IsZero() || candidate.Before(earliest) {
earliest = candidate
// If it's today but the time has passed, move to next week
if daysUntil == 0 && candidate.Before(now) {
candidate = candidate.AddDate(0, 0, 7)
}
// Track earliest
if earliest.IsZero() || candidate.Before(earliest) {
earliest = candidate
}
}
}
}

View File

@ -166,14 +166,14 @@ func getDossierCategories(dossierID string) []string {
}
lib.Query("SELECT category, COUNT(*) as cnt FROM entries WHERE dossier_id = ? AND category > 0 GROUP BY category", []any{dossierID}, &counts)
categoryNames := map[int]string{
1: "imaging", 2: "documents", 3: "labs", 4: "genome",
}
categories := []string{} // Empty slice, not nil
for _, c := range counts {
if name, ok := categoryNames[c.Category]; ok && c.Count > 0 {
categories = append(categories, name)
if c.Count > 0 {
// Use lib.CategoryName to get proper name for all categories
name := lib.CategoryName(c.Category)
if name != "unknown" {
categories = append(categories, name)
}
}
}
return categories

View File

@ -1,5 +1,7 @@
package main
import "encoding/json"
// --- LLM and Prompt Generation Structs ---
// TriageResponse is the structure for the first-pass classification of user input.
@ -44,16 +46,20 @@ type FormField struct {
Options []string `json:"options,omitempty"`
}
// ScheduleSlot defines a time for a prompt to be shown.
// ScheduleSlot defines when a prompt should be shown.
// Supports both old format (Time string) and new format (Times []string).
type ScheduleSlot struct {
Days []string `json:"days"`
Time string `json:"time"`
Days []string `json:"days"`
Time string `json:"time,omitempty"` // Old format: single time
Times []string `json:"times,omitempty"` // New format: multiple times
}
// EntryData represents a single piece of data extracted from user input.
type EntryData struct {
Value string `json:"value"`
Data interface{} `json:"data"`
Value string `json:"value"`
Data interface{} `json:"data"`
Timestamp int64 `json:"timestamp,omitempty"` // Legacy: Unix timestamp
Date string `json:"date,omitempty"` // New: ISO date YYYY-MM-DD
}
// ValidCategories is the list of recognized categories.
@ -65,3 +71,38 @@ var ValidCategories = map[string]bool{
"provider": true, "question": true, "history": true, "family_history": true,
"nutrition": true, "fertility": true, "out_of_domain": true,
}
// UnmarshalJSON handles both old array format and new single-object format for schedule.
func (e *ExtractionResult) UnmarshalJSON(data []byte) error {
// Define an alias to avoid recursion
type Alias ExtractionResult
aux := &struct {
Schedule interface{} `json:"schedule"`
*Alias
}{
Alias: (*Alias)(e),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
// Handle schedule field - can be array or single object
if aux.Schedule != nil {
switch v := aux.Schedule.(type) {
case []interface{}:
// Old format: array of schedule slots
schedJSON, _ := json.Marshal(v)
json.Unmarshal(schedJSON, &e.Schedule)
case map[string]interface{}:
// New format: single schedule object
schedJSON, _ := json.Marshal(v)
var slot ScheduleSlot
if json.Unmarshal(schedJSON, &slot) == nil {
e.Schedule = []ScheduleSlot{slot}
}
}
}
return nil
}

View File

@ -1,105 +1,37 @@
# Exercise Prompt
# Exercise Extraction
Extract exercise/activity details.
**Your task:** Create a structured tracking prompt with recurring schedule and backfill historical entries based on the user's description.
User said: "{{INPUT}}"
**Context:**
- Subject: {{DOSSIER_NAME}} (person being tracked), born {{DOSSIER_DOB}}
- Domain: Medical/health tracking
- **Today is {{CURRENT_DATE}} (year {{CURRENT_YEAR}})**
- Activities are historical or present, never future
- **CRITICAL: Two-digit year "26" means 2026, "25" means 2025, etc. Always interpret as 20XX.**
- **IMPORTANT: For backfill, create entries up to YESTERDAY only, not today. Today's entry will be filled in by the user.**
- **All entry dates must be between 1990 and yesterday. Verify dates are reasonable.**
- Names mentioned (other than {{DOSSIER_NAME}}) are practitioners/caregivers
User: "{{INPUT}}"
Language: {{LANGUAGE}}
IMPORTANT: Respond with ONLY the JSON object. No explanations, no markdown fences, no text before or after.
Generate JSON with these top-level fields: question, type, input_type, schedule, input_config, entries.
For complex activities with multiple parts, use the `input_config.groups` structure to group related fields.
**Structure requirements:**
- `input_type`: "form" when using `input_config.fields` or `input_config.groups`
- `schedule`: object with `times` (array of HH:MM strings) and `days` (array of 3-letter lowercase weekday abbreviations like "mon", "tue"). Same times apply to all specified days.
- `input_config`: object with either `fields` OR `groups` array (not both), plus optional `layout` string
- `input_config.layout`: set to "two-column" when using groups for paired/symmetric parts
- `input_config.groups`: array of objects with `title` and `fields`
- Field keys in groups: `{lowercase_group_title}_{semantic_field_name}` with all spaces replaced by underscores. Semantic field name describes what is measured, not units.
- `entries[].data`: flat object with keys matching field keys exactly
- Historical entries: include `date` (ISO format YYYY-MM-DD) for each occurrence, NOT Unix timestamps
- Temporal language like "5 daily since DATE" means one entry per day with count=5 in each entry
- Use 4-digit years: "1/28/26" means date "2026-01-28", "1/15/25" means "2025-01-15"
```json
{
"question": "tracking question in {{LANGUAGE}}",
"type": "exercise_type",
"input_type": "form",
"schedule": [{"days": [...], "time": "HH:MM"}],
"input_config": {
"groups": [
{
"title": "Group Title",
"fields": [{"key": "...", "type": "...", "label": "..."}]
}
]
},
"entries": [{"value": "...", "data": {...}}]
}
```
Capture specific details (practitioner names, equipment settings, etc.) as fields. Create one entry per occurrence for past events.
## Schedule format
- `days`: array of "mon", "tue", "wed", "thu", "fri", "sat", "sun"
- `time`: 24h format "08:00", "12:00", "18:00" etc.
- Daily = all 7 days
Return raw JSON only.
## Standard fields
- `duration`: number (minutes)
- `distance`: number (km, miles)
- `intensity`: select [Light, Moderate, Vigorous]
## Rules
- If the activity has multiple distinct parts (e.g. left and right leg), create a `group` in `input_config` for each part.
- `entries` should be used for recording initial data. If multiple parts, create multiple entries.
## Examples
Input: "walked 30 minutes"
```json
{
"question": "Did you walk today?",
"type": "walk",
"input_type": "form",
"schedule": [{"days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "time": "08:00"}],
"input_config": {"fields": [{"key": "duration", "type": "number", "label": "Duration", "unit": "min"}]},
"entries": [{"value": "30 min walk", "data": {"duration": 30, "unit": "min"}}]
}
```
Input: "For my daughter, we have a leg trainer exercise we do twice a day. The right leg is for 20 minutes at 40 degrees and speed 4. The left leg is for 20 minutes at 30 degrees and speed 4."
```json
{
"question": "Did you do the leg trainer exercises today?",
"category": "exercise",
"type": "leg_trainer",
"input_type": "form",
"schedule": [
{"days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "time": "09:00"},
{"days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "time": "17:00"}
],
"input_config": {
"groups": [
{
"title": "Right Leg",
"fields": [
{ "key": "right_leg_duration", "type": "number", "label": "Duration (min)" },
{ "key": "right_leg_angle", "type": "number", "label": "Angle (°)" },
{ "key": "right_leg_speed", "type": "number", "label": "Speed" }
]
},
{
"title": "Left Leg",
"fields": [
{ "key": "left_leg_duration", "type": "number", "label": "Duration (min)" },
{ "key": "left_leg_angle", "type": "number", "label": "Angle (°)" },
{ "key": "left_leg_speed", "type": "number", "label": "Speed" }
]
},
{
"title": "Notes",
"fields": [
{ "key": "notes", "type": "text", "label": "Session Notes" }
]
}
]
},
"entries": [
{
"value": "Leg trainer session",
"data": {
"right_leg": { "duration_min": 20, "degrees": 40, "speed": 4 },
"left_leg": { "duration_min": 20, "degrees": 30, "speed": 4 }
}
}
]
}
```

1
api/prompts/therapy.md Symbolic link
View File

@ -0,0 +1 @@
exercise.md

View File

@ -9,6 +9,11 @@ Your most important task is to first determine if the user input is a valid heal
---
**Context:**
- Subject: {{DOSSIER_NAME}} (person being tracked), born {{DOSSIER_DOB}}
- Domain: Medical/health tracking
- Names mentioned (other than {{DOSSIER_NAME}}) are practitioners/caregivers
User said: "{{INPUT}}"
If, and only if, the input is a valid health entry, classify it into a category and extract basic metadata.

View File

@ -0,0 +1,39 @@
package main
import (
"fmt"
"log"
"inou/lib"
)
func main() {
if err := lib.Init(); err != nil {
log.Fatal("lib.Init failed:", err)
}
lib.ConfigInit()
// Get all dossiers with lab entries
type dossierRow struct {
DossierID string `db:"dossier_id"`
Count int `db:"count"`
}
var dossiers []dossierRow
if err := lib.Query("SELECT dossier_id, COUNT(*) as count FROM entries WHERE category = 3 GROUP BY dossier_id",
[]any{}, &dossiers); err != nil {
log.Fatal("Query dossiers:", err)
}
fmt.Printf("Found %d dossiers with lab data\n", len(dossiers))
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)
if err := lib.Normalize(d.DossierID, lib.CategoryLab); err != nil {
log.Printf("Warning: normalize failed for %s: %v", d.DossierID, err)
continue
}
}
fmt.Println("Done!")
}

View File

@ -0,0 +1,614 @@
# Journal System Design
*February 2026*
## Overview
The journal system captures insights, protocols, hypotheses, and observations from LLM conversations about health data, making them available for future conversations and medical decision-making. It solves the "lost insights" problem where valuable reasoning and decisions from conversations are not preserved.
**Core principle:** Continuity of care across LLM conversations.
---
## Motivation
### The Problem
**Current state:**
- User has valuable health conversation with LLM (diagnosis exploration, protocol design, symptom analysis)
- Insights and reasoning are lost when conversation ends
- Next conversation starts from scratch - no context of what was discussed, tried, or decided
- User must re-explain medical history, recent events, and current hypotheses
**Example:**
- Feb 8: Wife discusses Sophia's post-fever symptoms with Opus → realizes hair loss may indicate CSF drainage started → develops hypothesis connecting multiple symptoms
- Feb 15: Husband asks different LLM about Sophia's condition → LLM has no knowledge of the drainage hypothesis or reasoning
- Lost context: Jan 20 Dr. I infusion, fever timeline, symptom connections
### The Solution
**Journal entries provide:**
1. **Cross-conversation memory** - insights from one conversation available in the next
2. **Reasoning preservation** - not just "what" but "why" decisions were made
3. **Timeline tracking** - when things were tried, observed, or decided
4. **Status tracking** - what's active, testing, resolved, or discarded
5. **Context for medical decisions** - doctors/family can see reasoning behind interventions
---
## Journal Entry Types
Journals are **entries** in the entries table with `category = CategoryNote` (or category-specific Type="journal").
### Core Types
| Type | Purpose | Example |
|------|---------|---------|
| `protocol` | Intervention plan | "Serrapeptase suppositories for aqueductal clearing" |
| `hypothesis` | Testable idea | "Post-fever CSF drainage via aqueduct" |
| `observation` | Notable finding | "Neurological improvements after Jan 10 fever" |
| `connection` | Linking data points | "MTHFR C677T affects DHA metabolism → impacts omega-3 protocol" |
| `question` | Open question | "Why does glymphatic clearance favor left-lateral position?" |
| `reference` | Background info | "Dr. Ibraheem's WBC therapy mechanism explained" |
### Entry Fields
```go
type Entry struct {
EntryID string // 16-char hex
DossierID string // who this is about
Category int // CategoryNote (or category-specific)
Type string // protocol, hypothesis, observation, etc.
// Display
Value string // Title (encrypted)
Summary string // 1-2 sentence summary (encrypted)
// Content
Data string // JSON (encrypted): full content, source, metadata
// Context
Tags string // comma-separated: "omega-3,aqueduct,nutrition"
Timestamp int64 // when created
Status int // 0=draft, 1=active, 2=resolved, 3=discarded
// Relationships
ParentID string // optional: link to specific entry
SearchKey string // optional: for filtering (encrypted)
}
```
### Data JSON Structure
```json
{
"source": "opus-4.6",
"conversation_id": "uuid-from-claude-desktop",
"conversation_date": "2026-02-08",
"content": "Full markdown content with sections, reasoning, etc.",
"related_entries": ["entry_id_1", "entry_id_2"],
"reasoning": "Why this matters / how we arrived at this",
"metadata": {
"weight": "30kg",
"age": "9yo",
"any_other_context": "relevant to this entry"
}
}
```
---
## Auto-Capture Modes
Users set journal behavior at MCP connection time or in Portal settings.
### Mode 1: Disabled
```json
"journal_mode": "disabled"
```
- LLM never creates journal entries
- User creates manually via Portal
- LLM can READ journals for context
### Mode 2: Suggest (Default)
```json
"journal_mode": "suggest"
```
- LLM drafts entries silently during conversation
- At conversation end: shows summary with approval prompt
- User can edit/approve/discard before saving
**End-of-conversation prompt:**
```
┌─────────────────────────────────────────┐
│ I've drafted 3 journal entries: │
│ │
│ ☑ HYPOTHESIS: Post-fever drainage │
│ Hair loss + improved tone/eyes may │
│ indicate aqueduct drainage started. │
│ │
│ ☑ PROTOCOL: Serrapeptase suppositories │
│ 5-10mg rectal for aqueduct clearing. │
│ │
│ ☑ OBSERVATION: Neurological gains │
│ Better tone, eye tracking, head hold. │
│ │
│ [Save All] [Review/Edit] [Discard] │
└─────────────────────────────────────────┘
```
### Mode 3: Automatic (Power Users)
```json
"journal_mode": "automatic",
"journal_notify": true // optional: show save indicators
```
- LLM creates entries mid-conversation as key moments happen
- No approval needed
- Optional: subtle notification `💾 Saved: Serrapeptase protocol`
- User can review/edit later via Portal
---
## MCP Integration
### Prompts: family_health_context
**Purpose:** Load available dossiers + announce what data exists (without content)
**MCP Protocol:**
```
Request: prompts/list
Response: [{"name": "family_health_context", "description": "..."}]
Request: prompts/get {"name": "family_health_context"}
Response: {messages: [{role: "user", content: {...}}]}
```
**Content Format:**
```
=== Family Health Context ===
Sophia (3b38234f2b0f7ee6)
- Age: 9yo, Weight: 30kg
- Condition: Hydrocephalus (aqueduct obstruction, dx 12/31/25)
Data available:
• Imaging: 3 studies (most recent: 12/31/25)
• Labs: 45 results (most recent: 11/15/25)
• Genome: 23andMe data available
• Documents: 12 files
• Exercise: tracked
• Nutrition: tracked (G-tube)
Recent journals (last 30 days):
• 2026-02-08: Post-fever aqueductal drainage (hypothesis, observing)
Jan 10 fever may have opened aqueduct. Observing neuro improvements.
• 2026-01-20: Dr. I WBC infusion (intervention, administered)
Autologous therapy given during illness.
• 2026-01-15: Caviar omega-3 protocol (protocol, active)
20-25g daily for SPM production targeting aqueduct inflammation.
Active protocols: 4 interventions tracked
---
Johan (xxx)
- Age: [age], self
Data available:
• Genome: 23andMe data available
• Labs: 12 results (most recent: 10/24/25)
Recent journals: 1 entry (last 30 days)
---
Alena (yyy)
- Age: [age], spouse
Data available:
• Genome: 23andMe data available
• Labs: 8 results (most recent: 09/20/25)
Recent journals: 0 entries
```
### Progressive Disclosure Strategy
**Announce EXISTENCE, not CONTENT** (except journals get brief summaries)
| Data Type | Announcement | Reasoning |
|-----------|--------------|-----------|
| **Imaging** | "3 studies (most recent: 12/31/25)" | Forces LLM to call `list_studies()` when relevant |
| **Labs** | "45 results (most recent: 11/15/25)" | Forces LLM to call `query_entries()` with LOINC code |
| **Genome** | "23andMe data available" | Forces LLM to call `query_genome(gene="MTHFR")` when relevant |
| **Documents** | "12 files" | Forces LLM to explore when needed |
| **Journals** | Title + 1-2 sentence summary | **Exception:** Summaries help LLM decide relevance without fetching each |
**Why this matters:**
- LLMs get lazy with summaries - if given "MTHFR C677T heterozygous" they just repeat it
- Announcing existence forces LLM to fetch fresh data: `query_genome(gene="MTHFR")`
- Journals are different: they're already curated insights, summary IS the point
**Journal summary balance:**
- ✅ Title + date + type + status + 1-2 sentence summary
- ❌ Full content, full reasoning, full protocol details
- LLM gets enough to know IF relevant, fetches full entry WHEN relevant
### Summary Generation
Summaries can come from two sources:
**1. Client-provided (preferred):**
- LLM has full conversation context
- Knows WHY this entry matters (e.g., "for aqueduct inflammation" not just "supplement protocol")
- Summary is contextual and specific
**2. Server-generated (fallback):**
- Uses **Gemini** (already deployed for simple tasks)
- Extracts/generates 1-2 sentence summary from content
- Ensures consistent format/length
- Used when client doesn't provide summary
**Server validation (all summaries):**
```go
// Validate or generate summary
if entry.Summary == "" {
// Generate using Gemini
entry.Summary = generateSummaryWithGemini(entry.Title, entry.Content)
} else {
// Validate client-provided summary
if len(entry.Summary) > 300 {
return errors.New("summary too long (max 300 chars)")
}
}
```
**Gemini prompt for summary generation:**
```
Summarize this health journal entry in 1-2 sentences (max 300 chars).
Focus on: what intervention/observation, for what condition, and key details.
Title: {title}
Type: {type}
Content: {content}
Example good summaries:
- "20-25g daily Beluga/Keta targeting phospholipid-DHA → SPM production for aqueduct inflammation."
- "Jan 10 fever may have opened aqueduct drainage. Observing improved tone, eye tracking, head control."
- "Autologous WBC therapy (Utheline/Ricasin/Notokill + ATP) given during active fever."
Summary:
```
### MCP Tools
```
mcp__inou__list_dossiers()
→ Returns list of accessible dossiers
mcp__inou__get_dossier(dossier_id)
→ Returns dossier info (used internally by prompts/get)
mcp__inou__list_journals(dossier_id, days=30, status=null, type=null)
→ Returns journal summaries (for explicit queries)
mcp__inou__get_journal_entry(dossier_id, entry_id)
→ Returns full journal entry with complete content
mcp__inou__create_journal_entry(
dossier_id,
type, // protocol, hypothesis, observation, etc.
title, // short title
summary, // 1-2 sentences
content, // full markdown
tags, // comma-separated
status, // draft, active, testing, resolved, discarded
related_entries // optional: array of entry IDs
)
→ Creates new journal entry
mcp__inou__update_journal_entry(
dossier_id,
entry_id,
status, // optional: change status
append_note // optional: add update without replacing content
)
→ Updates existing entry
```
---
## API Endpoints
### List Journal Summaries
```
GET /api/v1/dossiers/{dossier_id}/journal?days=30&status=active&type=protocol
Response:
{
"journals": [
{
"entry_id": "abc123",
"type": "protocol",
"title": "Serrapeptase suppositories",
"summary": "5-10mg rectal for aqueduct clearing. Stacks with BPC-157, TB-500.",
"date": "2026-02-08",
"status": "draft",
"tags": ["serrapeptase", "aqueduct", "suppository"]
}
]
}
```
### Get Full Journal Entry
```
GET /api/v1/dossiers/{dossier_id}/journal/{entry_id}
Response:
{
"entry_id": "abc123",
"type": "protocol",
"title": "Serrapeptase suppositories for aqueductal clearing",
"summary": "Adding serrapeptase...",
"content": "# Serrapeptase Protocol\n\n## Rationale\n...",
"date": "2026-02-08",
"status": "draft",
"tags": ["serrapeptase", "aqueduct"],
"related_entries": ["def456"],
"source": "opus-4.6",
"metadata": {
"conversation_url": "https://claude.ai/chat/...",
"weight": "30kg"
}
}
```
### Create Journal Entry
```
POST /api/v1/dossiers/{dossier_id}/journal
Body:
{
"type": "protocol", // required
"title": "Serrapeptase suppositories", // required
"summary": "5-10mg rectal for aqueduct...", // optional - generated with Gemini if not provided
"content": "# Full markdown content...", // required
"tags": ["serrapeptase", "aqueduct"], // optional
"status": "draft", // optional - defaults to "draft"
"related_entries": ["def456"], // optional
"metadata": { // optional
"source": "opus-4.6",
"conversation_url": "https://claude.ai/chat/..."
}
}
Response:
{
"entry_id": "abc123",
"created": "2026-02-08T12:34:56Z",
"summary": "5-10mg rectal for aqueduct clearing" // returned (client-provided or server-generated)
}
```
**Note:** If `summary` is not provided in request, server generates it using Gemini. If provided, server validates length (max 300 chars).
### Update Journal Entry
```
PATCH /api/v1/dossiers/{dossier_id}/journal/{entry_id}
Body:
{
"status": "active" // or append_note: "Update: started 2026-02-10"
}
Response:
{
"updated": "2026-02-08T12:35:00Z"
}
```
---
## Storage Schema
Journals are **entries** with `category = CategoryNote` (or category-specific Type="journal").
**No new tables needed.** Use existing `entries` table:
```sql
-- Journal entry example
INSERT INTO entries (
entry_id, dossier_id, category, type,
value, summary, data, tags,
timestamp, status
) VALUES (
'abc123...', '3b38234f2b0f7ee6', 15, 'protocol',
'Serrapeptase suppositories', -- encrypted
'5-10mg rectal for aqueduct clearing', -- encrypted
'{"source":"opus-4.6","content":"..."}', -- encrypted JSON
'serrapeptase,aqueduct,suppository', -- encrypted
1707408000, 0 -- timestamp, status (0=draft)
);
```
**Status values:**
- 0 = draft
- 1 = active
- 2 = resolved
- 3 = discarded
---
## User Configuration
### Custom Instructions (Claude Desktop)
```
At conversation start, load family health context using the
"family_health_context" prompt from inou MCP.
When discussing family members:
- Look up dossier by name (Sophia, Johan, Alena, etc.)
- Reference their journal summaries for context
- Save insights using journal_mode: automatic
```
### Portal Settings
```
Settings → MCP Access → Journal Behavior
○ Disabled (manual only)
○ Suggest at end (default)
● Automatic (save as we go)
□ Show notifications when entries are saved
```
---
## Use Cases
### Case 1: Reference Conversation
**User:** "Sophia losing hair after being sick. Why?"
**LLM sees from family_health_context:**
- Sophia has hydrocephalus (aqueduct obstruction)
- Recent journal: "Post-fever aqueductal drainage hypothesis (Feb 8)"
- Recent journal: "Dr. I WBC infusion (Jan 20)"
**LLM response:**
> "I see from your recent journals that Sophia had a high fever starting Jan 10, and received Dr. I's infusion on Jan 20 while still sick. You've been tracking a hypothesis that the fever may have opened aqueductal drainage, with observed improvements in tone, eye tracking, and head control. You noted hair loss as one of the adjustment symptoms.
>
> This fits the pattern - the hair loss after 5-day high fever is likely anagen effluvium..."
**Auto-saves:**
```json
{
"type": "observation",
"title": "Hair loss confirmed as post-fever anagen effluvium",
"summary": "Hair loss 4 weeks post-fever confirms anagen damage from 5-day high fever, consistent with neurological repair timeline.",
"status": "active"
}
```
### Case 2: Exploratory Conversation
**User:** "Can I substitute Omega 3 with Beluga caviar?"
**LLM sees from family_health_context:**
- Sophia: 9yo, 30kg, hydrocephalus (aqueduct obstruction)
- Data available: Genome (23andMe)
- No existing caviar protocol journal
**LLM response:**
> "For Sophia's aqueduct inflammation, let me think about this carefully. Let me first check her genome data for variants affecting omega-3 metabolism..."
>
> [calls `query_genome(gene="MTHFR,FADS1,FADS2")`]
>
> "I see she has MTHFR C677T heterozygous, which can reduce methylation efficiency and affect DHA metabolism. Caviar provides phospholipid-bound DHA which crosses the blood-brain barrier more efficiently than triglyceride supplements. For a 30kg child targeting neuroinflammation..."
**Conversation develops protocol → Auto-saves:**
```json
{
"type": "protocol",
"title": "Caviar omega-3 protocol for aqueduct inflammation",
"summary": "20-25g daily Beluga/Keta targeting phospholipid-DHA → SPM production. Replaces AlgaeCal. Based on 30kg weight, MTHFR C677T variant.",
"content": "# Caviar Protocol\n\n## Rationale\nPhospholipid-DHA...",
"tags": ["omega-3", "caviar", "DHA", "SPM", "aqueduct"],
"status": "active",
"related_entries": ["mthfr_variant_id"]
}
```
### Case 3: Protocol Evolution
**User (2 weeks later):** "How's the caviar protocol working?"
**LLM sees:**
- Journal: "Caviar protocol (active, started Feb 8)"
**LLM response:**
> "I see you started the caviar protocol (20-25g daily) on Feb 8. It's been 2 weeks - are you seeing any changes in Sophia's neurological status? Any updates on tone, alertness, or symptoms?"
**User provides update → LLM updates journal:**
```json
{
"entry_id": "abc123",
"status": "active",
"append_note": "Update 2026-02-22: After 2 weeks, parents report improved alertness, sleep quality slightly better. No adverse effects. Continuing protocol."
}
```
---
## Implementation Checklist
### Backend (inou API)
- [ ] `GET /api/v1/dossiers/{id}/journal` - list journal summaries
- [ ] `GET /api/v1/dossiers/{id}/journal/{entry_id}` - get full entry
- [ ] `POST /api/v1/dossiers/{id}/journal` - create entry
- [ ] Accept optional `summary` field in request
- [ ] If summary not provided, generate with Gemini
- [ ] Validate summary length (max 300 chars)
- [ ] Return generated summary in response
- [ ] `PATCH /api/v1/dossiers/{id}/journal/{entry_id}` - update entry
- [ ] Validation: type enum, status enum, required fields
- [ ] Gemini integration for summary generation
- [ ] Prompt template for journal summarization
- [ ] Fallback if Gemini unavailable (extract first sentences)
### MCP Server (inou-mcp)
- [ ] Implement `prompts/list` handler
- [ ] Implement `prompts/get` handler for `family_health_context`
- [ ] Fetch accessible dossiers
- [ ] Fetch journal summaries (last 30 days)
- [ ] Format announcement (existence + brief journal summaries)
- [ ] Add `mcp__inou__list_journals` tool
- [ ] Add `mcp__inou__get_journal_entry` tool
- [ ] Add `mcp__inou__create_journal_entry` tool
- [ ] Add `mcp__inou__update_journal_entry` tool
### Portal
- [ ] Journal list page: `/dossier/{id}/journal`
- [ ] Journal entry detail/edit page
- [ ] Settings: journal_mode preference (disabled/suggest/automatic)
- [ ] Integration with existing dossier sections
### Testing
- [ ] Test auto-capture in "suggest" mode
- [ ] Test auto-capture in "automatic" mode
- [ ] Test family_health_context prompt loads correctly
- [ ] Test progressive disclosure (LLM fetches genome when announced)
- [ ] Test journal summaries provide enough context without being too detailed
- [ ] Test summary generation:
- [ ] Client-provided summary is accepted and validated
- [ ] Missing summary triggers Gemini generation
- [ ] Generated summaries are contextual and concise (1-2 sentences)
- [ ] Summary length validation rejects >300 chars
---
## Design Principles
1. **Continuity over novelty** - preserve reasoning across conversations
2. **Progressive disclosure** - announce existence, force fetching
3. **Lazy prevention** - don't give LLMs summaries of raw data (genome, labs)
4. **Summary exception** - journals get brief summaries because they're curated insights
5. **Gemini for consistency** - server generates summaries with Gemini when not provided, ensuring consistent format/quality
6. **User control** - three modes (disabled/suggest/automatic) for different comfort levels
7. **Minimal schema** - reuse existing entries table, no new tables
8. **Source preservation** - track which LLM, which conversation created each entry
9. **Status tracking** - draft → active → resolved/discarded lifecycle
10. **Relationships** - link journals to relevant data (genome variants, lab results, imaging)
11. **Context for care** - doctors and family can see reasoning behind decisions
---
## Future Enhancements
- **Conversation import** - paste claude.ai conversation URL → auto-extract journal entries
- **Browser extension** - detect medical conversations → one-click save to inou
- **Timeline view** - visualize protocols/hypotheses/observations over time
- **Cross-dossier connections** - link family members' journals (e.g., genetic inheritance)
- **LLM-generated summaries** - auto-summarize long entries for quick reference
- **Version history** - track edits to protocols over time
- **Collaboration** - multiple family members/doctors can add notes to entries

View File

@ -31,28 +31,40 @@ func ConfigInit() {
data, err := os.ReadFile(configFile)
if err != nil {
log.Printf("Warning: %s not found: %v", configFile, err)
return
} else {
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
switch key {
case "GEMINI_API_KEY":
GeminiKey = value
case "ANTHROPIC_API_KEY":
AnthropicKey = value
case "SYSTEM_ACCESSOR_ID":
SystemAccessorID = value
}
}
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
switch key {
case "GEMINI_API_KEY":
GeminiKey = value
case "ANTHROPIC_API_KEY":
AnthropicKey = value
case "SYSTEM_ACCESSOR_ID":
SystemAccessorID = value
// Fallback to environment variables if not set from file
if GeminiKey == "" {
GeminiKey = os.Getenv("GEMINI_API_KEY")
}
if AnthropicKey == "" {
AnthropicKey = os.Getenv("ANTHROPIC_API_KEY")
}
if SystemAccessorID == "" {
if envID := os.Getenv("SYSTEM_ACCESSOR_ID"); envID != "" {
SystemAccessorID = envID
}
}

View File

@ -141,10 +141,11 @@ func Normalize(dossierID string, category int) error {
data = make(map[string]interface{})
}
// Skip if already fully normalized (name + loinc match)
// Skip if already fully normalized (name + loinc + search_key match)
existingName, _ := data["normalized_name"].(string)
existingLoinc, _ := data["loinc"].(string)
if existingName == norm.Name && (norm.Loinc == "" || existingLoinc == norm.Loinc) {
needsSearchKey := (norm.Loinc != "" && e.SearchKey == "")
if existingName == norm.Name && (norm.Loinc == "" || existingLoinc == norm.Loinc) && !needsSearchKey {
continue
}

Binary file not shown.

Binary file not shown.

View File

@ -542,6 +542,116 @@ func getToolDefinitions() []map[string]interface{} {
}
}
func getPromptDefinitions() []map[string]interface{} {
return []map[string]interface{}{
{
"name": "family_health_context",
"description": "Load health context for all accessible dossiers with recent journal summaries",
"arguments": []map[string]interface{}{},
},
}
}
func handlePromptGet(id interface{}, params json.RawMessage) {
var p struct {
Name string `json:"name"`
}
if err := json.Unmarshal(params, &p); err != nil {
respondError(id, -32602, "Invalid params")
return
}
if p.Name == "family_health_context" {
content, err := buildFamilyHealthContext()
if err != nil {
respondError(id, -32000, err.Error())
return
}
respond(id, map[string]interface{}{
"messages": []map[string]interface{}{
{
"role": "user",
"content": map[string]interface{}{
"type": "text",
"text": content,
},
},
},
})
} else {
respondError(id, -32602, "Unknown prompt: "+p.Name)
}
}
func buildFamilyHealthContext() (string, error) {
// Fetch all accessible dossiers
body, err := apiGet("/api/v1/dossiers", nil)
if err != nil {
return "", err
}
// API returns array of dossiers directly
var dossiers []map[string]interface{}
if err := json.Unmarshal(body, &dossiers); err != nil {
return "", err
}
var sb strings.Builder
sb.WriteString("=== Family Health Context ===\n\n")
for _, dossier := range dossiers {
dossierID, _ := dossier["id"].(string)
name, _ := dossier["name"].(string)
dob, _ := dossier["date_of_birth"].(string)
sex, _ := dossier["sex"].(string)
categories, _ := dossier["categories"].([]interface{})
sb.WriteString(fmt.Sprintf("%s (%s)\n", name, dossierID))
// Show DOB and sex if available
if dob != "" || sex != "" {
sb.WriteString("- ")
if dob != "" {
sb.WriteString(fmt.Sprintf("DOB: %s", dob))
if sex != "" {
sb.WriteString(", ")
}
}
if sex != "" {
sb.WriteString(fmt.Sprintf("Sex: %s", sex))
}
sb.WriteString("\n")
}
// Show available data categories
if len(categories) > 0 {
sb.WriteString("\nData available:\n")
for _, cat := range categories {
if catStr, ok := cat.(string); ok {
// Capitalize first letter for display
display := strings.ToUpper(catStr[:1]) + catStr[1:]
// For genome, maybe show which genomes exist
if catStr == "genome" {
// TODO: Query genome sources when available
sb.WriteString("• Genome data available\n")
} else {
sb.WriteString(fmt.Sprintf("• %s\n", display))
}
}
}
}
// TODO: Add journal summaries when journals are implemented
sb.WriteString("\nRecent journals: (coming soon)\n")
sb.WriteString("\n---\n\n")
}
return sb.String(), nil
}
func handleToolCall(id interface{}, params json.RawMessage) {
var p struct {
Name string `json:"name"`
@ -821,8 +931,11 @@ func main() {
case "initialize":
respond(req.ID, map[string]interface{}{
"protocolVersion": "2025-06-18",
"capabilities": map[string]interface{}{"tools": map[string]interface{}{}},
"serverInfo": map[string]interface{}{"name": "inou-bridge", "version": Version},
"capabilities": map[string]interface{}{
"tools": map[string]interface{}{},
"prompts": map[string]interface{}{},
},
"serverInfo": map[string]interface{}{"name": "inou-bridge", "version": Version},
})
case "notifications/initialized":
// No response needed
@ -834,6 +947,10 @@ func main() {
} else {
handleToolCall(req.ID, req.Params)
}
case "prompts/list":
respond(req.ID, map[string]interface{}{"prompts": getPromptDefinitions()})
case "prompts/get":
handlePromptGet(req.ID, req.Params)
default:
respondError(req.ID, -32601, "Method not found: "+req.Method)
}

View File

@ -0,0 +1,45 @@
-- Migration: Rename prompts to trackers and add entry metadata
-- Date: 2026-02-09
-- Description: Rename "prompt" concept to "tracker" and make entries self-contained
-- Drop old prompts table (test data only, safe to drop)
DROP TABLE IF EXISTS prompts;
-- Create new trackers table (renamed from prompts)
CREATE TABLE trackers (
tracker_id TEXT PRIMARY KEY,
dossier_id TEXT NOT NULL,
category TEXT,
type TEXT,
question TEXT,
frequency TEXT,
time_of_day TEXT,
next_ask INTEGER,
expires_at INTEGER,
input_type TEXT,
input_config TEXT,
group_name TEXT,
trigger_entry TEXT,
created_by TEXT,
last_response TEXT,
last_response_raw TEXT,
last_response_at INTEGER,
dismissed INTEGER DEFAULT 0,
active INTEGER DEFAULT 1,
created_at INTEGER,
updated_at INTEGER,
open INTEGER DEFAULT 0,
source_input TEXT,
schedule TEXT
);
CREATE INDEX idx_trackers_dossier ON trackers(dossier_id);
CREATE INDEX idx_trackers_dossier_active ON trackers(dossier_id, active, next_ask);
-- Update entries table to be self-contained
-- Add columns for storing tracker metadata snapshot
ALTER TABLE entries ADD COLUMN tracker_question TEXT DEFAULT '';
ALTER TABLE entries ADD COLUMN tracker_fields TEXT DEFAULT ''; -- JSON: field definitions
ALTER TABLE entries ADD COLUMN tracker_layout TEXT DEFAULT ''; -- layout type
-- Note: search_key is now optional - entries can be standalone or linked to tracker

View File

@ -0,0 +1,86 @@
#!/bin/bash
# Comprehensive rename: prompt → tracker everywhere
set -e
echo "=== Renaming prompt to tracker ==="
# 1. Rename Go files
echo "Renaming Go files..."
[ -f lib/prompts.go ] && git mv lib/prompts.go lib/trackers.go
[ -f portal/prompts.go ] && git mv portal/prompts.go portal/trackers.go
[ -f api/api_prompts.go ] && git mv api/api_prompts.go api/api_trackers.go
# 2. Rename template files
echo "Renaming template files..."
[ -f portal/templates/prompts.tmpl ] && git mv portal/templates/prompts.tmpl portal/templates/trackers.tmpl
[ -f portal/templates/prompt_card.tmpl ] && git mv portal/templates/prompt_card.tmpl portal/templates/tracker_card.tmpl
# 3. Rename prompt directory
echo "Renaming prompts directory..."
[ -d api/prompts ] && git mv api/prompts api/tracker_prompts
# 4. Content replacements (order matters - most specific first!)
echo "Replacing content in Go files..."
# Function names and variables - most specific first
find lib portal api -name "*.go" -type f -exec sed -i \
-e 's/PromptRespond/TrackerRespond/g' \
-e 's/PromptAdd/TrackerAdd/g' \
-e 's/PromptGet/TrackerGet/g' \
-e 's/PromptList/TrackerList/g' \
-e 's/PromptUpdate/TrackerUpdate/g' \
-e 's/PromptDelete/TrackerDelete/g' \
-e 's/PromptSkip/TrackerSkip/g' \
-e 's/PromptDismiss/TrackerDismiss/g' \
-e 's/PromptView/TrackerView/g' \
-e 's/PromptField/TrackerField/g' \
-e 's/PromptGroup/TrackerGroup/g' \
-e 's/PromptRespondRequest/TrackerRespondRequest/g' \
-e 's/handlePrompts/handleTrackers/g' \
-e 's/handlePromptRespond/handleTrackerRespond/g' \
-e 's/handlePromptCreate/handleTrackerCreate/g' \
-e 's/handleRenderPromptCard/handleRenderTrackerCard/g' \
-e 's/handlePromptsRouter/handleTrackersRouter/g' \
-e 's/tryGeneratePromptFromFreeform/tryGenerateTrackerFromFreeform/g' \
-e 's/callLLMForPrompt/callLLMForTracker/g' \
-e 's/getExistingPromptTypes/getExistingTrackerTypes/g' \
-e 's/type Prompt struct/type Tracker struct/g' \
-e 's/\*Prompt/\*Tracker/g' \
-e 's/\[\]Prompt/\[\]Tracker/g' \
-e 's/prompt_id/tracker_id/g' \
-e 's/PromptID/TrackerID/g' \
-e 's/promptID/trackerID/g' \
-e 's/newPrompt/newTracker/g' \
-e 's/\.Prompt/.Tracker/g' \
-e 's/ prompt / tracker /g' \
-e 's/ prompts / trackers /g' \
-e 's/\"prompts\"/\"trackers\"/g' \
-e 's/FROM prompts/FROM trackers/g' \
-e 's/INTO prompts/INTO trackers/g' \
-e 's/UPDATE prompts/UPDATE trackers/g' \
-e 's/DELETE FROM prompts/DELETE FROM trackers/g' \
-e 's/PromptsDir/TrackerPromptsDir/g' \
{} \;
# Template files
echo "Replacing content in templates..."
find portal/templates -name "*.tmpl" -type f -exec sed -i \
-e 's/prompt-/tracker-/g' \
-e 's/Prompt/Tracker/g' \
-e 's/prompt/tracker/g' \
-e 's/prompts/trackers/g' \
{} \;
# API endpoints in templates (JavaScript)
find portal/templates -name "*.tmpl" -type f -exec sed -i \
-e 's/\/api\/prompts/\/api\/trackers/g' \
-e 's/\/dossier\/.*\/prompts/\/dossier\/.*\/trackers/g' \
{} \;
echo "=== Rename complete! ==="
echo "Next steps:"
echo "1. Review changes with: git diff"
echo "2. Test compile: make"
echo "3. Commit: git add -A && git commit -m 'Rename prompt to tracker'"
echo "4. Deploy: make deploy"

View File

@ -2006,6 +2006,7 @@ func setupMux() http.Handler {
} else if strings.Contains(path, "/rbac/") { handleEditRBAC(w, r)
} else if strings.Contains(path, "/access/") { handleEditAccess(w, r)
} else if strings.HasSuffix(path, "/prompts") { handlePrompts(w, r)
} else if strings.Contains(path, "/prompts/card/") { handleRenderPromptCard(w, r)
} else if strings.HasSuffix(path, "/prompts/respond") { handlePromptRespond(w, r)
} else if strings.HasSuffix(path, "/upload") { if r.Method == "POST" { handleUploadPost(w, r) } else { handleUploadPage(w, r) }
} else if strings.Contains(path, "/files/") && strings.HasSuffix(path, "/delete") { handleDeleteFile(w, r)

View File

@ -245,6 +245,10 @@ func handleMCP(w http.ResponseWriter, r *http.Request) {
handleMCPToolsList(w, req)
case "tools/call":
handleMCPToolsCall(w, req, accessToken, token.DossierID)
case "prompts/list":
handleMCPPromptsList(w, req)
case "prompts/get":
handleMCPPromptsGet(w, req, accessToken, token.DossierID)
default:
sendMCPError(w, req.ID, -32601, "Method not found: "+req.Method)
}
@ -274,7 +278,8 @@ func handleMCPInitialize(w http.ResponseWriter, req mcpRequest) {
sendMCPResult(w, req.ID, map[string]interface{}{
"protocolVersion": mcpProtocolVersion,
"capabilities": map[string]interface{}{
"tools": map[string]interface{}{},
"tools": map[string]interface{}{},
"prompts": map[string]interface{}{},
},
"serverInfo": map[string]interface{}{
"name": mcpServerName,
@ -604,6 +609,122 @@ func mcpImageContent(base64Data, mimeType, text string) map[string]interface{} {
}
}
func handleMCPPromptsList(w http.ResponseWriter, req mcpRequest) {
prompts := []map[string]interface{}{
{
"name": "family_health_context",
"description": "Get overview of health data available for family members (accessible dossiers). Shows names, basic demographics, and what data categories exist without revealing actual medical data. Use this at the start of a conversation to understand what information is available.",
},
}
sendMCPResult(w, req.ID, map[string]interface{}{"prompts": prompts})
}
func handleMCPPromptsGet(w http.ResponseWriter, req mcpRequest, accessToken, dossierID string) {
var params struct {
Name string `json:"name"`
Arguments map[string]interface{} `json:"arguments"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
sendMCPError(w, req.ID, -32602, "Invalid params")
return
}
switch params.Name {
case "family_health_context":
result, err := buildFamilyHealthContext(accessToken)
if err != nil {
sendMCPError(w, req.ID, -32000, err.Error())
return
}
sendMCPResult(w, req.ID, map[string]interface{}{
"description": "Family health data overview",
"messages": []map[string]interface{}{
{
"role": "user",
"content": map[string]interface{}{
"type": "text",
"text": result,
},
},
},
})
default:
sendMCPError(w, req.ID, -32601, "Unknown prompt: "+params.Name)
}
}
func buildFamilyHealthContext(accessToken string) (string, error) {
// Parse the API response
var dossiers []struct {
ID string `json:"id"`
Name string `json:"name"`
DOB string `json:"date_of_birth"`
Sex string `json:"sex"`
Categories []string `json:"categories"`
}
// The mcpListDossiers returns formatted text, so we need to call the API directly
apiURL := "http://localhost:8081/api/v1/dossiers"
client := &http.Client{}
apiReq, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return "", err
}
apiReq.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := client.Do(apiReq)
if err != nil {
return "", fmt.Errorf("API request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
}
if err := json.NewDecoder(resp.Body).Decode(&dossiers); err != nil {
return "", fmt.Errorf("failed to decode API response: %v", err)
}
// Build the formatted output
var output strings.Builder
output.WriteString("# Family Health Data Overview\n\n")
output.WriteString("The following health records are accessible to you:\n\n")
for _, d := range dossiers {
output.WriteString(fmt.Sprintf("## %s\n", d.Name))
if d.DOB != "" {
output.WriteString(fmt.Sprintf("- DOB: %s\n", d.DOB))
}
if d.Sex != "" {
output.WriteString(fmt.Sprintf("- Sex: %s\n", d.Sex))
}
if len(d.Categories) > 0 {
output.WriteString("- Available data:\n")
for _, cat := range d.Categories {
output.WriteString(fmt.Sprintf(" - %s\n", cat))
}
} else {
output.WriteString("- No health data recorded yet\n")
}
output.WriteString("\n")
}
output.WriteString("---\n\n")
output.WriteString("To access specific data, use the appropriate MCP tools:\n")
output.WriteString("- Imaging: list_studies, list_series, list_slices, fetch_image\n")
output.WriteString("- Labs: query_entries with category='labs' (use LOINC codes for best results)\n")
output.WriteString("- Genome: query_genome (by gene name, rsid, or category)\n")
output.WriteString("- Documents: query_entries with category='documents'\n")
output.WriteString("- Other categories: Use get_categories to discover, then query_entries\n")
return output.String(), nil
}
// RegisterMCPRoutes registers MCP HTTP endpoints
func RegisterMCPRoutes(mux *http.ServeMux) {
mux.HandleFunc("/.well-known/oauth-protected-resource", handleOAuthProtectedResource)

View File

@ -28,12 +28,26 @@ type PromptField struct {
Value string // filled from last_response
}
type PromptGroup struct {
Title string `json:"title"`
Fields []PromptField `json:"fields"`
}
type ScheduleSlot struct {
Days []string `json:"days"`
Time string `json:"time"`
}
type PromptView struct {
ID string
Category string
Type string
Question string
Fields []PromptField
Groups []PromptGroup
Layout string // "two-column" or empty
Schedule []ScheduleSlot
ScheduleFormatted string
NextAsk int64
NextAskFormatted string
IsOverdue bool
@ -54,6 +68,8 @@ type EntryView struct {
Question string
SourceInput string
Fields []PromptField
Groups []PromptGroup
Layout string // "two-column" or empty
Timestamp int64
TimeFormatted string
PromptID string // linked prompt for delete
@ -113,6 +129,7 @@ func handlePrompts(w http.ResponseWriter, r *http.Request) {
NextAsk int64 `json:"next_ask"`
InputType string `json:"input_type"`
InputConfig json.RawMessage `json:"input_config"`
Schedule json.RawMessage `json:"schedule"`
SourceInput string `json:"source_input"`
LastResponse json.RawMessage `json:"last_response"`
LastResponseRaw string `json:"last_response_raw"`
@ -165,24 +182,47 @@ func handlePrompts(w http.ResponseWriter, r *http.Request) {
if len(ap.InputConfig) > 0 {
var config struct {
Fields []PromptField `json:"fields"`
Groups []PromptGroup `json:"groups"`
Layout string `json:"layout"`
}
if json.Unmarshal(ap.InputConfig, &config) == nil {
pv.Fields = config.Fields
pv.Groups = config.Groups
pv.Layout = config.Layout
}
}
// Fill values from last_response
log.Printf("DEBUG: q=%s lastResp=%d fields=%d", ap.Question, len(ap.LastResponse), len(pv.Fields))
if len(ap.LastResponse) > 0 && len(pv.Fields) > 0 {
// Parse schedule
if len(ap.Schedule) > 0 {
var schedule []ScheduleSlot
if json.Unmarshal(ap.Schedule, &schedule) == nil {
pv.Schedule = schedule
pv.ScheduleFormatted = formatSchedule(schedule)
}
}
// Fill values from last_response (for both flat fields and groups)
log.Printf("DEBUG: q=%s lastResp=%d fields=%d groups=%d", ap.Question, len(ap.LastResponse), len(pv.Fields), len(pv.Groups))
if len(ap.LastResponse) > 0 {
var lastResp map[string]interface{}
if err := json.Unmarshal(ap.LastResponse, &lastResp); err == nil {
log.Printf("DEBUG: parsed lastResp=%v", lastResp)
// Fill flat fields
for i := range pv.Fields {
if v, ok := lastResp[pv.Fields[i].Key]; ok {
pv.Fields[i].Value = fmt.Sprintf("%v", v)
log.Printf("DEBUG: set field %s = %v", pv.Fields[i].Key, v)
}
}
// Fill grouped fields
for gi := range pv.Groups {
for fi := range pv.Groups[gi].Fields {
if v, ok := lastResp[pv.Groups[gi].Fields[fi].Key]; ok {
pv.Groups[gi].Fields[fi].Value = fmt.Sprintf("%v", v)
log.Printf("DEBUG: set group field %s = %v", pv.Groups[gi].Fields[fi].Key, v)
}
}
}
} else {
log.Printf("DEBUG: unmarshal error: %v raw=%s", err, string(ap.LastResponse))
}
@ -231,12 +271,15 @@ func handlePrompts(w http.ResponseWriter, r *http.Request) {
if e.SearchKey != "" {
log.Printf("DEBUG: Looking for prompt with id=%s", e.SearchKey)
if prompt, ok := promptMap[e.SearchKey]; ok {
log.Printf("DEBUG: Found matching prompt: question=%s, fields=%d", prompt.Question, len(prompt.Fields))
log.Printf("DEBUG: Found matching prompt: question=%s, fields=%d, groups=%d", prompt.Question, len(prompt.Fields), len(prompt.Groups))
ev.Question = prompt.Question
ev.PromptID = prompt.ID
// Copy fields from prompt
// Copy fields, groups, and layout from prompt
ev.Fields = make([]PromptField, len(prompt.Fields))
copy(ev.Fields, prompt.Fields)
ev.Groups = make([]PromptGroup, len(prompt.Groups))
copy(ev.Groups, prompt.Groups)
ev.Layout = prompt.Layout
} else {
log.Printf("DEBUG: No prompt found with id=%s", e.SearchKey)
}
@ -255,11 +298,20 @@ func handlePrompts(w http.ResponseWriter, r *http.Request) {
// Fill field values
var values map[string]interface{}
if json.Unmarshal(dataMap.Values, &values) == nil {
// Fill flat fields
for i := range ev.Fields {
if v, ok := values[ev.Fields[i].Key]; ok {
ev.Fields[i].Value = fmt.Sprintf("%v", v)
}
}
// Fill grouped fields
for gi := range ev.Groups {
for fi := range ev.Groups[gi].Fields {
if v, ok := values[ev.Groups[gi].Fields[fi].Key]; ok {
ev.Groups[gi].Fields[fi].Value = fmt.Sprintf("%v", v)
}
}
}
}
}
}
@ -361,6 +413,26 @@ func handlePromptRespond(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("/dossier/%s/prompts", targetHex), http.StatusSeeOther)
}
// formatSchedule returns a human-readable schedule
func formatSchedule(slots []ScheduleSlot) string {
if len(slots) == 0 {
return ""
}
var parts []string
for _, slot := range slots {
// Check if all days
if len(slot.Days) == 7 {
parts = append(parts, fmt.Sprintf("Daily at %s", slot.Time))
} else {
dayStr := strings.Join(slot.Days, ", ")
parts = append(parts, fmt.Sprintf("%s at %s", dayStr, slot.Time))
}
}
return strings.Join(parts, " and ")
}
// formatDueDate returns a human-readable due date
func formatDueDate(ts int64) string {
now := time.Now()
@ -396,3 +468,162 @@ func formatDueDate(ts int64) string {
// Otherwise show date
return due.Format("Jan 2, 3:04 PM")
}
// handleRenderPromptCard renders just the prompt card HTML for a given prompt
func handleRenderPromptCard(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, "/")
// Path: /dossier/{id}/prompts/card/{promptID}
if len(parts) < 6 {
http.NotFound(w, r)
return
}
targetHex := parts[2]
promptID := parts[5]
// Get prompt from API
resp, err := http.Get(fmt.Sprintf("http://localhost:8082/api/prompts?dossier=%s", targetHex))
if err != nil {
http.Error(w, "Failed to fetch prompts", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
var prompts []map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&prompts); err != nil {
http.Error(w, "Failed to parse prompts", http.StatusInternalServerError)
return
}
// Find the specific prompt
var targetPrompt map[string]interface{}
for _, prompt := range prompts {
if prompt["id"] == promptID {
targetPrompt = prompt
break
}
}
if targetPrompt == nil {
http.NotFound(w, r)
return
}
// Convert to PromptView
promptView := convertToPromptView(targetPrompt)
// Render just the card template
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := templates.ExecuteTemplate(w, "prompt_card.tmpl", promptView); err != nil {
log.Printf("Template execution error: %v", err)
http.Error(w, "Rendering error", http.StatusInternalServerError)
}
}
// convertToPromptView converts API prompt JSON to PromptView
func convertToPromptView(prompt map[string]interface{}) PromptView {
view := PromptView{
ID: prompt["id"].(string),
Question: getString(prompt, "question"),
Category: getString(prompt, "category"),
}
// Schedule
if scheduleRaw, ok := prompt["schedule"].([]interface{}); ok {
for _, s := range scheduleRaw {
slot := s.(map[string]interface{})
var days []string
if daysRaw, ok := slot["days"].([]interface{}); ok {
for _, d := range daysRaw {
days = append(days, d.(string))
}
}
view.Schedule = append(view.Schedule, ScheduleSlot{
Days: days,
Time: getString(slot, "time"),
})
}
view.ScheduleFormatted = formatSchedule(view.Schedule)
}
// Next ask
if nextAsk, ok := prompt["next_ask"].(float64); ok {
view.NextAskFormatted = formatDueDate(int64(nextAsk))
}
// Layout
view.Layout = getString(prompt, "layout")
// Input config
if config, ok := prompt["input_config"].(map[string]interface{}); ok {
// Groups
if groupsRaw, ok := config["groups"].([]interface{}); ok {
for _, g := range groupsRaw {
group := g.(map[string]interface{})
fg := PromptGroup{
Title: getString(group, "title"),
}
if fieldsRaw, ok := group["fields"].([]interface{}); ok {
for _, f := range fieldsRaw {
field := f.(map[string]interface{})
fg.Fields = append(fg.Fields, PromptField{
Key: getString(field, "key"),
Label: getString(field, "label"),
Type: getString(field, "type"),
Unit: getString(field, "unit"),
})
}
}
view.Groups = append(view.Groups, fg)
}
}
// Flat fields
if fieldsRaw, ok := config["fields"].([]interface{}); ok {
for _, f := range fieldsRaw {
field := f.(map[string]interface{})
view.Fields = append(view.Fields, PromptField{
Key: getString(field, "key"),
Label: getString(field, "label"),
Type: getString(field, "type"),
Unit: getString(field, "unit"),
})
}
}
}
// Fill values from last_response (for both flat fields and groups)
if lastRespRaw, ok := prompt["last_response"].(string); ok && lastRespRaw != "" {
var lastResp map[string]interface{}
if err := json.Unmarshal([]byte(lastRespRaw), &lastResp); err == nil {
// Fill flat fields
for i := range view.Fields {
if v, ok := lastResp[view.Fields[i].Key]; ok {
view.Fields[i].Value = fmt.Sprintf("%v", v)
}
}
// Fill grouped fields
for gi := range view.Groups {
for fi := range view.Groups[gi].Fields {
if v, ok := lastResp[view.Groups[gi].Fields[fi].Key]; ok {
view.Groups[gi].Fields[fi].Value = fmt.Sprintf("%v", v)
}
}
}
}
}
return view
}
func getString(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok && v != nil {
return fmt.Sprintf("%v", v)
}
return ""
}

View File

@ -78,6 +78,28 @@
<div class="step">
<div class="step-header">
<span class="step-num">5</span>
<h3>Add Custom Instructions (Recommended)</h3>
</div>
<p>To automatically load your family's health data context in every conversation:</p>
<ol style="margin: 8px 0 12px 20px;">
<li>In Claude Desktop, go to <strong>Settings → Custom Instructions</strong></li>
<li>In the text area, add:</li>
</ol>
<div class="code-wrapper">
<pre id="custom-instructions">At the start of health-related conversations, use the family_health_context prompt from the Inou Health connector to understand what health data is available.</pre>
<button class="copy-icon" onclick="copyCode('custom-instructions', this)" title="Copy">
<svg viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</button>
</div>
<p>This tells Claude to check what health data is available for your family members at the beginning of each conversation, without filling up the context window with actual medical data.</p>
</div>
<div class="step">
<div class="step-header">
<span class="step-num">6</span>
<h3>Test</h3>
</div>
<p>Open Claude Desktop and try:</p>

View File

@ -0,0 +1,160 @@
{{/* Reusable prompt card partial - expects . to be a PromptView */}}
<div class="prompt-item prompt-pending" data-prompt-id="{{.ID}}">
<a href="#" class="prompt-dismiss" onclick="showDismissConfirm(this, '{{.ID}}'); return false;" title="Don't ask again">✕</a>
<div class="dismiss-confirm">
<span>Stop tracking?</span>
<a href="#" onclick="confirmDismiss('{{.ID}}'); return false;">Yes</a>
<a href="#" onclick="hideDismissConfirm(this); return false;">No</a>
</div>
<form class="prompt-form" data-prompt-id="{{.ID}}">
<div class="prompt-header">
<div style="display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px;">
<div>
<span class="prompt-question">{{.Question}}</span>
{{if .ScheduleFormatted}}
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 4px;">🔁 {{.ScheduleFormatted}}</div>
{{end}}
</div>
<div style="display: flex; gap: 12px; align-items: center;">
<span class="prompt-category">{{.Category}}</span>
<span class="prompt-due">{{.NextAskFormatted}}</span>
</div>
</div>
</div>
<div class="prompt-body">
{{if .Groups}}
{{if eq .Layout "two-column"}}
{{/* Two-column layout - first two groups side-by-side */}}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px; align-items: start;">
{{range $i, $g := .Groups}}
{{if lt $i 2}}
<div class="prompt-group">
{{if $g.Title}}<div class="prompt-group-title">{{$g.Title}}</div>{{end}}
<div class="prompt-input-row" style="flex-direction: column; align-items: flex-start; gap: 8px;">
{{range $g.Fields}}
{{if eq .Type "number"}}
<div style="display: flex; align-items: center; gap: 4px; width: 100%;">
{{if .Label}}<span class="prompt-field-label" style="min-width: 100px;">{{.Label}}:</span>{{end}}
<input type="number" name="field_{{.Key}}"
{{if .Min}}min="{{.Min}}"{{end}}
{{if .Max}}max="{{.Max}}"{{end}}
{{if .Step}}step="{{.Step}}"{{end}}
{{if .Value}}value="{{.Value}}"{{end}}
class="prompt-input-number"
style="max-width: 80px;"
onkeydown="if(event.key==='Enter'){event.preventDefault();saveItem(this.closest('.prompt-item'));}">
{{if .Unit}}<span class="prompt-unit">{{.Unit}}</span>{{end}}
</div>
{{end}}
{{end}}
</div>
</div>
{{end}}
{{end}}
</div>
{{/* Remaining groups below (index 2+) with save button inline */}}
{{range $i, $g := .Groups}}
{{if ge $i 2}}
<div class="prompt-group" style="margin-top: 16px;">
{{if $g.Title}}<div class="prompt-group-title">{{$g.Title}}</div>{{end}}
{{range $g.Fields}}
{{if eq .Type "text"}}
<div style="display: flex; align-items: center; gap: 8px;">
<input type="text" name="field_{{.Key}}"
{{if .Value}}value="{{.Value}}"{{end}}
{{if .MaxLength}}maxlength="{{.MaxLength}}"{{end}}
class="prompt-input-text"
style="flex: 1;"
placeholder="{{.Label}}">
<button type="button" class="btn-save" onclick="saveItem(this.closest('.prompt-item'))">Save</button>
</div>
{{end}}
{{end}}
</div>
{{end}}
{{end}}
{{/* Save button if no text fields */}}
{{$hasText := false}}
{{range .Groups}}
{{range .Fields}}
{{if eq .Type "text"}}{{$hasText = true}}{{end}}
{{end}}
{{end}}
{{if not $hasText}}
<div style="display: flex; justify-content: flex-end; margin-top: 12px;">
<button type="button" class="btn-save" onclick="saveItem(this.closest('.prompt-item'))">Save</button>
</div>
{{end}}
{{else}}
{{/* Regular grouped fields */}}
{{range .Groups}}
<div class="prompt-group">
{{if .Title}}<div class="prompt-group-title">{{.Title}}</div>{{end}}
<div class="prompt-input-row" style="justify-content: flex-start; align-items: center; flex-wrap: wrap;">
{{range .Fields}}
{{if eq .Type "number"}}
<div style="display: flex; align-items: center; gap: 4px; margin-right: 16px;">
{{if .Label}}<span class="prompt-field-label">{{.Label}}:</span>{{end}}
<input type="number" name="field_{{.Key}}"
{{if .Min}}min="{{.Min}}"{{end}}
{{if .Max}}max="{{.Max}}"{{end}}
{{if .Step}}step="{{.Step}}"{{end}}
{{if .Value}}value="{{.Value}}"{{end}}
class="prompt-input-number"
style="max-width: 80px;"
onkeydown="if(event.key==='Enter'){event.preventDefault();saveItem(this.closest('.prompt-item'));}">
{{if .Unit}}<span class="prompt-unit">{{.Unit}}</span>{{end}}
</div>
{{else if eq .Type "text"}}
<div style="display: flex; align-items: center; gap: 4px; width: 100%;">
<input type="text" name="field_{{.Key}}"
{{if .Value}}value="{{.Value}}"{{end}}
{{if .MaxLength}}maxlength="{{.MaxLength}}"{{end}}
class="prompt-input-text"
style="flex: 1;"
placeholder="{{.Label}}">
</div>
{{end}}
{{end}}
</div>
</div>
{{end}}
<div style="display: flex; justify-content: flex-end; margin-top: 12px;">
<button type="button" class="btn-save" onclick="saveItem(this.closest('.prompt-item'))">Save</button>
</div>
{{end}}
{{else if .Fields}}
{{/* Flat fields */}}
<div class="prompt-input-row" style="justify-content: flex-start; align-items: center;">
{{range .Fields}}
{{if eq .Type "number"}}
{{if .Label}}<span class="prompt-field-label">{{.Label}}</span>{{end}}
<input type="number" name="field_{{.Key}}"
{{if .Min}}min="{{.Min}}"{{end}}
{{if .Max}}max="{{.Max}}"{{end}}
{{if .Step}}step="{{.Step}}"{{end}}
{{if .Value}}value="{{.Value}}"{{else}}placeholder="0"{{end}}
class="prompt-input-number"
style="max-width: 120px;"
onkeydown="if(event.key==='Enter'){event.preventDefault();saveItem(this.closest('.prompt-item'));}">
{{if .Unit}}<span class="prompt-unit">{{.Unit}}</span>{{end}}
{{else if eq .Type "checkbox"}}
<button type="button" class="prompt-btn" data-field="{{.Key}}" data-value="yes" onclick="selectYesNo(this)">Yes</button>
<button type="button" class="prompt-btn" data-field="{{.Key}}" data-value="no" onclick="selectYesNo(this)">No</button>
<input type="hidden" name="field_{{.Key}}" value="">
{{else if eq .Type "text"}}
{{if .Label}}<span class="prompt-field-label">{{.Label}}</span>{{end}}
<input type="text" name="field_{{.Key}}"
{{if .Value}}value="{{.Value}}"{{end}}
{{if .MaxLength}}maxlength="{{.MaxLength}}"{{end}}
class="prompt-input-text"
placeholder="{{.Label}}"
onkeydown="if(event.key==='Enter'){event.preventDefault();saveItem(this.closest('.prompt-item'));}">
{{end}}
{{end}}
<button type="button" class="btn-save" onclick="saveItem(this.closest('.prompt-item'))" style="margin-left: auto;">Save</button>
</div>
{{end}}
</div>
</form>
</div>

View File

@ -24,35 +24,122 @@
<div class="prompt-list">
{{/* 1. FILLED CARDS - Entries from today (SHOW FIRST) */}}
{{range .Entries}}
<div class="prompt-item prompt-filled" data-entry-id="{{.ID}}">
<div class="prompt-item prompt-filled" data-entry-id="{{.ID}}" data-prompt-id="{{.PromptID}}">
<a href="#" class="prompt-dismiss" onclick="deleteEntry('{{.ID}}'); return false;" title="Delete">✕</a>
<div class="prompt-header">
<div style="display: flex; justify-content: space-between; align-items: baseline;">
<span class="prompt-question">{{.Question}}</span>
<span class="prompt-saved-time">{{.TimeFormatted}}</span>
<form class="prompt-form" data-entry-id="{{.ID}}">
<div class="prompt-header">
<div style="display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px;">
<span class="prompt-question">{{.Question}}</span>
<div style="display: flex; gap: 12px; align-items: center;">
<a href="#" class="prompt-edit" onclick="editEntry(this); return false;">edit</a>
<span class="prompt-category">{{.Category}}</span>
<span class="prompt-saved-time">{{.TimeFormatted}}</span>
</div>
</div>
</div>
<div style="margin-top: 4px;">
<span class="prompt-category">{{.Category}}</span>
</div>
</div>
<div class="prompt-body entry-readonly">
{{range .Fields}}
<div class="prompt-field-row">
{{if eq .Type "number"}}
<span class="entry-value">{{.Value}}</span>
{{if .Unit}}<span class="prompt-unit">{{.Unit}}</span>{{end}}
{{else if eq .Type "checkbox"}}
<span class="entry-check">{{if .Value}}✓{{else}}—{{end}}</span>
{{if .Label}}<span class="prompt-checkbox-label">{{.Label}}</span>{{end}}
<div class="prompt-body">
{{if .Groups}}
{{if eq .Layout "two-column"}}
{{/* Two-column layout - first two groups side-by-side */}}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px;">
{{range $i, $g := .Groups}}
{{if lt $i 2}}
<div class="prompt-group">
{{if $g.Title}}<div class="prompt-group-title">{{$g.Title}}</div>{{end}}
<div class="prompt-input-row" style="flex-direction: column; align-items: flex-start; gap: 8px;">
{{range $g.Fields}}
{{if eq .Type "number"}}
<div style="display: flex; align-items: center; gap: 4px; width: 100%;">
{{if .Label}}<span class="prompt-field-label" style="min-width: 100px;">{{.Label}}:</span>{{end}}
<input type="number" name="field_{{.Key}}"
{{if .Min}}min="{{.Min}}"{{end}}
{{if .Max}}max="{{.Max}}"{{end}}
{{if .Step}}step="{{.Step}}"{{end}}
value="{{.Value}}"
class="prompt-input-number"
style="max-width: 80px;"
disabled>
{{if .Unit}}<span class="prompt-unit">{{.Unit}}</span>{{end}}
</div>
{{end}}
{{end}}
</div>
</div>
{{end}}
{{end}}
</div>
{{/* Remaining groups below (index 2+) */}}
{{range $i, $g := .Groups}}
{{if ge $i 2}}
<div class="prompt-group" style="margin-top: 16px;">
{{if $g.Title}}<div class="prompt-group-title">{{$g.Title}}</div>{{end}}
{{range $g.Fields}}
{{if eq .Type "text"}}
{{if .Value}}
<div style="padding: 8px 0; color: var(--text-muted); font-size: 0.9rem;">{{.Value}}</div>
{{end}}
{{end}}
{{end}}
</div>
{{end}}
{{end}}
{{else}}
<span class="entry-value">{{.Value}}</span>
{{/* Regular grouped fields */}}
{{range .Groups}}
<div class="prompt-group">
{{if .Title}}<div class="prompt-group-title">{{.Title}}</div>{{end}}
<div class="prompt-input-row" style="justify-content: flex-start; align-items: center; flex-wrap: wrap;">
{{range .Fields}}
{{if eq .Type "number"}}
<div style="display: flex; align-items: center; gap: 4px; margin-right: 16px;">
{{if .Label}}<span class="prompt-field-label">{{.Label}}:</span>{{end}}
<input type="number" name="field_{{.Key}}"
{{if .Min}}min="{{.Min}}"{{end}}
{{if .Max}}max="{{.Max}}"{{end}}
{{if .Step}}step="{{.Step}}"{{end}}
value="{{.Value}}"
class="prompt-input-number"
style="max-width: 80px;"
disabled>
{{if .Unit}}<span class="prompt-unit">{{.Unit}}</span>{{end}}
</div>
{{else if eq .Type "text"}}
{{if .Value}}
<div style="width: 100%; padding: 8px 0; color: var(--text-muted); font-size: 0.9rem;">{{.Value}}</div>
{{end}}
{{end}}
{{end}}
</div>
</div>
{{end}}
{{end}}
</div>
<button type="button" class="btn-save" onclick="saveItem(this.closest('.prompt-item'))" style="display: none;">Save</button>
{{else if .Fields}}
{{/* Flat fields */}}
<div class="prompt-input-row" style="justify-content: flex-start; align-items: center;">
{{range .Fields}}
{{if eq .Type "number"}}
{{if .Label}}<span class="prompt-field-label">{{.Label}}</span>{{end}}
<input type="number" name="field_{{.Key}}"
{{if .Min}}min="{{.Min}}"{{end}}
{{if .Max}}max="{{.Max}}"{{end}}
{{if .Step}}step="{{.Step}}"{{end}}
value="{{.Value}}"
class="prompt-input-number"
style="max-width: 120px;"
disabled>
{{if .Unit}}<span class="prompt-unit">{{.Unit}}</span>{{end}}
{{else if eq .Type "checkbox"}}
<button type="button" class="prompt-btn" data-field="{{.Key}}" data-value="yes" onclick="selectYesNo(this)" disabled>Yes</button>
<button type="button" class="prompt-btn" data-field="{{.Key}}" data-value="no" onclick="selectYesNo(this)" disabled>No</button>
<input type="hidden" name="field_{{.Key}}" value="{{.Value}}">
{{end}}
{{end}}
<button type="button" class="btn-save" onclick="saveItem(this.closest('.prompt-item'))" style="margin-left: auto; display: none;">Save</button>
</div>
{{end}}
</div>
<div class="prompt-saved-footer">
<a href="#" class="prompt-edit" onclick="editEntry(this); return false;">edit</a>
</div>
</div>
</form>
{{if .SourceInput}}<div class="prompt-source">Created from: "{{.SourceInput}}"</div>{{end}}
</div>
{{end}}
@ -60,53 +147,7 @@
{{/* 2. ALL PROMPTS - Unfilled prompts (due + upcoming) */}}
{{range .AllPrompts}}
{{if not .HasResponse}}
<div class="prompt-item prompt-pending" data-prompt-id="{{.ID}}">
<a href="#" class="prompt-dismiss" onclick="showDismissConfirm(this, '{{.ID}}'); return false;" title="Don't ask again">✕</a>
<div class="dismiss-confirm">
<span>Stop tracking?</span>
<a href="#" onclick="confirmDismiss('{{.ID}}'); return false;">Yes</a>
<a href="#" onclick="hideDismissConfirm(this); return false;">No</a>
</div>
<form class="prompt-form" data-prompt-id="{{.ID}}">
<div class="prompt-header">
<div style="display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px;">
<span class="prompt-question">{{.Question}}</span>
<div style="display: flex; gap: 12px; align-items: center;">
<span class="prompt-category">{{.Category}}</span>
<span class="prompt-due">{{.NextAskFormatted}}</span>
</div>
</div>
</div>
<div class="prompt-body">
{{if .Fields}}
{{if eq (len .Fields) 1}}
{{with index .Fields 0}}
{{if eq .Type "number"}}
<div class="prompt-input-row" style="justify-content: flex-start;">
<input type="number" name="field_{{.Key}}"
{{if .Min}}min="{{.Min}}"{{end}}
{{if .Max}}max="{{.Max}}"{{end}}
{{if .Step}}step="{{.Step}}"{{end}}
{{if .Value}}value="{{.Value}}"{{end}}
class="prompt-input-number"
style="max-width: 120px;"
onkeydown="if(event.key==='Enter'){event.preventDefault();saveItem(this.closest('.prompt-item'));}">
{{if .Unit}}<span class="prompt-unit">{{.Unit}}</span>{{end}}
<button type="button" class="btn-save" onclick="saveItem(this.closest('.prompt-item'))" style="margin-left: auto;">Save</button>
</div>
{{else if eq .Type "checkbox"}}
<div class="prompt-buttons">
<button type="button" class="prompt-btn" data-field="{{.Key}}" data-value="yes" onclick="selectYesNo(this)">Yes</button>
<button type="button" class="prompt-btn" data-field="{{.Key}}" data-value="no" onclick="selectYesNo(this)">No</button>
<input type="hidden" name="field_{{.Key}}" value="">
</div>
{{end}}
{{end}}
{{end}}
{{end}}
</div>
</form>
</div>
{{template "prompt_card.tmpl" .}}
{{end}}
{{end}}
@ -264,6 +305,20 @@
.prompt-body {
margin-top: 8px;
}
.prompt-group {
margin-bottom: 16px;
}
.prompt-group:last-child {
margin-bottom: 0;
}
.prompt-group-title {
font-size: 0.85rem;
font-weight: 600;
color: var(--text);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.prompt-input-row {
display: flex;
align-items: flex-end;
@ -304,6 +359,16 @@
outline: none;
border-color: var(--accent);
}
.prompt-input-number:disabled {
background: #f8f9fa;
color: var(--text);
border-color: var(--border);
cursor: not-allowed;
}
.prompt-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.prompt-input-text {
width: 100%;
max-width: 300px;
@ -700,8 +765,20 @@ async function deleteEntry(entryId) {
}
function editEntry(btn) {
// TODO: implement entry editing
alert('Entry editing coming soon');
const item = btn.closest('.prompt-item');
const form = item.querySelector('.prompt-form');
// Enable all input fields
form.querySelectorAll('input, button.prompt-btn').forEach(el => {
el.disabled = false;
});
// Show save button, hide edit link
form.querySelector('.btn-save').style.display = 'block';
item.querySelector('.prompt-edit').style.display = 'none';
// Focus the input
form.querySelector('input:not([type=hidden])')?.focus();
}
function editPrompt(btn) {
@ -907,36 +984,87 @@ async function saveItem(item) {
return;
}
// For pending prompts, convert to filled card
// For pending prompts, transform to filled card
if (item.classList.contains('prompt-pending')) {
// Get the question from the prompt
const question = item.querySelector('.prompt-question').textContent;
const category = item.querySelector('.prompt-category')?.textContent || '';
// Change class
item.classList.remove('prompt-pending');
item.classList.add('prompt-filled');
// Create filled card
const filledCard = createFilledCard({
question: question,
category: category,
value: displayValue,
time: new Date().toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }),
promptId: promptId
// Disable all inputs
form.querySelectorAll('input, button.prompt-btn').forEach(el => {
el.disabled = true;
});
// Insert filled card before upcoming section (or at end of today section)
const promptList = document.querySelector('.prompt-list');
const freeformCard = promptList.querySelector('.prompt-freeform');
if (freeformCard && freeformCard.nextElementSibling) {
freeformCard.nextElementSibling.insertAdjacentHTML('beforebegin', filledCard);
} else {
promptList.insertAdjacentHTML('beforeend', filledCard);
// Hide save button
if (saveBtn) {
saveBtn.style.display = 'none';
saveBtn.classList.remove('saving');
saveBtn.textContent = originalText;
saveBtn.disabled = false;
}
// Remove the pending card
item.style.opacity = '0';
setTimeout(() => item.remove(), 300);
// Remove due time, add timestamp
const header = item.querySelector('.prompt-header > div');
const dueSpan = header.querySelector('.prompt-due');
if (dueSpan) {
dueSpan.className = 'prompt-saved-time';
dueSpan.textContent = new Date().toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
// Change dismiss button to delete
const dismissBtn = item.querySelector('.prompt-dismiss');
if (dismissBtn) {
dismissBtn.title = 'Delete';
dismissBtn.onclick = function() { deleteEntry(item.dataset.entryId || ''); return false; };
}
// Hide dismiss confirm
const dismissConfirm = item.querySelector('.dismiss-confirm');
if (dismissConfirm) dismissConfirm.remove();
// Add edit link
const existingFooter = item.querySelector('.prompt-saved-footer');
if (!existingFooter) {
const footer = document.createElement('div');
footer.className = 'prompt-saved-footer';
footer.innerHTML = '<a href="#" class="prompt-edit" onclick="editEntry(this); return false;">edit</a>';
form.appendChild(footer);
}
// Move to top of list (before other prompts)
const promptList = document.querySelector('.prompt-list');
const firstPrompt = promptList.querySelector('.prompt-pending');
if (firstPrompt) {
promptList.insertBefore(item, firstPrompt);
}
showNotification('✓ Saved', 'success');
}
// For filled cards being edited, re-disable fields
if (item.classList.contains('prompt-filled')) {
// Disable all input fields
form.querySelectorAll('input, button.prompt-btn').forEach(el => {
el.disabled = true;
});
// Hide save button, show edit link
if (saveBtn) {
saveBtn.style.display = 'none';
saveBtn.classList.remove('saving');
saveBtn.textContent = originalText;
saveBtn.disabled = false;
}
item.querySelector('.prompt-edit').style.display = 'inline';
// Update the timestamp
const timeEl = item.querySelector('.prompt-saved-time');
if (timeEl) {
timeEl.textContent = new Date().toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
showNotification('✓ Updated', 'success');
}
} else {
showNotification('Failed to save. Please try again.', 'error');
if (saveBtn) {
@ -977,55 +1105,27 @@ function createFilledCard(data) {
</div>`;
}
function addPendingCard(prompt) {
// Add a new pending card (from AI prompt generation)
async function addPendingCard(prompt) {
// Fetch server-rendered card HTML (avoids client-side duplication)
const promptList = document.querySelector('.prompt-list');
const freeformCard = promptList.querySelector('.prompt-freeform');
let fieldsHtml = '';
if (prompt.input_config && prompt.input_config.fields) {
const field = prompt.input_config.fields[0];
if (field.type === 'number') {
fieldsHtml = `
<div class="prompt-input-row">
<input type="number" name="field_${field.key}" class="prompt-input-number"
onkeydown="if(event.key==='Enter'){event.preventDefault();saveItem(this.closest('.prompt-item'));}">
${field.unit ? `<span class="prompt-unit">${field.unit}</span>` : ''}
<button type="button" class="btn-save" onclick="saveItem(this.closest('.prompt-item'))">Save</button>
</div>`;
} else if (field.type === 'checkbox') {
fieldsHtml = `
<div class="prompt-buttons">
<button type="button" class="prompt-btn" data-field="${field.key}" data-value="yes" onclick="selectYesNo(this)">Yes</button>
<button type="button" class="prompt-btn" data-field="${field.key}" data-value="no" onclick="selectYesNo(this)">No</button>
<input type="hidden" name="field_${field.key}" value="">
</div>`;
try {
const targetHex = window.location.pathname.split('/')[2];
const response = await fetch(`/dossier/${targetHex}/prompts/card/${prompt.id}`, {
credentials: 'same-origin'
});
if (!response.ok) throw new Error('Failed to render card');
const cardHtml = await response.text();
// Insert BEFORE freeform card (above "Anything else to track?")
if (freeformCard) {
freeformCard.insertAdjacentHTML('beforebegin', cardHtml);
}
}
const cardHtml = `
<div class="prompt-item prompt-pending" data-prompt-id="${prompt.id}" style="animation: slideIn 0.3s ease-out;">
<a href="#" class="prompt-dismiss" onclick="showDismissConfirm(this, '${prompt.id}'); return false;" title="Don't ask again">✕</a>
<div class="dismiss-confirm">
<span>Stop tracking?</span>
<a href="#" onclick="confirmDismiss('${prompt.id}'); return false;">Yes</a>
<a href="#" onclick="hideDismissConfirm(this); return false;">No</a>
</div>
<form class="prompt-form" data-prompt-id="${prompt.id}">
<div class="prompt-header">
<span class="prompt-category">${prompt.category}</span>
<span class="prompt-question">${prompt.question}</span>
<span class="prompt-due">now</span>
</div>
<div class="prompt-body">
${fieldsHtml}
</div>
</form>
</div>`;
// Insert after freeform card
if (freeformCard) {
freeformCard.insertAdjacentHTML('afterend', cardHtml);
} catch (err) {
console.error('Failed to add pending card:', err);
showNotification('Failed to display new prompt', 'error');
}
}

23
test-prompt.sh Executable file
View File

@ -0,0 +1,23 @@
#!/bin/bash
# Quick prompt testing without deploy
# Usage: ./test-prompt.sh "Julia did 5 daily ABM lessons since 1/28/26"
INPUT="$1"
LANGUAGE="${2:-en}"
if [ -z "$INPUT" ]; then
echo "Usage: $0 \"user input\" [language]"
exit 1
fi
GEMINI_KEY="AIzaSyAsSUSCVs3SPXL7ugsbXa-chzcOKKJJrbA"
# Load and substitute prompt
PROMPT=$(cat api/prompts/exercise.md | sed "s/{{INPUT}}/$INPUT/g" | sed "s/{{LANGUAGE}}/$LANGUAGE/g")
# Call Gemini
curl -s -X POST "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=$GEMINI_KEY" \
-H "Content-Type: application/json" \
-d "{\"contents\":[{\"parts\":[{\"text\":$(echo "$PROMPT" | jq -Rs .)}]}]}" | \
jq -r '.candidates[0].content.parts[0].text' | \
jq .

View File

@ -150,7 +150,14 @@ func runTriage(userInput string) (*TriageResponse, error) {
if err != nil {
return nil, fmt.Errorf("failed to load triage prompt: %v", err)
}
// Use same context as extraction
dossierName := "Sophia Helena"
dossierDOB := "2017-01-01"
prompt := strings.ReplaceAll(tmpl, "{{INPUT}}", userInput)
prompt = strings.ReplaceAll(prompt, "{{DOSSIER_NAME}}", dossierName)
prompt = strings.ReplaceAll(prompt, "{{DOSSIER_DOB}}", dossierDOB)
respText, err := lib.CallGemini(prompt)
if err != nil {
@ -194,11 +201,19 @@ func runExtraction(userInput, category, language string, existingTypes map[strin
existingStr = "(none yet)"
}
// Use Sophia's context for testing (child patient helps with practitioner detection)
dossierName := "Sophia Helena"
dossierDOB := "2017-01-01"
currentDate := "2026-02-08"
prompt := tmpl
prompt = strings.ReplaceAll(prompt, "{{INPUT}}", userInput)
prompt = strings.ReplaceAll(prompt, "{{LANGUAGE}}", language)
prompt = strings.ReplaceAll(prompt, "{{CATEGORY}}", category)
prompt = strings.ReplaceAll(prompt, "{{EXISTING_TYPES}}", existingStr)
prompt = strings.ReplaceAll(prompt, "{{DOSSIER_NAME}}", dossierName)
prompt = strings.ReplaceAll(prompt, "{{DOSSIER_DOB}}", dossierDOB)
prompt = strings.ReplaceAll(prompt, "{{CURRENT_DATE}}", currentDate)
respText, err := lib.CallGemini(prompt)
if err != nil {

View File

@ -12,6 +12,11 @@ if [ ! -f "$TEST_PROMPTS_EXEC" ]; then
exit 1
fi
# Set API key if not already set
if [ -z "$GEMINI_API_KEY" ]; then
export GEMINI_API_KEY="AIzaSyAsSUSCVs3SPXL7ugsbXa-chzcOKKJJrbA"
fi
echo "=== Batch Prompts Test ==="
echo
@ -107,6 +112,8 @@ declare -a positive_prompts=(
"my blood pressure is 87 over 123"
"I went to the dentist; next Monday I need to do a root canal"
"I'm pregnant!"
"Julia did 5 daily ABM lessons since she arrived on 1/28/26"
"For Sophia, we have a leg trainer exercise twice a day. Right leg: 20 minutes, 40 degrees, speed 4. Left leg: 20 minutes, 30 degrees, speed 4."
)
declare -A negative_prompts