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:
parent
94946baf00
commit
320895f1ad
155
api/api_v1.go
155
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])
|
||||
|
|
|
|||
Loading…
Reference in New Issue