From 7524545e80a2636ccbe9d1d9389d1b69691b8121 Mon Sep 17 00:00:00 2001 From: Spbd1 <148923621+Spbd1@users.noreply.github.com> Date: Mon, 18 May 2026 05:01:12 +0000 Subject: [PATCH] Build taxonomy workbench --- backend/app/api/routes_taxonomy.py | 76 ++++++++- backend/app/api/routes_taxonomy_workbench.py | 92 +++++++++-- backend/app/schemas/taxonomy.py | 28 +++- backend/app/schemas/taxonomy_workbench.py | 65 +++++++- backend/app/services/taxonomy_service.py | 49 +++++- .../services/taxonomy_workbench_service.py | 115 ++++++++++++- .../argument_risk_engine/taxonomy/indexer.py | 107 +++++++++++- .../taxonomy/quality_audit.py | 44 ++++- fastapi/__init__.py | 43 ++++- fastapi/testclient/__init__.py | 156 +++++++++++++++--- frontend/src/api/client.ts | 55 +++++- frontend/src/api/types.ts | 69 +++++++- .../taxonomy/TaxonomyDetailDrawer.tsx | 26 ++- .../components/taxonomy/TaxonomyFilters.tsx | 31 +++- .../src/components/taxonomy/TaxonomyPage.tsx | 40 ++++- .../src/components/taxonomy/TaxonomyTable.tsx | 37 ++++- .../TaxonomyActivationPanel.tsx | 36 +++- .../TaxonomyCoveragePanel.tsx | 15 +- .../TaxonomyImportExportPanel.tsx | 36 ++-- .../TaxonomyQualityPanel.tsx | 11 +- .../TaxonomyWorkbenchPage.tsx | 28 +++- frontend/src/styles/global.css | 21 +++ pyproject.toml | 1 + tests/test_api_taxonomy.py | 35 ++++ 24 files changed, 1128 insertions(+), 88 deletions(-) diff --git a/backend/app/api/routes_taxonomy.py b/backend/app/api/routes_taxonomy.py index b292994..450d38f 100644 --- a/backend/app/api/routes_taxonomy.py +++ b/backend/app/api/routes_taxonomy.py @@ -1,9 +1,77 @@ -from backend.app.schemas.taxonomy import TaxonomyListResponse -from backend.app.services.taxonomy_service import get_active_pack +from __future__ import annotations + +from argument_risk_engine.taxonomy.indexer import TaxonomyFilters + +from backend.app.schemas.taxonomy import ( + TaxonomyCategoriesResponse, + TaxonomyEntryResponse, + TaxonomyListResponse, + TaxonomySearchResponse, + TaxonomySummaryResponse, +) +from backend.app.services import taxonomy_service from fastapi import APIRouter + +def _bool_filter(value: bool | str | None) -> bool | None: + if isinstance(value, bool) or value is None: + return value + text = str(value).strip().lower() + if text in {"true", "1", "yes", "enabled", "active"}: + return True + if text in {"false", "0", "no", "disabled", "inactive"}: + return False + return None + + router = APIRouter(prefix="/taxonomy", tags=["taxonomy"]) + @router.get("", response_model=TaxonomyListResponse) -def list_taxonomy() -> TaxonomyListResponse: - return TaxonomyListResponse(entries=get_active_pack().entries) +def list_taxonomy( + category: str | None = None, + pack: str | None = None, + academic_status: str | None = None, + academic_consensus: str | None = None, + detection_level: str | None = None, + activation_status: str | None = None, + enabled_for_classification: bool | None = None, + false_positive_sensitivity: str | None = None, +) -> TaxonomyListResponse: + entries = taxonomy_service.list_entries( + TaxonomyFilters( + category=category, + pack=pack, + academic_status=academic_status, + academic_consensus=academic_consensus, + detection_level=detection_level, + activation_status=activation_status, + enabled_for_classification=_bool_filter(enabled_for_classification), + false_positive_sensitivity=false_positive_sensitivity, + ) + ) + return TaxonomyListResponse(entries=entries, total=len(entries)) + + +@router.get("/search", response_model=TaxonomySearchResponse) +def search_taxonomy(q: str = "") -> TaxonomySearchResponse: + entries = taxonomy_service.search_taxonomy(q) + return TaxonomySearchResponse(entries=entries, query=q, total=len(entries)) + + +@router.get("/categories", response_model=TaxonomyCategoriesResponse) +def taxonomy_categories() -> TaxonomyCategoriesResponse: + return TaxonomyCategoriesResponse(categories=taxonomy_service.categories()) + + +@router.get("/summary", response_model=TaxonomySummaryResponse) +def taxonomy_summary() -> TaxonomySummaryResponse: + return TaxonomySummaryResponse(**taxonomy_service.summary()) + + +@router.get("/{risk_id}", response_model=TaxonomyEntryResponse) +def get_taxonomy_entry(risk_id: str) -> TaxonomyEntryResponse | dict[str, str]: + entry = taxonomy_service.get_entry(risk_id) + if entry is None: + return {"detail": "taxonomy entry not found"} + return TaxonomyEntryResponse(entry=entry) diff --git a/backend/app/api/routes_taxonomy_workbench.py b/backend/app/api/routes_taxonomy_workbench.py index 0fc809b..c67b391 100644 --- a/backend/app/api/routes_taxonomy_workbench.py +++ b/backend/app/api/routes_taxonomy_workbench.py @@ -1,31 +1,101 @@ +from __future__ import annotations + from pathlib import Path from tempfile import NamedTemporaryFile -from backend.app.services.taxonomy_workbench_service import export_workbook, import_workbook, quality -from fastapi import APIRouter, File, UploadFile +from backend.app.schemas.taxonomy_workbench import ( + ActivationRequest, + ActivationResponse, + TaxonomyCoverageResponse, + TaxonomyImportResult, + TaxonomyPacksResponse, + TaxonomyQualityResponse, + TaxonomyValidationResponse, +) +from backend.app.services.taxonomy_workbench_service import ( + coverage, + export_workbook, + import_workbook, + packs, + quality, + set_activation, + validate_current_taxonomy, +) +from fastapi import APIRouter, File, Response, UploadFile router = APIRouter(prefix="/taxonomy-workbench", tags=["taxonomy-workbench"]) -@router.get("/quality") -def taxonomy_quality() -> dict[str, object]: - return quality() +@router.get("/packs", response_model=TaxonomyPacksResponse) +def taxonomy_packs() -> TaxonomyPacksResponse: + return TaxonomyPacksResponse(**packs()) -@router.post("/import") -def import_taxonomy(file: UploadFile = File(...)) -> dict[str, object]: +@router.get("/coverage", response_model=TaxonomyCoverageResponse) +def taxonomy_coverage() -> TaxonomyCoverageResponse: + return TaxonomyCoverageResponse(**coverage()) + + +@router.get("/quality-report", response_model=TaxonomyQualityResponse) +def taxonomy_quality() -> TaxonomyQualityResponse: + return TaxonomyQualityResponse(**quality()) + + +@router.post("/validate", response_model=TaxonomyValidationResponse) +def validate_taxonomy() -> TaxonomyValidationResponse: + return TaxonomyValidationResponse(**validate_current_taxonomy()) + + +@router.post("/import-excel", response_model=TaxonomyImportResult) +def import_taxonomy_excel(file: UploadFile = File(...)) -> TaxonomyImportResult: suffix = Path(getattr(file, "filename", "taxonomy.xlsx") or "taxonomy.xlsx").suffix or ".xlsx" with NamedTemporaryFile(delete=False, suffix=suffix) as handle: content = file.file.read() handle.write(content) temp_path = Path(handle.name) try: - return import_workbook(temp_path) + return TaxonomyImportResult(**import_workbook(temp_path)) finally: temp_path.unlink(missing_ok=True) +@router.get("/export-excel") +def export_taxonomy_excel() -> Response: + path = export_workbook() + content = path.read_bytes() + return Response( + content=content, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{path.name}"'}, + ) + + +@router.patch("/entries/{risk_id}/activation", response_model=ActivationResponse) +def update_activation(risk_id: str, request: ActivationRequest) -> ActivationResponse | dict[str, str]: + try: + result = set_activation( + risk_id=risk_id, + activation_status=request.activation_status, + enabled_for_classification=request.enabled_for_classification, + ) + except KeyError: + return {"detail": "taxonomy entry not found"} + except ValueError as error: + return {"detail": str(error)} + return ActivationResponse(**result) + + +# Backwards-compatible aliases for the original MVP endpoints. +@router.get("/quality") +def taxonomy_quality_alias() -> TaxonomyQualityResponse: + return taxonomy_quality() + + +@router.post("/import") +def import_taxonomy_alias(file: UploadFile = File(...)) -> TaxonomyImportResult: + return import_taxonomy_excel(file) + + @router.get("/export") -def export_taxonomy() -> dict[str, str]: - path = export_workbook(Path("data/taxonomy/exports/taxonomy.xlsx")) - return {"path": str(path)} +def export_taxonomy_alias() -> Response: + return export_taxonomy_excel() diff --git a/backend/app/schemas/taxonomy.py b/backend/app/schemas/taxonomy.py index 05110b1..8d6453a 100644 --- a/backend/app/schemas/taxonomy.py +++ b/backend/app/schemas/taxonomy.py @@ -1,7 +1,33 @@ +from __future__ import annotations + from argument_risk_engine.taxonomy.models import TaxonomyEntry -from pydantic import BaseModel +from pydantic import BaseModel, Field class TaxonomyListResponse(BaseModel): entries: list[TaxonomyEntry] + total: int = 0 + + +class TaxonomyEntryResponse(BaseModel): + entry: TaxonomyEntry + + +class TaxonomyCategoriesResponse(BaseModel): + categories: list[str] + + +class TaxonomySummaryResponse(BaseModel): + entry_count: int + active_count: int + enabled_for_classification_count: int + by_category: dict[str, int] = Field(default_factory=dict) + by_pack: dict[str, int] = Field(default_factory=dict) + by_activation_status: dict[str, int] = Field(default_factory=dict) + + +class TaxonomySearchResponse(BaseModel): + entries: list[TaxonomyEntry] + query: str + total: int = 0 diff --git a/backend/app/schemas/taxonomy_workbench.py b/backend/app/schemas/taxonomy_workbench.py index a2a65a3..9e7900e 100644 --- a/backend/app/schemas/taxonomy_workbench.py +++ b/backend/app/schemas/taxonomy_workbench.py @@ -1,6 +1,67 @@ -from pydantic import BaseModel +from __future__ import annotations + +from argument_risk_engine.taxonomy.models import TaxonomyEntry + +from pydantic import BaseModel, Field + + +class TaxonomyPackSummary(BaseModel): + pack: str + entry_count: int + active_count: int + enabled_for_classification_count: int + + +class TaxonomyPacksResponse(BaseModel): + packs: list[TaxonomyPackSummary] + + +class TaxonomyCoverageResponse(BaseModel): + entry_count: int + active_count: int + enabled_for_classification_count: int + review_required_count: int = 0 + deprecated_count: int = 0 + by_category: dict[str, int] = Field(default_factory=dict) + by_pack: dict[str, int] = Field(default_factory=dict) + by_detection_level: dict[str, int] = Field(default_factory=dict) + by_academic_status: dict[str, int] = Field(default_factory=dict) + by_activation_status: dict[str, int] = Field(default_factory=dict) + missing_examples_count: int = 0 + missing_false_positive_warnings_count: int = 0 class TaxonomyQualityResponse(BaseModel): - errors: list[str] + ok: bool entry_count: int + active_classification_count: int = 0 + error_count: int = 0 + warning_count: int = 0 + errors: list[dict[str, object]] = Field(default_factory=list) + warnings: list[dict[str, object]] = Field(default_factory=list) + coverage: dict[str, object] = Field(default_factory=dict) + + +class TaxonomyImportResult(BaseModel): + entry_count: int + errors: list[str] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + backup_paths: list[str] = Field(default_factory=list) + + +class TaxonomyValidationResponse(BaseModel): + ok: bool + entry_count: int + active_classification_count: int = 0 + errors: list[dict[str, object]] = Field(default_factory=list) + warnings: list[dict[str, object]] = Field(default_factory=list) + + +class ActivationRequest(BaseModel): + activation_status: str = "active" + enabled_for_classification: bool | None = None + + +class ActivationResponse(BaseModel): + entry: TaxonomyEntry + backup_path: str diff --git a/backend/app/services/taxonomy_service.py b/backend/app/services/taxonomy_service.py index fd03891..f0133fa 100644 --- a/backend/app/services/taxonomy_service.py +++ b/backend/app/services/taxonomy_service.py @@ -1,5 +1,17 @@ +from __future__ import annotations + +from pathlib import Path + +from argument_risk_engine.taxonomy.indexer import ( + TaxonomyFilters, + build_index, + facet_counts, + filter_entries, + search_entries, +) from argument_risk_engine.taxonomy.loader import load_taxonomy_pack, save_taxonomy_pack -from argument_risk_engine.taxonomy.models import TaxonomyPack +from argument_risk_engine.taxonomy.models import TaxonomyEntry, TaxonomyPack +from argument_risk_engine.taxonomy.quality_audit import coverage_report from backend.app.core.paths import TAXONOMY_PACK_PATH @@ -10,3 +22,38 @@ def get_active_pack() -> TaxonomyPack: def save_active_pack(pack: TaxonomyPack) -> None: save_taxonomy_pack(pack, TAXONOMY_PACK_PATH) + + +def list_entries(filters: TaxonomyFilters | None = None) -> list[TaxonomyEntry]: + entries = get_active_pack().entries + if filters is None: + return entries + return filter_entries(entries, filters) + + +def get_entry(risk_id: str) -> TaxonomyEntry | None: + return build_index(get_active_pack()).get(risk_id) + + +def search_taxonomy(query: str) -> list[TaxonomyEntry]: + return search_entries(get_active_pack().entries, query) + + +def categories() -> list[str]: + return sorted(facet_counts(get_active_pack().entries, "canonical_category")) + + +def summary() -> dict[str, object]: + report = coverage_report(get_active_pack()) + return { + "entry_count": report["entry_count"], + "active_count": report["active_count"], + "enabled_for_classification_count": report["enabled_for_classification_count"], + "by_category": report["by_category"], + "by_pack": report["by_pack"], + "by_activation_status": report["by_activation_status"], + } + + +def taxonomy_path() -> Path: + return TAXONOMY_PACK_PATH diff --git a/backend/app/services/taxonomy_workbench_service.py b/backend/app/services/taxonomy_workbench_service.py index 53ae562..64291ff 100644 --- a/backend/app/services/taxonomy_workbench_service.py +++ b/backend/app/services/taxonomy_workbench_service.py @@ -1,18 +1,79 @@ +from __future__ import annotations + +import shutil +from datetime import datetime, timezone from pathlib import Path +from typing import Any from argument_risk_engine.taxonomy.exporter import export_taxonomy_excel -from argument_risk_engine.taxonomy.importer import import_taxonomy_excel, import_workbook as import_taxonomy_workbook -from argument_risk_engine.taxonomy.validator import validate_taxonomy_pack +from argument_risk_engine.taxonomy.importer import import_taxonomy_excel +from argument_risk_engine.taxonomy.importer import import_workbook as import_taxonomy_workbook +from argument_risk_engine.taxonomy.loader import load_taxonomy_pack, save_taxonomy_pack +from argument_risk_engine.taxonomy.models import ActivationStatus, TaxonomyEntry, TaxonomyPack +from argument_risk_engine.taxonomy.quality_audit import audit_pack, coverage_report +from argument_risk_engine.taxonomy.validator import validate_taxonomy_pack_detailed +from backend.app.core.paths import DATA_DIR, TAXONOMY_PACK_PATH from backend.app.services.taxonomy_service import get_active_pack, save_active_pack +PACKS_DIR = DATA_DIR / "taxonomy" / "packs" +BACKUP_DIR = DATA_DIR / "taxonomy" / "backups" +EXPORT_DIR = DATA_DIR / "taxonomy" / "exports" + + +def _timestamp() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + +def backup_file(path: Path) -> Path: + BACKUP_DIR.mkdir(parents=True, exist_ok=True) + backup = BACKUP_DIR / f"{path.stem}.{_timestamp()}{path.suffix}" + shutil.copy2(path, backup) + return backup + + +def backup_pack_files() -> list[Path]: + backups: list[Path] = [] + for path in sorted(PACKS_DIR.glob("*.yaml")): + backups.append(backup_file(path)) + return backups + + +def packs() -> dict[str, object]: + entries = get_active_pack().entries + grouped: dict[str, list[TaxonomyEntry]] = {} + for entry in entries: + grouped.setdefault(entry.pack, []).append(entry) + return { + "packs": [ + { + "pack": pack, + "entry_count": len(pack_entries), + "active_count": sum(1 for entry in pack_entries if entry.active), + "enabled_for_classification_count": sum(1 for entry in pack_entries if entry.enabled_for_classification), + } + for pack, pack_entries in sorted(grouped.items()) + ] + } + + +def coverage() -> dict[str, Any]: + return coverage_report(get_active_pack()) + + +def quality() -> dict[str, Any]: + return audit_pack(get_active_pack()) -def quality() -> dict[str, object]: - pack = get_active_pack() - return {"errors": validate_taxonomy_pack(pack), "entry_count": len(pack.entries)} + +def validate_current_taxonomy() -> dict[str, Any]: + report = validate_taxonomy_pack_detailed(get_active_pack()) + return report.to_dict() def import_workbook(path: Path) -> dict[str, object]: + if path.suffix.lower() != ".xlsx": + return {"entry_count": 0, "errors": ["Uploaded taxonomy file must be an .xlsx workbook."], "warnings": [], "backup_paths": []} + backups = backup_pack_files() report = import_taxonomy_workbook(path) pack = import_taxonomy_excel(path) save_active_pack(pack) @@ -20,8 +81,48 @@ def import_workbook(path: Path) -> dict[str, object]: "entry_count": len(pack.entries), "errors": [issue.message for issue in report.errors], "warnings": [issue.message for issue in report.warnings], + "backup_paths": [str(path) for path in backups], } -def export_workbook(path: Path) -> Path: - return export_taxonomy_excel(get_active_pack(), path) +def export_workbook(path: Path | None = None) -> Path: + output = path or (EXPORT_DIR / f"taxonomy-{_timestamp()}.xlsx") + return export_taxonomy_excel(get_active_pack(), output) + + +def _find_pack_file_for_entry(risk_id: str) -> tuple[Path, TaxonomyPack, TaxonomyEntry] | None: + candidates = sorted(PACKS_DIR.glob("*.yaml")) + if TAXONOMY_PACK_PATH not in candidates and TAXONOMY_PACK_PATH.exists(): + candidates.insert(0, TAXONOMY_PACK_PATH) + for path in candidates: + pack = load_taxonomy_pack(path) + for entry in pack.entries: + if entry.id == risk_id: + return path, pack, entry + return None + + +def set_activation(risk_id: str, activation_status: str, enabled_for_classification: bool | None = None) -> dict[str, object]: + found = _find_pack_file_for_entry(risk_id) + if found is None: + raise KeyError(risk_id) + path, pack, target = found + allowed = {item.value for item in ActivationStatus} + if activation_status not in allowed: + raise ValueError(f"activation_status must be one of: {', '.join(sorted(allowed))}") + backup = backup_file(path) + for index, entry in enumerate(pack.entries): + if entry.id == risk_id: + data = entry.model_dump(mode="json") + data["activation_status"] = activation_status + if enabled_for_classification is None: + data["enabled_for_classification"] = activation_status == ActivationStatus.active.value + else: + data["enabled_for_classification"] = enabled_for_classification + pack.entries[index] = TaxonomyEntry(**data) + target = pack.entries[index] + break + save_taxonomy_pack(pack, path) + if path.resolve() == TAXONOMY_PACK_PATH.resolve(): + save_active_pack(pack) + return {"entry": target, "backup_path": str(backup)} diff --git a/engine/argument_risk_engine/taxonomy/indexer.py b/engine/argument_risk_engine/taxonomy/indexer.py index 31e7330..f6198f6 100644 --- a/engine/argument_risk_engine/taxonomy/indexer.py +++ b/engine/argument_risk_engine/taxonomy/indexer.py @@ -1,2 +1,107 @@ -def build_index(pack): +from __future__ import annotations + +from collections import Counter +from collections.abc import Iterable +from dataclasses import dataclass + +from argument_risk_engine.taxonomy.models import TaxonomyEntry, TaxonomyPack, normalize_id + + +@dataclass(frozen=True) +class TaxonomyFilters: + category: str | None = None + pack: str | None = None + academic_status: str | None = None + academic_consensus: str | None = None + detection_level: str | None = None + activation_status: str | None = None + enabled_for_classification: bool | None = None + false_positive_sensitivity: str | None = None + + +def build_index(pack: TaxonomyPack) -> dict[str, TaxonomyEntry]: + """Build an id -> entry lookup for a taxonomy pack.""" return {entry.id: entry for entry in pack.entries} + + +def searchable_text(entry: TaxonomyEntry) -> str: + parts: list[str] = [ + entry.id, + entry.name, + entry.pack, + entry.canonical_category, + entry.academic_status, + entry.academic_consensus, + entry.short_definition, + entry.long_definition, + entry.detection_level, + entry.minimum_evidence_requirement, + entry.notes, + ] + for value in [ + entry.signals, + entry.trigger_patterns, + entry.exclusion_criteria, + entry.common_false_positives, + entry.positive_examples, + entry.negative_examples, + entry.severity_guidance, + entry.related_risks, + entry.synonym_ids, + entry.source_refs, + ]: + parts.extend(value) + return " ".join(str(part) for part in parts if part).lower() + + +def _matches(value: str, expected: str | None) -> bool: + if expected in (None, "", "all"): + return True + return normalize_id(value) == normalize_id(expected) + + +def filter_entries(entries: Iterable[TaxonomyEntry], filters: TaxonomyFilters) -> list[TaxonomyEntry]: + filtered: list[TaxonomyEntry] = [] + for entry in entries: + if not _matches(entry.canonical_category, filters.category): + continue + if not _matches(entry.pack, filters.pack): + continue + if not _matches(entry.academic_status, filters.academic_status): + continue + if not _matches(entry.academic_consensus, filters.academic_consensus): + continue + if not _matches(entry.detection_level, filters.detection_level): + continue + if not _matches(entry.activation_status, filters.activation_status): + continue + if not _matches(entry.false_positive_sensitivity, filters.false_positive_sensitivity): + continue + if filters.enabled_for_classification is not None and entry.enabled_for_classification != filters.enabled_for_classification: + continue + filtered.append(entry) + return filtered + + +def search_entries(entries: Iterable[TaxonomyEntry], query: str | None) -> list[TaxonomyEntry]: + if not query: + return list(entries) + terms = [term for term in normalize_id(query).split("_") if term] + if not terms: + return list(entries) + scored: list[tuple[int, TaxonomyEntry]] = [] + for entry in entries: + text = searchable_text(entry) + if all(term in text for term in terms): + score = sum(text.count(term) for term in terms) + if any(term in entry.id.lower() for term in terms): + score += 5 + if any(term in entry.name.lower() for term in terms): + score += 3 + scored.append((score, entry)) + return [entry for _, entry in sorted(scored, key=lambda item: (-item[0], item[1].id))] + + +def facet_counts(entries: Iterable[TaxonomyEntry], field_name: str) -> dict[str, int]: + counts = Counter(str(getattr(entry, field_name, "") or "unknown") for entry in entries) + return dict(sorted(counts.items())) diff --git a/engine/argument_risk_engine/taxonomy/quality_audit.py b/engine/argument_risk_engine/taxonomy/quality_audit.py index 5dfad0c..45a4233 100644 --- a/engine/argument_risk_engine/taxonomy/quality_audit.py +++ b/engine/argument_risk_engine/taxonomy/quality_audit.py @@ -1,2 +1,42 @@ -def audit_pack(pack): - return {"entry_count": len(pack.entries), "active_count": sum(1 for e in pack.entries if e.active)} +from __future__ import annotations + +from collections import Counter +from typing import Any + +from argument_risk_engine.taxonomy.models import ActivationStatus, TaxonomyPack +from argument_risk_engine.taxonomy.validator import validate_taxonomy_pack_detailed + + +def coverage_report(pack: TaxonomyPack) -> dict[str, Any]: + entries = pack.entries + total = len(entries) + active = [entry for entry in entries if entry.active] + return { + "entry_count": total, + "active_count": len(active), + "enabled_for_classification_count": sum(1 for entry in entries if entry.enabled_for_classification), + "review_required_count": sum(1 for entry in entries if entry.activation_status == ActivationStatus.review_required.value), + "deprecated_count": sum(1 for entry in entries if entry.activation_status == ActivationStatus.deprecated.value), + "by_category": dict(sorted(Counter(entry.canonical_category for entry in entries).items())), + "by_pack": dict(sorted(Counter(entry.pack for entry in entries).items())), + "by_detection_level": dict(sorted(Counter(entry.detection_level for entry in entries).items())), + "by_academic_status": dict(sorted(Counter(entry.academic_status for entry in entries).items())), + "by_activation_status": dict(sorted(Counter(entry.activation_status for entry in entries).items())), + "missing_examples_count": sum(1 for entry in active if not entry.positive_examples or not entry.negative_examples), + "missing_false_positive_warnings_count": sum(1 for entry in active if not entry.common_false_positives), + } + + +def audit_pack(pack: TaxonomyPack) -> dict[str, Any]: + report = validate_taxonomy_pack_detailed(pack) + coverage = coverage_report(pack) + return { + "ok": report.ok, + "entry_count": report.entry_count, + "active_classification_count": report.active_classification_count, + "error_count": len(report.errors), + "warning_count": len(report.warnings), + "errors": [issue.to_dict() for issue in report.errors], + "warnings": [issue.to_dict() for issue in report.warnings], + "coverage": coverage, + } diff --git a/fastapi/__init__.py b/fastapi/__init__.py index 2b77dc4..4c4ff3f 100644 --- a/fastapi/__init__.py +++ b/fastapi/__init__.py @@ -1,50 +1,79 @@ from __future__ import annotations + class Response: - def __init__(self, content='', media_type='text/plain', status_code=200): + def __init__(self, content='', media_type='text/plain', status_code=200, headers=None): self.content = content self.media_type = media_type self.status_code = status_code + self.headers = headers or {} + class APIRouter: def __init__(self, prefix='', tags=None): self.prefix = prefix self.routes = {} + + def _add(self, method, path, fn): + self.routes[(method, self.prefix + path)] = fn + return fn + def get(self, path='', **kwargs): def deco(fn): - self.routes[('GET', self.prefix + path)] = fn - return fn + return self._add('GET', path, fn) return deco + def post(self, path='', **kwargs): def deco(fn): - self.routes[('POST', self.prefix + path)] = fn - return fn + return self._add('POST', path, fn) return deco + def put(self, path='', **kwargs): def deco(fn): - self.routes[('PUT', self.prefix + path)] = fn - return fn + return self._add('PUT', path, fn) + return deco + + def patch(self, path='', **kwargs): + def deco(fn): + return self._add('PATCH', path, fn) return deco + class FastAPI: def __init__(self, **kwargs): self.routes = {} + def add_middleware(self, *args, **kwargs): return None + def include_router(self, router, prefix=''): for (method, path), fn in router.routes.items(): self.routes[(method, prefix + path)] = fn + def get(self, path='', **kwargs): def deco(fn): self.routes[('GET', path)] = fn return fn return deco + def post(self, path='', **kwargs): + def deco(fn): + self.routes[('POST', path)] = fn + return fn + return deco + + def patch(self, path='', **kwargs): + def deco(fn): + self.routes[('PATCH', path)] = fn + return fn + return deco + class UploadFile: def __init__(self, filename='', file=None): self.filename = filename self.file = file + def File(default=None, **kwargs): return default diff --git a/fastapi/testclient/__init__.py b/fastapi/testclient/__init__.py index d39e37f..4fc5ae5 100644 --- a/fastapi/testclient/__init__.py +++ b/fastapi/testclient/__init__.py @@ -1,12 +1,23 @@ from __future__ import annotations -import json + +import inspect +from io import BytesIO +from urllib.parse import parse_qs, urlparse + +from fastapi import Response, UploadFile + class _Resp: def __init__(self, payload, status_code=200): self._payload = payload - self.status_code = status_code + self.status_code = getattr(payload, 'status_code', status_code) + self.content = getattr(payload, 'content', b'') + self.headers = getattr(payload, 'headers', {}) + def json(self): def conv(v): + if isinstance(v, Response): + return v.content if hasattr(v, 'model_dump'): return v.model_dump() if isinstance(v, dict): @@ -18,30 +29,127 @@ def conv(v): return v return conv(self._payload) + class TestClient: def __init__(self, app): self.app = app + def get(self, path): - fn = self.app.routes.get(('GET', path)) - return _Resp(fn() if fn else {'detail':'not found'}, 200 if fn else 404) - def post(self, path, json=None): - fn = self.app.routes.get(('POST', path)) - if not fn: return _Resp({'detail':'not found'}, 404) - arg = _make_arg(fn, json or {}) - return _Resp(fn(arg) if arg is not None else fn()) + fn, kwargs, query = _resolve(self.app, 'GET', path) + if not fn: + return _Resp({'detail': 'not found'}, 404) + kwargs.update(query) + return _Resp(_call(fn, kwargs)) + + def post(self, path, json=None, files=None): + fn, kwargs, query = _resolve(self.app, 'POST', path) + if not fn: + return _Resp({'detail': 'not found'}, 404) + kwargs.update(query) + kwargs.update(_json_args(fn, json or {})) + kwargs.update(_file_args(files)) + return _Resp(_call(fn, kwargs)) + def put(self, path, json=None): - fn = self.app.routes.get(('PUT', path)) - if not fn: return _Resp({'detail':'not found'}, 404) - arg = _make_arg(fn, json or {}) - return _Resp(fn(arg) if arg is not None else fn()) - -def _make_arg(fn, data): - anns = getattr(fn, '__annotations__', {}) - params = [k for k in anns if k != 'return'] - if not params: - return None - cls = anns[params[0]] - try: - return cls(**data) - except Exception: - return data + fn, kwargs, query = _resolve(self.app, 'PUT', path) + if not fn: + return _Resp({'detail': 'not found'}, 404) + kwargs.update(query) + kwargs.update(_json_args(fn, json or {})) + return _Resp(_call(fn, kwargs)) + + def patch(self, path, json=None): + fn, kwargs, query = _resolve(self.app, 'PATCH', path) + if not fn: + return _Resp({'detail': 'not found'}, 404) + kwargs.update(query) + kwargs.update(_json_args(fn, json or {})) + return _Resp(_call(fn, kwargs)) + + +def _resolve(app, method, raw_path): + parsed = urlparse(raw_path) + path = parsed.path + query = {key: values[-1] for key, values in parse_qs(parsed.query).items()} + exact = app.routes.get((method, path)) + if exact: + return exact, {}, query + path_parts = [part for part in path.split('/') if part] + for (route_method, route_path), fn in app.routes.items(): + if route_method != method: + continue + route_parts = [part for part in route_path.split('/') if part] + if len(route_parts) != len(path_parts): + continue + kwargs = {} + matched = True + for route_part, path_part in zip(route_parts, path_parts, strict=True): + if route_part.startswith('{') and route_part.endswith('}'): + kwargs[route_part[1:-1]] = path_part + elif route_part != path_part: + matched = False + break + if matched: + return fn, kwargs, query + return None, {}, query + + +def _call(fn, kwargs): + sig = inspect.signature(fn) + accepted = {} + for name, param in sig.parameters.items(): + if name in kwargs: + accepted[name] = kwargs[name] + elif param.default is inspect._empty: + ann = _resolve_annotation(fn, param.annotation) + if isinstance(kwargs, dict) and ann is not inspect._empty: + try: + accepted[name] = ann(**kwargs) + except Exception: + pass + return fn(**accepted) + + +def _json_args(fn, data): + if not data: + return {} + sig = inspect.signature(fn) + required_model_params = [] + for name, param in sig.parameters.items(): + if name in {'file'}: + continue + ann = _resolve_annotation(fn, param.annotation) + if ann is inspect._empty or ann in {str, int, bool, float}: + continue + if param.default is inspect._empty: + required_model_params.append((name, ann)) + if len(required_model_params) == 1: + name, cls = required_model_params[0] + try: + return {name: cls(**data)} + except Exception: + return {name: data} + return data + + +def _resolve_annotation(fn, ann): + if isinstance(ann, str): + builtin = {'str': str, 'int': int, 'bool': bool, 'float': float}.get(ann) + if builtin is not None: + return builtin + return getattr(inspect.getmodule(fn), ann, fn.__globals__.get(ann, ann)) + return ann + + +def _file_args(files): + if not files: + return {} + file_info = files.get('file') if isinstance(files, dict) else None + if file_info is None: + return {} + filename, content, *_ = file_info + if hasattr(content, 'read'): + file_obj = content + else: + file_obj = BytesIO(content if isinstance(content, bytes) else str(content).encode()) + return {'file': UploadFile(filename=filename, file=file_obj)} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 01a6e7c..722d651 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,4 +1,4 @@ -import type { AnalysisResponse, TaxonomyEntry, TaxonomyImportResult } from './types' +import type { AnalysisResponse, TaxonomyCoverage, TaxonomyEntry, TaxonomyImportResult, TaxonomyPackSummary, TaxonomyQualityReport, TaxonomyValidationResult } from './types' const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8000/api' @@ -12,21 +12,68 @@ export async function analyzeText(text: string): Promise { return response.json() } -export async function fetchTaxonomy(): Promise { - const response = await fetch(`${API_BASE}/taxonomy`) +export async function fetchTaxonomy(params: URLSearchParams = new URLSearchParams()): Promise { + const query = params.toString() + const response = await fetch(`${API_BASE}/taxonomy${query ? `?${query}` : ''}`) if (!response.ok) throw new Error('Taxonomy request failed') const payload = await response.json() return payload.entries } +export async function searchTaxonomy(q: string): Promise { + const response = await fetch(`${API_BASE}/taxonomy/search?q=${encodeURIComponent(q)}`) + if (!response.ok) throw new Error('Taxonomy search failed') + const payload = await response.json() + return payload.entries +} + +export async function fetchTaxonomyPacks(): Promise { + const response = await fetch(`${API_BASE}/taxonomy-workbench/packs`) + if (!response.ok) throw new Error('Taxonomy packs request failed') + return (await response.json()).packs +} + +export async function fetchTaxonomyCoverage(): Promise { + const response = await fetch(`${API_BASE}/taxonomy-workbench/coverage`) + if (!response.ok) throw new Error('Taxonomy coverage request failed') + return response.json() +} + +export async function fetchTaxonomyQuality(): Promise { + const response = await fetch(`${API_BASE}/taxonomy-workbench/quality-report`) + if (!response.ok) throw new Error('Taxonomy quality request failed') + return response.json() +} + +export async function validateTaxonomy(): Promise { + const response = await fetch(`${API_BASE}/taxonomy-workbench/validate`, { method: 'POST' }) + if (!response.ok) throw new Error('Taxonomy validation failed') + return response.json() +} export async function importTaxonomyWorkbook(file: File): Promise { const formData = new FormData() formData.append('file', file) - const response = await fetch(`${API_BASE}/taxonomy-workbench/import`, { + const response = await fetch(`${API_BASE}/taxonomy-workbench/import-excel`, { method: 'POST', body: formData, }) if (!response.ok) throw new Error('Taxonomy workbook import failed') return response.json() } + +export function exportTaxonomyWorkbook(): void { + window.location.href = `${API_BASE}/taxonomy-workbench/export-excel` +} + +export async function updateTaxonomyActivation(riskId: string, activationStatus: string, enabledForClassification?: boolean): Promise { + const response = await fetch(`${API_BASE}/taxonomy-workbench/entries/${encodeURIComponent(riskId)}/activation`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ activation_status: activationStatus, enabled_for_classification: enabledForClassification }), + }) + if (!response.ok) throw new Error('Activation update failed') + const payload = await response.json() + if (payload.detail) throw new Error(payload.detail) + return payload.entry +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 8806a84..e80ca38 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -2,6 +2,71 @@ export type AnalysisRequest = { text: string } export type Risk = { taxonomy_id: string; name: string; severity: string; confidence: number; score: number; explanation: string; evidence: { quote: string; start: number; end: number }; mitigation: string } export type Claim = { text: string; risks: Risk[] } export type AnalysisResponse = { analysis_id: string; summary: Record; claims: Claim[]; risks: Risk[] } -export type TaxonomyEntry = { id: string; name: string; description: string; severity: string; keywords: string[]; active: boolean } -export type TaxonomyImportResult = { entry_count: number; errors: string[]; warnings: string[] } +export type TaxonomyEntry = { + id: string + name: string + pack: string + canonical_category: string + academic_status: string + academic_consensus: string + short_definition: string + long_definition: string + detection_level: string + signals: string[] + trigger_patterns: string[] + minimum_evidence_requirement: string + exclusion_criteria: string[] + common_false_positives: string[] + positive_examples: string[] + negative_examples: string[] + severity_guidance: string[] + related_risks: string[] + synonym_ids: string[] + source_refs: string[] + enabled_for_mvp: boolean + enabled_for_retrieval: boolean + enabled_for_classification: boolean + requires_context: boolean + requires_human_judgment: boolean + false_positive_sensitivity: string + activation_status: string + healthy_suppressor: boolean + model_assisted_allowed: boolean + notes: string + description?: string + keywords?: string[] + severity?: string + active?: boolean +} + +export type TaxonomyFilters = { + category: string + pack: string + academic_status: string + academic_consensus: string + detection_level: string + activation_status: string + enabled_for_classification: string + false_positive_sensitivity: string +} + +export type TaxonomyImportResult = { entry_count: number; errors: string[]; warnings: string[]; backup_paths?: string[] } +export type TaxonomyCoverage = { + entry_count: number + active_count: number + enabled_for_classification_count: number + review_required_count: number + deprecated_count: number + by_category: Record + by_pack: Record + by_detection_level: Record + by_academic_status: Record + by_activation_status: Record + missing_examples_count: number + missing_false_positive_warnings_count: number +} +export type TaxonomyIssue = { code: string; message: string; severity: string; entry_id?: string; row_number?: number } +export type TaxonomyQualityReport = { ok: boolean; entry_count: number; active_classification_count: number; error_count: number; warning_count: number; errors: TaxonomyIssue[]; warnings: TaxonomyIssue[] } +export type TaxonomyPackSummary = { pack: string; entry_count: number; active_count: number; enabled_for_classification_count: number } +export type TaxonomyValidationResult = { ok: boolean; entry_count: number; active_classification_count: number; errors: TaxonomyIssue[]; warnings: TaxonomyIssue[] } diff --git a/frontend/src/components/taxonomy/TaxonomyDetailDrawer.tsx b/frontend/src/components/taxonomy/TaxonomyDetailDrawer.tsx index 78fe483..fbb1760 100644 --- a/frontend/src/components/taxonomy/TaxonomyDetailDrawer.tsx +++ b/frontend/src/components/taxonomy/TaxonomyDetailDrawer.tsx @@ -1 +1,25 @@ -export function TaxonomyDetailDrawer() { return } +import type { TaxonomyEntry } from '../../api/types' + +function ListBlock({ title, items }: { title: string; items: string[] }) { + return

{title}

{items.length ?
    {items.map((item) =>
  • {item}
  • )}
:

None recorded.

}
+} + +export function TaxonomyDetailDrawer({ entry, onClose }: { entry?: TaxonomyEntry; onClose: () => void }) { + if (!entry) return + return ( + + ) +} diff --git a/frontend/src/components/taxonomy/TaxonomyFilters.tsx b/frontend/src/components/taxonomy/TaxonomyFilters.tsx index 5594afc..3a15d6f 100644 --- a/frontend/src/components/taxonomy/TaxonomyFilters.tsx +++ b/frontend/src/components/taxonomy/TaxonomyFilters.tsx @@ -1 +1,30 @@ -export function TaxonomyFilters() { return } +import type { TaxonomyEntry, TaxonomyFilters as Filters } from '../../api/types' + +const filterFields: Array = ['category', 'pack', 'academic_status', 'academic_consensus', 'detection_level', 'activation_status', 'enabled_for_classification', 'false_positive_sensitivity'] + +function unique(entries: TaxonomyEntry[], field: keyof TaxonomyEntry): string[] { + return Array.from(new Set(entries.map((entry) => String(entry[field] ?? '')).filter(Boolean))).sort() +} + +export function TaxonomyFilters({ entries, filters, onChange }: { entries: TaxonomyEntry[]; filters: Filters; onChange: (filters: Filters) => void }) { + function setFilter(name: keyof Filters, value: string) { + onChange({ ...filters, [name]: value }) + } + + return ( +
+ {filterFields.map((field) => { + const options = field === 'category' ? unique(entries, 'canonical_category') : field === 'enabled_for_classification' ? ['true', 'false'] : unique(entries, field as keyof TaxonomyEntry) + return ( + + ) + })} +
+ ) +} diff --git a/frontend/src/components/taxonomy/TaxonomyPage.tsx b/frontend/src/components/taxonomy/TaxonomyPage.tsx index 0c094be..be298b3 100644 --- a/frontend/src/components/taxonomy/TaxonomyPage.tsx +++ b/frontend/src/components/taxonomy/TaxonomyPage.tsx @@ -1,6 +1,40 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { fetchTaxonomy } from '../../api/client' -import type { TaxonomyEntry } from '../../api/types' +import type { TaxonomyEntry, TaxonomyFilters as Filters } from '../../api/types' import { Card } from '../shared/Card' +import { ErrorState } from '../shared/ErrorState' +import { LoadingState } from '../shared/LoadingState' +import { TaxonomyDetailDrawer } from './TaxonomyDetailDrawer' +import { TaxonomyFilters } from './TaxonomyFilters' import { TaxonomyTable } from './TaxonomyTable' -export function TaxonomyPage() { const [entries, setEntries] = useState([]); useEffect(() => { fetchTaxonomy().then(setEntries).catch(() => setEntries([])) }, []); return

Taxonomy

} + +const emptyFilters: Filters = { category: '', pack: '', academic_status: '', academic_consensus: '', detection_level: '', activation_status: '', enabled_for_classification: '', false_positive_sensitivity: '' } + +export function TaxonomyPage() { + const [entries, setEntries] = useState([]) + const [query, setQuery] = useState('') + const [filters, setFilters] = useState(emptyFilters) + const [selected, setSelected] = useState() + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + useEffect(() => { fetchTaxonomy().then(setEntries).catch((err) => setError(err instanceof Error ? err.message : 'Could not load taxonomy')).finally(() => setLoading(false)) }, []) + + const filtered = useMemo(() => entries.filter((entry) => { + const haystack = [entry.id, entry.name, entry.short_definition, entry.long_definition, ...entry.signals, ...entry.trigger_patterns].join(' ').toLowerCase() + const matchesQuery = !query || haystack.includes(query.toLowerCase()) + const matchesFilters = Object.entries(filters).every(([key, value]) => !value || (key === 'category' ? entry.canonical_category === value : String(entry[key as keyof TaxonomyEntry]) === value)) + return matchesQuery && matchesFilters + }), [entries, filters, query]) + + return ( + +

Taxonomy

Inspect, search, filter, and open full risk details from Chrome.

{filtered.length} / {entries.length}
+ setQuery(event.target.value)} /> + + {loading && } + {error && } +
setSelected(undefined)} />
+
+ ) +} diff --git a/frontend/src/components/taxonomy/TaxonomyTable.tsx b/frontend/src/components/taxonomy/TaxonomyTable.tsx index cf4f528..7b891e5 100644 --- a/frontend/src/components/taxonomy/TaxonomyTable.tsx +++ b/frontend/src/components/taxonomy/TaxonomyTable.tsx @@ -1,2 +1,37 @@ import type { TaxonomyEntry } from '../../api/types' -export function TaxonomyTable({ entries }: { entries: TaxonomyEntry[] }) { return {entries.map(entry => )}
{entry.id}{entry.name}{entry.severity}
} +import { Badge } from '../shared/Badge' + +export function TaxonomyTable({ entries, selectedId, onSelect }: { entries: TaxonomyEntry[]; selectedId?: string; onSelect: (entry: TaxonomyEntry) => void }) { + return ( +
+ + + + + + + + + + + + + + + {entries.map((entry) => ( + onSelect(entry)}> + + + + + + + + + + ))} + +
IDNameCategoryPackDetectionStatusClassificationFP sensitivity
{entry.id}{entry.name}{entry.canonical_category}{entry.pack}{entry.detection_level}{entry.activation_status}{entry.enabled_for_classification ? 'Enabled' : 'Disabled'}{entry.false_positive_sensitivity}
+
+ ) +} diff --git a/frontend/src/components/taxonomy_workbench/TaxonomyActivationPanel.tsx b/frontend/src/components/taxonomy_workbench/TaxonomyActivationPanel.tsx index 0943215..e501209 100644 --- a/frontend/src/components/taxonomy_workbench/TaxonomyActivationPanel.tsx +++ b/frontend/src/components/taxonomy_workbench/TaxonomyActivationPanel.tsx @@ -1,2 +1,36 @@ +import { useEffect, useState } from 'react' +import { fetchTaxonomy, updateTaxonomyActivation } from '../../api/client' +import type { TaxonomyEntry } from '../../api/types' import { Card } from '../shared/Card' -export function TaxonomyActivationPanel() { return

TaxonomyActivation Panel

MVP placeholder wired for local file-backed workflows.

} + +export function TaxonomyActivationPanel({ refreshKey, onChanged }: { refreshKey: number; onChanged: () => void }) { + const [entries, setEntries] = useState([]) + const [selectedId, setSelectedId] = useState('') + const [message, setMessage] = useState('Choose an entry to activate or deactivate. A timestamped YAML backup is created before changes.') + const selected = entries.find((entry) => entry.id === selectedId) + + useEffect(() => { fetchTaxonomy().then((items) => { setEntries(items); setSelectedId((current) => current || items[0]?.id || '') }).catch(() => setMessage('Could not load activation entries.')) }, [refreshKey]) + + async function apply(status: string) { + if (!selected) return + if (status === 'active' && selected.false_positive_sensitivity === 'high' && !window.confirm('This entry has high false-positive sensitivity. Activate anyway?')) return + try { + const updated = await updateTaxonomyActivation(selected.id, status, status === 'active') + setEntries((items) => items.map((entry) => entry.id === updated.id ? updated : entry)) + setMessage(`${updated.id} set to ${updated.activation_status}.`) + onChanged() + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Activation update failed') + } + } + + return ( + +

Activation controls

+ + {selected &&

{selected.id} · false-positive sensitivity: {selected.false_positive_sensitivity} · classification {selected.enabled_for_classification ? 'enabled' : 'disabled'}

} +
+

{message}

+
+ ) +} diff --git a/frontend/src/components/taxonomy_workbench/TaxonomyCoveragePanel.tsx b/frontend/src/components/taxonomy_workbench/TaxonomyCoveragePanel.tsx index c4f4dce..6ece7c3 100644 --- a/frontend/src/components/taxonomy_workbench/TaxonomyCoveragePanel.tsx +++ b/frontend/src/components/taxonomy_workbench/TaxonomyCoveragePanel.tsx @@ -1,2 +1,15 @@ +import type { TaxonomyCoverage } from '../../api/types' import { Card } from '../shared/Card' -export function TaxonomyCoveragePanel() { return

TaxonomyCoverage Panel

MVP placeholder wired for local file-backed workflows.

} + +function CountMap({ title, values }: { title: string; values: Record }) { + return

{title}

{Object.entries(values).map(([key, value]) => {key}: {value})}
+} + +export function TaxonomyCoveragePanel({ coverage }: { coverage?: TaxonomyCoverage }) { + return ( + +

Coverage

+ {!coverage ?

Loading coverage…

: <>
{coverage.entry_count}Total entries
{coverage.active_count}Active
{coverage.review_required_count}Review required
{coverage.missing_false_positive_warnings_count}Missing FP warnings
} +
+ ) +} diff --git a/frontend/src/components/taxonomy_workbench/TaxonomyImportExportPanel.tsx b/frontend/src/components/taxonomy_workbench/TaxonomyImportExportPanel.tsx index bb5ad89..5d8dd99 100644 --- a/frontend/src/components/taxonomy_workbench/TaxonomyImportExportPanel.tsx +++ b/frontend/src/components/taxonomy_workbench/TaxonomyImportExportPanel.tsx @@ -1,23 +1,24 @@ import { useRef, useState } from 'react' -import { importTaxonomyWorkbook } from '../../api/client' +import { exportTaxonomyWorkbook, importTaxonomyWorkbook, validateTaxonomy } from '../../api/client' +import type { TaxonomyValidationResult } from '../../api/types' import { Card } from '../shared/Card' -export function TaxonomyImportExportPanel() { +export function TaxonomyImportExportPanel({ onChanged }: { onChanged: () => void }) { const inputRef = useRef(null) const [message, setMessage] = useState('Select a user-managed .xlsx taxonomy workbook to import.') + const [validation, setValidation] = useState(null) const [busy, setBusy] = useState(false) async function onUpload() { const file = inputRef.current?.files?.[0] - if (!file) { - setMessage('Choose an .xlsx workbook first.') - return - } + if (!file) return setMessage('Choose an .xlsx workbook first.') + if (!file.name.endsWith('.xlsx')) return setMessage('Only .xlsx workbooks can be imported.') setBusy(true) try { const result = await importTaxonomyWorkbook(file) - setMessage(`Imported ${result.entry_count} taxonomy entries with ${result.errors.length} errors and ${result.warnings.length} warnings.`) + setMessage(`Imported ${result.entry_count} taxonomy entries with ${result.errors.length} errors and ${result.warnings.length} warnings. Backups: ${result.backup_paths?.length ?? 0}.`) + onChanged() } catch (error) { setMessage(error instanceof Error ? error.message : 'Taxonomy import failed') } finally { @@ -25,15 +26,26 @@ export function TaxonomyImportExportPanel() { } } + async function onValidate() { + setBusy(true) + try { + const result = await validateTaxonomy() + setValidation(result) + setMessage(`Validation ${result.ok ? 'passed' : 'found issues'}: ${result.errors.length} errors, ${result.warnings.length} warnings. No files were modified.`) + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Validation failed') + } finally { + setBusy(false) + } + } + return (

Taxonomy import/export

-

- The real taxonomy workbook is imported from your local machine and is intentionally not committed to Git. -

- - +

Import a local .xlsx taxonomy workbook, validate without changing files, or download a fresh .xlsx export.

+

{message}

+ {validation &&
    {[...validation.errors, ...validation.warnings].slice(0, 6).map((issue) =>
  • {issue.severity}: {issue.message}
  • )}
}
) } diff --git a/frontend/src/components/taxonomy_workbench/TaxonomyQualityPanel.tsx b/frontend/src/components/taxonomy_workbench/TaxonomyQualityPanel.tsx index e080517..8312784 100644 --- a/frontend/src/components/taxonomy_workbench/TaxonomyQualityPanel.tsx +++ b/frontend/src/components/taxonomy_workbench/TaxonomyQualityPanel.tsx @@ -1,2 +1,11 @@ +import type { TaxonomyQualityReport } from '../../api/types' import { Card } from '../shared/Card' -export function TaxonomyQualityPanel() { return

TaxonomyQuality Panel

MVP placeholder wired for local file-backed workflows.

} + +export function TaxonomyQualityPanel({ report }: { report?: TaxonomyQualityReport }) { + return ( + +

Quality warnings

+ {!report ?

Loading quality report…

: <>

{report.error_count} errors · {report.warning_count} warnings · {report.ok ? 'Ready' : 'Needs attention'}

    {[...report.errors, ...report.warnings].slice(0, 12).map((issue) =>
  • {issue.code} {issue.entry_id ? `(${issue.entry_id})` : ''}: {issue.message}
  • )}
} +
+ ) +} diff --git a/frontend/src/components/taxonomy_workbench/TaxonomyWorkbenchPage.tsx b/frontend/src/components/taxonomy_workbench/TaxonomyWorkbenchPage.tsx index 48349c4..a6f071a 100644 --- a/frontend/src/components/taxonomy_workbench/TaxonomyWorkbenchPage.tsx +++ b/frontend/src/components/taxonomy_workbench/TaxonomyWorkbenchPage.tsx @@ -1,2 +1,28 @@ +import { useEffect, useState } from 'react' +import { fetchTaxonomyCoverage, fetchTaxonomyPacks, fetchTaxonomyQuality } from '../../api/client' +import type { TaxonomyCoverage, TaxonomyPackSummary, TaxonomyQualityReport } from '../../api/types' import { Card } from '../shared/Card' -export function TaxonomyWorkbenchPage() { return

TaxonomyWorkbench

MVP placeholder wired for local file-backed workflows.

} +import { TaxonomyActivationPanel } from './TaxonomyActivationPanel' +import { TaxonomyCoveragePanel } from './TaxonomyCoveragePanel' +import { TaxonomyImportExportPanel } from './TaxonomyImportExportPanel' +import { TaxonomyQualityPanel } from './TaxonomyQualityPanel' + +export function TaxonomyWorkbenchPage() { + const [coverage, setCoverage] = useState() + const [quality, setQuality] = useState() + const [packs, setPacks] = useState([]) + const [refreshKey, setRefreshKey] = useState(0) + const reload = () => setRefreshKey((key) => key + 1) + + useEffect(() => { fetchTaxonomyCoverage().then(setCoverage); fetchTaxonomyQuality().then(setQuality); fetchTaxonomyPacks().then(setPacks) }, [refreshKey]) + + return ( +
+

Taxonomy Workbench

Chrome-first controls for Excel import/export, validation, coverage review, quality warnings, and YAML-backed activation.

{packs.map((pack) => {pack.pack}: {pack.entry_count})}
+ + + + +
+ ) +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 82fd41c..9d8b8ee 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -16,3 +16,24 @@ textarea, input { border: 1px solid #c8d2e1; border-radius: 10px; padding: .75re .error { color: #a12222; } table { width: 100%; border-collapse: collapse; } td { border-bottom: 1px solid #edf1f7; padding: .45rem; } +.section-header, .drawer-header { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; } +.search-box { width: 100%; box-sizing: border-box; margin: .75rem 0; } +.filter-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .75rem; margin-bottom: 1rem; } +.field-label { display: grid; gap: .25rem; color: #334155; font-size: .85rem; text-transform: capitalize; } +select { border: 1px solid #c8d2e1; border-radius: 10px; padding: .65rem; font: inherit; background: white; } +.taxonomy-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 360px); gap: 1rem; align-items: start; } +.table-wrap { overflow: auto; max-height: 620px; border: 1px solid #edf1f7; border-radius: 12px; } +.taxonomy-table th { position: sticky; top: 0; background: #f8fafc; text-align: left; border-bottom: 1px solid #dfe5ef; padding: .55rem; font-size: .82rem; } +.taxonomy-table tr { cursor: pointer; } +.taxonomy-table tr:hover, .selected-row { background: #f4f7ff; } +.drawer { border: 1px solid #dfe5ef; border-radius: 12px; padding: 1rem; background: #fbfdff; max-height: 620px; overflow: auto; } +.drawer h3, .drawer h4 { margin-bottom: .25rem; } +.button-row { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; margin: .75rem 0; } +.metric-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: .75rem; margin: .75rem 0; } +.metric-grid div { border: 1px solid #edf1f7; border-radius: 12px; padding: .75rem; display: grid; gap: .25rem; } +.metric-grid strong { font-size: 1.5rem; color: #17368f; } +.metric-grid span { color: #617089; } +.chips { display: flex; flex-wrap: wrap; gap: .5rem; } +.issue-list { display: grid; gap: .45rem; padding-left: 1.1rem; } +.workbench-grid { display: grid; gap: 1rem; } +@media (max-width: 980px) { .taxonomy-layout { grid-template-columns: 1fr; } .drawer { max-height: none; } } diff --git a/pyproject.toml b/pyproject.toml index 3ffd5d4..26144b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "PyYAML>=6.0", "python-dotenv>=1.0", "openpyxl>=3.1", + "python-multipart>=0.0.9", "uvicorn[standard]>=0.27", ] diff --git a/tests/test_api_taxonomy.py b/tests/test_api_taxonomy.py index 12f49b5..0a3e706 100644 --- a/tests/test_api_taxonomy.py +++ b/tests/test_api_taxonomy.py @@ -6,3 +6,38 @@ def test_api_taxonomy(): response = TestClient(app).get("/api/taxonomy") assert response.status_code == 200 assert response.json()["entries"] + + +def test_taxonomy_lookup_search_and_summary(): + client = TestClient(app) + entries = client.get("/api/taxonomy").json()["entries"] + risk_id = entries[0]["id"] + + detail = client.get(f"/api/taxonomy/{risk_id}") + assert detail.status_code == 200 + assert detail.json()["entry"]["id"] == risk_id + + search = client.get(f"/api/taxonomy/search?q={risk_id}") + assert search.status_code == 200 + assert search.json()["entries"] + + categories = client.get("/api/taxonomy/categories") + assert categories.status_code == 200 + assert categories.json()["categories"] + + summary = client.get("/api/taxonomy/summary") + assert summary.status_code == 200 + assert summary.json()["entry_count"] >= len(entries) + + +def test_taxonomy_workbench_reports_and_export(): + client = TestClient(app) + assert client.get("/api/taxonomy-workbench/packs").json()["packs"] + assert client.get("/api/taxonomy-workbench/coverage").json()["entry_count"] > 0 + assert "warnings" in client.get("/api/taxonomy-workbench/quality-report").json() + assert "errors" in client.post("/api/taxonomy-workbench/validate").json() + + export = client.get("/api/taxonomy-workbench/export-excel") + assert export.status_code == 200 + assert export.content + assert export.headers["Content-Disposition"].endswith('.xlsx"')