feat: Faceted Search — Dropdown-Counts passen sich aktiven Filtern an
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 36s
CI/CD / test-python-backend-compliance (push) Successful in 42s
CI/CD / test-python-document-crawler (push) Successful in 30s
CI/CD / test-python-dsms-gateway (push) Successful in 21s
CI/CD / validate-canonical-controls (push) Successful in 13s
CI/CD / Deploy (push) Successful in 2s

Backend: controls-meta akzeptiert alle Filter-Parameter und berechnet
Faceted Counts (jede Dimension zaehlt mit allen ANDEREN Filtern).
Neue Facets: severity, verification_method, category, evidence_type,
release_state — zusaetzlich zu domains, sources, type_counts.

Frontend: loadMeta laedt bei jeder Filteraenderung neu, alle Dropdowns
zeigen kontextsensitive Zahlen. Proxy leitet Filter an controls-meta weiter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-26 15:00:40 +01:00
parent 2dee62fa6f
commit 52e463a7c8
4 changed files with 207 additions and 58 deletions

View File

@@ -471,46 +471,164 @@ async def count_controls(
@router.get("/controls-meta")
async def controls_meta():
"""Return aggregated metadata for filter dropdowns (domains, sources, counts)."""
async def controls_meta(
severity: Optional[str] = Query(None),
domain: Optional[str] = Query(None),
release_state: Optional[str] = Query(None),
verification_method: Optional[str] = Query(None),
category: Optional[str] = Query(None),
evidence_type: Optional[str] = Query(None),
target_audience: Optional[str] = Query(None),
source: Optional[str] = Query(None),
search: Optional[str] = Query(None),
control_type: Optional[str] = Query(None),
exclude_duplicates: bool = Query(False),
):
"""Return faceted metadata for filter dropdowns.
Each facet's counts respect ALL active filters EXCEPT the facet's own,
so dropdowns always show how many items each option would yield.
"""
def _build_where(skip: Optional[str] = None) -> tuple[str, dict[str, Any]]:
clauses = ["1=1"]
p: dict[str, Any] = {}
if exclude_duplicates:
clauses.append("release_state != 'duplicate'")
if severity and skip != "severity":
clauses.append("severity = :sev")
p["sev"] = severity
if domain and skip != "domain":
clauses.append("LEFT(control_id, LENGTH(:dom)) = :dom")
p["dom"] = domain.upper()
if release_state and skip != "release_state":
clauses.append("release_state = :rs")
p["rs"] = release_state
if verification_method and skip != "verification_method":
clauses.append("verification_method = :vm")
p["vm"] = verification_method
if category and skip != "category":
clauses.append("category = :cat")
p["cat"] = category
if evidence_type and skip != "evidence_type":
clauses.append("evidence_type = :et")
p["et"] = evidence_type
if target_audience and skip != "target_audience":
clauses.append("target_audience LIKE :ta_pattern")
p["ta_pattern"] = f'%"{target_audience}"%'
if source and skip != "source":
if source == "__none__":
clauses.append("(source_citation IS NULL OR source_citation->>'source' IS NULL OR source_citation->>'source' = '')")
else:
clauses.append("source_citation->>'source' = :src")
p["src"] = source
if control_type and skip != "control_type":
if control_type == "atomic":
clauses.append("decomposition_method = 'pass0b'")
elif control_type == "rich":
clauses.append("(decomposition_method IS NULL OR decomposition_method != 'pass0b')")
elif control_type == "eigenentwicklung":
clauses.append("""generation_strategy = 'ungrouped'
AND (pipeline_version = '1' OR pipeline_version IS NULL)
AND source_citation IS NULL
AND parent_control_uuid IS NULL""")
if search and skip != "search":
clauses.append("(control_id ILIKE :q OR title ILIKE :q OR objective ILIKE :q)")
p["q"] = f"%{search}%"
return " AND ".join(clauses), p
with SessionLocal() as db:
total = db.execute(text("SELECT count(*) FROM canonical_controls")).scalar()
# Total with ALL filters
w_all, p_all = _build_where()
total = db.execute(text(f"SELECT count(*) FROM canonical_controls WHERE {w_all}"), p_all).scalar()
domains = db.execute(text("""
# Domain facet (skip domain filter so user sees all domains)
w_dom, p_dom = _build_where(skip="domain")
domains = db.execute(text(f"""
SELECT UPPER(SPLIT_PART(control_id, '-', 1)) as domain, count(*) as cnt
FROM canonical_controls
FROM canonical_controls WHERE {w_dom}
GROUP BY domain ORDER BY domain
""")).fetchall()
"""), p_dom).fetchall()
sources = db.execute(text("""
# Source facet (skip source filter)
w_src, p_src = _build_where(skip="source")
sources = db.execute(text(f"""
SELECT source_citation->>'source' as src, count(*) as cnt
FROM canonical_controls
WHERE source_citation->>'source' IS NOT NULL AND source_citation->>'source' != ''
WHERE {w_src}
AND source_citation->>'source' IS NOT NULL AND source_citation->>'source' != ''
GROUP BY src ORDER BY cnt DESC
""")).fetchall()
"""), p_src).fetchall()
no_source = db.execute(text("""
no_source = db.execute(text(f"""
SELECT count(*) FROM canonical_controls
WHERE source_citation IS NULL OR source_citation->>'source' IS NULL OR source_citation->>'source' = ''
""")).scalar()
WHERE {w_src}
AND (source_citation IS NULL OR source_citation->>'source' IS NULL OR source_citation->>'source' = '')
"""), p_src).scalar()
# Type counts for filter dropdown
atomic_count = db.execute(text("""
SELECT count(*) FROM canonical_controls WHERE decomposition_method = 'pass0b'
""")).scalar() or 0
eigenentwicklung_count = db.execute(text("""
# Type facet (skip control_type filter)
w_typ, p_typ = _build_where(skip="control_type")
atomic_count = db.execute(text(f"""
SELECT count(*) FROM canonical_controls
WHERE generation_strategy = 'ungrouped'
WHERE {w_typ} AND decomposition_method = 'pass0b'
"""), p_typ).scalar() or 0
eigenentwicklung_count = db.execute(text(f"""
SELECT count(*) FROM canonical_controls
WHERE {w_typ}
AND generation_strategy = 'ungrouped'
AND (pipeline_version = '1' OR pipeline_version IS NULL)
AND source_citation IS NULL
AND parent_control_uuid IS NULL
""")).scalar() or 0
"""), p_typ).scalar() or 0
rich_count = db.execute(text("""
rich_count = db.execute(text(f"""
SELECT count(*) FROM canonical_controls
WHERE (decomposition_method IS NULL OR decomposition_method != 'pass0b')
""")).scalar() or 0
WHERE {w_typ}
AND (decomposition_method IS NULL OR decomposition_method != 'pass0b')
"""), p_typ).scalar() or 0
# Severity facet (skip severity filter)
w_sev, p_sev = _build_where(skip="severity")
severity_counts = db.execute(text(f"""
SELECT severity, count(*) as cnt
FROM canonical_controls WHERE {w_sev}
GROUP BY severity ORDER BY severity
"""), p_sev).fetchall()
# Verification method facet
w_vm, p_vm = _build_where(skip="verification_method")
vm_counts = db.execute(text(f"""
SELECT verification_method, count(*) as cnt
FROM canonical_controls WHERE {w_vm} AND verification_method IS NOT NULL
GROUP BY verification_method ORDER BY verification_method
"""), p_vm).fetchall()
# Category facet
w_cat, p_cat = _build_where(skip="category")
cat_counts = db.execute(text(f"""
SELECT category, count(*) as cnt
FROM canonical_controls WHERE {w_cat} AND category IS NOT NULL
GROUP BY category ORDER BY cnt DESC
"""), p_cat).fetchall()
# Evidence type facet
w_et, p_et = _build_where(skip="evidence_type")
et_counts = db.execute(text(f"""
SELECT evidence_type, count(*) as cnt
FROM canonical_controls WHERE {w_et} AND evidence_type IS NOT NULL
GROUP BY evidence_type ORDER BY evidence_type
"""), p_et).fetchall()
# Release state facet
w_rs, p_rs = _build_where(skip="release_state")
rs_counts = db.execute(text(f"""
SELECT release_state, count(*) as cnt
FROM canonical_controls WHERE {w_rs}
GROUP BY release_state ORDER BY release_state
"""), p_rs).fetchall()
return {
"total": total,
@@ -522,6 +640,11 @@ async def controls_meta():
"atomic": atomic_count,
"eigenentwicklung": eigenentwicklung_count,
},
"severity_counts": {r[0]: r[1] for r in severity_counts},
"verification_method_counts": {r[0]: r[1] for r in vm_counts},
"category_counts": {r[0]: r[1] for r in cat_counts},
"evidence_type_counts": {r[0]: r[1] for r in et_counts},
"release_state_counts": {r[0]: r[1] for r in rs_counts},
}