From 320895f1ad4d06fb7526e3c33ac799270366539a Mon Sep 17 00:00:00 2001 From: Johan Date: Sun, 1 Feb 2026 03:47:14 -0500 Subject: [PATCH] Add POST /api/v1/dossiers/{id}/parse endpoint Exposes LLM triage + extraction as a standalone API for web/mobile clients. Creates entries and prompts from free-form health input, returns structured response with created resource IDs. Co-Authored-By: Claude Opus 4.5 --- api/api_v1.go | 155 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/api/api_v1.go b/api/api_v1.go index 2313e3f..a8af215 100644 --- a/api/api_v1.go +++ b/api/api_v1.go @@ -400,6 +400,157 @@ func v1Audit(w http.ResponseWriter, r *http.Request, dossierID string) { v1JSON(w, result) } +// --- PARSE (LLM triage + extraction) --- + +type ParseRequest struct { + Input string `json:"input"` +} + +type ParseEntryResponse struct { + ID string `json:"id"` + Category string `json:"category"` + Type string `json:"type"` + Value string `json:"value"` + Timestamp int64 `json:"timestamp"` +} + +type ParsePromptResponse struct { + ID string `json:"id"` + Category string `json:"category"` + Type string `json:"type"` + Question string `json:"question"` + InputType string `json:"input_type"` + InputConfig any `json:"input_config,omitempty"` + NextAsk int64 `json:"next_ask"` +} + +type ParseResponse struct { + Category string `json:"category"` + Type string `json:"type"` + Entries []ParseEntryResponse `json:"entries"` + Prompt *ParsePromptResponse `json:"prompt,omitempty"` + Error string `json:"error,omitempty"` +} + +// POST /api/v1/dossiers/{id}/parse +// Parses free-form health input, creates entries and prompts, returns what happened +func v1Parse(w http.ResponseWriter, r *http.Request, dossierID string) { + if r.Method != "POST" { + v1Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + authID, ok := v1AuthRequired(w, r) + if !ok { + return + } + if !v1CanAccess(authID, dossierID) { + v1Error(w, "Access denied", http.StatusForbidden) + return + } + + var req ParseRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + v1Error(w, "Invalid request body", http.StatusBadRequest) + return + } + if req.Input == "" { + v1Error(w, "input is required", http.StatusBadRequest) + return + } + + // Run triage + extraction + generated, err := callLLMForPrompt(req.Input, dossierID) + if err != nil { + v1Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // If triage returned an error (not_health_related, no_medical_advice, etc.) + if generated.Error != "" { + v1JSON(w, ParseResponse{ + Error: generated.Error, + }) + return + } + + resp := ParseResponse{ + Category: generated.Category, + Type: generated.Type, + Entries: []ParseEntryResponse{}, + } + + now := time.Now() + + // Create entries from extracted data + for _, entryData := range generated.Entries { + entryJSON, _ := json.Marshal(entryData.Data) + entry := &lib.Entry{ + DossierID: dossierID, + Category: lib.CategoryFromString[generated.Category], + Type: generated.Type, + Value: entryData.Value, + Data: string(entryJSON), + Timestamp: now.Unix(), + } + if err := lib.EntryAdd(entry); err != nil { + continue // Skip failed entries but don't fail the whole request + } + resp.Entries = append(resp.Entries, ParseEntryResponse{ + ID: entry.EntryID, + Category: generated.Category, + Type: generated.Type, + Value: entryData.Value, + Timestamp: entry.Timestamp, + }) + } + + // Create prompt if there's a follow-up question with schedule + if generated.Question != "" && len(generated.Schedule) > 0 { + nextAsk := calculateNextAskFromSchedule(generated.Schedule, now) + scheduleJSON, _ := json.Marshal(generated.Schedule) + inputConfigJSON, _ := json.Marshal(generated.InputConfig) + + prompt := &lib.Prompt{ + DossierID: dossierID, + Category: generated.Category, + Type: generated.Type, + Question: generated.Question, + Schedule: string(scheduleJSON), + InputType: generated.InputType, + InputConfig: string(inputConfigJSON), + SourceInput: req.Input, + NextAsk: nextAsk, + Active: true, + } + + // Pre-fill last response from initial extracted data + if len(generated.Entries) > 0 && generated.Entries[0].Data != nil { + initialData, _ := json.Marshal(generated.Entries[0].Data) + prompt.LastResponse = string(initialData) + prompt.LastResponseRaw = generated.Entries[0].Value + prompt.LastResponseAt = now.Unix() + } + + if err := lib.PromptAdd(prompt); err == nil { + var inputConfig any + json.Unmarshal([]byte(prompt.InputConfig), &inputConfig) + + resp.Prompt = &ParsePromptResponse{ + ID: prompt.PromptID, + Category: prompt.Category, + Type: prompt.Type, + Question: prompt.Question, + InputType: prompt.InputType, + InputConfig: inputConfig, + NextAsk: prompt.NextAsk, + } + } + } + + v1JSON(w, resp) +} + // --- PROMPTS --- func v1Prompts(w http.ResponseWriter, r *http.Request, dossierID string) { @@ -618,6 +769,10 @@ func v1Router(w http.ResponseWriter, r *http.Request) { case len(parts) == 3 && parts[0] == "dossiers" && parts[2] == "prompts" && r.Method == "GET": v1Prompts(w, r, parts[1]) + // POST /dossiers/{id}/parse + case len(parts) == 3 && parts[0] == "dossiers" && parts[2] == "parse" && r.Method == "POST": + v1Parse(w, r, parts[1]) + // GET /images/{id} case len(parts) == 2 && parts[0] == "images" && r.Method == "GET": v1Image(w, r, parts[1])