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:
283
backend/internal/services/ai_service.go
Normal file
283
backend/internal/services/ai_service.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/anthropics/anthropic-sdk-go"
|
||||
"github.com/anthropics/anthropic-sdk-go/option"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
|
||||
)
|
||||
|
||||
type AIService struct {
|
||||
client anthropic.Client
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewAIService(apiKey string, db *sqlx.DB) *AIService {
|
||||
client := anthropic.NewClient(option.WithAPIKey(apiKey))
|
||||
return &AIService{client: client, db: db}
|
||||
}
|
||||
|
||||
// ExtractedDeadline represents a deadline extracted by AI from a document.
|
||||
type ExtractedDeadline struct {
|
||||
Title string `json:"title"`
|
||||
DueDate *string `json:"due_date"`
|
||||
DurationValue int `json:"duration_value"`
|
||||
DurationUnit string `json:"duration_unit"`
|
||||
Timing string `json:"timing"`
|
||||
TriggerEvent string `json:"trigger_event"`
|
||||
RuleReference string `json:"rule_reference"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
SourceQuote string `json:"source_quote"`
|
||||
}
|
||||
|
||||
type extractDeadlinesToolInput struct {
|
||||
Deadlines []ExtractedDeadline `json:"deadlines"`
|
||||
}
|
||||
|
||||
var deadlineExtractionTool = anthropic.ToolParam{
|
||||
Name: "extract_deadlines",
|
||||
Description: anthropic.String("Extract all legal deadlines found in the document. Return each deadline with its details."),
|
||||
InputSchema: anthropic.ToolInputSchemaParam{
|
||||
Properties: map[string]any{
|
||||
"deadlines": map[string]any{
|
||||
"type": "array",
|
||||
"description": "List of extracted deadlines",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"title": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Short title describing the deadline (e.g. 'Statement of Defence', 'Reply to Counterclaim')",
|
||||
},
|
||||
"due_date": map[string]any{
|
||||
"type": []string{"string", "null"},
|
||||
"description": "Absolute due date in YYYY-MM-DD format if determinable, null otherwise",
|
||||
},
|
||||
"duration_value": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "Numeric duration value (e.g. 3 for '3 months')",
|
||||
},
|
||||
"duration_unit": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"days", "weeks", "months"},
|
||||
"description": "Unit of the duration period",
|
||||
},
|
||||
"timing": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"after", "before"},
|
||||
"description": "Whether the deadline is before or after the trigger event",
|
||||
},
|
||||
"trigger_event": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The event that triggers this deadline (e.g. 'service of the Statement of Claim')",
|
||||
},
|
||||
"rule_reference": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Legal rule reference (e.g. 'Rule 23 RoP', 'Rule 222 RoP', '§ 276 ZPO')",
|
||||
},
|
||||
"confidence": map[string]any{
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1,
|
||||
"description": "Confidence score from 0.0 to 1.0",
|
||||
},
|
||||
"source_quote": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The exact quote from the document where this deadline was found",
|
||||
},
|
||||
},
|
||||
"required": []string{"title", "duration_value", "duration_unit", "timing", "trigger_event", "rule_reference", "confidence", "source_quote"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"deadlines"},
|
||||
},
|
||||
}
|
||||
|
||||
const extractionSystemPrompt = `You are a legal deadline extraction assistant for German and UPC (Unified Patent Court) patent litigation.
|
||||
|
||||
Your task is to extract all legal deadlines, time limits, and procedural time periods from the provided document.
|
||||
|
||||
For each deadline found, extract:
|
||||
- A clear title describing the deadline
|
||||
- The absolute due date if it can be determined from the document
|
||||
- The duration (value + unit: days/weeks/months)
|
||||
- Whether it runs before or after a trigger event
|
||||
- The trigger event that starts the deadline
|
||||
- The legal rule reference (e.g. Rule 23 RoP, § 276 ZPO)
|
||||
- Your confidence level (0.0-1.0) in the extraction
|
||||
- The exact source quote from the document
|
||||
|
||||
Be thorough: extract every deadline mentioned, including conditional ones. If a deadline references another deadline (e.g. "within 2 months of the defence"), capture that relationship in the trigger_event field.
|
||||
|
||||
If the document contains no deadlines, return an empty list.`
|
||||
|
||||
// ExtractDeadlines sends a document (PDF or text) to Claude for deadline extraction.
|
||||
func (s *AIService) ExtractDeadlines(ctx context.Context, pdfData []byte, text string) ([]ExtractedDeadline, error) {
|
||||
var contentBlocks []anthropic.ContentBlockParamUnion
|
||||
|
||||
if len(pdfData) > 0 {
|
||||
encoded := base64.StdEncoding.EncodeToString(pdfData)
|
||||
contentBlocks = append(contentBlocks, anthropic.ContentBlockParamUnion{
|
||||
OfDocument: &anthropic.DocumentBlockParam{
|
||||
Source: anthropic.DocumentBlockParamSourceUnion{
|
||||
OfBase64: &anthropic.Base64PDFSourceParam{
|
||||
Data: encoded,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
contentBlocks = append(contentBlocks, anthropic.NewTextBlock("Extract all legal deadlines from this document."))
|
||||
} else if text != "" {
|
||||
contentBlocks = append(contentBlocks, anthropic.NewTextBlock("Extract all legal deadlines from the following text:\n\n"+text))
|
||||
} else {
|
||||
return nil, fmt.Errorf("either pdf_data or text must be provided")
|
||||
}
|
||||
|
||||
msg, err := s.client.Messages.New(ctx, anthropic.MessageNewParams{
|
||||
Model: anthropic.ModelClaudeSonnet4_5,
|
||||
MaxTokens: 4096,
|
||||
System: []anthropic.TextBlockParam{
|
||||
{Text: extractionSystemPrompt},
|
||||
},
|
||||
Messages: []anthropic.MessageParam{
|
||||
anthropic.NewUserMessage(contentBlocks...),
|
||||
},
|
||||
Tools: []anthropic.ToolUnionParam{
|
||||
{OfTool: &deadlineExtractionTool},
|
||||
},
|
||||
ToolChoice: anthropic.ToolChoiceParamOfTool("extract_deadlines"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("claude API call: %w", err)
|
||||
}
|
||||
|
||||
// Find the tool_use block in the response
|
||||
for _, block := range msg.Content {
|
||||
if block.Type == "tool_use" && block.Name == "extract_deadlines" {
|
||||
var input extractDeadlinesToolInput
|
||||
if err := json.Unmarshal(block.Input, &input); err != nil {
|
||||
return nil, fmt.Errorf("parsing tool output: %w", err)
|
||||
}
|
||||
return input.Deadlines, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no tool_use block in response")
|
||||
}
|
||||
|
||||
const summarizeSystemPrompt = `You are a legal case summary assistant for German and UPC patent litigation case management.
|
||||
|
||||
Given a case's details, recent events, and deadlines, produce a concise 2-3 sentence summary of what matters right now. Focus on:
|
||||
- The most urgent upcoming deadline
|
||||
- Recent significant events
|
||||
- The current procedural stage
|
||||
|
||||
Write in clear, professional language suitable for a lawyer reviewing their case list. Be specific about dates and deadlines.`
|
||||
|
||||
// SummarizeCase generates an AI summary for a case and caches it in the database.
|
||||
func (s *AIService) SummarizeCase(ctx context.Context, tenantID, caseID uuid.UUID) (string, error) {
|
||||
// Load case
|
||||
var c models.Case
|
||||
err := s.db.GetContext(ctx, &c,
|
||||
"SELECT * FROM cases WHERE id = $1 AND tenant_id = $2", caseID, tenantID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("loading case: %w", err)
|
||||
}
|
||||
|
||||
// Load recent events
|
||||
var events []models.CaseEvent
|
||||
if err := s.db.SelectContext(ctx, &events,
|
||||
"SELECT * FROM case_events WHERE case_id = $1 AND tenant_id = $2 ORDER BY created_at DESC LIMIT 10",
|
||||
caseID, tenantID); err != nil {
|
||||
return "", fmt.Errorf("loading events: %w", err)
|
||||
}
|
||||
|
||||
// Load active deadlines
|
||||
var deadlines []models.Deadline
|
||||
if err := s.db.SelectContext(ctx, &deadlines,
|
||||
"SELECT * FROM deadlines WHERE case_id = $1 AND tenant_id = $2 AND status = 'active' ORDER BY due_date ASC LIMIT 10",
|
||||
caseID, tenantID); err != nil {
|
||||
return "", fmt.Errorf("loading deadlines: %w", err)
|
||||
}
|
||||
|
||||
// Build context text
|
||||
caseInfo := fmt.Sprintf("Case: %s — %s\nStatus: %s", c.CaseNumber, c.Title, c.Status)
|
||||
if c.Court != nil {
|
||||
caseInfo += fmt.Sprintf("\nCourt: %s", *c.Court)
|
||||
}
|
||||
if c.CourtRef != nil {
|
||||
caseInfo += fmt.Sprintf("\nCourt Reference: %s", *c.CourtRef)
|
||||
}
|
||||
if c.CaseType != nil {
|
||||
caseInfo += fmt.Sprintf("\nType: %s", *c.CaseType)
|
||||
}
|
||||
|
||||
eventText := "\n\nRecent Events:"
|
||||
if len(events) == 0 {
|
||||
eventText += "\nNo events recorded."
|
||||
}
|
||||
for _, e := range events {
|
||||
eventText += fmt.Sprintf("\n- [%s] %s", e.CreatedAt.Format("2006-01-02"), e.Title)
|
||||
if e.Description != nil {
|
||||
eventText += fmt.Sprintf(": %s", *e.Description)
|
||||
}
|
||||
}
|
||||
|
||||
deadlineText := "\n\nUpcoming Deadlines:"
|
||||
if len(deadlines) == 0 {
|
||||
deadlineText += "\nNo active deadlines."
|
||||
}
|
||||
for _, d := range deadlines {
|
||||
deadlineText += fmt.Sprintf("\n- %s: due %s (status: %s)", d.Title, d.DueDate, d.Status)
|
||||
if d.Description != nil {
|
||||
deadlineText += fmt.Sprintf(" — %s", *d.Description)
|
||||
}
|
||||
}
|
||||
|
||||
prompt := caseInfo + eventText + deadlineText
|
||||
|
||||
msg, err := s.client.Messages.New(ctx, anthropic.MessageNewParams{
|
||||
Model: anthropic.ModelClaudeSonnet4_5,
|
||||
MaxTokens: 512,
|
||||
System: []anthropic.TextBlockParam{
|
||||
{Text: summarizeSystemPrompt},
|
||||
},
|
||||
Messages: []anthropic.MessageParam{
|
||||
anthropic.NewUserMessage(anthropic.NewTextBlock("Summarize the current state of this case:\n\n" + prompt)),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("claude API call: %w", err)
|
||||
}
|
||||
|
||||
// Extract text from response
|
||||
var summary string
|
||||
for _, block := range msg.Content {
|
||||
if block.Type == "text" {
|
||||
summary += block.Text
|
||||
}
|
||||
}
|
||||
|
||||
if summary == "" {
|
||||
return "", fmt.Errorf("empty response from Claude")
|
||||
}
|
||||
|
||||
// Cache summary in database
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
"UPDATE cases SET ai_summary = $1, updated_at = $2 WHERE id = $3 AND tenant_id = $4",
|
||||
summary, time.Now(), caseID, tenantID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("caching summary: %w", err)
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
Reference in New Issue
Block a user