From 4fcb842a925728a3a658e10fcebab49b2c31d30f Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Mon, 13 Apr 2026 09:35:46 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Tender-Analyse=20Pipeline=20=E2=80=94?= =?UTF-8?q?=20Upload,=20Extraction,=20Control-Matching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 des Payment Compliance Moduls: 1. Backend: Tender Upload + LLM Requirement Extraction + Control Matching - DB Migration 025 (tender_analyses Tabelle) - TenderHandlers: Upload, Extract, Match, List, Get (5 Endpoints) - LLM-Extraktion via Anthropic API mit Keyword-Fallback - Control-Matching mit Domain-Bonus + Keyword-Overlap Relevance 2. Frontend: Dritter Tab "Ausschreibung" in /sdk/payment-compliance - PDF/TXT/Word Upload mit Drag-Area - Automatische Analyse-Pipeline (Upload → Extract → Match) - Ergebnis-Dashboard: Abgedeckt/Teilweise/Luecken - Requirement-by-Requirement Matching mit Control-IDs + Relevanz% - Gap-Beschreibung fuer nicht-gematchte Requirements - Analyse-Historie mit Klick-to-Detail Co-Authored-By: Claude Opus 4.6 (1M context) --- .../payment-compliance/tender/[id]/route.ts | 28 + .../sdk/v1/payment-compliance/tender/route.ts | 30 + .../app/sdk/payment-compliance/page.tsx | 192 +++++- ai-compliance-sdk/cmd/server/main.go | 8 + .../internal/api/handlers/payment_handlers.go | 5 + .../internal/api/handlers/tender_handlers.go | 557 ++++++++++++++++++ .../migrations/025_tender_analysis_schema.sql | 37 ++ 7 files changed, 854 insertions(+), 3 deletions(-) create mode 100644 admin-compliance/app/api/sdk/v1/payment-compliance/tender/[id]/route.ts create mode 100644 admin-compliance/app/api/sdk/v1/payment-compliance/tender/route.ts create mode 100644 ai-compliance-sdk/internal/api/handlers/tender_handlers.go create mode 100644 ai-compliance-sdk/migrations/025_tender_analysis_schema.sql diff --git a/admin-compliance/app/api/sdk/v1/payment-compliance/tender/[id]/route.ts b/admin-compliance/app/api/sdk/v1/payment-compliance/tender/[id]/route.ts new file mode 100644 index 0000000..34562ad --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/payment-compliance/tender/[id]/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from 'next/server' + +const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090' + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params + const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/tender/${id}`) + return NextResponse.json(await resp.json()) + } catch { + return NextResponse.json({ error: 'Failed' }, { status: 500 }) + } +} + +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params + const { searchParams } = new URL(request.url) + const action = searchParams.get('action') || 'extract' + const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/tender/${id}/${action}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + return NextResponse.json(await resp.json(), { status: resp.status }) + } catch { + return NextResponse.json({ error: 'Failed' }, { status: 500 }) + } +} diff --git a/admin-compliance/app/api/sdk/v1/payment-compliance/tender/route.ts b/admin-compliance/app/api/sdk/v1/payment-compliance/tender/route.ts new file mode 100644 index 0000000..c62f417 --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/payment-compliance/tender/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server' + +const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090' + +export async function GET(request: NextRequest) { + try { + const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' + const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/tender`, { + headers: { 'X-Tenant-ID': tenantId }, + }) + return NextResponse.json(await resp.json()) + } catch { + return NextResponse.json({ error: 'Failed' }, { status: 500 }) + } +} + +export async function POST(request: NextRequest) { + try { + const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' + const formData = await request.formData() + const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/tender/upload`, { + method: 'POST', + headers: { 'X-Tenant-ID': tenantId }, + body: formData, + }) + return NextResponse.json(await resp.json(), { status: resp.status }) + } catch { + return NextResponse.json({ error: 'Upload failed' }, { status: 500 }) + } +} diff --git a/admin-compliance/app/sdk/payment-compliance/page.tsx b/admin-compliance/app/sdk/payment-compliance/page.tsx index 174f4dd..b2b0c07 100644 --- a/admin-compliance/app/sdk/payment-compliance/page.tsx +++ b/admin-compliance/app/sdk/payment-compliance/page.tsx @@ -35,6 +35,22 @@ interface Assessment { created_at: string } +interface TenderAnalysis { + id: string + file_name: string + file_size: number + project_name: string + customer_name: string + status: string + total_requirements: number + matched_count: number + unmatched_count: number + partial_count: number + requirements?: Array<{ req_id: string; text: string; obligation_level: string; technical_domain: string; confidence: number }> + match_results?: Array<{ req_id: string; req_text: string; verdict: string; matched_controls: Array<{ control_id: string; title: string; relevance: number }>; gap_description?: string }> + created_at: string +} + const AUTOMATION_STYLES: Record = { high: { bg: 'bg-green-100', text: 'text-green-700' }, medium: { bg: 'bg-yellow-100', text: 'text-yellow-700' }, @@ -51,9 +67,13 @@ export default function PaymentCompliancePage() { const [controls, setControls] = useState([]) const [domains, setDomains] = useState([]) const [assessments, setAssessments] = useState([]) + const [tenderAnalyses, setTenderAnalyses] = useState([]) + const [selectedTender, setSelectedTender] = useState(null) const [selectedDomain, setSelectedDomain] = useState('all') const [loading, setLoading] = useState(true) - const [tab, setTab] = useState<'controls' | 'assessments'>('controls') + const [tab, setTab] = useState<'controls' | 'assessments' | 'tender'>('controls') + const [uploading, setUploading] = useState(false) + const [processing, setProcessing] = useState(false) const [showNewAssessment, setShowNewAssessment] = useState(false) const [newProject, setNewProject] = useState({ project_name: '', tender_reference: '', customer_name: '', system_type: 'full_stack' }) @@ -64,9 +84,10 @@ export default function PaymentCompliancePage() { async function loadData() { try { setLoading(true) - const [ctrlResp, assessResp] = await Promise.all([ + const [ctrlResp, assessResp, tenderResp] = await Promise.all([ fetch('/api/sdk/v1/payment-compliance?endpoint=controls'), fetch('/api/sdk/v1/payment-compliance?endpoint=assessments'), + fetch('/api/sdk/v1/payment-compliance/tender'), ]) if (ctrlResp.ok) { const data = await ctrlResp.json() @@ -77,10 +98,52 @@ export default function PaymentCompliancePage() { const data = await assessResp.json() setAssessments(data.assessments || []) } + if (tenderResp.ok) { + const data = await tenderResp.json() + setTenderAnalyses(data.analyses || []) + } } catch {} finally { setLoading(false) } } + async function handleTenderUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file) return + setUploading(true) + try { + const formData = new FormData() + formData.append('file', file) + formData.append('project_name', file.name.replace(/\.[^.]+$/, '')) + const resp = await fetch('/api/sdk/v1/payment-compliance/tender', { method: 'POST', body: formData }) + if (resp.ok) { + const data = await resp.json() + // Auto-start extraction + matching + setProcessing(true) + const extractResp = await fetch(`/api/sdk/v1/payment-compliance/tender/${data.id}?action=extract`, { method: 'POST' }) + if (extractResp.ok) { + await fetch(`/api/sdk/v1/payment-compliance/tender/${data.id}?action=match`, { method: 'POST' }) + } + // Reload and show result + const detailResp = await fetch(`/api/sdk/v1/payment-compliance/tender/${data.id}`) + if (detailResp.ok) { + const detail = await detailResp.json() + setSelectedTender(detail) + } + loadData() + } + } catch {} finally { + setUploading(false) + setProcessing(false) + } + } + + async function handleViewTender(id: string) { + const resp = await fetch(`/api/sdk/v1/payment-compliance/tender/${id}`) + if (resp.ok) { + setSelectedTender(await resp.json()) + } + } + async function handleCreateAssessment() { const resp = await fetch('/api/sdk/v1/payment-compliance', { method: 'POST', @@ -122,6 +185,10 @@ export default function PaymentCompliancePage() { className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === 'assessments' ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700'}`}> Assessments ({assessments.length}) + @@ -285,7 +352,126 @@ export default function PaymentCompliancePage() { )} - )} + ) : tab === 'tender' ? ( + <> + {/* Tender Analysis Tab */} +
+

Ausschreibung analysieren

+

+ Laden Sie ein Ausschreibungsdokument hoch. Die KI extrahiert automatisch alle Anforderungen und matcht sie gegen die Control-Bibliothek. +

+ +

PDF, TXT oder Word. Max 50 MB. Dokument wird nur fuer diese Analyse verwendet.

+
+ + {/* Selected Tender Detail */} + {selectedTender && ( +
+
+
+

{selectedTender.project_name}

+

{selectedTender.file_name} — {selectedTender.status}

+
+ +
+ + {/* Stats */} +
+
+
{selectedTender.total_requirements}
+
Anforderungen
+
+
+
{selectedTender.matched_count}
+
Abgedeckt
+
+
+
{selectedTender.partial_count}
+
Teilweise
+
+
+
{selectedTender.unmatched_count}
+
Luecken
+
+
+ + {/* Match Results */} + {selectedTender.match_results && selectedTender.match_results.length > 0 && ( +
+

Requirement → Control Matching

+ {selectedTender.match_results.map((mr, idx) => ( +
+
+
+
+ {mr.req_id} + + {mr.verdict === 'matched' ? 'Abgedeckt' : mr.verdict === 'partial' ? 'Teilweise' : 'Luecke'} + +
+

{mr.req_text}

+
+
+ {mr.matched_controls && mr.matched_controls.length > 0 && ( +
+ {mr.matched_controls.map(mc => ( + + {mc.control_id} ({Math.round(mc.relevance * 100)}%) + + ))} +
+ )} + {mr.gap_description && ( +

{mr.gap_description}

+ )} +
+ ))} +
+ )} +
+ )} + + {/* Previous Analyses */} + {tenderAnalyses.length > 0 && ( +
+

Bisherige Analysen

+
+ {tenderAnalyses.map(ta => ( + + ))} +
+
+ )} + + ) : null} ) } diff --git a/ai-compliance-sdk/cmd/server/main.go b/ai-compliance-sdk/cmd/server/main.go index 69e7a9a..a2ef44e 100644 --- a/ai-compliance-sdk/cmd/server/main.go +++ b/ai-compliance-sdk/cmd/server/main.go @@ -107,6 +107,7 @@ func main() { registrationStore := ucca.NewRegistrationStore(pool) registrationHandlers := handlers.NewRegistrationHandlers(registrationStore, uccaStore) paymentHandlers := handlers.NewPaymentHandlers(pool) + tenderHandlers := handlers.NewTenderHandlers(pool, paymentHandlers.GetControlLibrary()) roadmapHandlers := handlers.NewRoadmapHandlers(roadmapStore) workshopHandlers := handlers.NewWorkshopHandlers(workshopStore) portfolioHandlers := handlers.NewPortfolioHandlers(portfolioStore) @@ -307,6 +308,13 @@ func main() { payRoutes.GET("/assessments", paymentHandlers.ListAssessments) payRoutes.GET("/assessments/:id", paymentHandlers.GetAssessment) payRoutes.PATCH("/assessments/:id/verdict", paymentHandlers.UpdateControlVerdict) + + // Tender Analysis + payRoutes.POST("/tender/upload", tenderHandlers.Upload) + payRoutes.POST("/tender/:id/extract", tenderHandlers.Extract) + payRoutes.POST("/tender/:id/match", tenderHandlers.Match) + payRoutes.GET("/tender", tenderHandlers.ListAnalyses) + payRoutes.GET("/tender/:id", tenderHandlers.GetAnalysis) } // RAG routes - Legal Corpus Search & Versioning diff --git a/ai-compliance-sdk/internal/api/handlers/payment_handlers.go b/ai-compliance-sdk/internal/api/handlers/payment_handlers.go index 663c213..94567ea 100644 --- a/ai-compliance-sdk/internal/api/handlers/payment_handlers.go +++ b/ai-compliance-sdk/internal/api/handlers/payment_handlers.go @@ -95,6 +95,11 @@ func loadControlLibrary() *PaymentControlLibrary { return &PaymentControlLibrary{} } +// GetControlLibrary returns the loaded control library (for tender matching) +func (h *PaymentHandlers) GetControlLibrary() *PaymentControlLibrary { + return h.controls +} + // ListControls returns the control library func (h *PaymentHandlers) ListControls(c *gin.Context) { domain := c.Query("domain") diff --git a/ai-compliance-sdk/internal/api/handlers/tender_handlers.go b/ai-compliance-sdk/internal/api/handlers/tender_handlers.go new file mode 100644 index 0000000..710ca3c --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/tender_handlers.go @@ -0,0 +1,557 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +// TenderHandlers handles tender upload and requirement extraction +type TenderHandlers struct { + pool *pgxpool.Pool + controls *PaymentControlLibrary +} + +// TenderAnalysis represents a tender document analysis +type TenderAnalysis struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + FileName string `json:"file_name"` + FileSize int64 `json:"file_size"` + ProjectName string `json:"project_name"` + CustomerName string `json:"customer_name,omitempty"` + Status string `json:"status"` // uploaded, extracting, extracted, matched, completed + Requirements []ExtractedReq `json:"requirements,omitempty"` + MatchResults []MatchResult `json:"match_results,omitempty"` + TotalRequirements int `json:"total_requirements"` + MatchedCount int `json:"matched_count"` + UnmatchedCount int `json:"unmatched_count"` + PartialCount int `json:"partial_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ExtractedReq represents a single requirement extracted from a tender document +type ExtractedReq struct { + ReqID string `json:"req_id"` + Text string `json:"text"` + SourcePage int `json:"source_page,omitempty"` + SourceSection string `json:"source_section,omitempty"` + ObligationLevel string `json:"obligation_level"` // MUST, SHALL, SHOULD, MAY + TechnicalDomain string `json:"technical_domain"` // crypto, logging, payment_flow, etc. + CheckTarget string `json:"check_target"` // code, system, config, process, certificate + Confidence float64 `json:"confidence"` +} + +// MatchResult represents the matching of a requirement to controls +type MatchResult struct { + ReqID string `json:"req_id"` + ReqText string `json:"req_text"` + ObligationLevel string `json:"obligation_level"` + MatchedControls []ControlMatch `json:"matched_controls"` + Verdict string `json:"verdict"` // matched, partial, unmatched + GapDescription string `json:"gap_description,omitempty"` +} + +// ControlMatch represents a single control match for a requirement +type ControlMatch struct { + ControlID string `json:"control_id"` + Title string `json:"title"` + Relevance float64 `json:"relevance"` // 0-1 + CheckTarget string `json:"check_target"` +} + +// NewTenderHandlers creates tender handlers +func NewTenderHandlers(pool *pgxpool.Pool, controls *PaymentControlLibrary) *TenderHandlers { + return &TenderHandlers{pool: pool, controls: controls} +} + +// Upload handles tender document upload +func (h *TenderHandlers) Upload(c *gin.Context) { + tenantID, _ := uuid.Parse(c.GetHeader("X-Tenant-ID")) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "file required"}) + return + } + defer file.Close() + + projectName := c.PostForm("project_name") + if projectName == "" { + projectName = header.Filename + } + customerName := c.PostForm("customer_name") + + // Read file content + content, err := io.ReadAll(file) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"}) + return + } + + // Store analysis record + analysisID := uuid.New() + now := time.Now() + + _, err = h.pool.Exec(c.Request.Context(), ` + INSERT INTO tender_analyses ( + id, tenant_id, file_name, file_size, file_content, + project_name, customer_name, status, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'uploaded', $8, $9)`, + analysisID, tenantID, header.Filename, header.Size, content, + projectName, customerName, now, now, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store: " + err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "id": analysisID, + "file_name": header.Filename, + "file_size": header.Size, + "project_name": projectName, + "status": "uploaded", + "message": "Dokument hochgeladen. Starte Analyse mit POST /extract.", + }) +} + +// Extract extracts requirements from an uploaded tender document using LLM +func (h *TenderHandlers) Extract(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + // Get file content + var fileContent []byte + var fileName string + err = h.pool.QueryRow(c.Request.Context(), ` + SELECT file_content, file_name FROM tender_analyses WHERE id = $1`, id, + ).Scan(&fileContent, &fileName) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "analysis not found"}) + return + } + + // Update status + h.pool.Exec(c.Request.Context(), ` + UPDATE tender_analyses SET status = 'extracting', updated_at = NOW() WHERE id = $1`, id) + + // Extract text (simple: treat as text for now, PDF extraction would use embedding-service) + text := string(fileContent) + + // Use LLM to extract requirements + requirements := h.extractRequirementsWithLLM(c.Request.Context(), text) + + // Store results + reqJSON, _ := json.Marshal(requirements) + h.pool.Exec(c.Request.Context(), ` + UPDATE tender_analyses SET + status = 'extracted', + requirements = $2, + total_requirements = $3, + updated_at = NOW() + WHERE id = $1`, id, reqJSON, len(requirements)) + + c.JSON(http.StatusOK, gin.H{ + "id": id, + "status": "extracted", + "requirements": requirements, + "total": len(requirements), + }) +} + +// Match matches extracted requirements against the control library +func (h *TenderHandlers) Match(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + // Get requirements + var reqJSON json.RawMessage + err = h.pool.QueryRow(c.Request.Context(), ` + SELECT requirements FROM tender_analyses WHERE id = $1`, id, + ).Scan(&reqJSON) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "analysis not found"}) + return + } + + var requirements []ExtractedReq + json.Unmarshal(reqJSON, &requirements) + + // Match each requirement against controls + var results []MatchResult + matched, unmatched, partial := 0, 0, 0 + + for _, req := range requirements { + matches := h.findMatchingControls(req) + result := MatchResult{ + ReqID: req.ReqID, + ReqText: req.Text, + ObligationLevel: req.ObligationLevel, + MatchedControls: matches, + } + + if len(matches) == 0 { + result.Verdict = "unmatched" + result.GapDescription = "Kein passender Control gefunden — manueller Review erforderlich" + unmatched++ + } else if matches[0].Relevance >= 0.7 { + result.Verdict = "matched" + matched++ + } else { + result.Verdict = "partial" + result.GapDescription = "Teilweise Abdeckung — Control deckt Anforderung nicht vollstaendig ab" + partial++ + } + + results = append(results, result) + } + + // Store results + resultsJSON, _ := json.Marshal(results) + h.pool.Exec(c.Request.Context(), ` + UPDATE tender_analyses SET + status = 'matched', + match_results = $2, + matched_count = $3, + unmatched_count = $4, + partial_count = $5, + updated_at = NOW() + WHERE id = $1`, id, resultsJSON, matched, unmatched, partial) + + c.JSON(http.StatusOK, gin.H{ + "id": id, + "status": "matched", + "results": results, + "matched": matched, + "unmatched": unmatched, + "partial": partial, + "total": len(requirements), + }) +} + +// ListAnalyses lists all tender analyses for a tenant +func (h *TenderHandlers) ListAnalyses(c *gin.Context) { + tenantID, _ := uuid.Parse(c.GetHeader("X-Tenant-ID")) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + rows, err := h.pool.Query(c.Request.Context(), ` + SELECT id, tenant_id, file_name, file_size, project_name, customer_name, + status, total_requirements, matched_count, unmatched_count, partial_count, + created_at, updated_at + FROM tender_analyses + WHERE tenant_id = $1 + ORDER BY created_at DESC`, tenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + defer rows.Close() + + var analyses []TenderAnalysis + for rows.Next() { + var a TenderAnalysis + rows.Scan(&a.ID, &a.TenantID, &a.FileName, &a.FileSize, &a.ProjectName, &a.CustomerName, + &a.Status, &a.TotalRequirements, &a.MatchedCount, &a.UnmatchedCount, &a.PartialCount, + &a.CreatedAt, &a.UpdatedAt) + analyses = append(analyses, a) + } + if analyses == nil { + analyses = []TenderAnalysis{} + } + + c.JSON(http.StatusOK, gin.H{"analyses": analyses, "total": len(analyses)}) +} + +// GetAnalysis returns a single analysis with all details +func (h *TenderHandlers) GetAnalysis(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + var a TenderAnalysis + var reqJSON, matchJSON json.RawMessage + err = h.pool.QueryRow(c.Request.Context(), ` + SELECT id, tenant_id, file_name, file_size, project_name, customer_name, + status, requirements, match_results, + total_requirements, matched_count, unmatched_count, partial_count, + created_at, updated_at + FROM tender_analyses WHERE id = $1`, id).Scan( + &a.ID, &a.TenantID, &a.FileName, &a.FileSize, &a.ProjectName, &a.CustomerName, + &a.Status, &reqJSON, &matchJSON, + &a.TotalRequirements, &a.MatchedCount, &a.UnmatchedCount, &a.PartialCount, + &a.CreatedAt, &a.UpdatedAt) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + if reqJSON != nil { + json.Unmarshal(reqJSON, &a.Requirements) + } + if matchJSON != nil { + json.Unmarshal(matchJSON, &a.MatchResults) + } + + c.JSON(http.StatusOK, a) +} + +// --- Internal helpers --- + +func (h *TenderHandlers) extractRequirementsWithLLM(ctx context.Context, text string) []ExtractedReq { + // Try Anthropic API for requirement extraction + apiKey := os.Getenv("ANTHROPIC_API_KEY") + if apiKey == "" { + // Fallback: simple keyword-based extraction + return h.extractRequirementsKeyword(text) + } + + prompt := fmt.Sprintf(`Analysiere das folgende Ausschreibungsdokument und extrahiere alle technischen Anforderungen. + +Fuer jede Anforderung gib zurueck: +- req_id: fortlaufende ID (REQ-001, REQ-002, ...) +- text: die Anforderung als kurzer Satz +- obligation_level: MUST, SHALL, SHOULD oder MAY +- technical_domain: eines von: payment_flow, logging, crypto, api_security, terminal_comm, firmware, reporting, access_control, error_handling, build_deploy +- check_target: eines von: code, system, config, process, certificate + +Antworte NUR mit JSON Array. Keine Erklaerung. + +Dokument: +%s`, text[:min(len(text), 15000)]) + + body := map[string]interface{}{ + "model": "claude-haiku-4-5-20251001", + "max_tokens": 4096, + "messages": []map[string]string{{"role": "user", "content": prompt}}, + } + bodyJSON, _ := json.Marshal(body) + + req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.anthropic.com/v1/messages", strings.NewReader(string(bodyJSON))) + req.Header.Set("x-api-key", apiKey) + req.Header.Set("anthropic-version", "2023-06-01") + req.Header.Set("content-type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil || resp.StatusCode != 200 { + return h.extractRequirementsKeyword(text) + } + defer resp.Body.Close() + + var result struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + } + json.NewDecoder(resp.Body).Decode(&result) + + if len(result.Content) == 0 { + return h.extractRequirementsKeyword(text) + } + + // Parse LLM response + responseText := result.Content[0].Text + // Find JSON array in response + start := strings.Index(responseText, "[") + end := strings.LastIndex(responseText, "]") + if start < 0 || end < 0 { + return h.extractRequirementsKeyword(text) + } + + var reqs []ExtractedReq + if err := json.Unmarshal([]byte(responseText[start:end+1]), &reqs); err != nil { + return h.extractRequirementsKeyword(text) + } + + // Set confidence for LLM-extracted requirements + for i := range reqs { + reqs[i].Confidence = 0.8 + } + + return reqs +} + +func (h *TenderHandlers) extractRequirementsKeyword(text string) []ExtractedReq { + // Simple keyword-based extraction as fallback + keywords := map[string]string{ + "muss": "MUST", + "muessen": "MUST", + "ist sicherzustellen": "MUST", + "soll": "SHOULD", + "sollte": "SHOULD", + "kann": "MAY", + "wird gefordert": "MUST", + "nachzuweisen": "MUST", + "zertifiziert": "MUST", + } + + var reqs []ExtractedReq + lines := strings.Split(text, "\n") + reqNum := 1 + + for _, line := range lines { + line = strings.TrimSpace(line) + if len(line) < 20 || len(line) > 500 { + continue + } + + for keyword, level := range keywords { + if strings.Contains(strings.ToLower(line), keyword) { + reqs = append(reqs, ExtractedReq{ + ReqID: fmt.Sprintf("REQ-%03d", reqNum), + Text: line, + ObligationLevel: level, + TechnicalDomain: inferDomain(line), + CheckTarget: inferCheckTarget(line), + Confidence: 0.5, + }) + reqNum++ + break + } + } + } + + return reqs +} + +func (h *TenderHandlers) findMatchingControls(req ExtractedReq) []ControlMatch { + var matches []ControlMatch + + reqLower := strings.ToLower(req.Text + " " + req.TechnicalDomain) + + for _, ctrl := range h.controls.Controls { + titleLower := strings.ToLower(ctrl.Title + " " + ctrl.Objective) + relevance := calculateRelevance(reqLower, titleLower, req.TechnicalDomain, ctrl.Domain) + + if relevance > 0.3 { + matches = append(matches, ControlMatch{ + ControlID: ctrl.ControlID, + Title: ctrl.Title, + Relevance: relevance, + CheckTarget: ctrl.CheckTarget, + }) + } + } + + // Sort by relevance (simple bubble sort for small lists) + for i := 0; i < len(matches); i++ { + for j := i + 1; j < len(matches); j++ { + if matches[j].Relevance > matches[i].Relevance { + matches[i], matches[j] = matches[j], matches[i] + } + } + } + + // Return top 5 + if len(matches) > 5 { + matches = matches[:5] + } + + return matches +} + +func calculateRelevance(reqText, ctrlText, reqDomain, ctrlDomain string) float64 { + score := 0.0 + + // Domain match bonus + domainMap := map[string]string{ + "payment_flow": "PAY", + "logging": "LOG", + "crypto": "CRYPTO", + "api_security": "API", + "terminal_comm": "TERM", + "firmware": "FW", + "reporting": "REP", + "access_control": "ACC", + "error_handling": "ERR", + "build_deploy": "BLD", + } + + if mapped, ok := domainMap[reqDomain]; ok && mapped == ctrlDomain { + score += 0.4 + } + + // Keyword overlap + reqWords := strings.Fields(reqText) + for _, word := range reqWords { + if len(word) > 3 && strings.Contains(ctrlText, word) { + score += 0.1 + } + } + + if score > 1.0 { + score = 1.0 + } + return score +} + +func inferDomain(text string) string { + textLower := strings.ToLower(text) + domainKeywords := map[string][]string{ + "payment_flow": {"zahlung", "transaktion", "buchung", "payment", "betrag"}, + "logging": {"log", "protokoll", "audit", "nachvollzieh"}, + "crypto": {"verschlüssel", "schlüssel", "krypto", "tls", "ssl", "hsm", "pin"}, + "api_security": {"api", "schnittstelle", "authentifiz", "autorisier"}, + "terminal_comm": {"terminal", "zvt", "opi", "gerät", "kontaktlos", "nfc"}, + "firmware": {"firmware", "update", "signatur", "boot"}, + "reporting": {"bericht", "report", "abrechnung", "export", "abgleich"}, + "access_control": {"zugang", "benutzer", "passwort", "rolle", "berechtigung"}, + "error_handling": {"fehler", "ausfall", "recovery", "offline", "störung"}, + "build_deploy": {"build", "deploy", "release", "ci", "pipeline"}, + } + + for domain, keywords := range domainKeywords { + for _, kw := range keywords { + if strings.Contains(textLower, kw) { + return domain + } + } + } + return "general" +} + +func inferCheckTarget(text string) string { + textLower := strings.ToLower(text) + if strings.Contains(textLower, "zertifik") || strings.Contains(textLower, "zulassung") { + return "certificate" + } + if strings.Contains(textLower, "prozess") || strings.Contains(textLower, "verfahren") { + return "process" + } + if strings.Contains(textLower, "konfigur") { + return "config" + } + return "code" +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/ai-compliance-sdk/migrations/025_tender_analysis_schema.sql b/ai-compliance-sdk/migrations/025_tender_analysis_schema.sql new file mode 100644 index 0000000..8e6ab80 --- /dev/null +++ b/ai-compliance-sdk/migrations/025_tender_analysis_schema.sql @@ -0,0 +1,37 @@ +-- Migration 025: Tender Analysis Schema +-- Stores uploaded tender documents, extracted requirements, and control matching results + +CREATE TABLE IF NOT EXISTS tender_analyses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- Document + file_name VARCHAR(500) NOT NULL, + file_size BIGINT DEFAULT 0, + file_content BYTEA, + + -- Project + project_name VARCHAR(500), + customer_name VARCHAR(500), + + -- Status + status VARCHAR(50) DEFAULT 'uploaded', + -- CHECK (status IN ('uploaded', 'extracting', 'extracted', 'matched', 'completed', 'error')) + + -- Extracted requirements + requirements JSONB DEFAULT '[]'::jsonb, + total_requirements INT DEFAULT 0, + + -- Match results + match_results JSONB DEFAULT '[]'::jsonb, + matched_count INT DEFAULT 0, + unmatched_count INT DEFAULT 0, + partial_count INT DEFAULT 0, + + -- Audit + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ta_tenant ON tender_analyses (tenant_id); +CREATE INDEX IF NOT EXISTS idx_ta_status ON tender_analyses (status);