Files
breakpilot-compliance/ai-compliance-sdk/internal/api/handlers/usecase_handler.go
T
Benjamin Admin e785b6d695
Build + Deploy / build-admin-compliance (push) Successful in 14s
Build + Deploy / build-developer-portal (push) Successful in 10s
Build + Deploy / build-tts (push) Successful in 11s
Build + Deploy / build-document-crawler (push) Successful in 20s
Build + Deploy / build-dsms-gateway (push) Successful in 13s
Build + Deploy / build-dsms-node (push) Successful in 13s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 18s
Build + Deploy / trigger-orca (push) Successful in 2m26s
Build + Deploy / build-backend-compliance (push) Successful in 13s
Build + Deploy / build-ai-sdk (push) Successful in 11s
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 2m50s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 43s
CI / test-python-backend (push) Successful in 38s
CI / test-python-document-crawler (push) Successful in 26s
CI / test-python-dsms-gateway (push) Successful in 25s
CI / validate-canonical-controls (push) Successful in 16s
fix(use-case-compiler): compile questions from MCs, not hardcoded
Changes the compile flow to always query Master Controls from DB first:
1. doc_check_controls → Mode A (deterministic)
2. LLM generation via Ollama/Claude → Mode B
3. Derive from MC name → fallback
4. Template hardcoded questions → absolute fallback

Previously, templates with pre-defined questions just returned those
without ever hitting the DB. Now MC-compiled questions take priority
and template questions fill gaps for uncovered topics.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 17:34:41 +02:00

298 lines
7.8 KiB
Go

