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 }