396 lines
11 KiB
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 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()
|
|
} |