package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
"github.com/breakpilot/ai-compliance-sdk/internal/usecase"
)
// UseCaseHandler handles use-case compiler endpoints.
type UseCaseHandler struct {
store *usecase.Store
compiler *usecase.Compiler
gapDetector *usecase.GapDetector
}
// NewUseCaseHandler creates a new UseCaseHandler.
func NewUseCaseHandler(pool *pgxpool.Pool, registry *llm.ProviderRegistry) *UseCaseHandler {
store := usecase.NewStore(pool)
llmGen := usecase.NewLLMQuestionGenerator(registry)
return &UseCaseHandler{
store: store,
compiler: usecase.NewCompiler(store, llmGen),
gapDetector: usecase.NewGapDetector(store),
}
}
// GetTemplates returns all available use-case templates.
// GET /sdk/v1/use-case/templates
func (h *UseCaseHandler) GetTemplates(c *gin.Context) {
templates := usecase.TemplateList()
c.JSON(http.StatusOK, gin.H{"templates": templates, "total": len(templates)})
}
// GetTemplate returns a specific template with compiled questions.
// GET /sdk/v1/use-case/templates/:id
func (h *UseCaseHandler) GetTemplate(c *gin.Context) {
id := c.Param("id")
tmpl, ok := usecase.Templates[id]
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "template not found"})
return
}
questions, err := h.compiler.Compile(&tmpl)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
tmpl.Questions = questions
c.JSON(http.StatusOK, gin.H{"template": tmpl})
}
// Compile generates questions from MC filters ad-hoc.
// POST /sdk/v1/use-case/compile
// Uses the full pipeline: doc_check → LLM → deterministic fallback
func (h *UseCaseHandler) Compile(c *gin.Context) {
var req struct {
MCFilters []string `json:"mc_filters" binding:"required"`
Regulations []string `json:"regulations"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tmpl := &usecase.Template{
ID: "custom",
MCFilters: req.MCFilters,
Regulations: req.Regulations,
}
questions, err := h.compiler.Compile(tmpl)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"questions": questions, "total": len(questions)})
}
// CreateAudit starts a new audit from a template.
// POST /sdk/v1/use-case/audits
func (h *UseCaseHandler) CreateAudit(c *gin.Context) {
var input usecase.CreateAuditInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID, err := uuid.Parse(c.GetHeader("X-Tenant-ID"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "X-Tenant-ID required"})
return
}
tmpl, ok := usecase.Templates[input.TemplateID]
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown template_id"})
return
}
questions, err := h.compiler.Compile(&tmpl)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
audit := &usecase.Audit{
TenantID: tenantID,
TemplateID: input.TemplateID,
Name: input.Name,
TargetName: input.TargetName,
TotalQuestions: len(questions),
Questions: questions,
}
if err := h.store.CreateAudit(audit); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create audit"})
return
}
c.JSON(http.StatusCreated, gin.H{"audit": audit})
}
// ListAudits returns all audits for a tenant.
// GET /sdk/v1/use-case/audits
func (h *UseCaseHandler) ListAudits(c *gin.Context) {
tenantID, err := uuid.Parse(c.GetHeader("X-Tenant-ID"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "X-Tenant-ID required"})
return
}
audits, err := h.store.ListAudits(tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list audits"})
return
}
c.JSON(http.StatusOK, gin.H{"audits": audits, "total": len(audits)})
}
// GetAudit returns an audit with questions and answers.
// GET /sdk/v1/use-case/audits/:id
func (h *UseCaseHandler) GetAudit(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid audit ID"})
return
}
audit, err := h.store.GetAudit(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "audit not found"})
return
}
answers, err := h.store.ListAnswers(id)
if err != nil {
answers = nil
}
c.JSON(http.StatusOK, gin.H{"audit": audit, "answers": answers})
}
// AnswerQuestion saves an answer for a question in an audit.
// POST /sdk/v1/use-case/audits/:id/answer
func (h *UseCaseHandler) AnswerQuestion(c *gin.Context) {
auditID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid audit ID"})
return
}
var input usecase.AnswerInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Find MC ID from the question
audit, err := h.store.GetAudit(auditID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "audit not found"})
return
}
var mcID string
for _, q := range audit.Questions {
if q.ID == input.QuestionID {
mcID = q.MCID
break
}
}
status := usecase.AnswerStatusAnswered
if input.Status == "skipped" {
status = usecase.AnswerStatusSkipped
} else if input.Status == "escalated" {
status = usecase.AnswerStatusEscalated
}
answer := &usecase.Answer{
AuditID: auditID,
QuestionID: input.QuestionID,
MCID: mcID,
Value: input.Value,
Comment: input.Comment,
EvidenceIDs: input.EvidenceIDs,
Status: status,
}
if err := h.store.SaveAnswer(answer); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save answer"})
return
}
// Update audit counters
answers, _ := h.store.ListAnswers(auditID)
score := usecase.Score(audit, answers)
auditStatus := usecase.StatusInProgress
if score.Answered >= audit.TotalQuestions {
auditStatus = usecase.StatusCompleted
}
h.store.UpdateAuditScore(auditID, score.Answered, score.ComplianceScore, auditStatus)
c.JSON(http.StatusOK, gin.H{"answer": answer, "progress": score})
}
// GetScore calculates and returns the compliance score.
// GET /sdk/v1/use-case/audits/:id/score
func (h *UseCaseHandler) GetScore(c *gin.Context) {
auditID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid audit ID"})
return
}
audit, err := h.store.GetAudit(auditID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "audit not found"})
return
}
answers, err := h.store.ListAnswers(auditID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load answers"})
return
}
score := usecase.Score(audit, answers)
c.JSON(http.StatusOK, score)
}
// GetGaps returns missing regulation sources for an audit.
// GET /sdk/v1/use-case/audits/:id/gaps
func (h *UseCaseHandler) GetGaps(c *gin.Context) {
auditID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid audit ID"})
return
}
audit, err := h.store.GetAudit(auditID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "audit not found"})
return
}
tmpl, ok := usecase.Templates[audit.TemplateID]
if !ok {
c.JSON(http.StatusOK, gin.H{"gaps": []interface{}{}, "audit_gaps": []interface{}{}})
return
}
// Missing regulation sources (from MC analysis)
missingRegs, err := h.gapDetector.DetectMissingRegulations(&tmpl)
if err != nil {
missingRegs = nil
}
// Audit-specific gaps (from answer analysis)
answers, _ := h.store.ListAnswers(auditID)
auditGaps := h.gapDetector.DetectAuditGaps(audit, answers)
c.JSON(http.StatusOK, gin.H{
"missing_sources": missingRegs,
"audit_gaps": auditGaps,
})
}