feat: add AI deadline extraction and case summary endpoints (Phase 2J)

Add two Claude API-powered endpoints:
- POST /api/ai/extract-deadlines: accepts PDF upload or JSON text, extracts
  legal deadlines using Claude tool_use for structured output
- POST /api/ai/summarize-case: generates AI summary from case events/deadlines,
  caches result in cases.ai_summary

New files:
- internal/services/ai_service.go: AIService with Anthropic SDK integration
- internal/handlers/ai.go: HTTP handlers for both endpoints
- internal/services/ai_service_test.go: tool schema and serialization tests

Uses anthropic-sdk-go v1.27.1 with Claude Sonnet 4.5. AI service is optional —
endpoints only registered when ANTHROPIC_API_KEY is set.
This commit is contained in:
m
2026-03-25 13:40:27 +01:00
parent 2c16f26448
commit 5758e2c37f
9 changed files with 547 additions and 13 deletions

View File

@@ -0,0 +1,109 @@
package services
import (
"encoding/json"
"testing"
)
func TestDeadlineExtractionToolSchema(t *testing.T) {
// Verify the tool schema serializes correctly
data, err := json.Marshal(deadlineExtractionTool)
if err != nil {
t.Fatalf("failed to marshal tool: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("failed to unmarshal tool JSON: %v", err)
}
if parsed["name"] != "extract_deadlines" {
t.Errorf("expected name 'extract_deadlines', got %v", parsed["name"])
}
schema, ok := parsed["input_schema"].(map[string]any)
if !ok {
t.Fatal("input_schema is not a map")
}
if schema["type"] != "object" {
t.Errorf("expected schema type 'object', got %v", schema["type"])
}
props, ok := schema["properties"].(map[string]any)
if !ok {
t.Fatal("properties is not a map")
}
deadlines, ok := props["deadlines"].(map[string]any)
if !ok {
t.Fatal("deadlines property is not a map")
}
if deadlines["type"] != "array" {
t.Errorf("expected deadlines type 'array', got %v", deadlines["type"])
}
items, ok := deadlines["items"].(map[string]any)
if !ok {
t.Fatal("items is not a map")
}
itemProps, ok := items["properties"].(map[string]any)
if !ok {
t.Fatal("item properties is not a map")
}
expectedFields := []string{"title", "due_date", "duration_value", "duration_unit", "timing", "trigger_event", "rule_reference", "confidence", "source_quote"}
for _, field := range expectedFields {
if _, ok := itemProps[field]; !ok {
t.Errorf("missing expected field %q in item properties", field)
}
}
required, ok := items["required"].([]any)
if !ok {
t.Fatal("required is not a list")
}
if len(required) != 8 {
t.Errorf("expected 8 required fields, got %d", len(required))
}
}
func TestExtractedDeadlineJSON(t *testing.T) {
dueDate := "2026-04-15"
d := ExtractedDeadline{
Title: "Statement of Defence",
DueDate: &dueDate,
DurationValue: 3,
DurationUnit: "months",
Timing: "after",
TriggerEvent: "service of the Statement of Claim",
RuleReference: "Rule 23 RoP",
Confidence: 0.95,
SourceQuote: "The defendant shall file a defence within 3 months",
}
data, err := json.Marshal(d)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var parsed ExtractedDeadline
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if parsed.Title != d.Title {
t.Errorf("title mismatch: %q != %q", parsed.Title, d.Title)
}
if *parsed.DueDate != *d.DueDate {
t.Errorf("due_date mismatch: %q != %q", *parsed.DueDate, *d.DueDate)
}
if parsed.DurationValue != d.DurationValue {
t.Errorf("duration_value mismatch: %d != %d", parsed.DurationValue, d.DurationValue)
}
if parsed.Confidence != d.Confidence {
t.Errorf("confidence mismatch: %f != %f", parsed.Confidence, d.Confidence)
}
}