feat: fix year interpretation, new schedule format, exclude today from backfill
This commit is contained in:
parent
3014f21d72
commit
9781b31c7d
|
|
@ -0,0 +1 @@
|
|||
GEMINI_API_KEY=AIzaSyAsSUSCVs3SPXL7ugsbXa-chzcOKKJJrbA
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
ANTHROPIC_API_KEY=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
|
||||
GEMINI_API_KEY=AIzaSyAsSUSCVs3SPXL7ugsbXa-chzcOKKJJrbA
|
||||
SYSTEM_ACCESSOR_ID=7b3a3ee1c2776dcd
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,12 +341,22 @@ 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 time in the slot
|
||||
for _, timeStr := range times {
|
||||
// Parse time
|
||||
hour, min := 8, 0
|
||||
fmt.Sscanf(timeStr, "%d:%d", &hour, &min)
|
||||
|
||||
// For each day in the slot, find next occurrence
|
||||
for _, dayName := range slot.Days {
|
||||
targetWeekday, ok := dayMap[dayName]
|
||||
|
|
@ -368,6 +385,7 @@ func calculateNextAskFromSchedule(schedule []ScheduleSlot, now time.Time) int64
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if earliest.IsZero() {
|
||||
// Fallback: tomorrow at 08:00
|
||||
|
|
|
|||
|
|
@ -166,16 +166,16 @@ 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 {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
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"`
|
||||
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
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
|
@ -0,0 +1 @@
|
|||
exercise.md
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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!")
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -31,9 +31,7 @@ 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, "#") {
|
||||
|
|
@ -55,6 +53,20 @@ func ConfigInit() {
|
|||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize SystemContext with loaded ID
|
||||
SystemContext = &AccessContext{
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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,7 +931,10 @@ func main() {
|
|||
case "initialize":
|
||||
respond(req.ID, map[string]interface{}{
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": map[string]interface{}{"tools": map[string]interface{}{}},
|
||||
"capabilities": map[string]interface{}{
|
||||
"tools": map[string]interface{}{},
|
||||
"prompts": map[string]interface{}{},
|
||||
},
|
||||
"serverInfo": map[string]interface{}{"name": "inou-bridge", "version": Version},
|
||||
})
|
||||
case "notifications/initialized":
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -275,6 +279,7 @@ func handleMCPInitialize(w http.ResponseWriter, req mcpRequest) {
|
|||
"protocolVersion": mcpProtocolVersion,
|
||||
"capabilities": 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, ¶ms); 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)
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
<form class="prompt-form" data-entry-id="{{.ID}}">
|
||||
<div class="prompt-header">
|
||||
<div style="display: flex; justify-content: space-between; align-items: baseline;">
|
||||
<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 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">
|
||||
<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"}}
|
||||
<span class="entry-value">{{.Value}}</span>
|
||||
<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}}
|
||||
{{/* 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}}
|
||||
<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"}}
|
||||
<span class="entry-check">{{if .Value}}✓{{else}}—{{end}}</span>
|
||||
{{if .Label}}<span class="prompt-checkbox-label">{{.Label}}</span>{{end}}
|
||||
{{else}}
|
||||
<span class="entry-value">{{.Value}}</span>
|
||||
<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>
|
||||
</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 = `
|
||||
<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>`;
|
||||
const cardHtml = await response.text();
|
||||
|
||||
// Insert after freeform card
|
||||
// Insert BEFORE freeform card (above "Anything else to track?")
|
||||
if (freeformCard) {
|
||||
freeformCard.insertAdjacentHTML('afterend', cardHtml);
|
||||
freeformCard.insertAdjacentHTML('beforebegin', cardHtml);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to add pending card:', err);
|
||||
showNotification('Failed to display new prompt', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 .
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue