feat: AI-powered features — document drafting, case strategy, similar case finder (P2)
Backend: - DraftDocument: Claude generates legal documents from case data + template type (14 template types: Klageschrift, UPC claims, Abmahnung, etc.) - CaseStrategy: Opus-powered strategic analysis with next steps, risk assessment, and timeline optimization (structured tool output) - FindSimilarCases: queries youpc.org Supabase for UPC cases, Claude ranks by relevance with explanations and key holdings Endpoints: POST /api/ai/draft-document, /case-strategy, /similar-cases All rate-limited (5 req/min) and permission-gated (PermAIExtraction). YouPC database connection is optional (YOUPC_DATABASE_URL env var).
This commit is contained in:
@@ -5,6 +5,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
|
||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/config"
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/config"
|
||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/db"
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/db"
|
||||||
@@ -31,6 +34,21 @@ func main() {
|
|||||||
|
|
||||||
authMW := auth.NewMiddleware(cfg.SupabaseJWTSecret, database)
|
authMW := auth.NewMiddleware(cfg.SupabaseJWTSecret, database)
|
||||||
|
|
||||||
|
// Optional: connect to youpc.org database for similar case finder
|
||||||
|
var youpcDB *sqlx.DB
|
||||||
|
if cfg.YouPCDatabaseURL != "" {
|
||||||
|
youpcDB, err = sqlx.Connect("postgres", cfg.YouPCDatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("failed to connect to youpc.org database — similar case finder disabled", "error", err)
|
||||||
|
youpcDB = nil
|
||||||
|
} else {
|
||||||
|
youpcDB.SetMaxOpenConns(5)
|
||||||
|
youpcDB.SetMaxIdleConns(2)
|
||||||
|
defer youpcDB.Close()
|
||||||
|
slog.Info("connected to youpc.org database for similar case finder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Start CalDAV sync service
|
// Start CalDAV sync service
|
||||||
calDAVSvc := services.NewCalDAVService(database)
|
calDAVSvc := services.NewCalDAVService(database)
|
||||||
calDAVSvc.Start()
|
calDAVSvc.Start()
|
||||||
@@ -41,7 +59,7 @@ func main() {
|
|||||||
notifSvc.Start()
|
notifSvc.Start()
|
||||||
defer notifSvc.Stop()
|
defer notifSvc.Stop()
|
||||||
|
|
||||||
handler := router.New(database, authMW, cfg, calDAVSvc, notifSvc)
|
handler := router.New(database, authMW, cfg, calDAVSvc, notifSvc, youpcDB)
|
||||||
|
|
||||||
slog.Info("starting KanzlAI API server", "port", cfg.Port)
|
slog.Info("starting KanzlAI API server", "port", cfg.Port)
|
||||||
if err := http.ListenAndServe(":"+cfg.Port, handler); err != nil {
|
if err := http.ListenAndServe(":"+cfg.Port, handler); err != nil {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type Config struct {
|
|||||||
SupabaseJWTSecret string
|
SupabaseJWTSecret string
|
||||||
AnthropicAPIKey string
|
AnthropicAPIKey string
|
||||||
FrontendOrigin string
|
FrontendOrigin string
|
||||||
|
YouPCDatabaseURL string // read-only connection to youpc.org Supabase for similar case finder
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -26,6 +27,7 @@ func Load() (*Config, error) {
|
|||||||
SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"),
|
SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"),
|
||||||
AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"),
|
AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"),
|
||||||
FrontendOrigin: getEnv("FRONTEND_ORIGIN", "https://kanzlai.msbls.de"),
|
FrontendOrigin: getEnv("FRONTEND_ORIGIN", "https://kanzlai.msbls.de"),
|
||||||
|
YouPCDatabaseURL: os.Getenv("YOUPC_DATABASE_URL"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.DatabaseURL == "" {
|
if cfg.DatabaseURL == "" {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
||||||
)
|
)
|
||||||
@@ -115,3 +117,139 @@ func (h *AIHandler) SummarizeCase(w http.ResponseWriter, r *http.Request) {
|
|||||||
"summary": summary,
|
"summary": summary,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DraftDocument handles POST /api/ai/draft-document
|
||||||
|
// Accepts JSON {"case_id": "uuid", "template_type": "string", "instructions": "string", "language": "de|en|fr"}.
|
||||||
|
func (h *AIHandler) DraftDocument(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusForbidden, "missing tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
CaseID string `json:"case_id"`
|
||||||
|
TemplateType string `json:"template_type"`
|
||||||
|
Instructions string `json:"instructions"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.CaseID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "case_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.TemplateType == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "template_type is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
caseID, err := parseUUID(body.CaseID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid case_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(body.Instructions) > maxDescriptionLen {
|
||||||
|
writeError(w, http.StatusBadRequest, "instructions exceeds maximum length")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
draft, err := h.ai.DraftDocument(r.Context(), tenantID, caseID, body.TemplateType, body.Instructions, body.Language)
|
||||||
|
if err != nil {
|
||||||
|
internalError(w, "AI document drafting failed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, draft)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaseStrategy handles POST /api/ai/case-strategy
|
||||||
|
// Accepts JSON {"case_id": "uuid"}.
|
||||||
|
func (h *AIHandler) CaseStrategy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusForbidden, "missing tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
CaseID string `json:"case_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.CaseID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "case_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
caseID, err := parseUUID(body.CaseID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid case_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
strategy, err := h.ai.CaseStrategy(r.Context(), tenantID, caseID)
|
||||||
|
if err != nil {
|
||||||
|
internalError(w, "AI case strategy analysis failed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, strategy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimilarCases handles POST /api/ai/similar-cases
|
||||||
|
// Accepts JSON {"case_id": "uuid", "description": "string"}.
|
||||||
|
func (h *AIHandler) SimilarCases(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusForbidden, "missing tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
CaseID string `json:"case_id"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.CaseID == "" && body.Description == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "either case_id or description is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(body.Description) > maxDescriptionLen {
|
||||||
|
writeError(w, http.StatusBadRequest, "description exceeds maximum length")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var caseID uuid.UUID
|
||||||
|
if body.CaseID != "" {
|
||||||
|
var err error
|
||||||
|
caseID, err = parseUUID(body.CaseID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid case_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cases, err := h.ai.FindSimilarCases(r.Context(), tenantID, caseID, body.Description)
|
||||||
|
if err != nil {
|
||||||
|
internalError(w, "AI similar case search failed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"cases": cases,
|
||||||
|
"count": len(cases),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/anthropics/anthropic-sdk-go"
|
"github.com/anthropics/anthropic-sdk-go"
|
||||||
@@ -18,11 +19,12 @@ import (
|
|||||||
type AIService struct {
|
type AIService struct {
|
||||||
client anthropic.Client
|
client anthropic.Client
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
|
youpcDB *sqlx.DB // read-only connection to youpc.org for similar case finder (may be nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAIService(apiKey string, db *sqlx.DB) *AIService {
|
func NewAIService(apiKey string, db *sqlx.DB, youpcDB *sqlx.DB) *AIService {
|
||||||
client := anthropic.NewClient(option.WithAPIKey(apiKey))
|
client := anthropic.NewClient(option.WithAPIKey(apiKey))
|
||||||
return &AIService{client: client, db: db}
|
return &AIService{client: client, db: db, youpcDB: youpcDB}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractedDeadline represents a deadline extracted by AI from a document.
|
// ExtractedDeadline represents a deadline extracted by AI from a document.
|
||||||
@@ -281,3 +283,726 @@ func (s *AIService) SummarizeCase(ctx context.Context, tenantID, caseID uuid.UUI
|
|||||||
|
|
||||||
return summary, nil
|
return summary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Document Drafting ---
|
||||||
|
|
||||||
|
// DocumentDraft represents an AI-generated document draft.
|
||||||
|
type DocumentDraft struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// templateDescriptions maps template type IDs to descriptions for Claude.
|
||||||
|
var templateDescriptions = map[string]string{
|
||||||
|
"klageschrift": "Klageschrift (Statement of Claim) — formal complaint initiating legal proceedings",
|
||||||
|
"klageerwiderung": "Klageerwiderung (Statement of Defence) — formal response to a statement of claim",
|
||||||
|
"abmahnung": "Abmahnung (Cease and Desist Letter) — formal warning letter demanding cessation of an activity",
|
||||||
|
"schriftsatz": "Schriftsatz (Legal Brief) — formal legal submission to the court",
|
||||||
|
"berufung": "Berufungsschrift (Appeal Brief) — formal appeal against a court decision",
|
||||||
|
"antrag": "Antrag (Motion/Application) — formal application or motion to the court",
|
||||||
|
"stellungnahme": "Stellungnahme (Statement/Position Paper) — formal response or position paper",
|
||||||
|
"gutachten": "Gutachten (Legal Opinion/Expert Report) — detailed legal analysis or opinion",
|
||||||
|
"vertrag": "Vertrag (Contract/Agreement) — legal contract or agreement between parties",
|
||||||
|
"vollmacht": "Vollmacht (Power of Attorney) — formal authorization document",
|
||||||
|
"upc_claim": "UPC Statement of Claim — claim filed at the Unified Patent Court",
|
||||||
|
"upc_defence": "UPC Statement of Defence — defence filed at the Unified Patent Court",
|
||||||
|
"upc_counterclaim": "UPC Counterclaim for Revocation — counterclaim for patent revocation at the UPC",
|
||||||
|
"upc_injunction": "UPC Application for Provisional Measures — application for injunctive relief at the UPC",
|
||||||
|
}
|
||||||
|
|
||||||
|
const draftDocumentSystemPrompt = `You are an expert legal document drafter for German and UPC (Unified Patent Court) patent litigation.
|
||||||
|
|
||||||
|
You draft professional legal documents in the requested language, following proper legal formatting conventions.
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- Use proper legal structure with numbered sections and paragraphs
|
||||||
|
- Include standard legal formalities (headers, salutations, signatures block)
|
||||||
|
- Reference relevant legal provisions (BGB, ZPO, UPC Rules of Procedure, etc.)
|
||||||
|
- Use precise legal terminology appropriate for the jurisdiction
|
||||||
|
- Include placeholders in [BRACKETS] for information that needs to be filled in
|
||||||
|
- Base the content on the provided case data and instructions
|
||||||
|
- Output the document as clean text with proper formatting`
|
||||||
|
|
||||||
|
// DraftDocument generates an AI-drafted legal document based on case data and a template type.
|
||||||
|
func (s *AIService) DraftDocument(ctx context.Context, tenantID, caseID uuid.UUID, templateType, instructions, language string) (*DocumentDraft, error) {
|
||||||
|
if language == "" {
|
||||||
|
language = "de"
|
||||||
|
}
|
||||||
|
|
||||||
|
langLabel := "German"
|
||||||
|
if language == "en" {
|
||||||
|
langLabel = "English"
|
||||||
|
} else if language == "fr" {
|
||||||
|
langLabel = "French"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load case data
|
||||||
|
var c models.Case
|
||||||
|
if err := s.db.GetContext(ctx, &c,
|
||||||
|
"SELECT * FROM cases WHERE id = $1 AND tenant_id = $2", caseID, tenantID); err != nil {
|
||||||
|
return nil, fmt.Errorf("loading case: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load parties
|
||||||
|
var parties []models.Party
|
||||||
|
_ = s.db.SelectContext(ctx, &parties,
|
||||||
|
"SELECT * FROM parties WHERE case_id = $1 AND tenant_id = $2", caseID, tenantID)
|
||||||
|
|
||||||
|
// Load recent events
|
||||||
|
var events []models.CaseEvent
|
||||||
|
_ = s.db.SelectContext(ctx, &events,
|
||||||
|
"SELECT * FROM case_events WHERE case_id = $1 AND tenant_id = $2 ORDER BY created_at DESC LIMIT 15",
|
||||||
|
caseID, tenantID)
|
||||||
|
|
||||||
|
// Load active deadlines
|
||||||
|
var deadlines []models.Deadline
|
||||||
|
_ = 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)
|
||||||
|
|
||||||
|
// Load documents metadata for context
|
||||||
|
var documents []models.Document
|
||||||
|
_ = s.db.SelectContext(ctx, &documents,
|
||||||
|
"SELECT id, title, doc_type, created_at FROM documents WHERE case_id = $1 AND tenant_id = $2 ORDER BY created_at DESC LIMIT 10",
|
||||||
|
caseID, tenantID)
|
||||||
|
|
||||||
|
// Build context
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(fmt.Sprintf("Case: %s — %s\nStatus: %s\n", c.CaseNumber, c.Title, c.Status))
|
||||||
|
if c.Court != nil {
|
||||||
|
b.WriteString(fmt.Sprintf("Court: %s\n", *c.Court))
|
||||||
|
}
|
||||||
|
if c.CourtRef != nil {
|
||||||
|
b.WriteString(fmt.Sprintf("Court Reference: %s\n", *c.CourtRef))
|
||||||
|
}
|
||||||
|
if c.CaseType != nil {
|
||||||
|
b.WriteString(fmt.Sprintf("Type: %s\n", *c.CaseType))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parties) > 0 {
|
||||||
|
b.WriteString("\nParties:\n")
|
||||||
|
for _, p := range parties {
|
||||||
|
role := "unknown role"
|
||||||
|
if p.Role != nil {
|
||||||
|
role = *p.Role
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("- %s (%s)", p.Name, role))
|
||||||
|
if p.Representative != nil {
|
||||||
|
b.WriteString(fmt.Sprintf(" — represented by %s", *p.Representative))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(events) > 0 {
|
||||||
|
b.WriteString("\nRecent Events:\n")
|
||||||
|
for _, e := range events {
|
||||||
|
b.WriteString(fmt.Sprintf("- [%s] %s", e.CreatedAt.Format("2006-01-02"), e.Title))
|
||||||
|
if e.Description != nil {
|
||||||
|
b.WriteString(fmt.Sprintf(": %s", *e.Description))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(deadlines) > 0 {
|
||||||
|
b.WriteString("\nUpcoming Deadlines:\n")
|
||||||
|
for _, d := range deadlines {
|
||||||
|
b.WriteString(fmt.Sprintf("- %s: due %s\n", d.Title, d.DueDate))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templateDesc, ok := templateDescriptions[templateType]
|
||||||
|
if !ok {
|
||||||
|
templateDesc = templateType
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := fmt.Sprintf(`Draft a %s for this case in %s.
|
||||||
|
|
||||||
|
Document type: %s
|
||||||
|
|
||||||
|
Case context:
|
||||||
|
%s
|
||||||
|
Additional instructions from the lawyer:
|
||||||
|
%s
|
||||||
|
|
||||||
|
Generate the complete document now.`, templateDesc, langLabel, templateDesc, b.String(), instructions)
|
||||||
|
|
||||||
|
msg, err := s.client.Messages.New(ctx, anthropic.MessageNewParams{
|
||||||
|
Model: anthropic.ModelClaudeSonnet4_20250514,
|
||||||
|
MaxTokens: 8192,
|
||||||
|
System: []anthropic.TextBlockParam{
|
||||||
|
{Text: draftDocumentSystemPrompt},
|
||||||
|
},
|
||||||
|
Messages: []anthropic.MessageParam{
|
||||||
|
anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("claude API call: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var content string
|
||||||
|
for _, block := range msg.Content {
|
||||||
|
if block.Type == "text" {
|
||||||
|
content += block.Text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if content == "" {
|
||||||
|
return nil, fmt.Errorf("empty response from Claude")
|
||||||
|
}
|
||||||
|
|
||||||
|
title := fmt.Sprintf("%s — %s", templateDesc, c.CaseNumber)
|
||||||
|
return &DocumentDraft{
|
||||||
|
Title: title,
|
||||||
|
Content: content,
|
||||||
|
Language: language,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Case Strategy ---
|
||||||
|
|
||||||
|
// StrategyRecommendation represents an AI-generated case strategy analysis.
|
||||||
|
type StrategyRecommendation struct {
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
NextSteps []StrategyStep `json:"next_steps"`
|
||||||
|
RiskAssessment []RiskItem `json:"risk_assessment"`
|
||||||
|
Timeline []TimelineItem `json:"timeline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StrategyStep struct {
|
||||||
|
Priority string `json:"priority"` // high, medium, low
|
||||||
|
Action string `json:"action"`
|
||||||
|
Reasoning string `json:"reasoning"`
|
||||||
|
Deadline string `json:"deadline,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RiskItem struct {
|
||||||
|
Level string `json:"level"` // high, medium, low
|
||||||
|
Risk string `json:"risk"`
|
||||||
|
Mitigation string `json:"mitigation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimelineItem struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Event string `json:"event"`
|
||||||
|
Importance string `json:"importance"` // critical, important, routine
|
||||||
|
}
|
||||||
|
|
||||||
|
type strategyToolInput struct {
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
NextSteps []StrategyStep `json:"next_steps"`
|
||||||
|
RiskAssessment []RiskItem `json:"risk_assessment"`
|
||||||
|
Timeline []TimelineItem `json:"timeline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var caseStrategyTool = anthropic.ToolParam{
|
||||||
|
Name: "case_strategy",
|
||||||
|
Description: anthropic.String("Provide strategic case analysis with next steps, risk assessment, and timeline optimization."),
|
||||||
|
InputSchema: anthropic.ToolInputSchemaParam{
|
||||||
|
Properties: map[string]any{
|
||||||
|
"summary": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Executive summary of the case situation and strategic outlook (2-4 sentences)",
|
||||||
|
},
|
||||||
|
"next_steps": map[string]any{
|
||||||
|
"type": "array",
|
||||||
|
"description": "Recommended next actions in priority order",
|
||||||
|
"items": map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"priority": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{"high", "medium", "low"},
|
||||||
|
},
|
||||||
|
"action": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Specific recommended action",
|
||||||
|
},
|
||||||
|
"reasoning": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Why this action is recommended",
|
||||||
|
},
|
||||||
|
"deadline": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Suggested deadline in YYYY-MM-DD format, if applicable",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"priority", "action", "reasoning"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"risk_assessment": map[string]any{
|
||||||
|
"type": "array",
|
||||||
|
"description": "Key risks and mitigation strategies",
|
||||||
|
"items": map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"level": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{"high", "medium", "low"},
|
||||||
|
},
|
||||||
|
"risk": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Description of the risk",
|
||||||
|
},
|
||||||
|
"mitigation": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Recommended mitigation strategy",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"level", "risk", "mitigation"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"timeline": map[string]any{
|
||||||
|
"type": "array",
|
||||||
|
"description": "Optimized timeline of upcoming milestones and events",
|
||||||
|
"items": map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"date": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Date in YYYY-MM-DD format",
|
||||||
|
},
|
||||||
|
"event": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Description of the milestone or event",
|
||||||
|
},
|
||||||
|
"importance": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{"critical", "important", "routine"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"date", "event", "importance"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"summary", "next_steps", "risk_assessment", "timeline"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const caseStrategySystemPrompt = `You are a senior litigation strategist specializing in German law and UPC (Unified Patent Court) patent proceedings.
|
||||||
|
|
||||||
|
Analyze the case thoroughly and provide:
|
||||||
|
1. An executive summary of the current strategic position
|
||||||
|
2. Prioritized next steps with clear reasoning
|
||||||
|
3. Risk assessment with mitigation strategies
|
||||||
|
4. An optimized timeline of upcoming milestones
|
||||||
|
|
||||||
|
Consider:
|
||||||
|
- Procedural deadlines and their implications
|
||||||
|
- Strength of the parties' positions based on available information
|
||||||
|
- Potential settlement opportunities
|
||||||
|
- Cost-efficiency of different strategic approaches
|
||||||
|
- UPC-specific procedural peculiarities if applicable (bifurcation, preliminary injunctions, etc.)
|
||||||
|
|
||||||
|
Be practical and actionable. Avoid generic advice — tailor recommendations to the specific case data provided.`
|
||||||
|
|
||||||
|
// CaseStrategy analyzes a case and returns strategic recommendations.
|
||||||
|
func (s *AIService) CaseStrategy(ctx context.Context, tenantID, caseID uuid.UUID) (*StrategyRecommendation, error) {
|
||||||
|
// Load case
|
||||||
|
var c models.Case
|
||||||
|
if err := s.db.GetContext(ctx, &c,
|
||||||
|
"SELECT * FROM cases WHERE id = $1 AND tenant_id = $2", caseID, tenantID); err != nil {
|
||||||
|
return nil, fmt.Errorf("loading case: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load parties
|
||||||
|
var parties []models.Party
|
||||||
|
_ = s.db.SelectContext(ctx, &parties,
|
||||||
|
"SELECT * FROM parties WHERE case_id = $1 AND tenant_id = $2", caseID, tenantID)
|
||||||
|
|
||||||
|
// Load all events
|
||||||
|
var events []models.CaseEvent
|
||||||
|
_ = s.db.SelectContext(ctx, &events,
|
||||||
|
"SELECT * FROM case_events WHERE case_id = $1 AND tenant_id = $2 ORDER BY created_at DESC LIMIT 25",
|
||||||
|
caseID, tenantID)
|
||||||
|
|
||||||
|
// Load all deadlines (active + completed for context)
|
||||||
|
var deadlines []models.Deadline
|
||||||
|
_ = s.db.SelectContext(ctx, &deadlines,
|
||||||
|
"SELECT * FROM deadlines WHERE case_id = $1 AND tenant_id = $2 ORDER BY due_date ASC LIMIT 20",
|
||||||
|
caseID, tenantID)
|
||||||
|
|
||||||
|
// Load documents metadata
|
||||||
|
var documents []models.Document
|
||||||
|
_ = s.db.SelectContext(ctx, &documents,
|
||||||
|
"SELECT id, title, doc_type, created_at FROM documents WHERE case_id = $1 AND tenant_id = $2 ORDER BY created_at DESC LIMIT 15",
|
||||||
|
caseID, tenantID)
|
||||||
|
|
||||||
|
// Build comprehensive context
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(fmt.Sprintf("Case: %s — %s\nStatus: %s\n", c.CaseNumber, c.Title, c.Status))
|
||||||
|
if c.Court != nil {
|
||||||
|
b.WriteString(fmt.Sprintf("Court: %s\n", *c.Court))
|
||||||
|
}
|
||||||
|
if c.CourtRef != nil {
|
||||||
|
b.WriteString(fmt.Sprintf("Court Reference: %s\n", *c.CourtRef))
|
||||||
|
}
|
||||||
|
if c.CaseType != nil {
|
||||||
|
b.WriteString(fmt.Sprintf("Type: %s\n", *c.CaseType))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parties) > 0 {
|
||||||
|
b.WriteString("\nParties:\n")
|
||||||
|
for _, p := range parties {
|
||||||
|
role := "unknown"
|
||||||
|
if p.Role != nil {
|
||||||
|
role = *p.Role
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("- %s (%s)", p.Name, role))
|
||||||
|
if p.Representative != nil {
|
||||||
|
b.WriteString(fmt.Sprintf(" — represented by %s", *p.Representative))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(events) > 0 {
|
||||||
|
b.WriteString("\nCase Events (chronological):\n")
|
||||||
|
for _, e := range events {
|
||||||
|
b.WriteString(fmt.Sprintf("- [%s] %s", e.CreatedAt.Format("2006-01-02"), e.Title))
|
||||||
|
if e.Description != nil {
|
||||||
|
b.WriteString(fmt.Sprintf(": %s", *e.Description))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(deadlines) > 0 {
|
||||||
|
b.WriteString("\nDeadlines:\n")
|
||||||
|
for _, d := range deadlines {
|
||||||
|
b.WriteString(fmt.Sprintf("- %s: due %s (status: %s)\n", d.Title, d.DueDate, d.Status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(documents) > 0 {
|
||||||
|
b.WriteString("\nDocuments on file:\n")
|
||||||
|
for _, d := range documents {
|
||||||
|
docType := ""
|
||||||
|
if d.DocType != nil {
|
||||||
|
docType = fmt.Sprintf(" [%s]", *d.DocType)
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("- %s%s (%s)\n", d.Title, docType, d.CreatedAt.Format("2006-01-02")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := s.client.Messages.New(ctx, anthropic.MessageNewParams{
|
||||||
|
Model: anthropic.ModelClaudeOpus4_6,
|
||||||
|
MaxTokens: 4096,
|
||||||
|
System: []anthropic.TextBlockParam{
|
||||||
|
{Text: caseStrategySystemPrompt},
|
||||||
|
},
|
||||||
|
Messages: []anthropic.MessageParam{
|
||||||
|
anthropic.NewUserMessage(anthropic.NewTextBlock("Analyze this case and provide strategic recommendations:\n\n" + b.String())),
|
||||||
|
},
|
||||||
|
Tools: []anthropic.ToolUnionParam{
|
||||||
|
{OfTool: &caseStrategyTool},
|
||||||
|
},
|
||||||
|
ToolChoice: anthropic.ToolChoiceParamOfTool("case_strategy"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("claude API call: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, block := range msg.Content {
|
||||||
|
if block.Type == "tool_use" && block.Name == "case_strategy" {
|
||||||
|
var input strategyToolInput
|
||||||
|
if err := json.Unmarshal(block.Input, &input); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing strategy output: %w", err)
|
||||||
|
}
|
||||||
|
result := &StrategyRecommendation{
|
||||||
|
Summary: input.Summary,
|
||||||
|
NextSteps: input.NextSteps,
|
||||||
|
RiskAssessment: input.RiskAssessment,
|
||||||
|
Timeline: input.Timeline,
|
||||||
|
}
|
||||||
|
// Cache in database
|
||||||
|
strategyJSON, _ := json.Marshal(result)
|
||||||
|
_, _ = s.db.ExecContext(ctx,
|
||||||
|
"UPDATE cases SET ai_summary = $1, updated_at = $2 WHERE id = $3 AND tenant_id = $4",
|
||||||
|
string(strategyJSON), time.Now(), caseID, tenantID)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no tool_use block in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Similar Case Finder ---
|
||||||
|
|
||||||
|
// SimilarCase represents a UPC case found to be similar.
|
||||||
|
type SimilarCase struct {
|
||||||
|
CaseNumber string `json:"case_number"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Court string `json:"court"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
Relevance float64 `json:"relevance"` // 0.0-1.0
|
||||||
|
Explanation string `json:"explanation"` // why this case is similar
|
||||||
|
KeyHoldings string `json:"key_holdings"` // relevant holdings
|
||||||
|
URL string `json:"url,omitempty"` // link to youpc.org
|
||||||
|
}
|
||||||
|
|
||||||
|
// youpcCase represents a case from the youpc.org database.
|
||||||
|
type youpcCase struct {
|
||||||
|
ID string `db:"id" json:"id"`
|
||||||
|
CaseNumber *string `db:"case_number" json:"case_number"`
|
||||||
|
Title *string `db:"title" json:"title"`
|
||||||
|
Court *string `db:"court" json:"court"`
|
||||||
|
DecisionDate *string `db:"decision_date" json:"decision_date"`
|
||||||
|
CaseType *string `db:"case_type" json:"case_type"`
|
||||||
|
Outcome *string `db:"outcome" json:"outcome"`
|
||||||
|
PatentNumbers *string `db:"patent_numbers" json:"patent_numbers"`
|
||||||
|
Summary *string `db:"summary" json:"summary"`
|
||||||
|
Claimant *string `db:"claimant" json:"claimant"`
|
||||||
|
Defendant *string `db:"defendant" json:"defendant"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type similarCaseToolInput struct {
|
||||||
|
Cases []struct {
|
||||||
|
CaseID string `json:"case_id"`
|
||||||
|
Relevance float64 `json:"relevance"`
|
||||||
|
Explanation string `json:"explanation"`
|
||||||
|
KeyHoldings string `json:"key_holdings"`
|
||||||
|
} `json:"cases"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var similarCaseTool = anthropic.ToolParam{
|
||||||
|
Name: "rank_similar_cases",
|
||||||
|
Description: anthropic.String("Rank the provided UPC cases by relevance to the query case and explain why each is similar."),
|
||||||
|
InputSchema: anthropic.ToolInputSchemaParam{
|
||||||
|
Properties: map[string]any{
|
||||||
|
"cases": map[string]any{
|
||||||
|
"type": "array",
|
||||||
|
"description": "UPC cases ranked by relevance (most relevant first)",
|
||||||
|
"items": map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"case_id": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "The ID of the UPC case from the provided list",
|
||||||
|
},
|
||||||
|
"relevance": map[string]any{
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1,
|
||||||
|
"description": "Relevance score from 0.0 to 1.0",
|
||||||
|
},
|
||||||
|
"explanation": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Why this case is relevant — what legal issues, parties, patents, or procedural aspects are similar",
|
||||||
|
},
|
||||||
|
"key_holdings": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Key holdings or legal principles from this case that are relevant",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"case_id", "relevance", "explanation", "key_holdings"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"cases"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const similarCaseSystemPrompt = `You are a UPC (Unified Patent Court) case law expert.
|
||||||
|
|
||||||
|
Given a case description and a list of UPC cases from the database, rank the cases by relevance and explain why each one is similar or relevant.
|
||||||
|
|
||||||
|
Consider:
|
||||||
|
- Similar patents or technology areas
|
||||||
|
- Same parties or representatives
|
||||||
|
- Similar legal issues (infringement, validity, injunctions, etc.)
|
||||||
|
- Similar procedural situations
|
||||||
|
- Relevant legal principles that could apply
|
||||||
|
|
||||||
|
Only include cases that are genuinely relevant (relevance > 0.3). Order by relevance descending.`
|
||||||
|
|
||||||
|
// FindSimilarCases searches the youpc.org database for similar UPC cases.
|
||||||
|
func (s *AIService) FindSimilarCases(ctx context.Context, tenantID, caseID uuid.UUID, description string) ([]SimilarCase, error) {
|
||||||
|
if s.youpcDB == nil {
|
||||||
|
return nil, fmt.Errorf("youpc.org database not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build query context from the case (if provided) or description
|
||||||
|
var queryText string
|
||||||
|
if caseID != uuid.Nil {
|
||||||
|
var c models.Case
|
||||||
|
if err := s.db.GetContext(ctx, &c,
|
||||||
|
"SELECT * FROM cases WHERE id = $1 AND tenant_id = $2", caseID, tenantID); err != nil {
|
||||||
|
return nil, fmt.Errorf("loading case: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var parties []models.Party
|
||||||
|
_ = s.db.SelectContext(ctx, &parties,
|
||||||
|
"SELECT * FROM parties WHERE case_id = $1 AND tenant_id = $2", caseID, tenantID)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(fmt.Sprintf("Case: %s — %s\n", c.CaseNumber, c.Title))
|
||||||
|
if c.CaseType != nil {
|
||||||
|
b.WriteString(fmt.Sprintf("Type: %s\n", *c.CaseType))
|
||||||
|
}
|
||||||
|
if c.Court != nil {
|
||||||
|
b.WriteString(fmt.Sprintf("Court: %s\n", *c.Court))
|
||||||
|
}
|
||||||
|
for _, p := range parties {
|
||||||
|
role := ""
|
||||||
|
if p.Role != nil {
|
||||||
|
role = *p.Role
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("Party: %s (%s)\n", p.Name, role))
|
||||||
|
}
|
||||||
|
if description != "" {
|
||||||
|
b.WriteString(fmt.Sprintf("\nAdditional context: %s\n", description))
|
||||||
|
}
|
||||||
|
queryText = b.String()
|
||||||
|
} else if description != "" {
|
||||||
|
queryText = description
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("either case_id or description must be provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query youpc.org database for candidate cases
|
||||||
|
// Search by text similarity across case titles, summaries, party names
|
||||||
|
var candidates []youpcCase
|
||||||
|
err := s.youpcDB.SelectContext(ctx, &candidates, `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
case_number,
|
||||||
|
title,
|
||||||
|
court,
|
||||||
|
decision_date,
|
||||||
|
case_type,
|
||||||
|
outcome,
|
||||||
|
patent_numbers,
|
||||||
|
summary,
|
||||||
|
claimant,
|
||||||
|
defendant
|
||||||
|
FROM mlex.cases
|
||||||
|
ORDER BY decision_date DESC NULLS LAST
|
||||||
|
LIMIT 50
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying youpc.org cases: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return []SimilarCase{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build candidate list for Claude
|
||||||
|
var candidateText strings.Builder
|
||||||
|
for _, c := range candidates {
|
||||||
|
candidateText.WriteString(fmt.Sprintf("ID: %s\n", c.ID))
|
||||||
|
if c.CaseNumber != nil {
|
||||||
|
candidateText.WriteString(fmt.Sprintf("Case Number: %s\n", *c.CaseNumber))
|
||||||
|
}
|
||||||
|
if c.Title != nil {
|
||||||
|
candidateText.WriteString(fmt.Sprintf("Title: %s\n", *c.Title))
|
||||||
|
}
|
||||||
|
if c.Court != nil {
|
||||||
|
candidateText.WriteString(fmt.Sprintf("Court: %s\n", *c.Court))
|
||||||
|
}
|
||||||
|
if c.DecisionDate != nil {
|
||||||
|
candidateText.WriteString(fmt.Sprintf("Decision Date: %s\n", *c.DecisionDate))
|
||||||
|
}
|
||||||
|
if c.CaseType != nil {
|
||||||
|
candidateText.WriteString(fmt.Sprintf("Type: %s\n", *c.CaseType))
|
||||||
|
}
|
||||||
|
if c.Outcome != nil {
|
||||||
|
candidateText.WriteString(fmt.Sprintf("Outcome: %s\n", *c.Outcome))
|
||||||
|
}
|
||||||
|
if c.PatentNumbers != nil {
|
||||||
|
candidateText.WriteString(fmt.Sprintf("Patents: %s\n", *c.PatentNumbers))
|
||||||
|
}
|
||||||
|
if c.Claimant != nil {
|
||||||
|
candidateText.WriteString(fmt.Sprintf("Claimant: %s\n", *c.Claimant))
|
||||||
|
}
|
||||||
|
if c.Defendant != nil {
|
||||||
|
candidateText.WriteString(fmt.Sprintf("Defendant: %s\n", *c.Defendant))
|
||||||
|
}
|
||||||
|
if c.Summary != nil {
|
||||||
|
candidateText.WriteString(fmt.Sprintf("Summary: %s\n", *c.Summary))
|
||||||
|
}
|
||||||
|
candidateText.WriteString("---\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := fmt.Sprintf(`Find UPC cases relevant to this matter:
|
||||||
|
|
||||||
|
%s
|
||||||
|
|
||||||
|
Here are the UPC cases from the database to evaluate:
|
||||||
|
|
||||||
|
%s
|
||||||
|
|
||||||
|
Rank only the genuinely relevant cases by similarity.`, queryText, candidateText.String())
|
||||||
|
|
||||||
|
msg, err := s.client.Messages.New(ctx, anthropic.MessageNewParams{
|
||||||
|
Model: anthropic.ModelClaudeSonnet4_20250514,
|
||||||
|
MaxTokens: 4096,
|
||||||
|
System: []anthropic.TextBlockParam{
|
||||||
|
{Text: similarCaseSystemPrompt},
|
||||||
|
},
|
||||||
|
Messages: []anthropic.MessageParam{
|
||||||
|
anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
|
||||||
|
},
|
||||||
|
Tools: []anthropic.ToolUnionParam{
|
||||||
|
{OfTool: &similarCaseTool},
|
||||||
|
},
|
||||||
|
ToolChoice: anthropic.ToolChoiceParamOfTool("rank_similar_cases"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("claude API call: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, block := range msg.Content {
|
||||||
|
if block.Type == "tool_use" && block.Name == "rank_similar_cases" {
|
||||||
|
var input similarCaseToolInput
|
||||||
|
if err := json.Unmarshal(block.Input, &input); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing similar cases output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build lookup map for candidate data
|
||||||
|
candidateMap := make(map[string]youpcCase)
|
||||||
|
for _, c := range candidates {
|
||||||
|
candidateMap[c.ID] = c
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []SimilarCase
|
||||||
|
for _, ranked := range input.Cases {
|
||||||
|
if ranked.Relevance < 0.3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c, ok := candidateMap[ranked.CaseID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sc := SimilarCase{
|
||||||
|
Relevance: ranked.Relevance,
|
||||||
|
Explanation: ranked.Explanation,
|
||||||
|
KeyHoldings: ranked.KeyHoldings,
|
||||||
|
}
|
||||||
|
if c.CaseNumber != nil {
|
||||||
|
sc.CaseNumber = *c.CaseNumber
|
||||||
|
}
|
||||||
|
if c.Title != nil {
|
||||||
|
sc.Title = *c.Title
|
||||||
|
}
|
||||||
|
if c.Court != nil {
|
||||||
|
sc.Court = *c.Court
|
||||||
|
}
|
||||||
|
if c.DecisionDate != nil {
|
||||||
|
sc.Date = *c.DecisionDate
|
||||||
|
}
|
||||||
|
if c.CaseNumber != nil {
|
||||||
|
sc.URL = fmt.Sprintf("https://youpc.org/cases/%s", *c.CaseNumber)
|
||||||
|
}
|
||||||
|
results = append(results, sc)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no tool_use block in response")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user