Extends the Compliance Advisor from a Q&A chatbot into a full drafting engine that can generate, validate, and refine compliance documents within Scope Engine constraints. Includes intent classifier, state projector, constraint enforcer, SOUL templates, Go backend endpoints, and React UI components. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
185 lines
6.1 KiB
TypeScript
185 lines
6.1 KiB
TypeScript
/**
|
|
* Drafting Engine Chat API
|
|
*
|
|
* Verbindet das DraftingEngineWidget mit dem LLM Backend.
|
|
* Unterstuetzt alle 4 Modi: explain, ask, draft, validate.
|
|
* Nutzt State-Projection fuer token-effiziente Kontextgabe.
|
|
*/
|
|
|
|
import { NextRequest, NextResponse } from 'next/server'
|
|
|
|
const KLAUSUR_SERVICE_URL = process.env.KLAUSUR_SERVICE_URL || 'http://klausur-service:8086'
|
|
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
|
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
|
|
|
|
// SOUL System Prompt (from agent-core/soul/drafting-agent.soul.md)
|
|
const DRAFTING_SYSTEM_PROMPT = `# Drafting Agent - Compliance-Dokumententwurf
|
|
|
|
## Identitaet
|
|
Du bist der BreakPilot Drafting Agent. Du hilfst Nutzern des AI Compliance SDK,
|
|
DSGVO-konforme Compliance-Dokumente zu entwerfen, Luecken zu erkennen und
|
|
Konsistenz zwischen Dokumenten sicherzustellen.
|
|
|
|
## Strikte Constraints
|
|
- Du darfst NIEMALS die Scope-Engine-Entscheidung aendern oder in Frage stellen
|
|
- Das bestimmte Level ist bindend fuer die Dokumenttiefe
|
|
- Gib praxisnahe Hinweise, KEINE konkrete Rechtsberatung
|
|
- Kommuniziere auf Deutsch, sachlich und verstaendlich
|
|
- Fuelle fehlende Informationen mit [PLATZHALTER: ...] Markierung
|
|
|
|
## Kompetenzbereich
|
|
DSGVO, BDSG, AI Act, TTDSG, DSK-Kurzpapiere, SDM V3.0, BSI-Grundschutz, ISO 27001/27701, EDPB Guidelines, WP248`
|
|
|
|
/**
|
|
* Query the RAG corpus for relevant documents
|
|
*/
|
|
async function queryRAG(query: string): Promise<string> {
|
|
try {
|
|
const url = `${KLAUSUR_SERVICE_URL}/api/v1/dsfa-rag/search?query=${encodeURIComponent(query)}&top_k=3`
|
|
const res = await fetch(url, {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
signal: AbortSignal.timeout(10000),
|
|
})
|
|
|
|
if (!res.ok) return ''
|
|
|
|
const data = await res.json()
|
|
if (data.results?.length > 0) {
|
|
return data.results
|
|
.map(
|
|
(r: { source_name?: string; source_code?: string; content?: string }, i: number) =>
|
|
`[Quelle ${i + 1}: ${r.source_name || r.source_code || 'Unbekannt'}]\n${r.content || ''}`
|
|
)
|
|
.join('\n\n---\n\n')
|
|
}
|
|
return ''
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const body = await request.json()
|
|
const {
|
|
message,
|
|
history = [],
|
|
sdkStateProjection,
|
|
mode = 'explain',
|
|
documentType,
|
|
} = body
|
|
|
|
if (!message || typeof message !== 'string') {
|
|
return NextResponse.json({ error: 'Message is required' }, { status: 400 })
|
|
}
|
|
|
|
// 1. Query RAG for legal context
|
|
const ragContext = await queryRAG(message)
|
|
|
|
// 2. Build system prompt with mode-specific instructions + state projection
|
|
let systemContent = DRAFTING_SYSTEM_PROMPT
|
|
|
|
// Mode-specific instructions
|
|
const modeInstructions: Record<string, string> = {
|
|
explain: '\n\n## Aktueller Modus: EXPLAIN\nBeantworte Fragen verstaendlich mit Quellenangaben.',
|
|
ask: '\n\n## Aktueller Modus: ASK\nAnalysiere Luecken und stelle gezielte Fragen. Eine Frage pro Antwort.',
|
|
draft: `\n\n## Aktueller Modus: DRAFT\nEntwirf strukturierte Dokument-Sections. Dokumenttyp: ${documentType || 'nicht spezifiziert'}.\nAntworte mit JSON wenn ein Draft angefragt wird.`,
|
|
validate: '\n\n## Aktueller Modus: VALIDATE\nPruefe Cross-Dokument-Konsistenz. Gib Errors, Warnings und Suggestions zurueck.',
|
|
}
|
|
systemContent += modeInstructions[mode] || modeInstructions.explain
|
|
|
|
// Add state projection context
|
|
if (sdkStateProjection) {
|
|
systemContent += `\n\n## SDK-State Projektion (${mode}-Kontext)\n${JSON.stringify(sdkStateProjection, null, 0).slice(0, 3000)}`
|
|
}
|
|
|
|
// Add RAG context
|
|
if (ragContext) {
|
|
systemContent += `\n\n## Relevanter Rechtskontext\n${ragContext}`
|
|
}
|
|
|
|
// 3. Build messages array
|
|
const messages = [
|
|
{ role: 'system', content: systemContent },
|
|
...history.slice(-10).map((h: { role: string; content: string }) => ({
|
|
role: h.role === 'user' ? 'user' : 'assistant',
|
|
content: h.content,
|
|
})),
|
|
{ role: 'user', content: message },
|
|
]
|
|
|
|
// 4. Call LLM with streaming
|
|
const ollamaResponse = await fetch(`${OLLAMA_URL}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model: LLM_MODEL,
|
|
messages,
|
|
stream: true,
|
|
options: {
|
|
temperature: mode === 'draft' ? 0.2 : 0.3,
|
|
num_predict: mode === 'draft' ? 16384 : 8192,
|
|
},
|
|
}),
|
|
signal: AbortSignal.timeout(120000),
|
|
})
|
|
|
|
if (!ollamaResponse.ok) {
|
|
const errorText = await ollamaResponse.text()
|
|
console.error('LLM error:', ollamaResponse.status, errorText)
|
|
return NextResponse.json(
|
|
{ error: `LLM nicht erreichbar (Status ${ollamaResponse.status})` },
|
|
{ status: 502 }
|
|
)
|
|
}
|
|
|
|
// 5. Stream response back
|
|
const encoder = new TextEncoder()
|
|
const stream = new ReadableStream({
|
|
async start(controller) {
|
|
const reader = ollamaResponse.body!.getReader()
|
|
const decoder = new TextDecoder()
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
|
|
const chunk = decoder.decode(value, { stream: true })
|
|
const lines = chunk.split('\n').filter((l) => l.trim())
|
|
|
|
for (const line of lines) {
|
|
try {
|
|
const json = JSON.parse(line)
|
|
if (json.message?.content) {
|
|
controller.enqueue(encoder.encode(json.message.content))
|
|
}
|
|
} catch {
|
|
// Partial JSON, skip
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Stream error:', error)
|
|
} finally {
|
|
controller.close()
|
|
}
|
|
},
|
|
})
|
|
|
|
return new NextResponse(stream, {
|
|
headers: {
|
|
'Content-Type': 'text/plain; charset=utf-8',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive',
|
|
},
|
|
})
|
|
} catch (error) {
|
|
console.error('Drafting engine chat error:', error)
|
|
return NextResponse.json(
|
|
{ error: 'Verbindung zum LLM fehlgeschlagen.' },
|
|
{ status: 503 }
|
|
)
|
|
}
|
|
}
|