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 <noreply@anthropic.com>
This commit is contained in:
Johan 2026-02-01 03:47:14 -05:00
parent 94946baf00
commit 320895f1ad
1 changed files with 155 additions and 0 deletions

View File

@ -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])