diff --git a/drizzle/0004_document_source_scope.sql b/drizzle/0004_document_source_scope.sql new file mode 100644 index 0000000..a2bf36f --- /dev/null +++ b/drizzle/0004_document_source_scope.sql @@ -0,0 +1,17 @@ +-- Add source scope to documents table (AIIA-66) +-- Documents are either case-specific or globally available + +DO $$ BEGIN + CREATE TYPE "document_source_scope" AS ENUM ('case', 'global'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +ALTER TABLE "documents" + ADD COLUMN IF NOT EXISTS "source_scope" "document_source_scope" NOT NULL DEFAULT 'case'; + +-- Auto-set global scope for norm documents (they are always globally available) +UPDATE "documents" SET "source_scope" = 'global' WHERE "category" = 'norm'; + +-- Index for efficient source scope queries +CREATE INDEX IF NOT EXISTS "documents_source_scope_idx" ON "documents" ("source_scope"); diff --git a/src/app/(dashboard)/analyse/analyse-form.tsx b/src/app/(dashboard)/analyse/analyse-form.tsx index 43f72ca..00fc332 100644 --- a/src/app/(dashboard)/analyse/analyse-form.tsx +++ b/src/app/(dashboard)/analyse/analyse-form.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState } from 'react'; +import SourceSelection from '@/components/documents/source-selection'; interface CaseOption { id: string; @@ -19,6 +20,7 @@ export default function AnalyseForm({ cases }: { cases: CaseOption[] }) { const [mode, setMode] = useState('gutachten'); const [caseId, setCaseId] = useState(''); const [question, setQuestion] = useState(''); + const [selectedDocumentIds, setSelectedDocumentIds] = useState([]); const [result, setResult] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); @@ -40,6 +42,7 @@ export default function AnalyseForm({ cases }: { cases: CaseOption[] }) { title: `${MODES.find((m) => m.key === mode)?.label ?? mode} — ${question.trim().slice(0, 80)}`, query: question.trim(), caseId: caseId || undefined, + documentIds: selectedDocumentIds.length > 0 ? selectedDocumentIds : undefined, }), }); @@ -110,6 +113,12 @@ export default function AnalyseForm({ cases }: { cases: CaseOption[] }) { /> + + {error && (
{error}
)} diff --git a/src/app/api/analyses/route.ts b/src/app/api/analyses/route.ts index 3eed163..f851157 100644 --- a/src/app/api/analyses/route.ts +++ b/src/app/api/analyses/route.ts @@ -18,7 +18,7 @@ export async function POST(request: NextRequest) { const { ctx } = auth; const body = await request.json(); - const { mode, title, query, caseId, normIds, decisionIds, stichtag } = body; + const { mode, title, query, caseId, normIds, decisionIds, documentIds, stichtag } = body; if (!mode || !VALID_MODES.has(mode)) { return Response.json( @@ -43,6 +43,7 @@ export async function POST(request: NextRequest) { query, normIds, decisionIds, + documentIds, stichtag, }); diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 7010d70..c3ffff8 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -7,6 +7,7 @@ import { logAuditEvent } from '@/lib/auth/audit'; import { requirePermission } from '@/lib/auth/rbac'; const VALID_CATEGORIES = new Set(['entscheidung', 'norm', 'falldokument', 'sonstiges']); +const VALID_SOURCE_SCOPES = new Set(['case', 'global']); /** Convert empty/whitespace-only strings to undefined (FormData sends "" for blank fields). */ function emptyToUndefined(value: string | null): string | undefined { @@ -22,6 +23,7 @@ export async function POST(request: NextRequest) { const formData = await request.formData(); const file = formData.get('file'); const category = (formData.get('category') as string) || 'sonstiges'; + const sourceScope = emptyToUndefined(formData.get('sourceScope') as string | null); const caseId = emptyToUndefined(formData.get('caseId') as string | null); const decisionId = emptyToUndefined(formData.get('decisionId') as string | null); const normInstrumentId = emptyToUndefined(formData.get('normInstrumentId') as string | null); @@ -50,6 +52,9 @@ export async function POST(request: NextRequest) { userId: ctx.userId, file, category: category as 'entscheidung' | 'norm' | 'falldokument' | 'sonstiges', + sourceScope: sourceScope && VALID_SOURCE_SCOPES.has(sourceScope) + ? sourceScope as 'case' | 'global' + : undefined, caseId, decisionId, normInstrumentId, @@ -81,6 +86,7 @@ export async function GET(request: NextRequest) { const limit = Math.min(parseInt(searchParams.get('limit') ?? '20', 10), 100); const offset = parseInt(searchParams.get('offset') ?? '0', 10); const category = searchParams.get('category') as string | null; + const sourceScope = searchParams.get('sourceScope') as string | null; const caseId = emptyToUndefined(searchParams.get('caseId')); const decisionId = emptyToUndefined(searchParams.get('decisionId')); const normInstrumentId = emptyToUndefined(searchParams.get('normInstrumentId')); @@ -91,6 +97,9 @@ export async function GET(request: NextRequest) { category: category && VALID_CATEGORIES.has(category) ? category as 'entscheidung' | 'norm' | 'falldokument' | 'sonstiges' : undefined, + sourceScope: sourceScope && VALID_SOURCE_SCOPES.has(sourceScope) + ? sourceScope as 'case' | 'global' + : undefined, caseId, decisionId, normInstrumentId, diff --git a/src/components/documents/dokument-upload.tsx b/src/components/documents/dokument-upload.tsx index 44299f5..ecb40ef 100644 --- a/src/components/documents/dokument-upload.tsx +++ b/src/components/documents/dokument-upload.tsx @@ -4,6 +4,8 @@ import { useState, useRef, useCallback, useEffect } from 'react'; interface DokumentUploadProps { category: 'entscheidung' | 'norm' | 'falldokument' | 'sonstiges'; + /** Source scope — case-specific or globally available */ + sourceScope?: 'case' | 'global'; /** Optional linked entity ID */ caseId?: string; decisionId?: string; @@ -43,6 +45,7 @@ function formatFileSize(bytes: number): string { export default function DokumentUpload({ category, + sourceScope, caseId, decisionId, normInstrumentId, @@ -85,6 +88,7 @@ export default function DokumentUpload({ const formData = new FormData(); formData.append('file', file); formData.append('category', category); + if (sourceScope) formData.append('sourceScope', sourceScope); if (caseId) formData.append('caseId', caseId); if (decisionId) formData.append('decisionId', decisionId); if (normInstrumentId) formData.append('normInstrumentId', normInstrumentId); diff --git a/src/components/documents/source-selection.tsx b/src/components/documents/source-selection.tsx new file mode 100644 index 0000000..58ef06d --- /dev/null +++ b/src/components/documents/source-selection.tsx @@ -0,0 +1,206 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; + +interface DocumentSource { + id: string; + filename: string; + category: string; + sourceScope: string; + status: string; + caseId: string | null; +} + +interface SourceSelectionProps { + caseId?: string; + selectedIds: string[]; + onChange: (ids: string[]) => void; +} + +const CATEGORY_LABELS: Record = { + entscheidung: 'Entscheidung', + norm: 'Norm', + falldokument: 'Falldokument', + sonstiges: 'Sonstiges', +}; + +const SCOPE_LABELS: Record = { + global: 'Global', + case: 'Fallbezogen', +}; + +export default function SourceSelection({ caseId, selectedIds, onChange }: SourceSelectionProps) { + const [sources, setSources] = useState([]); + const [loading, setLoading] = useState(true); + const [expanded, setExpanded] = useState(false); + + const fetchSources = useCallback(async () => { + setLoading(true); + try { + // Fetch global documents + case-specific documents (if a case is selected) + const params = new URLSearchParams(); + const results: DocumentSource[] = []; + + // Always fetch global documents + const globalRes = await fetch('/api/documents?sourceScope=global'); + if (globalRes.ok) { + const data = await globalRes.json(); + results.push(...data.filter((d: DocumentSource) => d.status === 'extracted')); + } + + // Fetch case documents if case is selected + if (caseId) { + const caseRes = await fetch(`/api/documents?caseId=${caseId}&sourceScope=case`); + if (caseRes.ok) { + const data = await caseRes.json(); + results.push(...data.filter((d: DocumentSource) => d.status === 'extracted')); + } + } + + setSources(results); + } catch { + // Silently fail + } finally { + setLoading(false); + } + }, [caseId]); + + useEffect(() => { + fetchSources(); + }, [fetchSources]); + + function toggleSource(id: string) { + if (selectedIds.includes(id)) { + onChange(selectedIds.filter((s) => s !== id)); + } else { + onChange([...selectedIds, id]); + } + } + + function toggleAll() { + if (selectedIds.length === sources.length) { + onChange([]); + } else { + onChange(sources.map((s) => s.id)); + } + } + + // Group by scope + const globalSources = sources.filter((s) => s.sourceScope === 'global'); + const caseSources = sources.filter((s) => s.sourceScope === 'case'); + + if (loading) { + return ( +
Quellen werden geladen...
+ ); + } + + if (sources.length === 0) { + return ( +
+ Keine Dokumente verfügbar. Laden Sie Dokumente hoch, um sie als KI-Quellen zu verwenden. +
+ ); + } + + return ( +
+ + + {expanded && ( +
+
+ + Wählen Sie die Dokumente, die die KI berücksichtigen soll. + + +
+ + {globalSources.length > 0 && ( +
+

+ Globale Quellen (Normen / Gesetze) +

+
+ {globalSources.map((src) => ( + toggleSource(src.id)} + /> + ))} +
+
+ )} + + {caseSources.length > 0 && ( +
+

+ Fallbezogene Dokumente +

+
+ {caseSources.map((src) => ( + toggleSource(src.id)} + /> + ))} +
+
+ )} +
+ )} +
+ ); +} + +function SourceItem({ + source, + selected, + onToggle, +}: { + source: DocumentSource; + selected: boolean; + onToggle: () => void; +}) { + return ( + + ); +} diff --git a/src/lib/ai/analysis.ts b/src/lib/ai/analysis.ts index 7072f26..41e35d8 100644 --- a/src/lib/ai/analysis.ts +++ b/src/lib/ai/analysis.ts @@ -5,8 +5,8 @@ import { getModelForTenant } from './providers'; import { SYSTEM_PROMPTS, buildContextBlock, type AnalysisModeKey } from './prompts'; import { ANALYSIS_MODES } from './modes'; import { AnalyseMode } from '@/types'; -import { db } from '@/lib/db'; -import { norms, normInstruments, decisions, analyses } from '@/lib/db/schema'; +import { db, withTenantDb } from '@/lib/db'; +import { norms, normInstruments, decisions, analyses, documents } from '@/lib/db/schema'; import { eq, and, lte, or, isNull, gte, inArray } from 'drizzle-orm'; interface AnalysisInput { @@ -20,6 +20,8 @@ interface AnalysisInput { normIds?: string[]; /** Optional: specific decision IDs to include as context */ decisionIds?: string[]; + /** Optional: specific document IDs to include as context */ + documentIds?: string[]; /** Optional: reference date for norm versioning (Stichtag) */ stichtag?: string; } @@ -89,6 +91,39 @@ async function fetchDecisionContext( .limit(decisionIds?.length ? 50 : 10); } +/** + * Fetch document content for the analysis context. + * When documentIds are given, fetch those specific documents. + * Respects source scope: global documents are always available, + * case documents only within their case context. + */ +async function fetchDocumentContext( + tenantId: string, + documentIds?: string[], + caseId?: string, +) { + if (!documentIds?.length) return []; + + return withTenantDb(tenantId, async (tdb) => { + const conditions = [ + inArray(documents.id, documentIds), + eq(documents.status, 'extracted'), + ]; + + return tdb + .select({ + id: documents.id, + filename: documents.filename, + category: documents.category, + sourceScope: documents.sourceScope, + extractedText: documents.extractedText, + }) + .from(documents) + .where(and(...conditions)) + .limit(20); + }); +} + /** * Create an analysis record in the database and return a streaming response. */ @@ -97,16 +132,19 @@ export async function runAnalysis(input: AnalysisInput) { const systemPromptKey = modeConfig.systemPromptKey as AnalysisModeKey; // Fetch context in parallel - const [normContext, decisionContext] = await Promise.all([ + const [normContext, decisionContext, documentContext] = await Promise.all([ modeConfig.requiresNorms ? fetchNormContext(input.tenantId, input.normIds, input.stichtag) : Promise.resolve([]), modeConfig.requiresDecisions ? fetchDecisionContext(input.tenantId, input.decisionIds) : Promise.resolve([]), + input.documentIds?.length + ? fetchDocumentContext(input.tenantId, input.documentIds, input.caseId) + : Promise.resolve([]), ]); - const contextBlock = buildContextBlock(normContext, decisionContext); + const contextBlock = buildContextBlock(normContext, decisionContext, documentContext); const { model, provider, modelId } = await getModelForTenant(input.tenantId); @@ -126,7 +164,7 @@ export async function runAnalysis(input: AnalysisInput) { sources: { normIds: normContext.map((n) => n.id), decisionIds: decisionContext.map((d) => d.id), - otherSources: [], + otherSources: documentContext.map((d) => d.id), }, }) .returning(); @@ -168,16 +206,19 @@ export async function runAnalysisSync(input: AnalysisInput) { const modeConfig = ANALYSIS_MODES[input.mode]; const systemPromptKey = modeConfig.systemPromptKey as AnalysisModeKey; - const [normContext, decisionContext] = await Promise.all([ + const [normContext, decisionContext, documentContext] = await Promise.all([ modeConfig.requiresNorms ? fetchNormContext(input.tenantId, input.normIds, input.stichtag) : Promise.resolve([]), modeConfig.requiresDecisions ? fetchDecisionContext(input.tenantId, input.decisionIds) : Promise.resolve([]), + input.documentIds?.length + ? fetchDocumentContext(input.tenantId, input.documentIds, input.caseId) + : Promise.resolve([]), ]); - const contextBlock = buildContextBlock(normContext, decisionContext); + const contextBlock = buildContextBlock(normContext, decisionContext, documentContext); const userMessage = contextBlock ? `${contextBlock}\n\n---\n\n## Rechtsfrage\n\n${input.query}` : input.query; @@ -199,7 +240,7 @@ export async function runAnalysisSync(input: AnalysisInput) { sources: { normIds: normContext.map((n) => n.id), decisionIds: decisionContext.map((d) => d.id), - otherSources: [], + otherSources: documentContext.map((d) => d.id), }, }) .returning(); @@ -230,6 +271,7 @@ export async function runAnalysisSync(input: AnalysisInput) { sources: { normIds: normContext.map((n) => n.id), decisionIds: decisionContext.map((d) => d.id), + documentIds: documentContext.map((d) => d.id), }, }; } diff --git a/src/lib/ai/prompts.ts b/src/lib/ai/prompts.ts index b3e3e49..fffb9e4 100644 --- a/src/lib/ai/prompts.ts +++ b/src/lib/ai/prompts.ts @@ -118,6 +118,12 @@ export function buildContextBlock( headnote: string | null; reasoning: string | null; }>, + documents?: Array<{ + filename: string; + category: string; + sourceScope: string; + extractedText: string | null; + }>, ): string { const parts: string[] = []; @@ -149,5 +155,17 @@ export function buildContextBlock( } } + const docsWithText = documents?.filter((d) => d.extractedText) ?? []; + if (docsWithText.length > 0) { + parts.push('## Hochgeladene Dokumente\n'); + for (const d of docsWithText) { + const scopeLabel = d.sourceScope === 'global' ? 'Global' : 'Fallbezogen'; + parts.push(`### ${d.filename} [${scopeLabel}]`); + const text = d.extractedText!; + parts.push(text.slice(0, 2000) + (text.length > 2000 ? '…' : '')); + parts.push(''); + } + } + return parts.join('\n'); } diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index b67b0e0..c7c6b5d 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -896,6 +896,12 @@ export const nonRenewalDeadlines = pgTable( // Dokumente (Generic Document Upload — Phase 3.4) // ============================================================ +/** Document source scope — case-specific vs globally available */ +export const documentSourceScopeEnum = pgEnum("document_source_scope", [ + "case", // Case-specific document (only available within its case) + "global", // Globally available (laws, regulations — available everywhere) +]); + /** Document category — what kind of document this is */ export const documentCategoryEnum = pgEnum("document_category", [ "entscheidung", // Court decision / arbitration award document @@ -924,6 +930,8 @@ export const documents = pgTable( userId: uuid("user_id").notNull().references(() => users.id), /** Document category */ category: documentCategoryEnum("category").notNull().default("sonstiges"), + /** Source scope: case-specific or globally available */ + sourceScope: documentSourceScopeEnum("source_scope").notNull().default("case"), /** Optional link to a case */ caseId: uuid("case_id").references(() => cases.id, { onDelete: "set null" }), /** Optional link to a decision */ diff --git a/src/lib/documents/index.ts b/src/lib/documents/index.ts index aee2e3e..83de14d 100644 --- a/src/lib/documents/index.ts +++ b/src/lib/documents/index.ts @@ -14,12 +14,14 @@ const ALLOWED_MIME_TYPES = new Set([ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB type DocumentCategory = 'entscheidung' | 'norm' | 'falldokument' | 'sonstiges'; +type DocumentSourceScope = 'case' | 'global'; interface UploadOptions { tenantId: string; userId: string; file: File; category: DocumentCategory; + sourceScope?: DocumentSourceScope; caseId?: string; decisionId?: string; normInstrumentId?: string; @@ -36,7 +38,9 @@ interface UploadResult { * Text extraction happens asynchronously via extractDocumentText(). */ export async function uploadDocument(opts: UploadOptions): Promise { - const { tenantId, userId, file, category, caseId, decisionId, normInstrumentId } = opts; + const { tenantId, userId, file, category, sourceScope, caseId, decisionId, normInstrumentId } = opts; + // Norm documents are always global; case documents default to case-scoped + const resolvedScope = sourceScope ?? (category === 'norm' ? 'global' : 'case'); if (!ALLOWED_MIME_TYPES.has(file.type)) { throw new Error( @@ -70,6 +74,7 @@ export async function uploadDocument(opts: UploadOptions): Promise tenantId, userId, category, + sourceScope: resolvedScope, caseId: caseId ?? null, decisionId: decisionId ?? null, normInstrumentId: normInstrumentId ?? null, @@ -165,6 +170,7 @@ export async function listDocuments( tenantId: string, filters?: { category?: DocumentCategory; + sourceScope?: DocumentSourceScope; caseId?: string; decisionId?: string; normInstrumentId?: string; @@ -178,6 +184,9 @@ export async function listDocuments( if (filters?.category) { conditions.push(eq(documents.category, filters.category)); } + if (filters?.sourceScope) { + conditions.push(eq(documents.sourceScope, filters.sourceScope)); + } if (filters?.caseId) { conditions.push(eq(documents.caseId, filters.caseId)); } @@ -195,6 +204,7 @@ export async function listDocuments( mimeType: documents.mimeType, fileSizeBytes: documents.fileSizeBytes, category: documents.category, + sourceScope: documents.sourceScope, status: documents.status, caseId: documents.caseId, decisionId: documents.decisionId,