From bfd5e354ad6048f9891c23e08c422eed044a4dd2 Mon Sep 17 00:00:00 2001 From: m Date: Mon, 30 Mar 2026 11:25:41 +0200 Subject: [PATCH 1/3] fix: resolve merge conflicts from P0 role-based + audit trail branches Combine role-based permissions (VerifyAccess/GetUserRole) with audit trail (IP/user-agent context capture) in auth middleware and tenant resolver. --- backend/internal/auth/context.go | 32 ++++++------------ backend/internal/auth/middleware.go | 33 ++----------------- backend/internal/auth/tenant_resolver.go | 25 ++------------ backend/internal/auth/tenant_resolver_test.go | 28 ++++------------ backend/internal/handlers/deadlines.go | 12 +------ backend/internal/router/router.go | 29 +++++----------- 6 files changed, 32 insertions(+), 127 deletions(-) diff --git a/backend/internal/auth/context.go b/backend/internal/auth/context.go index c2aeaef..9553104 100644 --- a/backend/internal/auth/context.go +++ b/backend/internal/auth/context.go @@ -9,19 +9,11 @@ import ( type contextKey string const ( -<<<<<<< HEAD userIDKey contextKey = "user_id" tenantIDKey contextKey = "tenant_id" + userRoleKey contextKey = "user_role" ipKey contextKey = "ip_address" userAgentKey contextKey = "user_agent" -||||||| 82878df - userIDKey contextKey = "user_id" - tenantIDKey contextKey = "tenant_id" -======= - userIDKey contextKey = "user_id" - tenantIDKey contextKey = "tenant_id" - userRoleKey contextKey = "user_role" ->>>>>>> mai/pike/p0-role-based ) func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context { @@ -41,7 +33,15 @@ func TenantFromContext(ctx context.Context) (uuid.UUID, bool) { id, ok := ctx.Value(tenantIDKey).(uuid.UUID) return id, ok } -<<<<<<< HEAD + +func ContextWithUserRole(ctx context.Context, role string) context.Context { + return context.WithValue(ctx, userRoleKey, role) +} + +func UserRoleFromContext(ctx context.Context) string { + role, _ := ctx.Value(userRoleKey).(string) + return role +} func ContextWithRequestInfo(ctx context.Context, ip, userAgent string) context.Context { ctx = context.WithValue(ctx, ipKey, ip) @@ -62,15 +62,3 @@ func UserAgentFromContext(ctx context.Context) *string { } return nil } -||||||| 82878df -======= - -func ContextWithUserRole(ctx context.Context, role string) context.Context { - return context.WithValue(ctx, userRoleKey, role) -} - -func UserRoleFromContext(ctx context.Context) string { - role, _ := ctx.Value(userRoleKey).(string) - return role -} ->>>>>>> mai/pike/p0-role-based diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go index 02428c6..2a45942 100644 --- a/backend/internal/auth/middleware.go +++ b/backend/internal/auth/middleware.go @@ -35,36 +35,6 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler { } ctx := ContextWithUserID(r.Context(), userID) -<<<<<<< HEAD - // Tenant resolution is handled by TenantResolver middleware for scoped routes. - // Tenant management routes handle their own access control. -||||||| 82878df - - // Resolve tenant and role from user_tenants - var membership struct { - TenantID uuid.UUID `db:"tenant_id"` - Role string `db:"role"` - } - err = m.db.GetContext(r.Context(), &membership, - "SELECT tenant_id, role FROM user_tenants WHERE user_id = $1 LIMIT 1", userID) - if err != nil { - http.Error(w, "no tenant found for user", http.StatusForbidden) - return - } - ctx = ContextWithTenantID(ctx, membership.TenantID) - ctx = ContextWithUserRole(ctx, membership.Role) - -======= - - // Resolve tenant from user_tenants - var tenantID uuid.UUID - err = m.db.GetContext(r.Context(), &tenantID, - "SELECT tenant_id FROM user_tenants WHERE user_id = $1 LIMIT 1", userID) - if err != nil { - http.Error(w, "no tenant found for user", http.StatusForbidden) - return - } - ctx = ContextWithTenantID(ctx, tenantID) // Capture IP and user-agent for audit logging ip := r.Header.Get("X-Forwarded-For") @@ -73,7 +43,8 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler { } ctx = ContextWithRequestInfo(ctx, ip, r.UserAgent()) ->>>>>>> mai/knuth/p0-audit-trail-append + // Tenant resolution is handled by TenantResolver middleware for scoped routes. + // Tenant management routes handle their own access control. next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/backend/internal/auth/tenant_resolver.go b/backend/internal/auth/tenant_resolver.go index 0dab57f..c4b883a 100644 --- a/backend/internal/auth/tenant_resolver.go +++ b/backend/internal/auth/tenant_resolver.go @@ -12,12 +12,8 @@ import ( // Defined as an interface to avoid circular dependency with services. type TenantLookup interface { FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) -<<<<<<< HEAD VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) -||||||| 82878df -======= GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) ->>>>>>> mai/pike/p0-role-based } // TenantResolver is middleware that resolves the tenant from X-Tenant-ID header @@ -46,35 +42,20 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler { http.Error(w, `{"error":"invalid X-Tenant-ID"}`, http.StatusBadRequest) return } -<<<<<<< HEAD - // Verify user has access to this tenant - hasAccess, err := tr.lookup.VerifyAccess(r.Context(), userID, parsed) + // Verify user has access and get their role + role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed) if err != nil { slog.Error("tenant access check failed", "error", err, "user_id", userID, "tenant_id", parsed) http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return } - if !hasAccess { + if role == "" { http.Error(w, `{"error":"no access to tenant"}`, http.StatusForbidden) return } -||||||| 82878df -======= - // Verify user has access and get their role - role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed) - if err != nil { - http.Error(w, "error checking tenant access", http.StatusInternalServerError) - return - } - if role == "" { - http.Error(w, "no access to this tenant", http.StatusForbidden) - return - } ->>>>>>> mai/pike/p0-role-based tenantID = parsed - // Override the role from middleware with the correct one for this tenant r = r.WithContext(ContextWithUserRole(r.Context(), role)) } else { // Default to user's first tenant (role already set by middleware) diff --git a/backend/internal/auth/tenant_resolver_test.go b/backend/internal/auth/tenant_resolver_test.go index d0300c2..cdb210a 100644 --- a/backend/internal/auth/tenant_resolver_test.go +++ b/backend/internal/auth/tenant_resolver_test.go @@ -10,49 +10,35 @@ import ( ) type mockTenantLookup struct { -<<<<<<< HEAD tenantID *uuid.UUID err error hasAccess bool accessErr error -||||||| 82878df - tenantID *uuid.UUID - err error -======= - tenantID *uuid.UUID - role string - err error ->>>>>>> mai/pike/p0-role-based + role string + noAccess bool // when true, GetUserRole returns "" } func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) { return m.tenantID, m.err } -<<<<<<< HEAD func (m *mockTenantLookup) VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) { return m.hasAccess, m.accessErr } -||||||| 82878df -======= 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 } return "associate", m.err } ->>>>>>> mai/pike/p0-role-based func TestTenantResolver_FromHeader(t *testing.T) { tenantID := uuid.New() -<<<<<<< HEAD - tr := NewTenantResolver(&mockTenantLookup{hasAccess: true}) -||||||| 82878df - tr := NewTenantResolver(&mockTenantLookup{}) -======= - tr := NewTenantResolver(&mockTenantLookup{role: "partner"}) ->>>>>>> mai/pike/p0-role-based + tr := NewTenantResolver(&mockTenantLookup{role: "partner", hasAccess: true}) var gotTenantID uuid.UUID next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -81,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") diff --git a/backend/internal/handlers/deadlines.go b/backend/internal/handlers/deadlines.go index e1a2a39..1ff7629 100644 --- a/backend/internal/handlers/deadlines.go +++ b/backend/internal/handlers/deadlines.go @@ -198,18 +198,8 @@ func (h *DeadlineHandlers) Delete(w http.ResponseWriter, r *http.Request) { return } -<<<<<<< HEAD - if err := h.deadlines.Delete(tenantID, deadlineID); err != nil { + if err := h.deadlines.Delete(r.Context(), tenantID, deadlineID); err != nil { writeError(w, http.StatusNotFound, "deadline not found") -||||||| 82878df - err = h.deadlines.Delete(tenantID, deadlineID) - if err != nil { - writeError(w, http.StatusNotFound, err.Error()) -======= - err = h.deadlines.Delete(r.Context(), tenantID, deadlineID) - if err != nil { - writeError(w, http.StatusNotFound, err.Error()) ->>>>>>> mai/knuth/p0-audit-trail-append return } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 84b7f8e..d64c403 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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 @@ -29,19 +29,17 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se deadlineRuleSvc := services.NewDeadlineRuleService(db) calculator := services.NewDeadlineCalculator(holidaySvc) storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey) -<<<<<<< HEAD documentSvc := services.NewDocumentService(db, storageCli, auditSvc) -||||||| 82878df - documentSvc := services.NewDocumentService(db, storageCli) -======= - documentSvc := services.NewDocumentService(db, storageCli) assignmentSvc := services.NewCaseAssignmentService(db) ->>>>>>> mai/pike/p0-role-based // 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) } @@ -162,16 +160,10 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se // Dashboard — all can view scoped.HandleFunc("GET /api/dashboard", dashboardH.Get) -<<<<<<< HEAD // Audit log scoped.HandleFunc("GET /api/audit-log", auditH.List) - // Documents -||||||| 82878df - // Documents -======= // Documents — all can upload, delete checked in handler (own vs all) ->>>>>>> mai/pike/p0-role-based scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase) scoped.HandleFunc("POST /api/cases/{id}/documents", perm(auth.PermUploadDocuments, docH.Upload)) scoped.HandleFunc("GET /api/documents/{docId}", docH.Download) @@ -183,9 +175,11 @@ 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))) } -<<<<<<< HEAD // Notifications if notifH != nil { scoped.HandleFunc("GET /api/notifications", notifH.List) @@ -196,12 +190,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se scoped.HandleFunc("PUT /api/notification-preferences", notifH.UpdatePreferences) } - // CalDAV sync endpoints -||||||| 82878df - // CalDAV sync endpoints -======= // CalDAV sync endpoints — settings permission required ->>>>>>> mai/pike/p0-role-based if calDAVSvc != nil { calDAVH := handlers.NewCalDAVHandler(calDAVSvc) scoped.HandleFunc("POST /api/caldav/sync", perm(auth.PermManageSettings, calDAVH.TriggerSync)) From dd683281e0fa1b318f581478f51e02d1bc49cc15 Mon Sep 17 00:00:00 2001 From: m Date: Mon, 30 Mar 2026 11:25:52 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20AI-powered=20features=20=E2=80=94?= =?UTF-8?q?=20document=20drafting,=20case=20strategy,=20similar=20case=20f?= =?UTF-8?q?inder=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") +} From fdb4ac55a1d84c7c8d672c8a3e6df1e54c9120e8 Mon Sep 17 00:00:00 2001 From: m Date: Mon, 30 Mar 2026 11:26:01 +0200 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20frontend=20AI=20tab=20=E2=80=94=20K?= =?UTF-8?q?I-Strategie,=20KI-Entwurf,=20Aehnliche=20Faelle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New "KI" tab on case detail page with three sub-panels: - KI-Strategie: one-click strategic analysis with next steps, risks, timeline - KI-Entwurf: document drafting with template selection, language, instructions - Aehnliche Faelle: UPC similar case search with relevance scores Components: CaseStrategy, DocumentDrafter, SimilarCaseFinder Types: StrategyRecommendation, DocumentDraft, SimilarCase, etc. --- frontend/src/app/(app)/cases/[id]/ki/page.tsx | 51 ++++ frontend/src/app/(app)/cases/[id]/layout.tsx | 3 + frontend/src/components/ai/CaseStrategy.tsx | 226 ++++++++++++++++++ .../src/components/ai/DocumentDrafter.tsx | 198 +++++++++++++++ .../src/components/ai/SimilarCaseFinder.tsx | 183 ++++++++++++++ frontend/src/lib/types.ts | 78 ++++++ 6 files changed, 739 insertions(+) create mode 100644 frontend/src/app/(app)/cases/[id]/ki/page.tsx create mode 100644 frontend/src/components/ai/CaseStrategy.tsx create mode 100644 frontend/src/components/ai/DocumentDrafter.tsx create mode 100644 frontend/src/components/ai/SimilarCaseFinder.tsx diff --git a/frontend/src/app/(app)/cases/[id]/ki/page.tsx b/frontend/src/app/(app)/cases/[id]/ki/page.tsx new file mode 100644 index 0000000..98be0a9 --- /dev/null +++ b/frontend/src/app/(app)/cases/[id]/ki/page.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useState } from "react"; +import { useParams } from "next/navigation"; +import { Brain, FileText, Search } from "lucide-react"; +import { CaseStrategy } from "@/components/ai/CaseStrategy"; +import { DocumentDrafter } from "@/components/ai/DocumentDrafter"; +import { SimilarCaseFinder } from "@/components/ai/SimilarCaseFinder"; + +type AITab = "strategy" | "draft" | "similar"; + +const TABS: { id: AITab; label: string; icon: typeof Brain }[] = [ + { id: "strategy", label: "KI-Strategie", icon: Brain }, + { id: "draft", label: "KI-Entwurf", icon: FileText }, + { id: "similar", label: "Aehnliche Faelle", icon: Search }, +]; + +export default function CaseAIPage() { + const { id } = useParams<{ id: string }>(); + const [activeTab, setActiveTab] = useState("strategy"); + + return ( +
+ {/* Sub-tabs */} +
+ {TABS.map((tab) => { + const isActive = activeTab === tab.id; + return ( + + ); + })} +
+ + {/* Content */} + {activeTab === "strategy" && } + {activeTab === "draft" && } + {activeTab === "similar" && } +
+ ); +} diff --git a/frontend/src/app/(app)/cases/[id]/layout.tsx b/frontend/src/app/(app)/cases/[id]/layout.tsx index 857f570..7037917 100644 --- a/frontend/src/app/(app)/cases/[id]/layout.tsx +++ b/frontend/src/app/(app)/cases/[id]/layout.tsx @@ -17,6 +17,7 @@ import { StickyNote, AlertTriangle, ScrollText, + Brain, } from "lucide-react"; import { format } from "date-fns"; import { de } from "date-fns/locale"; @@ -48,6 +49,7 @@ const TABS = [ { segment: "mitarbeiter", label: "Mitarbeiter", icon: UserCheck }, { segment: "notizen", label: "Notizen", icon: StickyNote }, { segment: "protokoll", label: "Protokoll", icon: ScrollText }, + { segment: "ki", label: "KI", icon: Brain }, ] as const; const TAB_LABELS: Record = { @@ -58,6 +60,7 @@ const TAB_LABELS: Record = { mitarbeiter: "Mitarbeiter", notizen: "Notizen", protokoll: "Protokoll", + ki: "KI", }; function CaseDetailSkeleton() { diff --git a/frontend/src/components/ai/CaseStrategy.tsx b/frontend/src/components/ai/CaseStrategy.tsx new file mode 100644 index 0000000..1c98c36 --- /dev/null +++ b/frontend/src/components/ai/CaseStrategy.tsx @@ -0,0 +1,226 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import type { StrategyRecommendation } from "@/lib/types"; +import { + Loader2, + Brain, + AlertTriangle, + ArrowRight, + Shield, + Calendar, + RefreshCw, +} from "lucide-react"; + +interface CaseStrategyProps { + caseId: string; +} + +const PRIORITY_STYLES = { + high: "bg-red-50 text-red-700 border-red-200", + medium: "bg-amber-50 text-amber-700 border-amber-200", + low: "bg-emerald-50 text-emerald-700 border-emerald-200", +} as const; + +const IMPORTANCE_STYLES = { + critical: "border-l-red-500", + important: "border-l-amber-500", + routine: "border-l-neutral-300", +} as const; + +export function CaseStrategy({ caseId }: CaseStrategyProps) { + const mutation = useMutation({ + mutationFn: () => + api.post("/ai/case-strategy", { + case_id: caseId, + }), + }); + + if (!mutation.data && !mutation.isPending && !mutation.isError) { + return ( +
+
+ +
+
+

+ KI-Strategieanalyse +

+

+ Claude analysiert die Akte und gibt strategische Empfehlungen. +

+
+ +
+ ); + } + + if (mutation.isPending) { + return ( +
+ +

+ Claude analysiert die Akte... +

+

+ Dies kann bis zu 30 Sekunden dauern. +

+
+ ); + } + + if (mutation.isError) { + return ( +
+
+ +
+

Analyse fehlgeschlagen

+ +
+ ); + } + + const data = mutation.data!; + + return ( +
+
+

+ KI-Strategieanalyse +

+ +
+ + {/* Summary */} +
+ {data.summary} +
+ + {/* Next Steps */} + {data.next_steps?.length > 0 && ( +
+

+ + Naechste Schritte +

+
+ {data.next_steps.map((step, i) => ( +
+
+ + {step.priority === "high" + ? "Hoch" + : step.priority === "medium" + ? "Mittel" + : "Niedrig"} + +
+

+ {step.action} +

+

+ {step.reasoning} +

+ {step.deadline && ( +

+ Frist: {step.deadline} +

+ )} +
+
+
+ ))} +
+
+ )} + + {/* Risk Assessment */} + {data.risk_assessment?.length > 0 && ( +
+

+ + Risikobewertung +

+
+ {data.risk_assessment.map((risk, i) => ( +
+
+ + {risk.level === "high" + ? "Hoch" + : risk.level === "medium" + ? "Mittel" + : "Niedrig"} + +
+

+ {risk.risk} +

+

+ Massnahme: {risk.mitigation} +

+
+
+
+ ))} +
+
+ )} + + {/* Timeline */} + {data.timeline?.length > 0 && ( +
+

+ + Zeitplan +

+
+ {data.timeline.map((item, i) => ( +
+
+ + {item.date} + + {item.event} +
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/ai/DocumentDrafter.tsx b/frontend/src/components/ai/DocumentDrafter.tsx new file mode 100644 index 0000000..548f1a8 --- /dev/null +++ b/frontend/src/components/ai/DocumentDrafter.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import type { DocumentDraft, DraftDocumentRequest } from "@/lib/types"; +import { FileText, Loader2, Copy, Check, Download } from "lucide-react"; + +const TEMPLATES = { + klageschrift: "Klageschrift", + klageerwiderung: "Klageerwiderung", + abmahnung: "Abmahnung", + schriftsatz: "Schriftsatz", + berufung: "Berufungsschrift", + antrag: "Antrag", + stellungnahme: "Stellungnahme", + gutachten: "Gutachten", + vertrag: "Vertrag", + vollmacht: "Vollmacht", + upc_claim: "UPC Statement of Claim", + upc_defence: "UPC Statement of Defence", + upc_counterclaim: "UPC Counterclaim for Revocation", + upc_injunction: "UPC Provisional Measures", +} as const; + +const LANGUAGES = [ + { value: "de", label: "Deutsch" }, + { value: "en", label: "English" }, + { value: "fr", label: "Francais" }, +] as const; + +const inputClass = + "w-full rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-900 outline-none transition-colors focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"; + +interface DocumentDrafterProps { + caseId: string; +} + +export function DocumentDrafter({ caseId }: DocumentDrafterProps) { + const [templateType, setTemplateType] = useState(""); + const [instructions, setInstructions] = useState(""); + const [language, setLanguage] = useState("de"); + const [copied, setCopied] = useState(false); + + const mutation = useMutation({ + mutationFn: (req: DraftDocumentRequest) => + api.post("/ai/draft-document", req), + }); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!templateType) return; + mutation.mutate({ + case_id: caseId, + template_type: templateType, + instructions, + language, + }); + } + + function handleCopy() { + if (mutation.data?.content) { + navigator.clipboard.writeText(mutation.data.content); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + } + + function handleDownload() { + if (!mutation.data?.content) return; + const blob = new Blob([mutation.data.content], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${templateType}_entwurf.txt`; + a.click(); + URL.revokeObjectURL(url); + } + + return ( +
+
+
+ + +
+ +
+ + +
+ +
+ +