inou/api/api_trackers.go

396 lines
11 KiB
Go

package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"inou/lib"
)
// PromptResponse is the API representation of a prompt, including dynamic data.
type TrackerResponse 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.Tracker
LastResponse json.RawMessage `json:"last_response,omitempty"`
LastResponseRaw string `json:"last_response_raw,omitempty"`
LastResponseAt int64 `json:"last_response_at,omitempty"`
}
type TrackerRespondRequest struct {
TrackerID string `json:"tracker_id"`
Response string `json:"response"` // JSON string
ResponseRaw string `json:"response_raw"` // what they typed
Action string `json:"action"` // "respond", "skip", "dismiss"
}
// GET /api/trackers?dossier=X
func handleTrackers(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 trackers []*lib.Tracker
var err error
if r.URL.Query().Get("all") == "1" {
trackers, err = lib.TrackerQueryAll(dossierID)
} else {
trackers, err = lib.TrackerQueryActive(dossierID)
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Ensure there's always a freeform prompt
hasFreeform := false
for _, p := range trackers {
if p.InputType == "freeform" && p.Active {
hasFreeform = true
break
}
}
if !hasFreeform {
freeform := &lib.Tracker{
DossierID: dossierID,
Category: "note",
Type: "freeform",
Question: "Anything else to track?",
InputType: "freeform",
Active: true,
}
if err := lib.TrackerAdd(freeform); err == nil {
trackers = append(trackers, freeform)
}
}
result := make([]TrackerResponse, 0, len(trackers))
now := time.Now().Unix()
for _, p := range trackers {
isDue := p.NextAsk <= now || p.NextAsk == 0 || p.InputType == "freeform"
pr := TrackerResponse{
ID: p.TrackerID,
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/trackers/respond
func handleTrackerRespond(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 TrackerRespondRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
trackerID := req.TrackerID
if trackerID == "" {
http.Error(w, "tracker_id required", http.StatusBadRequest)
return
}
var err error
var newTracker *lib.Tracker
switch req.Action {
case "respond", "":
err = lib.TrackerRespond(trackerID, req.Response, req.ResponseRaw)
// Check if this is a freeform tracker - if so, generate new tracker from input
if err == nil && req.ResponseRaw != "" {
newTracker, _ = tryGenerateTrackerFromFreeform(trackerID, req.ResponseRaw)
}
case "skip":
err = lib.TrackerSkip(trackerID)
case "dismiss":
err = lib.TrackerDismiss(trackerID)
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,
"tracker_id": req.TrackerID,
"action": req.Action,
}
if newTracker != nil {
np := promptToAPI(newTracker)
result["new_prompt"] = np
}
json.NewEncoder(w).Encode(result)
}
// Router for /api/trackers and /api/trackers/*
func handleTrackersRouter(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
switch {
case path == "/api/trackers" && r.Method == "GET":
handleTrackers(w, r)
case path == "/api/trackers" && r.Method == "POST":
handleTrackerCreate(w, r)
case path == "/api/trackers/respond":
handleTrackerRespond(w, r)
default:
http.NotFound(w, r)
}
}
// tryGenerateTrackerFromFreeform checks if the tracker is freeform and generates a new tracker from user input
func tryGenerateTrackerFromFreeform(trackerID string, userInput string) (*lib.Tracker, string) {
p, err := lib.TrackerGet(trackerID)
if err != nil || p == nil {
return nil, ""
}
if p.InputType != "freeform" {
return nil, ""
}
generated, err := callLLMForTracker(userInput, p.DossierID)
if err != nil {
log.Printf("Failed to generate tracker 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.EntryWrite("", 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)
newTracker := &lib.Tracker{
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.TrackerAdd(newTracker); err != nil {
log.Printf("Failed to create prompt: %v", err)
return nil, ""
}
// Update the entries we just created to link them to this tracker via SearchKey
for _, entry := range createdEntries {
entry.SearchKey = newTracker.TrackerID
if err := lib.EntryWrite("", entry); err != nil {
log.Printf("Failed to update entry search_key: %v", err)
}
}
log.Printf("Created tracker from freeform: %s (%s/%s) and linked %d entries", newTracker.Question, newTracker.Category, newTracker.Type, len(createdEntries))
return newTracker, primaryEntryValue
}
// promptToAPI converts a Prompt to API response format, now much simpler
func promptToAPI(p *lib.Tracker) 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.TrackerID,
"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 handleTrackerCreate(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()
}