inou/lib/journal.go

360 lines
10 KiB
Go

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 := Save("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 := Load("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 := Query(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 := Load("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 := Save("entries", &entry); err != nil {
return fmt.Errorf("failed to save entry: %w", err)
}
return nil
}