360 lines
10 KiB
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 := 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
|
|
}
|