inou/api/api_prompts.go

378 lines
10 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)
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: time.Now().Unix(),
}
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,
}
// Set LastResponse from the initial generated entry data
if len(generated.Entries) > 0 && generated.Entries[0].Data != nil {
initialResponseData, _ := json.Marshal(generated.Entries[0].Data)
newPrompt.LastResponse = string(initialResponseData)
newPrompt.LastResponseRaw = generated.Entries[0].Value
newPrompt.LastResponseAt = time.Now().Unix()
}
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 {
// Parse time
hour, min := 8, 0 // default to 08:00
if slot.Time != "" {
fmt.Sscanf(slot.Time, "%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()
}