Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
460 lines
13 KiB
TypeScript
460 lines
13 KiB
TypeScript
/**
|
|
* Contract Analyzer
|
|
*
|
|
* LLM-based contract review for GDPR compliance
|
|
*/
|
|
|
|
import {
|
|
Finding,
|
|
Citation,
|
|
FindingType,
|
|
FindingCategory,
|
|
FindingSeverity,
|
|
DocumentType,
|
|
LocalizedText,
|
|
} from '../types'
|
|
import { AVV_CHECKLIST, INCIDENT_CHECKLIST, TRANSFER_CHECKLIST } from './checklists'
|
|
|
|
// ==========================================
|
|
// TYPES
|
|
// ==========================================
|
|
|
|
export interface ContractAnalysisRequest {
|
|
contractId: string
|
|
vendorId: string
|
|
tenantId: string
|
|
documentText: string
|
|
documentType?: DocumentType
|
|
language?: 'de' | 'en'
|
|
analysisScope?: AnalysisScope[]
|
|
}
|
|
|
|
export interface ContractAnalysisResponse {
|
|
documentType: DocumentType
|
|
language: 'de' | 'en'
|
|
parties: ContractPartyInfo[]
|
|
findings: Finding[]
|
|
complianceScore: number
|
|
topRisks: LocalizedText[]
|
|
requiredActions: LocalizedText[]
|
|
metadata: ExtractedMetadata
|
|
}
|
|
|
|
export interface ContractPartyInfo {
|
|
role: 'CONTROLLER' | 'PROCESSOR' | 'PARTY'
|
|
name: string
|
|
address?: string
|
|
}
|
|
|
|
export interface ExtractedMetadata {
|
|
effectiveDate?: string
|
|
expirationDate?: string
|
|
autoRenewal?: boolean
|
|
terminationNoticePeriod?: number
|
|
governingLaw?: string
|
|
jurisdiction?: string
|
|
}
|
|
|
|
export type AnalysisScope =
|
|
| 'AVV_COMPLIANCE'
|
|
| 'SUBPROCESSOR'
|
|
| 'INCIDENT_RESPONSE'
|
|
| 'AUDIT_RIGHTS'
|
|
| 'DELETION'
|
|
| 'TOM'
|
|
| 'TRANSFER'
|
|
| 'LIABILITY'
|
|
| 'SLA'
|
|
|
|
// ==========================================
|
|
// SYSTEM PROMPTS
|
|
// ==========================================
|
|
|
|
export const CONTRACT_REVIEW_SYSTEM_PROMPT = `Du bist ein Datenschutz-Rechtsexperte, der Verträge auf DSGVO-Konformität prüft.
|
|
|
|
WICHTIG:
|
|
1. Jede Feststellung MUSS mit einer Textstelle belegt werden (Citation)
|
|
2. Gib niemals Rechtsberatung - nur Compliance-Hinweise
|
|
3. Markiere unklare Stellen als UNKNOWN, nicht als GAP
|
|
4. Sei konservativ: im Zweifel RISK statt OK
|
|
|
|
PRÜFUNGSSCHEMA Art. 28 DSGVO AVV:
|
|
${AVV_CHECKLIST.map((item) => `- ${item.id}: ${item.requirement.de} (${item.article})`).join('\n')}
|
|
|
|
INCIDENT RESPONSE:
|
|
${INCIDENT_CHECKLIST.map((item) => `- ${item.id}: ${item.requirement.de} (${item.article})`).join('\n')}
|
|
|
|
DRITTLANDTRANSFER:
|
|
${TRANSFER_CHECKLIST.map((item) => `- ${item.id}: ${item.requirement.de} (${item.article})`).join('\n')}
|
|
|
|
AUSGABEFORMAT (JSON):
|
|
{
|
|
"document_type": "AVV|MSA|SLA|SCC|NDA|TOM_ANNEX|OTHER|UNKNOWN",
|
|
"language": "de|en",
|
|
"parties": [
|
|
{
|
|
"role": "CONTROLLER|PROCESSOR|PARTY",
|
|
"name": "...",
|
|
"address": "..."
|
|
}
|
|
],
|
|
"findings": [
|
|
{
|
|
"category": "AVV_CONTENT|SUBPROCESSOR|INCIDENT|AUDIT_RIGHTS|DELETION|TOM|TRANSFER|LIABILITY|SLA|DATA_SUBJECT_RIGHTS|CONFIDENTIALITY|INSTRUCTION|GENERAL",
|
|
"type": "OK|GAP|RISK|UNKNOWN",
|
|
"severity": "LOW|MEDIUM|HIGH|CRITICAL",
|
|
"title_de": "...",
|
|
"title_en": "...",
|
|
"description_de": "...",
|
|
"description_en": "...",
|
|
"recommendation_de": "...",
|
|
"recommendation_en": "...",
|
|
"citations": [
|
|
{
|
|
"page": 3,
|
|
"quoted_text": "Der Auftragnehmer...",
|
|
"start_char": 1234,
|
|
"end_char": 1456
|
|
}
|
|
],
|
|
"affected_requirement": "Art. 28 Abs. 3 lit. a DSGVO"
|
|
}
|
|
],
|
|
"compliance_score": 72,
|
|
"top_risks": [
|
|
{"de": "...", "en": "..."}
|
|
],
|
|
"required_actions": [
|
|
{"de": "...", "en": "..."}
|
|
],
|
|
"metadata": {
|
|
"effective_date": "2024-01-01",
|
|
"expiration_date": "2025-12-31",
|
|
"auto_renewal": true,
|
|
"termination_notice_period": 90,
|
|
"governing_law": "Germany",
|
|
"jurisdiction": "Frankfurt am Main"
|
|
}
|
|
}`
|
|
|
|
export const CONTRACT_CLASSIFICATION_PROMPT = `Analysiere den folgenden Vertragstext und klassifiziere ihn:
|
|
|
|
1. Dokumenttyp (AVV, MSA, SLA, SCC, NDA, TOM_ANNEX, OTHER)
|
|
2. Sprache (de, en)
|
|
3. Vertragsparteien mit Rollen
|
|
|
|
Antworte im JSON-Format:
|
|
{
|
|
"document_type": "...",
|
|
"language": "...",
|
|
"parties": [...]
|
|
}`
|
|
|
|
export const METADATA_EXTRACTION_PROMPT = `Extrahiere die folgenden Metadaten aus dem Vertrag:
|
|
|
|
1. Inkrafttreten / Effective Date
|
|
2. Laufzeit / Ablaufdatum
|
|
3. Automatische Verlängerung
|
|
4. Kündigungsfrist
|
|
5. Anwendbares Recht
|
|
6. Gerichtsstand
|
|
|
|
Antworte im JSON-Format.`
|
|
|
|
// ==========================================
|
|
// ANALYSIS FUNCTIONS
|
|
// ==========================================
|
|
|
|
/**
|
|
* Analyze a contract for GDPR compliance
|
|
*/
|
|
export async function analyzeContract(
|
|
request: ContractAnalysisRequest
|
|
): Promise<ContractAnalysisResponse> {
|
|
// This function would typically call an LLM API
|
|
// For now, we provide the structure that would be used
|
|
|
|
const apiEndpoint = '/api/sdk/v1/vendor-compliance/contracts/analyze'
|
|
|
|
const response = await fetch(apiEndpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
contract_id: request.contractId,
|
|
vendor_id: request.vendorId,
|
|
tenant_id: request.tenantId,
|
|
document_text: request.documentText,
|
|
document_type: request.documentType,
|
|
language: request.language || 'de',
|
|
analysis_scope: request.analysisScope || [
|
|
'AVV_COMPLIANCE',
|
|
'SUBPROCESSOR',
|
|
'INCIDENT_RESPONSE',
|
|
'AUDIT_RIGHTS',
|
|
'DELETION',
|
|
'TOM',
|
|
'TRANSFER',
|
|
],
|
|
}),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Contract analysis failed')
|
|
}
|
|
|
|
const result = await response.json()
|
|
return transformAnalysisResponse(result, request)
|
|
}
|
|
|
|
/**
|
|
* Transform LLM response to typed response
|
|
*/
|
|
function transformAnalysisResponse(
|
|
llmResponse: Record<string, unknown>,
|
|
request: ContractAnalysisRequest
|
|
): ContractAnalysisResponse {
|
|
const findings: Finding[] = (llmResponse.findings as Array<Record<string, unknown>> || []).map((f, idx) => ({
|
|
id: `finding-${request.contractId}-${idx}`,
|
|
tenantId: request.tenantId,
|
|
contractId: request.contractId,
|
|
vendorId: request.vendorId,
|
|
type: (f.type as FindingType) || 'UNKNOWN',
|
|
category: (f.category as FindingCategory) || 'GENERAL',
|
|
severity: (f.severity as FindingSeverity) || 'MEDIUM',
|
|
title: {
|
|
de: (f.title_de as string) || '',
|
|
en: (f.title_en as string) || '',
|
|
},
|
|
description: {
|
|
de: (f.description_de as string) || '',
|
|
en: (f.description_en as string) || '',
|
|
},
|
|
recommendation: f.recommendation_de ? {
|
|
de: f.recommendation_de as string,
|
|
en: (f.recommendation_en as string) || '',
|
|
} : undefined,
|
|
citations: ((f.citations as Array<Record<string, unknown>>) || []).map((c) => ({
|
|
documentId: request.contractId,
|
|
page: (c.page as number) || 1,
|
|
startChar: (c.start_char as number) || 0,
|
|
endChar: (c.end_char as number) || 0,
|
|
quotedText: (c.quoted_text as string) || '',
|
|
quoteHash: generateQuoteHash((c.quoted_text as string) || ''),
|
|
})),
|
|
affectedRequirement: f.affected_requirement as string | undefined,
|
|
triggeredControls: [],
|
|
status: 'OPEN',
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
}))
|
|
|
|
const metadata = llmResponse.metadata as Record<string, unknown> || {}
|
|
|
|
return {
|
|
documentType: (llmResponse.document_type as DocumentType) || 'OTHER',
|
|
language: (llmResponse.language as 'de' | 'en') || 'de',
|
|
parties: ((llmResponse.parties as Array<Record<string, unknown>>) || []).map((p) => ({
|
|
role: (p.role as 'CONTROLLER' | 'PROCESSOR' | 'PARTY') || 'PARTY',
|
|
name: (p.name as string) || '',
|
|
address: p.address as string | undefined,
|
|
})),
|
|
findings,
|
|
complianceScore: (llmResponse.compliance_score as number) || 0,
|
|
topRisks: ((llmResponse.top_risks as Array<Record<string, string>>) || []).map((r) => ({
|
|
de: r.de || '',
|
|
en: r.en || '',
|
|
})),
|
|
requiredActions: ((llmResponse.required_actions as Array<Record<string, string>>) || []).map((a) => ({
|
|
de: a.de || '',
|
|
en: a.en || '',
|
|
})),
|
|
metadata: {
|
|
effectiveDate: metadata.effective_date as string | undefined,
|
|
expirationDate: metadata.expiration_date as string | undefined,
|
|
autoRenewal: metadata.auto_renewal as boolean | undefined,
|
|
terminationNoticePeriod: metadata.termination_notice_period as number | undefined,
|
|
governingLaw: metadata.governing_law as string | undefined,
|
|
jurisdiction: metadata.jurisdiction as string | undefined,
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a hash for quote verification
|
|
*/
|
|
function generateQuoteHash(text: string): string {
|
|
// Simple hash for demo - in production use crypto.subtle.digest
|
|
let hash = 0
|
|
for (let i = 0; i < text.length; i++) {
|
|
const char = text.charCodeAt(i)
|
|
hash = ((hash << 5) - hash) + char
|
|
hash = hash & hash
|
|
}
|
|
return Math.abs(hash).toString(16).padStart(16, '0')
|
|
}
|
|
|
|
// ==========================================
|
|
// CITATION UTILITIES
|
|
// ==========================================
|
|
|
|
/**
|
|
* Verify citation integrity
|
|
*/
|
|
export function verifyCitation(
|
|
citation: Citation,
|
|
documentText: string
|
|
): boolean {
|
|
const extractedText = documentText.substring(citation.startChar, citation.endChar)
|
|
const expectedHash = generateQuoteHash(extractedText)
|
|
return citation.quoteHash === expectedHash
|
|
}
|
|
|
|
/**
|
|
* Find citation context in document
|
|
*/
|
|
export function getCitationContext(
|
|
citation: Citation,
|
|
documentText: string,
|
|
contextChars: number = 100
|
|
): {
|
|
before: string
|
|
quoted: string
|
|
after: string
|
|
} {
|
|
const start = Math.max(0, citation.startChar - contextChars)
|
|
const end = Math.min(documentText.length, citation.endChar + contextChars)
|
|
|
|
return {
|
|
before: documentText.substring(start, citation.startChar),
|
|
quoted: documentText.substring(citation.startChar, citation.endChar),
|
|
after: documentText.substring(citation.endChar, end),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Highlight citations in text
|
|
*/
|
|
export function highlightCitations(
|
|
documentText: string,
|
|
citations: Citation[]
|
|
): string {
|
|
// Sort citations by start position (reverse to avoid offset issues)
|
|
const sortedCitations = [...citations].sort((a, b) => b.startChar - a.startChar)
|
|
|
|
let result = documentText
|
|
|
|
for (const citation of sortedCitations) {
|
|
const before = result.substring(0, citation.startChar)
|
|
const quoted = result.substring(citation.startChar, citation.endChar)
|
|
const after = result.substring(citation.endChar)
|
|
|
|
result = `${before}<mark data-citation-id="${citation.documentId}">${quoted}</mark>${after}`
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// ==========================================
|
|
// COMPLIANCE SCORE CALCULATION
|
|
// ==========================================
|
|
|
|
export interface ComplianceScoreBreakdown {
|
|
totalScore: number
|
|
categoryScores: Record<FindingCategory, number>
|
|
severityCounts: Record<FindingSeverity, number>
|
|
findingCounts: {
|
|
total: number
|
|
gaps: number
|
|
risks: number
|
|
ok: number
|
|
unknown: number
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate detailed compliance score
|
|
*/
|
|
export function calculateComplianceScore(findings: Finding[]): ComplianceScoreBreakdown {
|
|
const severityWeights: Record<FindingSeverity, number> = {
|
|
CRITICAL: 25,
|
|
HIGH: 15,
|
|
MEDIUM: 8,
|
|
LOW: 3,
|
|
}
|
|
|
|
const categoryWeights: Partial<Record<FindingCategory, number>> = {
|
|
AVV_CONTENT: 1.5,
|
|
SUBPROCESSOR: 1.3,
|
|
INCIDENT: 1.3,
|
|
DELETION: 1.2,
|
|
AUDIT_RIGHTS: 1.1,
|
|
TOM: 1.2,
|
|
TRANSFER: 1.4,
|
|
}
|
|
|
|
let totalDeductions = 0
|
|
const maxPossibleDeductions = 100
|
|
|
|
const categoryScores: Partial<Record<FindingCategory, number>> = {}
|
|
const severityCounts: Record<FindingSeverity, number> = {
|
|
LOW: 0,
|
|
MEDIUM: 0,
|
|
HIGH: 0,
|
|
CRITICAL: 0,
|
|
}
|
|
|
|
let gaps = 0
|
|
let risks = 0
|
|
let ok = 0
|
|
let unknown = 0
|
|
|
|
for (const finding of findings) {
|
|
severityCounts[finding.severity]++
|
|
|
|
switch (finding.type) {
|
|
case 'GAP':
|
|
gaps++
|
|
totalDeductions += severityWeights[finding.severity] * (categoryWeights[finding.category] || 1)
|
|
break
|
|
case 'RISK':
|
|
risks++
|
|
totalDeductions += severityWeights[finding.severity] * 0.7 * (categoryWeights[finding.category] || 1)
|
|
break
|
|
case 'OK':
|
|
ok++
|
|
break
|
|
case 'UNKNOWN':
|
|
unknown++
|
|
totalDeductions += severityWeights[finding.severity] * 0.3 * (categoryWeights[finding.category] || 1)
|
|
break
|
|
}
|
|
}
|
|
|
|
// Calculate category scores
|
|
const categories = new Set(findings.map((f) => f.category))
|
|
for (const category of categories) {
|
|
const categoryFindings = findings.filter((f) => f.category === category)
|
|
const categoryOk = categoryFindings.filter((f) => f.type === 'OK').length
|
|
const categoryTotal = categoryFindings.length
|
|
categoryScores[category] = categoryTotal > 0 ? Math.round((categoryOk / categoryTotal) * 100) : 100
|
|
}
|
|
|
|
const totalScore = Math.max(0, Math.round(100 - (totalDeductions / maxPossibleDeductions) * 100))
|
|
|
|
return {
|
|
totalScore,
|
|
categoryScores: categoryScores as Record<FindingCategory, number>,
|
|
severityCounts,
|
|
findingCounts: {
|
|
total: findings.length,
|
|
gaps,
|
|
risks,
|
|
ok,
|
|
unknown,
|
|
},
|
|
}
|
|
}
|
|
|