package main import ( "encoding/json" "fmt" "log" "net/http" "time" "inou/lib" ) // PromptResponse is the API representation of a prompt, including dynamic data. type PromptResponse struct { ID string `json:"id"` Category string `json:"category"` Type string `json:"type"` Question string `json:"question"` NextAsk int64 `json:"next_ask,omitempty"` InputType string `json:"input_type"` InputConfig json.RawMessage `json:"input_config,omitempty"` GroupName string `json:"group_name,omitempty"` SourceInput string `json:"source_input,omitempty"` Active bool `json:"active"` IsDue bool `json:"is_due"` // Last response (for pre-filling) - restored from lib.Prompt LastResponse json.RawMessage `json:"last_response,omitempty"` LastResponseRaw string `json:"last_response_raw,omitempty"` LastResponseAt int64 `json:"last_response_at,omitempty"` } type PromptRespondRequest struct { PromptID string `json:"prompt_id"` Response string `json:"response"` // JSON string ResponseRaw string `json:"response_raw"` // what they typed Action string `json:"action"` // "respond", "skip", "dismiss" } // GET /api/prompts?dossier=X func handlePrompts(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") dossierHex := r.URL.Query().Get("dossier") if dossierHex == "" { http.Error(w, "dossier parameter required", http.StatusBadRequest) return } dossierID := dossierHex var prompts []*lib.Prompt var err error if r.URL.Query().Get("all") == "1" { prompts, err = lib.PromptQueryAll(dossierID) } else { prompts, err = lib.PromptQueryActive(dossierID) } if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Ensure there's always a freeform prompt hasFreeform := false for _, p := range prompts { if p.InputType == "freeform" && p.Active { hasFreeform = true break } } if !hasFreeform { freeform := &lib.Prompt{ DossierID: dossierID, Category: "note", Type: "freeform", Question: "Anything else to track?", InputType: "freeform", Active: true, } if err := lib.PromptAdd(freeform); err == nil { prompts = append(prompts, freeform) } } result := make([]PromptResponse, 0, len(prompts)) now := time.Now().Unix() for _, p := range prompts { isDue := p.NextAsk <= now || p.NextAsk == 0 || p.InputType == "freeform" pr := PromptResponse{ ID: p.PromptID, Category: p.Category, Type: p.Type, Question: p.Question, NextAsk: p.NextAsk, InputType: p.InputType, GroupName: p.GroupName, SourceInput: p.SourceInput, Active: p.Active, IsDue: isDue, LastResponseRaw: p.LastResponseRaw, LastResponseAt: p.LastResponseAt, } if p.InputConfig != "" { pr.InputConfig = json.RawMessage(p.InputConfig) } if p.LastResponse != "" { pr.LastResponse = json.RawMessage(p.LastResponse) } result = append(result, pr) } json.NewEncoder(w).Encode(result) } // POST /api/prompts/respond func handlePromptRespond(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != "POST" { http.Error(w, "POST required", http.StatusMethodNotAllowed) return } var req PromptRespondRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } promptID := req.PromptID if promptID == "" { http.Error(w, "prompt_id required", http.StatusBadRequest) return } var err error var newPrompt *lib.Prompt switch req.Action { case "respond", "": err = lib.PromptRespond(promptID, req.Response, req.ResponseRaw) // Check if this is a freeform prompt - if so, generate new prompt from input if err == nil && req.ResponseRaw != "" { newPrompt, _ = tryGeneratePromptFromFreeform(promptID, req.ResponseRaw) } case "skip": err = lib.PromptSkip(promptID) case "dismiss": err = lib.PromptDismiss(promptID) default: http.Error(w, "invalid action", http.StatusBadRequest) return } if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } result := map[string]interface{}{ "ok": true, "prompt_id": req.PromptID, "action": req.Action, } if newPrompt != nil { np := promptToAPI(newPrompt) result["new_prompt"] = np } json.NewEncoder(w).Encode(result) } // Router for /api/prompts and /api/prompts/* func handlePromptsRouter(w http.ResponseWriter, r *http.Request) { path := r.URL.Path switch { case path == "/api/prompts" && r.Method == "GET": handlePrompts(w, r) case path == "/api/prompts" && r.Method == "POST": handlePromptCreate(w, r) case path == "/api/prompts/respond": handlePromptRespond(w, r) default: http.NotFound(w, r) } } // tryGeneratePromptFromFreeform checks if the prompt is freeform and generates a new prompt from user input func tryGeneratePromptFromFreeform(promptID string, userInput string) (*lib.Prompt, string) { p, err := lib.PromptGet(promptID) if err != nil || p == nil { return nil, "" } if p.InputType != "freeform" { return nil, "" } generated, err := callLLMForPrompt(userInput, p.DossierID) if err != nil { log.Printf("Failed to generate prompt from freeform: %v", err) return nil, "" } log.Printf("LLM generated: category=%s, type=%s, entries=%d", generated.Category, generated.Type, len(generated.Entries)) // Create entries from the LLM result (keep track of entry IDs) var primaryEntryValue string var createdEntries []*lib.Entry if len(generated.Entries) > 0 { 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: timestamp, } if err := lib.EntryAdd(entry); err != nil { log.Printf("Failed to create entry from freeform: %v", err) } else { createdEntries = append(createdEntries, entry) } } } if len(generated.Schedule) == 0 || generated.Question == "" { return nil, primaryEntryValue } now := time.Now() nextAsk := calculateNextAskFromSchedule(generated.Schedule, now) // Use the new calculateNextAskFromSchedule scheduleJSON, _ := json.Marshal(generated.Schedule) inputConfigJSON, _ := json.Marshal(generated.InputConfig) newPrompt := &lib.Prompt{ DossierID: p.DossierID, Category: generated.Category, Type: generated.Type, Question: generated.Question, Schedule: string(scheduleJSON), InputType: generated.InputType, InputConfig: string(inputConfigJSON), SourceInput: userInput, NextAsk: nextAsk, Active: true, } // 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) return nil, "" } // Update the entries we just created to link them to this prompt via SearchKey for _, entry := range createdEntries { entry.SearchKey = newPrompt.PromptID if err := lib.EntryAdd(entry); err != nil { log.Printf("Failed to update entry search_key: %v", err) } } log.Printf("Created prompt from freeform: %s (%s/%s) and linked %d entries", newPrompt.Question, newPrompt.Category, newPrompt.Type, len(createdEntries)) return newPrompt, primaryEntryValue } // promptToAPI converts a Prompt to API response format, now much simpler func promptToAPI(p *lib.Prompt) map[string]interface{} { now := time.Now().Unix() isDue := p.NextAsk <= now || p.NextAsk == 0 || p.InputType == "freeform" var inputConfig interface{} json.Unmarshal([]byte(p.InputConfig), &inputConfig) result := map[string]interface{}{ "id": p.PromptID, "category": p.Category, "type": p.Type, "question": p.Question, "input_config": inputConfig, "next_ask": p.NextAsk, "is_due": isDue, "last_response": json.RawMessage(p.LastResponse), "last_response_raw": p.LastResponseRaw, "last_response_at": p.LastResponseAt, } return result } func handlePromptCreate(w http.ResponseWriter, r *http.Request) { // This function needs to be updated to use the new Prompt struct http.Error(w, "Not implemented", http.StatusNotImplemented) } func handleFreeform(w http.ResponseWriter, r *http.Request) { // This function needs to be updated to use the new centralized logic http.Error(w, "Not implemented", http.StatusNotImplemented) } // calculateNextAskFromSchedule finds the next occurrence based on schedule slots func calculateNextAskFromSchedule(schedule []ScheduleSlot, now time.Time) int64 { if len(schedule) == 0 { return 0 } // Day name to weekday mapping dayMap := map[string]time.Weekday{ "sun": time.Sunday, "mon": time.Monday, "tue": time.Tuesday, "wed": time.Wednesday, "thu": time.Thursday, "fri": time.Friday, "sat": time.Saturday, } var earliest time.Time for _, slot := range schedule { // 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] if !ok { continue } // Calculate days until target weekday currentWeekday := now.Weekday() daysUntil := int(targetWeekday) - int(currentWeekday) if daysUntil < 0 { daysUntil += 7 } // Build candidate time candidate := time.Date(now.Year(), now.Month(), now.Day()+daysUntil, hour, min, 0, 0, now.Location()) // If it's today but the time has passed, move to next week if daysUntil == 0 && candidate.Before(now) { candidate = candidate.AddDate(0, 0, 7) } // Track earliest if earliest.IsZero() || candidate.Before(earliest) { earliest = candidate } } } } if earliest.IsZero() { // Fallback: tomorrow at 08:00 return now.Add(24 * time.Hour).Unix() } return earliest.Unix() }