package lib import ( "encoding/json" "fmt" "strings" "time" ) // Journal entry types const ( JournalTypeProtocol = "protocol" JournalTypeHypothesis = "hypothesis" JournalTypeObservation = "observation" JournalTypeConnection = "connection" JournalTypeQuestion = "question" JournalTypeReference = "reference" ) // Journal status values const ( JournalStatusDraft = 0 JournalStatusActive = 1 JournalStatusResolved = 2 JournalStatusDiscarded = 3 ) // JournalData represents the encrypted JSON data stored in entry.Data type JournalData struct { Source string `json:"source"` ConversationID string `json:"conversation_id,omitempty"` ConversationURL string `json:"conversation_url,omitempty"` Content string `json:"content"` RelatedEntries []string `json:"related_entries,omitempty"` Reasoning string `json:"reasoning,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` } // CreateJournalInput represents the input for creating a journal entry type CreateJournalInput struct { DossierID string Type string // protocol, hypothesis, observation, etc. Title string Summary string // optional - generated if not provided Content string // full markdown content Tags []string // optional Status int // optional - defaults to draft RelatedEntries []string // optional Source string // optional - e.g., "opus-4.6" Reasoning string // optional Metadata map[string]string } // JournalSummary represents a journal entry summary for list view type JournalSummary struct { EntryID string `json:"entry_id"` Type string `json:"type"` Title string `json:"title"` Summary string `json:"summary"` Date time.Time `json:"date"` Status int `json:"status"` Tags []string `json:"tags"` DossierID string `json:"dossier_id,omitempty"` } // JournalEntry represents a full journal entry type JournalEntry struct { EntryID string `json:"entry_id"` DossierID string `json:"dossier_id"` Type string `json:"type"` Title string `json:"title"` Summary string `json:"summary"` Content string `json:"content"` Date time.Time `json:"date"` Status int `json:"status"` Tags []string `json:"tags"` RelatedEntries []string `json:"related_entries,omitempty"` Source string `json:"source,omitempty"` Reasoning string `json:"reasoning,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` } // GenerateJournalSummary uses Gemini to generate a 1-2 sentence summary func GenerateJournalSummary(title, journalType, content string) (string, error) { prompt := fmt.Sprintf(`Summarize this health journal entry in 1-2 sentences (max 300 chars). Focus on: what intervention/observation, for what condition, and key details. Title: %s Type: %s Content: %s 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." Respond with ONLY the summary text, no JSON, no extra formatting.`, title, journalType, content) // Override default config to return plain text plainText := "text/plain" maxTokens := 150 temp := 0.3 config := &GeminiConfig{ Temperature: &temp, MaxOutputTokens: &maxTokens, ResponseMimeType: &plainText, } summary, err := CallGeminiMultimodal([]GeminiPart{{Text: prompt}}, config) if err != nil { return "", fmt.Errorf("failed to generate summary: %w", err) } summary = strings.TrimSpace(summary) if len(summary) > 300 { summary = summary[:297] + "..." } return summary, nil } // CreateJournal creates a new journal entry func CreateJournal(input CreateJournalInput) (string, error) { // Validate required fields if input.DossierID == "" { return "", fmt.Errorf("dossier_id required") } if input.Type == "" { return "", fmt.Errorf("type required") } if input.Title == "" { return "", fmt.Errorf("title required") } if input.Content == "" { return "", fmt.Errorf("content required") } // Generate summary if not provided if input.Summary == "" { var err error input.Summary, err = GenerateJournalSummary(input.Title, input.Type, input.Content) if err != nil { return "", fmt.Errorf("failed to generate summary: %w", err) } } else { // Validate client-provided summary if len(input.Summary) > 300 { return "", fmt.Errorf("summary too long (max 300 chars)") } } // Build data JSON data := JournalData{ Source: input.Source, Content: input.Content, RelatedEntries: input.RelatedEntries, Reasoning: input.Reasoning, Metadata: input.Metadata, } dataJSON, err := json.Marshal(data) if err != nil { return "", fmt.Errorf("failed to marshal data: %w", err) } // Create entry entry := Entry{ EntryID: NewID(), DossierID: input.DossierID, Category: CategoryNote, Type: input.Type, Value: input.Title, Summary: input.Summary, Data: string(dataJSON), Tags: strings.Join(input.Tags, ","), Timestamp: time.Now().Unix(), Status: input.Status, // defaults to 0 (draft) if not set } if err := dbSave("entries", &entry); err != nil { return "", fmt.Errorf("failed to save entry: %w", err) } return entry.EntryID, nil } // GetJournal retrieves a full journal entry func GetJournal(dossierID, entryID string) (*JournalEntry, error) { var entry Entry if err := dbLoad("entries", entryID, &entry); err != nil { return nil, fmt.Errorf("failed to load entry: %w", err) } // Verify access if entry.DossierID != dossierID { return nil, fmt.Errorf("access denied") } // Verify it's a journal entry if entry.Category != CategoryNote { return nil, fmt.Errorf("not a journal entry") } // Parse data JSON var data JournalData if entry.Data != "" { if err := json.Unmarshal([]byte(entry.Data), &data); err != nil { return nil, fmt.Errorf("failed to parse data: %w", err) } } // Parse tags var tags []string if entry.Tags != "" { tags = strings.Split(entry.Tags, ",") } return &JournalEntry{ EntryID: entry.EntryID, DossierID: entry.DossierID, Type: entry.Type, Title: entry.Value, Summary: entry.Summary, Content: data.Content, Date: time.Unix(entry.Timestamp, 0), Status: entry.Status, Tags: tags, RelatedEntries: data.RelatedEntries, Source: data.Source, Reasoning: data.Reasoning, Metadata: data.Metadata, }, nil } // ListJournalsInput represents filters for listing journals type ListJournalsInput struct { DossierID string Days int // filter to last N days (0 = all) Status *int // filter by status (nil = all) Type string // filter by type (empty = all) } // ListJournals retrieves journal summaries with optional filters func ListJournals(input ListJournalsInput) ([]JournalSummary, error) { // Build query query := `SELECT entry_id, dossier_id, type, value, summary, tags, timestamp, status FROM entries WHERE dossier_id = ? AND category = ?` args := []interface{}{input.DossierID, CategoryNote} // Add type filter if input.Type != "" { query += " AND type = ?" args = append(args, input.Type) } // Add status filter if input.Status != nil { query += " AND status = ?" args = append(args, *input.Status) } // Add days filter if input.Days > 0 { cutoff := time.Now().AddDate(0, 0, -input.Days).Unix() query += " AND timestamp >= ?" args = append(args, cutoff) } query += " ORDER BY timestamp DESC" // Execute query var entries []Entry if err := dbQuery(query, args, &entries); err != nil { return nil, fmt.Errorf("failed to query entries: %w", err) } // Convert to summaries summaries := make([]JournalSummary, len(entries)) for i, entry := range entries { var tags []string if entry.Tags != "" { tags = strings.Split(entry.Tags, ",") } summaries[i] = JournalSummary{ EntryID: entry.EntryID, Type: entry.Type, Title: entry.Value, Summary: entry.Summary, Date: time.Unix(entry.Timestamp, 0), Status: entry.Status, Tags: tags, DossierID: entry.DossierID, } } return summaries, nil } // UpdateJournalStatusInput represents input for updating journal status type UpdateJournalStatusInput struct { DossierID string EntryID string Status *int // optional: new status AppendNote string // optional: append update to content } // UpdateJournalStatus updates a journal entry's status or appends a note func UpdateJournalStatus(input UpdateJournalStatusInput) error { // Load entry var entry Entry if err := dbLoad("entries", input.EntryID, &entry); err != nil { return fmt.Errorf("failed to load entry: %w", err) } // Verify access if entry.DossierID != input.DossierID { return fmt.Errorf("access denied") } // Verify it's a journal entry if entry.Category != CategoryNote { return fmt.Errorf("not a journal entry") } // Update status if provided if input.Status != nil { entry.Status = *input.Status } // Append note if provided if input.AppendNote != "" { var data JournalData if entry.Data != "" { if err := json.Unmarshal([]byte(entry.Data), &data); err != nil { return fmt.Errorf("failed to parse data: %w", err) } } // Append to content timestamp := time.Now().Format("2006-01-02") update := fmt.Sprintf("\n\n---\n**Update %s:** %s", timestamp, input.AppendNote) data.Content += update dataJSON, err := json.Marshal(data) if err != nil { return fmt.Errorf("failed to marshal data: %w", err) } entry.Data = string(dataJSON) } // Save entry if err := dbSave("entries", &entry); err != nil { return fmt.Errorf("failed to save entry: %w", err) } return nil }