feat: AI features — drafting, strategy, similar cases (P2)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -35,8 +35,6 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
ctx := ContextWithUserID(r.Context(), userID)
|
||||
// Tenant resolution is handled by TenantResolver middleware for scoped routes.
|
||||
// Tenant management routes handle their own access control.
|
||||
|
||||
// Capture IP and user-agent for audit logging
|
||||
ip := r.Header.Get("X-Forwarded-For")
|
||||
@@ -45,6 +43,8 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
|
||||
}
|
||||
ctx = ContextWithRequestInfo(ctx, ip, r.UserAgent())
|
||||
|
||||
// Tenant resolution is handled by TenantResolver middleware for scoped routes.
|
||||
// Tenant management routes handle their own access control.
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
var tenantID uuid.UUID
|
||||
ctx := r.Context()
|
||||
|
||||
if header := r.Header.Get("X-Tenant-ID"); header != "" {
|
||||
parsed, err := uuid.Parse(header)
|
||||
@@ -57,16 +56,9 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
tenantID = parsed
|
||||
<<<<<<< HEAD
|
||||
r = r.WithContext(ContextWithUserRole(r.Context(), role))
|
||||
||||||| 8e65463
|
||||
// Override the role from middleware with the correct one for this tenant
|
||||
r = r.WithContext(ContextWithUserRole(r.Context(), role))
|
||||
=======
|
||||
ctx = ContextWithUserRole(ctx, role)
|
||||
>>>>>>> mai/ritchie/p1-document-templates
|
||||
} else {
|
||||
// Default to user's first tenant
|
||||
// Default to user's first tenant (role already set by middleware)
|
||||
first, err := tr.lookup.FirstTenantForUser(r.Context(), userID)
|
||||
if err != nil {
|
||||
slog.Error("failed to resolve default tenant", "error", err, "user_id", userID)
|
||||
@@ -78,31 +70,9 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
tenantID = *first
|
||||
<<<<<<< HEAD
|
||||
|
||||
// Also resolve role for default tenant
|
||||
role, err := tr.lookup.GetUserRole(r.Context(), userID, tenantID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get role for default tenant", "error", err, "user_id", userID, "tenant_id", tenantID)
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
r = r.WithContext(ContextWithUserRole(r.Context(), role))
|
||||
||||||| 8e65463
|
||||
=======
|
||||
|
||||
// Get role for default tenant
|
||||
role, err := tr.lookup.GetUserRole(r.Context(), userID, tenantID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get user role", "error", err, "user_id", userID, "tenant_id", tenantID)
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ctx = ContextWithUserRole(ctx, role)
|
||||
>>>>>>> mai/ritchie/p1-document-templates
|
||||
}
|
||||
|
||||
ctx = ContextWithTenantID(ctx, tenantID)
|
||||
ctx := ContextWithTenantID(r.Context(), tenantID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ type mockTenantLookup struct {
|
||||
hasAccess bool
|
||||
accessErr error
|
||||
role string
|
||||
noAccess bool // when true, GetUserRole returns ""
|
||||
}
|
||||
|
||||
func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
|
||||
@@ -26,18 +27,18 @@ func (m *mockTenantLookup) VerifyAccess(ctx context.Context, userID, tenantID uu
|
||||
}
|
||||
|
||||
func (m *mockTenantLookup) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) {
|
||||
if m.noAccess {
|
||||
return "", m.err
|
||||
}
|
||||
if m.role != "" {
|
||||
return m.role, m.err
|
||||
}
|
||||
if m.hasAccess {
|
||||
return "associate", m.err
|
||||
}
|
||||
return "", m.err
|
||||
return "associate", m.err
|
||||
}
|
||||
|
||||
func TestTenantResolver_FromHeader(t *testing.T) {
|
||||
tenantID := uuid.New()
|
||||
tr := NewTenantResolver(&mockTenantLookup{hasAccess: true, role: "partner"})
|
||||
tr := NewTenantResolver(&mockTenantLookup{role: "partner", hasAccess: true})
|
||||
|
||||
var gotTenantID uuid.UUID
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -66,7 +67,7 @@ func TestTenantResolver_FromHeader(t *testing.T) {
|
||||
|
||||
func TestTenantResolver_FromHeader_NoAccess(t *testing.T) {
|
||||
tenantID := uuid.New()
|
||||
tr := NewTenantResolver(&mockTenantLookup{hasAccess: false})
|
||||
tr := NewTenantResolver(&mockTenantLookup{noAccess: true})
|
||||
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("next should not be called")
|
||||
@@ -86,13 +87,7 @@ func TestTenantResolver_FromHeader_NoAccess(t *testing.T) {
|
||||
|
||||
func TestTenantResolver_DefaultsToFirst(t *testing.T) {
|
||||
tenantID := uuid.New()
|
||||
<<<<<<< HEAD
|
||||
tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID, role: "owner"})
|
||||
||||||| 8e65463
|
||||
tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID})
|
||||
=======
|
||||
tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID, role: "associate"})
|
||||
>>>>>>> mai/ritchie/p1-document-templates
|
||||
|
||||
var gotTenantID uuid.UUID
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
||||
)
|
||||
|
||||
func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *services.CalDAVService, notifSvc *services.NotificationService) http.Handler {
|
||||
func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *services.CalDAVService, notifSvc *services.NotificationService, youpcDB ...*sqlx.DB) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Services
|
||||
@@ -35,7 +35,11 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
||||
// AI service (optional — only if API key is configured)
|
||||
var aiH *handlers.AIHandler
|
||||
if cfg.AnthropicAPIKey != "" {
|
||||
aiSvc := services.NewAIService(cfg.AnthropicAPIKey, db)
|
||||
var ydb *sqlx.DB
|
||||
if len(youpcDB) > 0 {
|
||||
ydb = youpcDB[0]
|
||||
}
|
||||
aiSvc := services.NewAIService(cfg.AnthropicAPIKey, db, ydb)
|
||||
aiH = handlers.NewAIHandler(aiSvc)
|
||||
}
|
||||
|
||||
@@ -44,7 +48,6 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
||||
|
||||
noteSvc := services.NewNoteService(db, auditSvc)
|
||||
dashboardSvc := services.NewDashboardService(db)
|
||||
reportingSvc := services.NewReportingService(db)
|
||||
|
||||
// Notification handler (optional — nil in tests)
|
||||
var notifH *handlers.NotificationHandler
|
||||
@@ -62,7 +65,6 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
||||
ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc)
|
||||
calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc)
|
||||
dashboardH := handlers.NewDashboardHandler(dashboardSvc)
|
||||
reportH := handlers.NewReportHandler(reportingSvc)
|
||||
noteH := handlers.NewNoteHandler(noteSvc)
|
||||
eventH := handlers.NewCaseEventHandler(db)
|
||||
docH := handlers.NewDocumentHandler(documentSvc)
|
||||
@@ -158,12 +160,6 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
||||
// Dashboard — all can view
|
||||
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
|
||||
|
||||
// Reports — all can view
|
||||
scoped.HandleFunc("GET /api/reports/cases", reportH.Cases)
|
||||
scoped.HandleFunc("GET /api/reports/deadlines", reportH.Deadlines)
|
||||
scoped.HandleFunc("GET /api/reports/workload", reportH.Workload)
|
||||
scoped.HandleFunc("GET /api/reports/billing", reportH.Billing)
|
||||
|
||||
// Audit log
|
||||
scoped.HandleFunc("GET /api/audit-log", auditH.List)
|
||||
|
||||
@@ -179,6 +175,9 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
||||
aiLimiter := middleware.NewTokenBucket(5.0/60.0, 10)
|
||||
scoped.HandleFunc("POST /api/ai/extract-deadlines", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.ExtractDeadlines)))
|
||||
scoped.HandleFunc("POST /api/ai/summarize-case", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SummarizeCase)))
|
||||
scoped.HandleFunc("POST /api/ai/draft-document", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.DraftDocument)))
|
||||
scoped.HandleFunc("POST /api/ai/case-strategy", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.CaseStrategy)))
|
||||
scoped.HandleFunc("POST /api/ai/similar-cases", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SimilarCases)))
|
||||
}
|
||||
|
||||
// Notifications
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user