feat: Applicability Engine + API-Filter + DB-Sync + Cleanup
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 35s
CI / test-python-voice (push) Successful in 33s
CI / test-bqas (push) Successful in 37s
CI / Deploy (push) Failing after 2s

- Applicability Engine (deterministisch, kein LLM): filtert Controls
  nach Branche, Unternehmensgroesse, Scope-Signalen
- API-Filter auf GET /controls, /controls-count, /controls-meta
- POST /controls/applicable Endpoint fuer Company-Profile-Matching
- 35 Unit-Tests fuer Engine
- Port-8098-Konflikt mit Nginx gefixt (nur expose, kein Host-Port)
- CLAUDE.md: control-pipeline Dokumentation ergaenzt
- 6 internationale Gesetze geloescht (ES/FR/HU/NL/SE/CZ — nur DACH)
- DB-Backup-Import-Script (import_backup.py)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-09 21:58:17 +02:00
parent ee5241a7bc
commit 441d5740bd
6 changed files with 829 additions and 1 deletions

View File

@@ -13,6 +13,7 @@ Endpoints:
GET /v1/canonical/controls/{control_id}/traceability — Traceability chain
GET /v1/canonical/controls/{control_id}/similar — Find similar controls
POST /v1/canonical/controls — Create a control
POST /v1/canonical/controls/applicable — Applicability filter (C2)
PUT /v1/canonical/controls/{control_id} — Update a control
DELETE /v1/canonical/controls/{control_id} — Delete a control
GET /v1/canonical/categories — Category list
@@ -151,6 +152,15 @@ class ControlUpdateRequest(BaseModel):
scope_conditions: Optional[dict] = None
class ApplicabilityRequest(BaseModel):
"""Request body for POST /v1/canonical/controls/applicable."""
industry: Optional[str] = None
company_size: Optional[str] = None
scope_signals: Optional[list] = None
limit: int = 100
offset: int = 0
class SimilarityCheckRequest(BaseModel):
source_text: str
candidate_text: str
@@ -321,6 +331,9 @@ async def list_controls(
search: Optional[str] = Query(None, description="Full-text search in control_id, title, objective"),
control_type: Optional[str] = Query(None, description="Filter: atomic, rich, or all"),
exclude_duplicates: bool = Query(False, description="Exclude controls with release_state='duplicate'"),
industry: Optional[str] = Query(None, description="Filter by applicable industry (e.g. Telekommunikation, Energie)"),
company_size: Optional[str] = Query(None, description="Filter by company size: micro/small/medium/large/enterprise"),
scope_signal: Optional[str] = Query(None, description="Filter by scope signal: uses_ai, third_country_transfer, etc."),
sort: Optional[str] = Query("control_id", description="Sort field: control_id, created_at, severity"),
order: Optional[str] = Query("asc", description="Sort order: asc or desc"),
limit: Optional[int] = Query(None, ge=1, le=5000, description="Max results"),
@@ -386,6 +399,22 @@ async def list_controls(
query += " AND (control_id ILIKE :q OR title ILIKE :q OR objective ILIKE :q)"
params["q"] = f"%{search}%"
# Scoped Control Applicability filters (C1)
if industry:
query += """ AND (applicable_industries IS NULL
OR applicable_industries LIKE '%"all"%'
OR applicable_industries LIKE '%' || :industry || '%')"""
params["industry"] = industry
if company_size:
query += """ AND (applicable_company_size IS NULL
OR applicable_company_size LIKE '%"all"%'
OR applicable_company_size LIKE '%' || :company_size || '%')"""
params["company_size"] = company_size
if scope_signal:
query += """ AND (scope_conditions IS NULL
OR scope_conditions LIKE '%' || :scope_signal || '%')"""
params["scope_signal"] = scope_signal
# Sorting
sort_col = "control_id"
if sort in ("created_at", "updated_at", "severity", "control_id"):
@@ -425,6 +454,9 @@ async def count_controls(
search: Optional[str] = Query(None),
control_type: Optional[str] = Query(None),
exclude_duplicates: bool = Query(False, description="Exclude controls with release_state='duplicate'"),
industry: Optional[str] = Query(None, description="Filter by applicable industry"),
company_size: Optional[str] = Query(None, description="Filter by company size: micro/small/medium/large/enterprise"),
scope_signal: Optional[str] = Query(None, description="Filter by scope signal: uses_ai, third_country_transfer, etc."),
):
"""Count controls matching filters (for pagination)."""
query = "SELECT count(*) FROM canonical_controls WHERE 1=1"
@@ -482,6 +514,22 @@ async def count_controls(
query += " AND (control_id ILIKE :q OR title ILIKE :q OR objective ILIKE :q)"
params["q"] = f"%{search}%"
# Scoped Control Applicability filters (C1)
if industry:
query += """ AND (applicable_industries IS NULL
OR applicable_industries LIKE '%"all"%'
OR applicable_industries LIKE '%' || :industry || '%')"""
params["industry"] = industry
if company_size:
query += """ AND (applicable_company_size IS NULL
OR applicable_company_size LIKE '%"all"%'
OR applicable_company_size LIKE '%' || :company_size || '%')"""
params["company_size"] = company_size
if scope_signal:
query += """ AND (scope_conditions IS NULL
OR scope_conditions LIKE '%' || :scope_signal || '%')"""
params["scope_signal"] = scope_signal
with SessionLocal() as db:
total = db.execute(text(query), params).scalar()
@@ -499,6 +547,9 @@ async def controls_meta(
target_audience: Optional[str] = Query(None),
source: Optional[str] = Query(None),
search: Optional[str] = Query(None),
industry: Optional[str] = Query(None),
company_size: Optional[str] = Query(None),
scope_signal: Optional[str] = Query(None),
control_type: Optional[str] = Query(None),
exclude_duplicates: bool = Query(False),
):
@@ -564,6 +615,22 @@ async def controls_meta(
clauses.append("(control_id ILIKE :q OR title ILIKE :q OR objective ILIKE :q)")
p["q"] = f"%{search}%"
# Scoped Control Applicability filters (C1)
if industry and skip != "industry":
clauses.append("""(applicable_industries IS NULL
OR applicable_industries LIKE '%"all"%'
OR applicable_industries LIKE '%' || :industry || '%')""")
p["industry"] = industry
if company_size and skip != "company_size":
clauses.append("""(applicable_company_size IS NULL
OR applicable_company_size LIKE '%"all"%'
OR applicable_company_size LIKE '%' || :company_size || '%')""")
p["company_size"] = company_size
if scope_signal and skip != "scope_signal":
clauses.append("""(scope_conditions IS NULL
OR scope_conditions LIKE '%' || :scope_signal || '%')""")
p["scope_signal"] = scope_signal
return " AND ".join(clauses), p
with SessionLocal() as db:
@@ -675,6 +742,51 @@ async def controls_meta(
}
@router.post("/controls/applicable")
async def get_applicable_controls_endpoint(body: ApplicabilityRequest):
"""Return controls applicable to a given company profile.
Filters controls based on industry, company size, and scope signals.
Deterministic -- no LLM needed. Controls with NULL applicability fields
are always included (they apply to everyone). Controls with '["all"]'
match all queries.
Request body:
- industry: e.g. "Telekommunikation", "Energie"
- company_size: e.g. "medium", "large", "enterprise"
- scope_signals: e.g. ["uses_ai", "third_country_transfer"]
- limit: max results (default 100)
- offset: pagination offset (default 0)
Returns:
- total_applicable: count of matching controls
- controls: paginated list
- breakdown: stats by domain, severity, industry
"""
from services.applicability_engine import get_applicable_controls
# Validate company_size
valid_sizes = {"micro", "small", "medium", "large", "enterprise"}
if body.company_size and body.company_size not in valid_sizes:
raise HTTPException(
status_code=400,
detail=f"Invalid company_size '{body.company_size}'. "
f"Must be one of: {', '.join(sorted(valid_sizes))}",
)
with SessionLocal() as db:
result = get_applicable_controls(
db=db,
industry=body.industry,
company_size=body.company_size,
scope_signals=body.scope_signals or [],
limit=body.limit,
offset=body.offset,
)
return result
@router.get("/controls/atomic-stats")
async def atomic_stats():
"""Return aggregated statistics for atomic controls (masters only)."""