Merge pull request 'feat: implement AI source selection and toggle for document-based knowledge (AIIA-66)' (#2) from feat/aiia-66-source-selection into master
All checks were successful
Deploy to VPS / deploy (push) Successful in 32s

This commit is contained in:
2026-04-10 21:21:37 +00:00
10 changed files with 334 additions and 10 deletions

View File

@@ -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");

View File

@@ -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<string>('gutachten');
const [caseId, setCaseId] = useState('');
const [question, setQuestion] = useState('');
const [selectedDocumentIds, setSelectedDocumentIds] = useState<string[]>([]);
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[] }) {
/>
</div>
<SourceSelection
caseId={caseId || undefined}
selectedIds={selectedDocumentIds}
onChange={setSelectedDocumentIds}
/>
{error && (
<div className="text-sm text-danger bg-danger/10 rounded-lg px-3 py-2">{error}</div>
)}

View File

@@ -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,
});

View File

@@ -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,

View File

@@ -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;
@@ -115,6 +117,7 @@ function IngestionProgress({ doc, debug }: { doc: DocumentItem; debug: boolean }
export default function DokumentUpload({
category,
sourceScope,
caseId,
decisionId,
normInstrumentId,
@@ -181,6 +184,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);

View File

@@ -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<string, string> = {
entscheidung: 'Entscheidung',
norm: 'Norm',
falldokument: 'Falldokument',
sonstiges: 'Sonstiges',
};
const SCOPE_LABELS: Record<string, string> = {
global: 'Global',
case: 'Fallbezogen',
};
export default function SourceSelection({ caseId, selectedIds, onChange }: SourceSelectionProps) {
const [sources, setSources] = useState<DocumentSource[]>([]);
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 (
<div className="text-xs text-muted py-2">Quellen werden geladen...</div>
);
}
if (sources.length === 0) {
return (
<div className="text-xs text-muted py-2">
Keine Dokumente verfügbar. Laden Sie Dokumente hoch, um sie als KI-Quellen zu verwenden.
</div>
);
}
return (
<div className="border border-card-border rounded-lg">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center justify-between px-3 py-2.5 text-sm text-foreground hover:bg-gray-50 transition-colors rounded-lg"
>
<span className="font-medium">
KI-Quellen ({selectedIds.length}/{sources.length} ausgewählt)
</span>
<svg
className={`w-4 h-4 text-muted transition-transform ${expanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{expanded && (
<div className="border-t border-card-border px-3 py-2 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs text-muted">
Wählen Sie die Dokumente, die die KI berücksichtigen soll.
</span>
<button
type="button"
onClick={toggleAll}
className="text-xs text-primary hover:underline"
>
{selectedIds.length === sources.length ? 'Alle abwählen' : 'Alle auswählen'}
</button>
</div>
{globalSources.length > 0 && (
<div>
<p className="text-xs font-semibold text-muted uppercase tracking-wide mb-1">
Globale Quellen (Normen / Gesetze)
</p>
<div className="space-y-1">
{globalSources.map((src) => (
<SourceItem
key={src.id}
source={src}
selected={selectedIds.includes(src.id)}
onToggle={() => toggleSource(src.id)}
/>
))}
</div>
</div>
)}
{caseSources.length > 0 && (
<div>
<p className="text-xs font-semibold text-muted uppercase tracking-wide mb-1">
Fallbezogene Dokumente
</p>
<div className="space-y-1">
{caseSources.map((src) => (
<SourceItem
key={src.id}
source={src}
selected={selectedIds.includes(src.id)}
onToggle={() => toggleSource(src.id)}
/>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
function SourceItem({
source,
selected,
onToggle,
}: {
source: DocumentSource;
selected: boolean;
onToggle: () => void;
}) {
return (
<label className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-50 cursor-pointer">
<input
type="checkbox"
checked={selected}
onChange={onToggle}
className="rounded border-gray-300 text-primary focus:ring-primary/30"
/>
<span className="text-sm text-foreground truncate flex-1">{source.filename}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-muted shrink-0">
{CATEGORY_LABELS[source.category] ?? source.category}
</span>
</label>
);
}

View File

@@ -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),
},
};
}

View File

@@ -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');
}

View File

@@ -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 */

View File

@@ -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<UploadResult> {
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<UploadResult>
tenantId,
userId,
category,
sourceScope: resolvedScope,
caseId: caseId ?? null,
decisionId: decisionId ?? null,
normInstrumentId: normInstrumentId ?? null,
@@ -196,6 +201,7 @@ export async function listDocuments(
tenantId: string,
filters?: {
category?: DocumentCategory;
sourceScope?: DocumentSourceScope;
caseId?: string;
decisionId?: string;
normInstrumentId?: string;
@@ -209,6 +215,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));
}
@@ -226,6 +235,7 @@ export async function listDocuments(
mimeType: documents.mimeType,
fileSizeBytes: documents.fileSizeBytes,
category: documents.category,
sourceScope: documents.sourceScope,
status: documents.status,
errorMessage: documents.errorMessage,
caseId: documents.caseId,