From dd683281e0fa1b318f581478f51e02d1bc49cc15 Mon Sep 17 00:00:00 2001 From: m Date: Mon, 30 Mar 2026 11:25:52 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20AI-powered=20features=20=E2=80=94=20doc?= =?UTF-8?q?ument=20drafting,=20case=20strategy,=20similar=20case=20finder?= =?UTF-8?q?=20(P2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- backend/cmd/server/main.go | 20 +- backend/internal/config/config.go | 2 + backend/internal/handlers/ai.go | 138 +++++ backend/internal/services/ai_service.go | 729 +++++++++++++++++++++++- 4 files changed, 886 insertions(+), 3 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 44e5f34..fbe14bd 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -5,6 +5,9 @@ import ( "net/http" "os" + "github.com/jmoiron/sqlx" + _ "github.com/lib/pq" + "mgit.msbls.de/m/KanzlAI-mGMT/internal/auth" "mgit.msbls.de/m/KanzlAI-mGMT/internal/config" "mgit.msbls.de/m/KanzlAI-mGMT/internal/db" @@ -31,6 +34,21 @@ func main() { 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 calDAVSvc := services.NewCalDAVService(database) calDAVSvc.Start() @@ -41,7 +59,7 @@ func main() { notifSvc.Start() 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) if err := http.ListenAndServe(":"+cfg.Port, handler); err != nil { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 620a7f4..ad8500f 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -14,6 +14,7 @@ type Config struct { SupabaseJWTSecret string AnthropicAPIKey string FrontendOrigin string + YouPCDatabaseURL string // read-only connection to youpc.org Supabase for similar case finder } func Load() (*Config, error) { @@ -26,6 +27,7 @@ func Load() (*Config, error) { SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"), AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"), FrontendOrigin: getEnv("FRONTEND_ORIGIN", "https://kanzlai.msbls.de"), + YouPCDatabaseURL: os.Getenv("YOUPC_DATABASE_URL"), } if cfg.DatabaseURL == "" { diff --git a/backend/internal/handlers/ai.go b/backend/internal/handlers/ai.go index 1a03c37..5affa2f 100644 --- a/backend/internal/handlers/ai.go +++ b/backend/internal/handlers/ai.go @@ -5,6 +5,8 @@ import ( "io" "net/http" + "github.com/google/uuid" + "mgit.msbls.de/m/KanzlAI-mGMT/internal/auth" "mgit.msbls.de/m/KanzlAI-mGMT/internal/services" ) @@ -115,3 +117,139 @@ func (h *AIHandler) SummarizeCase(w http.ResponseWriter, r *http.Request) { "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), + }) +} diff --git a/backend/internal/services/ai_service.go b/backend/internal/services/ai_service.go index d456cc3..8a304ca 100644 --- a/backend/internal/services/ai_service.go +++ b/backend/internal/services/ai_service.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "strings" "time" "github.com/anthropics/anthropic-sdk-go" @@ -18,11 +19,12 @@ import ( type AIService struct { client anthropic.Client 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)) - return &AIService{client: client, db: db} + return &AIService{client: client, db: db, youpcDB: youpcDB} } // 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 } + +// --- 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") +}