feat: add compliance modules 2-5 (dashboard, security templates, process manager, evidence collector)
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 32s
CI/CD / test-python-backend-compliance (push) Successful in 34s
CI/CD / test-python-document-crawler (push) Successful in 23s
CI/CD / test-python-dsms-gateway (push) Successful in 21s
CI/CD / validate-canonical-controls (push) Successful in 11s
CI/CD / Deploy (push) Successful in 2s
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 32s
CI/CD / test-python-backend-compliance (push) Successful in 34s
CI/CD / test-python-document-crawler (push) Successful in 23s
CI/CD / test-python-dsms-gateway (push) Successful in 21s
CI/CD / validate-canonical-controls (push) Successful in 11s
CI/CD / Deploy (push) Successful in 2s
Module 2: Extended Compliance Dashboard with roadmap, module-status, next-actions, snapshots, score-history Module 3: 7 German security document templates (IT-Sicherheitskonzept, Datenschutz, Backup, Logging, Incident-Response, Zugriff, Risikomanagement) Module 4: Compliance Process Manager with CRUD, complete/skip/seed, ~50 seed tasks, 3-tab UI Module 5: Evidence Collector Extended with automated checks, control-mapping, coverage report, 4-tab UI Also includes: canonical control library enhancements (verification method, categories, dedup), control generator improvements, RAG client extensions 52 tests pass, frontend builds clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -280,6 +280,7 @@ func main() {
|
||||
ragRoutes.GET("/regulations", ragHandlers.ListRegulations)
|
||||
ragRoutes.GET("/corpus-status", ragHandlers.CorpusStatus)
|
||||
ragRoutes.GET("/corpus-versions/:collection", ragHandlers.CorpusVersionHistory)
|
||||
ragRoutes.GET("/scroll", ragHandlers.HandleScrollChunks)
|
||||
}
|
||||
|
||||
// Roadmap routes - Compliance Implementation Roadmaps
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/ucca"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -157,3 +158,47 @@ func (h *RAGHandlers) CorpusVersionHistory(c *gin.Context) {
|
||||
"count": len(versions),
|
||||
})
|
||||
}
|
||||
|
||||
// HandleScrollChunks scrolls/lists all chunks in a Qdrant collection with pagination.
|
||||
// GET /sdk/v1/rag/scroll?collection=...&offset=...&limit=...
|
||||
func (h *RAGHandlers) HandleScrollChunks(c *gin.Context) {
|
||||
collection := c.Query("collection")
|
||||
if collection == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'collection' is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if !AllowedCollections[collection] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown collection: " + collection})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse limit (default 100, max 500)
|
||||
limit := 100
|
||||
if limitStr := c.Query("limit"); limitStr != "" {
|
||||
parsed, err := strconv.Atoi(limitStr)
|
||||
if err != nil || parsed < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "limit must be a positive integer"})
|
||||
return
|
||||
}
|
||||
limit = parsed
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
|
||||
// Offset is optional (empty string = start from beginning)
|
||||
offset := c.Query("offset")
|
||||
|
||||
chunks, nextOffset, err := h.ragClient.ScrollChunks(c.Request.Context(), collection, offset, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "scroll failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"chunks": chunks,
|
||||
"next_offset": nextOffset,
|
||||
"total": len(chunks),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -91,6 +91,72 @@ func TestSearch_WithCollectionParam_BindsCorrectly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleScrollChunks_MissingCollection_Returns400(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := &RAGHandlers{}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/sdk/v1/rag/scroll", nil)
|
||||
|
||||
handler.HandleScrollChunks(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["error"] == nil {
|
||||
t.Error("Expected error message in response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleScrollChunks_InvalidCollection_Returns400(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := &RAGHandlers{}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/sdk/v1/rag/scroll?collection=bp_evil_collection", nil)
|
||||
|
||||
handler.HandleScrollChunks(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleScrollChunks_InvalidLimit_Returns400(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := &RAGHandlers{}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/sdk/v1/rag/scroll?collection=bp_compliance_ce&limit=abc", nil)
|
||||
|
||||
handler.HandleScrollChunks(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleScrollChunks_NegativeLimit_Returns400(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := &RAGHandlers{}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/sdk/v1/rag/scroll?collection=bp_compliance_ce&limit=-5", nil)
|
||||
|
||||
handler.HandleScrollChunks(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_EmptyCollection_IsAllowed(t *testing.T) {
|
||||
// Empty collection should be allowed (falls back to default in the handler)
|
||||
body := `{"query":"test"}`
|
||||
|
||||
@@ -443,6 +443,142 @@ func (c *LegalRAGClient) FormatLegalContextForPrompt(lc *LegalContext) string {
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// ScrollChunkResult represents a single chunk from the scroll/list endpoint.
|
||||
type ScrollChunkResult struct {
|
||||
ID string `json:"id"`
|
||||
Text string `json:"text"`
|
||||
RegulationCode string `json:"regulation_code"`
|
||||
RegulationName string `json:"regulation_name"`
|
||||
RegulationShort string `json:"regulation_short"`
|
||||
Category string `json:"category"`
|
||||
Article string `json:"article,omitempty"`
|
||||
Paragraph string `json:"paragraph,omitempty"`
|
||||
SourceURL string `json:"source_url,omitempty"`
|
||||
}
|
||||
|
||||
// qdrantScrollRequest for the Qdrant scroll API.
|
||||
type qdrantScrollRequest struct {
|
||||
Limit int `json:"limit"`
|
||||
Offset interface{} `json:"offset,omitempty"` // string (UUID) or null
|
||||
WithPayload bool `json:"with_payload"`
|
||||
WithVectors bool `json:"with_vectors"`
|
||||
}
|
||||
|
||||
// qdrantScrollResponse from the Qdrant scroll API.
|
||||
type qdrantScrollResponse struct {
|
||||
Result struct {
|
||||
Points []qdrantScrollPoint `json:"points"`
|
||||
NextPageOffset interface{} `json:"next_page_offset"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
type qdrantScrollPoint struct {
|
||||
ID interface{} `json:"id"`
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
}
|
||||
|
||||
// ScrollChunks iterates over all chunks in a Qdrant collection using the scroll API.
|
||||
// Pass an empty offset to start from the beginning. Returns chunks, next offset ID, and error.
|
||||
func (c *LegalRAGClient) ScrollChunks(ctx context.Context, collection string, offset string, limit int) ([]ScrollChunkResult, string, error) {
|
||||
scrollReq := qdrantScrollRequest{
|
||||
Limit: limit,
|
||||
WithPayload: true,
|
||||
WithVectors: false,
|
||||
}
|
||||
if offset != "" {
|
||||
// Qdrant expects integer point IDs — parse the offset string back to a number
|
||||
// Try parsing as integer first, fall back to string (for UUID-based collections)
|
||||
var offsetInt uint64
|
||||
if _, err := fmt.Sscanf(offset, "%d", &offsetInt); err == nil {
|
||||
scrollReq.Offset = offsetInt
|
||||
} else {
|
||||
scrollReq.Offset = offset
|
||||
}
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(scrollReq)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to marshal scroll request: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/collections/%s/points/scroll", c.qdrantURL, collection)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create scroll request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.qdrantAPIKey != "" {
|
||||
req.Header.Set("api-key", c.qdrantAPIKey)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("scroll request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, "", fmt.Errorf("qdrant returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var scrollResp qdrantScrollResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&scrollResp); err != nil {
|
||||
return nil, "", fmt.Errorf("failed to decode scroll response: %w", err)
|
||||
}
|
||||
|
||||
// Convert points to results
|
||||
chunks := make([]ScrollChunkResult, len(scrollResp.Result.Points))
|
||||
for i, pt := range scrollResp.Result.Points {
|
||||
// Extract point ID as string
|
||||
pointID := ""
|
||||
if pt.ID != nil {
|
||||
pointID = fmt.Sprintf("%v", pt.ID)
|
||||
}
|
||||
|
||||
chunks[i] = ScrollChunkResult{
|
||||
ID: pointID,
|
||||
Text: getString(pt.Payload, "text"),
|
||||
RegulationCode: getString(pt.Payload, "regulation_code"),
|
||||
RegulationName: getString(pt.Payload, "regulation_name"),
|
||||
RegulationShort: getString(pt.Payload, "regulation_short"),
|
||||
Category: getString(pt.Payload, "category"),
|
||||
Article: getString(pt.Payload, "article"),
|
||||
Paragraph: getString(pt.Payload, "paragraph"),
|
||||
SourceURL: getString(pt.Payload, "source_url"),
|
||||
}
|
||||
|
||||
// Fallback: try alternate payload field names used in ingestion
|
||||
if chunks[i].Text == "" {
|
||||
chunks[i].Text = getString(pt.Payload, "chunk_text")
|
||||
}
|
||||
if chunks[i].RegulationCode == "" {
|
||||
chunks[i].RegulationCode = getString(pt.Payload, "regulation_id")
|
||||
}
|
||||
if chunks[i].RegulationName == "" {
|
||||
chunks[i].RegulationName = getString(pt.Payload, "regulation_name_de")
|
||||
}
|
||||
if chunks[i].SourceURL == "" {
|
||||
chunks[i].SourceURL = getString(pt.Payload, "source")
|
||||
}
|
||||
}
|
||||
|
||||
// Extract next offset — Qdrant returns integer point IDs
|
||||
nextOffset := ""
|
||||
if scrollResp.Result.NextPageOffset != nil {
|
||||
switch v := scrollResp.Result.NextPageOffset.(type) {
|
||||
case float64:
|
||||
nextOffset = fmt.Sprintf("%.0f", v)
|
||||
case string:
|
||||
nextOffset = v
|
||||
default:
|
||||
nextOffset = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
return chunks, nextOffset, nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func getString(m map[string]interface{}, key string) string {
|
||||
|
||||
@@ -87,6 +87,197 @@ func TestSearchCollection_FallbackDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrollChunks_ReturnsChunksAndNextOffset(t *testing.T) {
|
||||
qdrantMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.URL.Path, "/points/scroll") {
|
||||
t.Errorf("Expected scroll endpoint, got: %s", r.URL.Path)
|
||||
}
|
||||
|
||||
// Decode request to verify fields
|
||||
var reqBody map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&reqBody)
|
||||
|
||||
if reqBody["with_vectors"] != false {
|
||||
t.Error("Expected with_vectors=false")
|
||||
}
|
||||
if reqBody["with_payload"] != true {
|
||||
t.Error("Expected with_payload=true")
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"points": []map[string]interface{}{
|
||||
{
|
||||
"id": "abc-123",
|
||||
"payload": map[string]interface{}{
|
||||
"text": "Artikel 35 DSGVO",
|
||||
"regulation_code": "eu_2016_679",
|
||||
"regulation_name": "DSGVO",
|
||||
"regulation_short": "DSGVO",
|
||||
"category": "regulation",
|
||||
"article": "Art. 35",
|
||||
"paragraph": "1",
|
||||
"source_url": "https://example.com/dsgvo",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "def-456",
|
||||
"payload": map[string]interface{}{
|
||||
"chunk_text": "AI Act Titel III",
|
||||
"regulation_id": "eu_2024_1689",
|
||||
"regulation_name_de": "KI-Verordnung",
|
||||
"regulation_short": "AI Act",
|
||||
"category": "regulation",
|
||||
"source": "https://example.com/ai-act",
|
||||
},
|
||||
},
|
||||
},
|
||||
"next_page_offset": "def-456",
|
||||
},
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer qdrantMock.Close()
|
||||
|
||||
client := &LegalRAGClient{
|
||||
qdrantURL: qdrantMock.URL,
|
||||
httpClient: http.DefaultClient,
|
||||
}
|
||||
|
||||
chunks, nextOffset, err := client.ScrollChunks(context.Background(), "bp_compliance_ce", "", 100)
|
||||
if err != nil {
|
||||
t.Fatalf("ScrollChunks failed: %v", err)
|
||||
}
|
||||
|
||||
if len(chunks) != 2 {
|
||||
t.Fatalf("Expected 2 chunks, got %d", len(chunks))
|
||||
}
|
||||
|
||||
// First chunk uses direct field names
|
||||
if chunks[0].ID != "abc-123" {
|
||||
t.Errorf("Expected ID abc-123, got %s", chunks[0].ID)
|
||||
}
|
||||
if chunks[0].Text != "Artikel 35 DSGVO" {
|
||||
t.Errorf("Expected text 'Artikel 35 DSGVO', got '%s'", chunks[0].Text)
|
||||
}
|
||||
if chunks[0].RegulationCode != "eu_2016_679" {
|
||||
t.Errorf("Expected regulation_code eu_2016_679, got %s", chunks[0].RegulationCode)
|
||||
}
|
||||
if chunks[0].Article != "Art. 35" {
|
||||
t.Errorf("Expected article 'Art. 35', got '%s'", chunks[0].Article)
|
||||
}
|
||||
|
||||
// Second chunk uses fallback field names (chunk_text, regulation_id, etc.)
|
||||
if chunks[1].Text != "AI Act Titel III" {
|
||||
t.Errorf("Expected fallback text 'AI Act Titel III', got '%s'", chunks[1].Text)
|
||||
}
|
||||
if chunks[1].RegulationCode != "eu_2024_1689" {
|
||||
t.Errorf("Expected fallback regulation_code eu_2024_1689, got '%s'", chunks[1].RegulationCode)
|
||||
}
|
||||
if chunks[1].RegulationName != "KI-Verordnung" {
|
||||
t.Errorf("Expected fallback regulation_name 'KI-Verordnung', got '%s'", chunks[1].RegulationName)
|
||||
}
|
||||
|
||||
if nextOffset != "def-456" {
|
||||
t.Errorf("Expected next_offset 'def-456', got '%s'", nextOffset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrollChunks_EmptyCollection_ReturnsEmpty(t *testing.T) {
|
||||
qdrantMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"points": []interface{}{},
|
||||
"next_page_offset": nil,
|
||||
},
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer qdrantMock.Close()
|
||||
|
||||
client := &LegalRAGClient{
|
||||
qdrantURL: qdrantMock.URL,
|
||||
httpClient: http.DefaultClient,
|
||||
}
|
||||
|
||||
chunks, nextOffset, err := client.ScrollChunks(context.Background(), "bp_compliance_ce", "", 100)
|
||||
if err != nil {
|
||||
t.Fatalf("ScrollChunks failed: %v", err)
|
||||
}
|
||||
|
||||
if len(chunks) != 0 {
|
||||
t.Errorf("Expected 0 chunks, got %d", len(chunks))
|
||||
}
|
||||
|
||||
if nextOffset != "" {
|
||||
t.Errorf("Expected empty next_offset, got '%s'", nextOffset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrollChunks_WithOffset_SendsOffset(t *testing.T) {
|
||||
var receivedBody map[string]interface{}
|
||||
|
||||
qdrantMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewDecoder(r.Body).Decode(&receivedBody)
|
||||
resp := map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"points": []interface{}{},
|
||||
"next_page_offset": nil,
|
||||
},
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer qdrantMock.Close()
|
||||
|
||||
client := &LegalRAGClient{
|
||||
qdrantURL: qdrantMock.URL,
|
||||
httpClient: http.DefaultClient,
|
||||
}
|
||||
|
||||
_, _, err := client.ScrollChunks(context.Background(), "bp_compliance_ce", "some-offset-id", 50)
|
||||
if err != nil {
|
||||
t.Fatalf("ScrollChunks failed: %v", err)
|
||||
}
|
||||
|
||||
if receivedBody["offset"] != "some-offset-id" {
|
||||
t.Errorf("Expected offset 'some-offset-id', got '%v'", receivedBody["offset"])
|
||||
}
|
||||
if receivedBody["limit"] != float64(50) {
|
||||
t.Errorf("Expected limit 50, got %v", receivedBody["limit"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrollChunks_SendsAPIKey(t *testing.T) {
|
||||
var receivedAPIKey string
|
||||
|
||||
qdrantMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedAPIKey = r.Header.Get("api-key")
|
||||
resp := map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"points": []interface{}{},
|
||||
"next_page_offset": nil,
|
||||
},
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer qdrantMock.Close()
|
||||
|
||||
client := &LegalRAGClient{
|
||||
qdrantURL: qdrantMock.URL,
|
||||
qdrantAPIKey: "test-api-key-123",
|
||||
httpClient: http.DefaultClient,
|
||||
}
|
||||
|
||||
_, _, err := client.ScrollChunks(context.Background(), "bp_compliance_ce", "", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ScrollChunks failed: %v", err)
|
||||
}
|
||||
|
||||
if receivedAPIKey != "test-api-key-123" {
|
||||
t.Errorf("Expected api-key 'test-api-key-123', got '%s'", receivedAPIKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_StillWorks(t *testing.T) {
|
||||
var requestedURL string
|
||||
|
||||
|
||||
Reference in New Issue
Block a user