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:
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,22 @@ interface Assessment {
|
|||||||
created_at: string
|
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 }> = {
|
const AUTOMATION_STYLES: Record<string, { bg: string; text: string }> = {
|
||||||
high: { bg: 'bg-green-100', text: 'text-green-700' },
|
high: { bg: 'bg-green-100', text: 'text-green-700' },
|
||||||
medium: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
|
medium: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
|
||||||
@@ -51,9 +67,13 @@ export default function PaymentCompliancePage() {
|
|||||||
const [controls, setControls] = useState<PaymentControl[]>([])
|
const [controls, setControls] = useState<PaymentControl[]>([])
|
||||||
const [domains, setDomains] = useState<PaymentDomain[]>([])
|
const [domains, setDomains] = useState<PaymentDomain[]>([])
|
||||||
const [assessments, setAssessments] = useState<Assessment[]>([])
|
const [assessments, setAssessments] = useState<Assessment[]>([])
|
||||||
|
const [tenderAnalyses, setTenderAnalyses] = useState<TenderAnalysis[]>([])
|
||||||
|
const [selectedTender, setSelectedTender] = useState<TenderAnalysis | null>(null)
|
||||||
const [selectedDomain, setSelectedDomain] = useState<string>('all')
|
const [selectedDomain, setSelectedDomain] = useState<string>('all')
|
||||||
const [loading, setLoading] = useState(true)
|
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 [showNewAssessment, setShowNewAssessment] = useState(false)
|
||||||
const [newProject, setNewProject] = useState({ project_name: '', tender_reference: '', customer_name: '', system_type: 'full_stack' })
|
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() {
|
async function loadData() {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
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=controls'),
|
||||||
fetch('/api/sdk/v1/payment-compliance?endpoint=assessments'),
|
fetch('/api/sdk/v1/payment-compliance?endpoint=assessments'),
|
||||||
|
fetch('/api/sdk/v1/payment-compliance/tender'),
|
||||||
])
|
])
|
||||||
if (ctrlResp.ok) {
|
if (ctrlResp.ok) {
|
||||||
const data = await ctrlResp.json()
|
const data = await ctrlResp.json()
|
||||||
@@ -77,10 +98,52 @@ export default function PaymentCompliancePage() {
|
|||||||
const data = await assessResp.json()
|
const data = await assessResp.json()
|
||||||
setAssessments(data.assessments || [])
|
setAssessments(data.assessments || [])
|
||||||
}
|
}
|
||||||
|
if (tenderResp.ok) {
|
||||||
|
const data = await tenderResp.json()
|
||||||
|
setTenderAnalyses(data.analyses || [])
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
finally { setLoading(false) }
|
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() {
|
async function handleCreateAssessment() {
|
||||||
const resp = await fetch('/api/sdk/v1/payment-compliance', {
|
const resp = await fetch('/api/sdk/v1/payment-compliance', {
|
||||||
method: 'POST',
|
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'}`}>
|
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})
|
Assessments ({assessments.length})
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -285,7 +352,126 @@ export default function PaymentCompliancePage() {
|
|||||||
</div>
|
</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">×</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ func main() {
|
|||||||
registrationStore := ucca.NewRegistrationStore(pool)
|
registrationStore := ucca.NewRegistrationStore(pool)
|
||||||
registrationHandlers := handlers.NewRegistrationHandlers(registrationStore, uccaStore)
|
registrationHandlers := handlers.NewRegistrationHandlers(registrationStore, uccaStore)
|
||||||
paymentHandlers := handlers.NewPaymentHandlers(pool)
|
paymentHandlers := handlers.NewPaymentHandlers(pool)
|
||||||
|
tenderHandlers := handlers.NewTenderHandlers(pool, paymentHandlers.GetControlLibrary())
|
||||||
roadmapHandlers := handlers.NewRoadmapHandlers(roadmapStore)
|
roadmapHandlers := handlers.NewRoadmapHandlers(roadmapStore)
|
||||||
workshopHandlers := handlers.NewWorkshopHandlers(workshopStore)
|
workshopHandlers := handlers.NewWorkshopHandlers(workshopStore)
|
||||||
portfolioHandlers := handlers.NewPortfolioHandlers(portfolioStore)
|
portfolioHandlers := handlers.NewPortfolioHandlers(portfolioStore)
|
||||||
@@ -307,6 +308,13 @@ func main() {
|
|||||||
payRoutes.GET("/assessments", paymentHandlers.ListAssessments)
|
payRoutes.GET("/assessments", paymentHandlers.ListAssessments)
|
||||||
payRoutes.GET("/assessments/:id", paymentHandlers.GetAssessment)
|
payRoutes.GET("/assessments/:id", paymentHandlers.GetAssessment)
|
||||||
payRoutes.PATCH("/assessments/:id/verdict", paymentHandlers.UpdateControlVerdict)
|
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
|
// RAG routes - Legal Corpus Search & Versioning
|
||||||
|
|||||||
@@ -95,6 +95,11 @@ func loadControlLibrary() *PaymentControlLibrary {
|
|||||||
return &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
|
// ListControls returns the control library
|
||||||
func (h *PaymentHandlers) ListControls(c *gin.Context) {
|
func (h *PaymentHandlers) ListControls(c *gin.Context) {
|
||||||
domain := c.Query("domain")
|
domain := c.Query("domain")
|
||||||
|
|||||||
557
ai-compliance-sdk/internal/api/handlers/tender_handlers.go
Normal file
557
ai-compliance-sdk/internal/api/handlers/tender_handlers.go
Normal 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
|
||||||
|
}
|
||||||
37
ai-compliance-sdk/migrations/025_tender_analysis_schema.sql
Normal file
37
ai-compliance-sdk/migrations/025_tender_analysis_schema.sql
Normal 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);
|
||||||
Reference in New Issue
Block a user