feat: Tender-Analyse Pipeline — Upload, Extraction, Control-Matching

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) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-13 09:35:46 +02:00
parent 38d3d24121
commit 4fcb842a92
7 changed files with 854 additions and 3 deletions

View File

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

View File

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

View File

@@ -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<string, { bg: string; text: string }> = {
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<PaymentControl[]>([])
const [domains, setDomains] = useState<PaymentDomain[]>([])
const [assessments, setAssessments] = useState<Assessment[]>([])
const [tenderAnalyses, setTenderAnalyses] = useState<TenderAnalysis[]>([])
const [selectedTender, setSelectedTender] = useState<TenderAnalysis | null>(null)
const [selectedDomain, setSelectedDomain] = useState<string>('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<HTMLInputElement>) {
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})
</button>
<button onClick={() => setTab('tender')}
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === 'tender' ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700'}`}>
Ausschreibung ({tenderAnalyses.length})
</button>
</div>
</div>
@@ -285,7 +352,126 @@ export default function PaymentCompliancePage() {
</div>
)}
</>
)}
) : tab === 'tender' ? (
<>
{/* Tender Analysis Tab */}
<div className="mb-6 p-6 bg-white rounded-xl border-2 border-dashed border-purple-300 text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">Ausschreibung analysieren</h3>
<p className="text-sm text-gray-500 mb-4">
Laden Sie ein Ausschreibungsdokument hoch. Die KI extrahiert automatisch alle Anforderungen und matcht sie gegen die Control-Bibliothek.
</p>
<label className="inline-block px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 cursor-pointer">
{uploading ? 'Hochladen...' : processing ? 'Analysiere...' : 'PDF / Dokument hochladen'}
<input type="file" className="hidden" accept=".pdf,.txt,.doc,.docx" onChange={handleTenderUpload} disabled={uploading || processing} />
</label>
<p className="text-xs text-gray-400 mt-2">PDF, TXT oder Word. Max 50 MB. Dokument wird nur fuer diese Analyse verwendet.</p>
</div>
{/* Selected Tender Detail */}
{selectedTender && (
<div className="mb-6 p-6 bg-white rounded-xl border border-purple-200">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900">{selectedTender.project_name}</h3>
<p className="text-sm text-gray-500">{selectedTender.file_name} {selectedTender.status}</p>
</div>
<button onClick={() => setSelectedTender(null)} className="text-gray-400 hover:text-gray-600 text-xl">&times;</button>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-3 mb-6">
<div className="text-center p-3 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold">{selectedTender.total_requirements}</div>
<div className="text-xs text-gray-500">Anforderungen</div>
</div>
<div className="text-center p-3 bg-green-50 rounded-lg">
<div className="text-2xl font-bold text-green-700">{selectedTender.matched_count}</div>
<div className="text-xs text-gray-500">Abgedeckt</div>
</div>
<div className="text-center p-3 bg-yellow-50 rounded-lg">
<div className="text-2xl font-bold text-yellow-700">{selectedTender.partial_count}</div>
<div className="text-xs text-gray-500">Teilweise</div>
</div>
<div className="text-center p-3 bg-red-50 rounded-lg">
<div className="text-2xl font-bold text-red-700">{selectedTender.unmatched_count}</div>
<div className="text-xs text-gray-500">Luecken</div>
</div>
</div>
{/* Match Results */}
{selectedTender.match_results && selectedTender.match_results.length > 0 && (
<div className="space-y-3">
<h4 className="font-semibold text-gray-900">Requirement Control Matching</h4>
{selectedTender.match_results.map((mr, idx) => (
<div key={idx} className={`p-4 rounded-lg border ${
mr.verdict === 'matched' ? 'border-green-200 bg-green-50' :
mr.verdict === 'partial' ? 'border-yellow-200 bg-yellow-50' :
'border-red-200 bg-red-50'
}`}>
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono bg-white px-2 py-0.5 rounded border">{mr.req_id}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${
mr.verdict === 'matched' ? 'bg-green-200 text-green-800' :
mr.verdict === 'partial' ? 'bg-yellow-200 text-yellow-800' :
'bg-red-200 text-red-800'
}`}>
{mr.verdict === 'matched' ? 'Abgedeckt' : mr.verdict === 'partial' ? 'Teilweise' : 'Luecke'}
</span>
</div>
<p className="text-sm text-gray-900">{mr.req_text}</p>
</div>
</div>
{mr.matched_controls && mr.matched_controls.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{mr.matched_controls.map(mc => (
<span key={mc.control_id} className="text-xs bg-white border px-2 py-0.5 rounded">
{mc.control_id} ({Math.round(mc.relevance * 100)}%)
</span>
))}
</div>
)}
{mr.gap_description && (
<p className="text-xs text-orange-700 mt-2">{mr.gap_description}</p>
)}
</div>
))}
</div>
)}
</div>
)}
{/* Previous Analyses */}
{tenderAnalyses.length > 0 && (
<div>
<h4 className="font-semibold text-gray-900 mb-3">Bisherige Analysen</h4>
<div className="space-y-3">
{tenderAnalyses.map(ta => (
<button key={ta.id} onClick={() => handleViewTender(ta.id)}
className="w-full text-left bg-white rounded-xl border border-gray-200 p-4 hover:border-purple-300 transition-all">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-gray-900">{ta.project_name}</h3>
<p className="text-xs text-gray-500">{ta.file_name} {new Date(ta.created_at).toLocaleDateString('de-DE')}</p>
</div>
<div className="flex gap-2">
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">{ta.matched_count} matched</span>
{ta.unmatched_count > 0 && (
<span className="text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded-full">{ta.unmatched_count} gaps</span>
)}
<span className={`text-xs px-2 py-0.5 rounded-full ${
ta.status === 'matched' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700'
}`}>{ta.status}</span>
</div>
</div>
</button>
))}
</div>
</div>
)}
</>
) : null}
</div>
)
}

View File

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

View File

@@ -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")

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
}

View File

@@ -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);