merge: Feature-Module (Payment, BetrVG, FISA 702) in refakturierten main
Some checks failed
Build + Deploy / build-admin-compliance (push) Successful in 1m30s
Build + Deploy / build-backend-compliance (push) Successful in 13s
Build + Deploy / build-ai-sdk (push) Failing after 29s
Build + Deploy / build-developer-portal (push) Successful in 6s
Build + Deploy / build-tts (push) Successful in 6s
Build + Deploy / build-document-crawler (push) Successful in 6s
Build + Deploy / build-dsms-gateway (push) Successful in 6s
Build + Deploy / trigger-orca (push) Has been skipped
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 12s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m18s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Failing after 29s
CI / test-python-backend (push) Successful in 34s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 19s
CI / validate-canonical-controls (push) Successful in 30s

Merged feature/fisa-702-drittland-risiko in den refakturierten main-Branch.
Konflikte in 8 Dateien aufgelöst — neue Features in die aufgesplittete
Modulstruktur integriert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-22 23:52:11 +02:00
58 changed files with 15705 additions and 445 deletions

View File

@@ -0,0 +1,290 @@
package handlers
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// PaymentHandlers handles payment compliance endpoints
type PaymentHandlers struct {
pool *pgxpool.Pool
controls *PaymentControlLibrary
}
// PaymentControlLibrary holds the control catalog
type PaymentControlLibrary struct {
Domains []PaymentDomain `json:"domains"`
Controls []PaymentControl `json:"controls"`
}
type PaymentDomain struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
type PaymentControl struct {
ControlID string `json:"control_id"`
Domain string `json:"domain"`
Title string `json:"title"`
Objective string `json:"objective"`
CheckTarget string `json:"check_target"`
Evidence []string `json:"evidence"`
Automation string `json:"automation"`
}
type PaymentAssessment struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
ProjectName string `json:"project_name"`
TenderReference string `json:"tender_reference,omitempty"`
CustomerName string `json:"customer_name,omitempty"`
Description string `json:"description,omitempty"`
SystemType string `json:"system_type,omitempty"`
PaymentMethods json.RawMessage `json:"payment_methods,omitempty"`
Protocols json.RawMessage `json:"protocols,omitempty"`
TotalControls int `json:"total_controls"`
ControlsPassed int `json:"controls_passed"`
ControlsFailed int `json:"controls_failed"`
ControlsPartial int `json:"controls_partial"`
ControlsNA int `json:"controls_not_applicable"`
ControlsUnchecked int `json:"controls_not_checked"`
ComplianceScore float64 `json:"compliance_score"`
Status string `json:"status"`
ControlResults json.RawMessage `json:"control_results,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy string `json:"created_by,omitempty"`
}
// NewPaymentHandlers creates payment handlers with loaded control library
func NewPaymentHandlers(pool *pgxpool.Pool) *PaymentHandlers {
lib := loadControlLibrary()
return &PaymentHandlers{pool: pool, controls: lib}
}
func loadControlLibrary() *PaymentControlLibrary {
// Try to load from policies directory
paths := []string{
"policies/payment_controls_v1.json",
"/app/policies/payment_controls_v1.json",
}
for _, p := range paths {
data, err := os.ReadFile(p)
if err != nil {
// Try relative to executable
execDir, _ := os.Executable()
altPath := filepath.Join(filepath.Dir(execDir), p)
data, err = os.ReadFile(altPath)
if err != nil {
continue
}
}
var lib PaymentControlLibrary
if err := json.Unmarshal(data, &lib); err == nil {
return &lib
}
}
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")
automation := c.Query("automation")
controls := h.controls.Controls
if domain != "" {
var filtered []PaymentControl
for _, ctrl := range controls {
if ctrl.Domain == domain {
filtered = append(filtered, ctrl)
}
}
controls = filtered
}
if automation != "" {
var filtered []PaymentControl
for _, ctrl := range controls {
if ctrl.Automation == automation {
filtered = append(filtered, ctrl)
}
}
controls = filtered
}
c.JSON(http.StatusOK, gin.H{
"controls": controls,
"domains": h.controls.Domains,
"total": len(controls),
})
}
// CreateAssessment creates a new payment compliance assessment
func (h *PaymentHandlers) CreateAssessment(c *gin.Context) {
var req PaymentAssessment
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID, _ := uuid.Parse(c.GetHeader("X-Tenant-ID"))
if tenantID == uuid.Nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
return
}
req.ID = uuid.New()
req.TenantID = tenantID
req.Status = "draft"
req.TotalControls = len(h.controls.Controls)
req.ControlsUnchecked = req.TotalControls
req.CreatedAt = time.Now()
req.UpdatedAt = time.Now()
_, err := h.pool.Exec(c.Request.Context(), `
INSERT INTO payment_compliance_assessments (
id, tenant_id, project_name, tender_reference, customer_name, description,
system_type, payment_methods, protocols,
total_controls, controls_not_checked, status, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
req.ID, req.TenantID, req.ProjectName, req.TenderReference, req.CustomerName, req.Description,
req.SystemType, req.PaymentMethods, req.Protocols,
req.TotalControls, req.ControlsUnchecked, req.Status, req.CreatedBy,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, req)
}
// ListAssessments lists all payment assessments for a tenant
func (h *PaymentHandlers) ListAssessments(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, project_name, tender_reference, customer_name,
system_type, total_controls, controls_passed, controls_failed,
controls_partial, controls_not_applicable, controls_not_checked,
compliance_score, status, created_at, updated_at
FROM payment_compliance_assessments
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 assessments []PaymentAssessment
for rows.Next() {
var a PaymentAssessment
rows.Scan(&a.ID, &a.TenantID, &a.ProjectName, &a.TenderReference, &a.CustomerName,
&a.SystemType, &a.TotalControls, &a.ControlsPassed, &a.ControlsFailed,
&a.ControlsPartial, &a.ControlsNA, &a.ControlsUnchecked,
&a.ComplianceScore, &a.Status, &a.CreatedAt, &a.UpdatedAt)
assessments = append(assessments, a)
}
if assessments == nil {
assessments = []PaymentAssessment{}
}
c.JSON(http.StatusOK, gin.H{"assessments": assessments, "total": len(assessments)})
}
// GetAssessment returns a single assessment with control results
func (h *PaymentHandlers) GetAssessment(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 PaymentAssessment
err = h.pool.QueryRow(c.Request.Context(), `
SELECT id, tenant_id, project_name, tender_reference, customer_name, description,
system_type, payment_methods, protocols,
total_controls, controls_passed, controls_failed, controls_partial,
controls_not_applicable, controls_not_checked, compliance_score,
status, control_results, created_at, updated_at, created_by
FROM payment_compliance_assessments WHERE id = $1`, id).Scan(
&a.ID, &a.TenantID, &a.ProjectName, &a.TenderReference, &a.CustomerName, &a.Description,
&a.SystemType, &a.PaymentMethods, &a.Protocols,
&a.TotalControls, &a.ControlsPassed, &a.ControlsFailed, &a.ControlsPartial,
&a.ControlsNA, &a.ControlsUnchecked, &a.ComplianceScore,
&a.Status, &a.ControlResults, &a.CreatedAt, &a.UpdatedAt, &a.CreatedBy)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "assessment not found"})
return
}
c.JSON(http.StatusOK, a)
}
// UpdateControlVerdict updates the verdict for a single control
func (h *PaymentHandlers) UpdateControlVerdict(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
return
}
var body struct {
ControlID string `json:"control_id"`
Verdict string `json:"verdict"` // passed, failed, partial, na, unchecked
Evidence string `json:"evidence,omitempty"`
Notes string `json:"notes,omitempty"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Update the control_results JSONB and recalculate scores
_, err = h.pool.Exec(c.Request.Context(), `
WITH updated AS (
SELECT id,
COALESCE(control_results, '[]'::jsonb) AS existing_results
FROM payment_compliance_assessments WHERE id = $1
)
UPDATE payment_compliance_assessments SET
control_results = (
SELECT jsonb_agg(
CASE WHEN elem->>'control_id' = $2 THEN
jsonb_build_object('control_id', $2, 'verdict', $3, 'evidence', $4, 'notes', $5)
ELSE elem END
) FROM updated, jsonb_array_elements(
CASE WHEN existing_results @> jsonb_build_array(jsonb_build_object('control_id', $2))
THEN existing_results
ELSE existing_results || jsonb_build_array(jsonb_build_object('control_id', $2, 'verdict', $3, 'evidence', $4, 'notes', $5))
END
) AS elem
),
updated_at = NOW()
WHERE id = $1`,
id, body.ControlID, body.Verdict, body.Evidence, body.Notes)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "updated", "control_id": body.ControlID, "verdict": body.Verdict})
}

View File

@@ -0,0 +1,220 @@
package handlers
import (
"net/http"
"github.com/breakpilot/ai-compliance-sdk/internal/ucca"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// RegistrationHandlers handles EU AI Database registration endpoints
type RegistrationHandlers struct {
store *ucca.RegistrationStore
uccaStore *ucca.Store
}
// NewRegistrationHandlers creates new registration handlers
func NewRegistrationHandlers(store *ucca.RegistrationStore, uccaStore *ucca.Store) *RegistrationHandlers {
return &RegistrationHandlers{store: store, uccaStore: uccaStore}
}
// Create creates a new registration
func (h *RegistrationHandlers) Create(c *gin.Context) {
var reg ucca.AIRegistration
if err := c.ShouldBindJSON(&reg); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
tenantID, _ := uuid.Parse(c.GetHeader("X-Tenant-ID"))
if tenantID == uuid.Nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
return
}
reg.TenantID = tenantID
if reg.SystemName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "system_name required"})
return
}
if err := h.store.Create(c.Request.Context(), &reg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create registration: " + err.Error()})
return
}
c.JSON(http.StatusCreated, reg)
}
// List lists all registrations for the tenant
func (h *RegistrationHandlers) List(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
}
registrations, err := h.store.List(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list registrations: " + err.Error()})
return
}
if registrations == nil {
registrations = []ucca.AIRegistration{}
}
c.JSON(http.StatusOK, gin.H{"registrations": registrations, "total": len(registrations)})
}
// Get returns a single registration
func (h *RegistrationHandlers) Get(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
reg, err := h.store.GetByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Registration not found"})
return
}
c.JSON(http.StatusOK, reg)
}
// Update updates a registration
func (h *RegistrationHandlers) Update(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
existing, err := h.store.GetByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Registration not found"})
return
}
var updates ucca.AIRegistration
if err := c.ShouldBindJSON(&updates); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
// Merge updates into existing
updates.ID = existing.ID
updates.TenantID = existing.TenantID
updates.CreatedAt = existing.CreatedAt
if err := h.store.Update(c.Request.Context(), &updates); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update: " + err.Error()})
return
}
c.JSON(http.StatusOK, updates)
}
// UpdateStatus changes the registration status
func (h *RegistrationHandlers) UpdateStatus(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
var body struct {
Status string `json:"status"`
SubmittedBy string `json:"submitted_by"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
validStatuses := map[string]bool{
"draft": true, "ready": true, "submitted": true,
"registered": true, "update_required": true, "withdrawn": true,
}
if !validStatuses[body.Status] {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status. Valid: draft, ready, submitted, registered, update_required, withdrawn"})
return
}
if err := h.store.UpdateStatus(c.Request.Context(), id, body.Status, body.SubmittedBy); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update status: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"id": id, "status": body.Status})
}
// Prefill creates a registration pre-filled from a UCCA assessment
func (h *RegistrationHandlers) Prefill(c *gin.Context) {
assessmentID, err := uuid.Parse(c.Param("assessment_id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assessment ID"})
return
}
tenantID, _ := uuid.Parse(c.GetHeader("X-Tenant-ID"))
if tenantID == uuid.Nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
return
}
// Load UCCA assessment
assessment, err := h.uccaStore.GetAssessment(c.Request.Context(), assessmentID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Assessment not found"})
return
}
// Pre-fill registration from assessment intake
intake := assessment.Intake
reg := ucca.AIRegistration{
TenantID: tenantID,
SystemName: intake.Title,
SystemDescription: intake.UseCaseText,
IntendedPurpose: intake.UseCaseText,
RiskClassification: string(assessment.RiskLevel),
GPAIClassification: "none",
RegistrationStatus: "draft",
UCCAAssessmentID: &assessmentID,
}
// Map domain to readable text
if intake.Domain != "" {
reg.IntendedPurpose = string(intake.Domain) + ": " + intake.UseCaseText
}
c.JSON(http.StatusOK, reg)
}
// Export generates the EU AI Database submission JSON
func (h *RegistrationHandlers) Export(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
reg, err := h.store.GetByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Registration not found"})
return
}
exportJSON := h.store.BuildExportJSON(reg)
// Save export data to DB
reg.ExportData = exportJSON
h.store.Update(c.Request.Context(), reg)
c.Header("Content-Type", "application/json")
c.Header("Content-Disposition", "attachment; filename=eu_ai_registration_"+reg.SystemName+".json")
c.Data(http.StatusOK, "application/json", exportJSON)
}

View File

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