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
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:
@@ -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)."""
|
||||
|
||||
Reference in New Issue
Block a user