diff --git a/evaluation/ai-assistant/.gitignore b/evaluation/ai-assistant/.gitignore new file mode 100644 index 0000000000..7cbd1fa89e --- /dev/null +++ b/evaluation/ai-assistant/.gitignore @@ -0,0 +1,32 @@ +# Override root gitignore's lib/ rule for src/app/lib/ +!src/app/lib/ + +# Dependencies +node_modules/ + +# Build output +dist/ + +# Environment / IDE +.env +.env.* +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Python backend +backend/.venv/ +backend/__pycache__/ +backend/**/__pycache__/ + +# Persisted dataset copies +backend/data/ + +*.pyc +*.pyo + +# Vite +*.local diff --git a/evaluation/ai-assistant/backend/datasets.json b/evaluation/ai-assistant/backend/datasets.json new file mode 100644 index 0000000000..adfa1a8d8e --- /dev/null +++ b/evaluation/ai-assistant/backend/datasets.json @@ -0,0 +1,19 @@ +[ + { + "id": "example-dataset", + "filename": "example_pii_dataset.csv", + "name": "Example Dataset", + "description": "3 synthetic PII records covering healthcare, finance, and lab-result scenarios.", + "path": "data/example_pii_dataset.csv", + "stored_path": "/Users/ronshakutai/projects_folder/presidio/evaluation/ai-assistant/backend/data/Example_Dataset_example-dataset.csv", + "format": "csv", + "record_count": 3, + "has_entities": false, + "has_final_entities": true, + "ran_configs": [ + "default_spacy" + ], + "text_column": "text", + "entities_column": null + } +] \ No newline at end of file diff --git a/evaluation/ai-assistant/backend/llm_service.py b/evaluation/ai-assistant/backend/llm_service.py new file mode 100644 index 0000000000..ec05981cdf --- /dev/null +++ b/evaluation/ai-assistant/backend/llm_service.py @@ -0,0 +1,97 @@ +"""LLM Judge service using Azure OpenAI via LangExtract.""" + +from __future__ import annotations + +import logging +from typing import Optional + +from models import Entity + +logger = logging.getLogger(__name__) + +# Lazy-loaded recognizer singleton +_recognizer = None + + +class LLMServiceError(Exception): + """Raised when LLM service encounters an error.""" + + +def configure( + azure_endpoint: str, + api_key: Optional[str] = None, + deployment_name: str = "gpt-4o", + api_version: str = "2024-02-15-preview", +) -> dict: + """Initialise the Azure OpenAI LangExtract recognizer. + + :param azure_endpoint: Azure OpenAI endpoint URL. + :param api_key: API key (or None for managed identity). + :param deployment_name: Azure deployment / model name. + :param api_version: Azure OpenAI API version. + :returns: Status dict. + """ + global _recognizer + + try: + from presidio_analyzer.predefined_recognizers.third_party.azure_openai_langextract_recognizer import ( # noqa: E501 + AzureOpenAILangExtractRecognizer, + ) + except ImportError as exc: + raise LLMServiceError( + "langextract or presidio-analyzer is not installed. " + "Run: pip install langextract presidio-analyzer" + ) from exc + + try: + _recognizer = AzureOpenAILangExtractRecognizer( + model_id=deployment_name, + azure_endpoint=azure_endpoint, + api_key=api_key, + api_version=api_version, + ) + except Exception as exc: + _recognizer = None + raise LLMServiceError(f"Failed to initialise recognizer: {exc}") from exc + + logger.info( + "LLM Judge configured: endpoint=%s deployment=%s", + azure_endpoint, + deployment_name, + ) + return {"status": "configured", "deployment": deployment_name} + + +def is_configured() -> bool: + """Return True if the recognizer has been initialised.""" + return _recognizer is not None + + +def disconnect() -> None: + """Reset the recognizer so a new model can be configured.""" + global _recognizer + _recognizer = None + logger.info("LLM Judge disconnected") + + +def analyze_text(text: str) -> list[Entity]: + """Run the LLM recognizer on a single text and return Entity objects.""" + if _recognizer is None: + raise LLMServiceError( + "LLM service not configured. Call /api/llm/configure first." + ) + + results = _recognizer.analyze(text=text, entities=None) + + entities: list[Entity] = [] + for r in results: + entities.append( + Entity( + text=text[r.start:r.end], + entity_type=r.entity_type, + start=r.start, + end=r.end, + score=round(r.score, 4), + ) + ) + return entities diff --git a/evaluation/ai-assistant/backend/main.py b/evaluation/ai-assistant/backend/main.py new file mode 100644 index 0000000000..a856d7c887 --- /dev/null +++ b/evaluation/ai-assistant/backend/main.py @@ -0,0 +1,44 @@ +import logging + +logging.basicConfig(level=logging.INFO) + +# Eagerly import presidio_analyzer so the module is fully initialised before +# concurrent requests trigger lazy imports from different threads (which would +# hit a circular-import race in the presidio_analyzer package). +try: + import presidio_analyzer # noqa: F401 +except ImportError: + pass # Optional dependency – endpoints will return clear errors if missing + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from routers import ( + decision, + evaluation, + llm, + presidio_service, + review, + upload, +) + +app = FastAPI(title="Presidio Evaluation Flow API", version="0.1.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173"], + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(upload.router) +app.include_router(review.router) +app.include_router(evaluation.router) +app.include_router(decision.router) +app.include_router(llm.router) +app.include_router(presidio_service.router) + + +@app.get("/api/health") +async def health(): + """Return service health status.""" + return {"status": "ok"} diff --git a/evaluation/ai-assistant/backend/mock_data.py b/evaluation/ai-assistant/backend/mock_data.py new file mode 100644 index 0000000000..d5affc7e00 --- /dev/null +++ b/evaluation/ai-assistant/backend/mock_data.py @@ -0,0 +1,113 @@ +"""Mock data for evaluation / decision stages only.""" + +from datetime import datetime + +from models import ( + Entity, + EntityMiss, + EvaluationMetrics, + EvaluationRun, + MissType, + RiskLevel, +) + +EVALUATION_RUNS: list[EvaluationRun] = [ + EvaluationRun( + id="run-001", + timestamp=datetime(2025, 2, 20, 10, 30), + sample_size=500, + config_version="baseline-v1.0", + metrics=EvaluationMetrics( + precision=0.87, + recall=0.73, + f1_score=0.79, + true_positives=245, + false_positives=36, + false_negatives=91, + true_negatives=128, + ), + ), + EvaluationRun( + id="run-002", + timestamp=datetime(2025, 2, 22, 14, 15), + sample_size=500, + config_version="tuned-v1.1", + metrics=EvaluationMetrics( + precision=0.91, + recall=0.81, + f1_score=0.86, + true_positives=272, + false_positives=27, + false_negatives=64, + true_negatives=137, + ), + ), + EvaluationRun( + id="run-003", + timestamp=datetime(2025, 2, 25, 9, 0), + sample_size=500, + config_version="tuned-v1.2", + metrics=EvaluationMetrics( + precision=0.94, + recall=0.88, + f1_score=0.91, + true_positives=296, + false_positives=19, + false_negatives=40, + true_negatives=145, + ), + ), +] + +ENTITY_MISSES: list[EntityMiss] = [ + EntityMiss( + record_id="rec-004", + record_text=( + "Credit card ending in 4532 was used for " + "transaction. Customer: alice.wong@company.com." + ), + missed_entity=Entity( + text="4532", entity_type="CREDIT_CARD", start=22, end=26, score=0.65 + ), + miss_type=MissType.false_negative, + entity_type="CREDIT_CARD", + risk_level=RiskLevel.high, + ), + EntityMiss( + record_id="rec-002", + record_text=( + "Dr. Sarah Johnson reviewed the case. " + "Insurance Policy: POL-8821-USA." + ), + missed_entity=Entity( + text="POL-8821-USA", entity_type="INSURANCE_POLICY", start=56, end=68 + ), + miss_type=MissType.false_negative, + entity_type="INSURANCE_POLICY", + risk_level=RiskLevel.medium, + ), + EntityMiss( + record_id="rec-005", + record_text=( + "Prescription for Robert Chen: Medication ABC-123, dosage 50mg. " + "Doctor notes indicate history of diabetes." + ), + missed_entity=Entity( + text="diabetes", entity_type="MEDICAL_CONDITION", start=97, end=105 + ), + miss_type=MissType.false_negative, + entity_type="MEDICAL_CONDITION", + risk_level=RiskLevel.high, + ), + EntityMiss( + record_id="rec-003", + record_text=( + "Employee ID: EMP-8821, Jane Doe, " + "started 2023-06-01. Salary: $85,000." + ), + missed_entity=Entity(text="$85,000", entity_type="SALARY", start=61, end=68), + miss_type=MissType.false_negative, + entity_type="SALARY", + risk_level=RiskLevel.medium, + ), +] diff --git a/evaluation/ai-assistant/backend/models.py b/evaluation/ai-assistant/backend/models.py new file mode 100644 index 0000000000..2cba627165 --- /dev/null +++ b/evaluation/ai-assistant/backend/models.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Optional + +from pydantic import BaseModel + + +class ComplianceFramework(str, Enum): + """Supported compliance frameworks.""" + + hipaa = "hipaa" + gdpr = "gdpr" + ccpa = "ccpa" + general = "general" + + +class DatasetType(str, Enum): + """Dataset origin type.""" + + customer = "customer" + internal = "internal" + + +class Dataset(BaseModel): + """Dataset metadata.""" + + id: str + name: str + type: DatasetType + record_count: int + description: str + + +class Entity(BaseModel): + """A detected PII entity span.""" + + text: str + entity_type: str + start: int + end: int + score: Optional[float] = None + # Audit fields: original span before human boundary adjustment + original_start: Optional[int] = None + original_end: Optional[int] = None + original_text: Optional[str] = None + + +class Record(BaseModel): + """A text record with detected entities.""" + + id: str + text: str + presidio_entities: list[Entity] = [] + llm_entities: list[Entity] = [] + dataset_entities: list[Entity] = [] + golden_entities: Optional[list[Entity]] = None + final_entities: Optional[list[Entity]] = None + + +class EvaluationMetrics(BaseModel): + """Precision/recall metrics for an evaluation run.""" + + precision: float + recall: float + f1_score: float + false_positives: int + false_negatives: int + true_positives: int + true_negatives: int + + +class EvaluationRun(BaseModel): + """Snapshot of a single evaluation run.""" + + id: str + timestamp: datetime + sample_size: int + metrics: EvaluationMetrics + config_version: str + + +class MissType(str, Enum): + """Classification of an entity miss.""" + + true_positive = "true-positive" + false_positive = "false-positive" + false_negative = "false-negative" + + +class RiskLevel(str, Enum): + """Risk severity level.""" + + high = "high" + medium = "medium" + low = "low" + + +class EntityMiss(BaseModel): + """An entity detection result (true positive, false positive, or false negative).""" + + record_id: str + record_text: str + missed_entity: Entity + miss_type: MissType + entity_type: str + risk_level: RiskLevel + + +# --- Request / Response models --- + + +class DatasetLoadRequest(BaseModel): + """Request to load a dataset from a file path.""" + + path: str + format: str # "csv" | "json" + text_column: str = "text" + entities_column: str | None = None + name: str | None = None # user-friendly display name + description: str | None = None # optional description + + +class UploadedDataset(BaseModel): + """Metadata for an uploaded dataset.""" + + id: str + filename: str + name: str # user-friendly display name + description: str = "" # optional user-provided description + path: str # absolute file path + stored_path: str = "" # local copy in backend/data/ + format: str # "csv" | "json" + record_count: int + has_entities: bool + has_final_entities: bool = False + ran_configs: list[str] = [] + text_column: str = "text" + entities_column: str | None = None + + +class DatasetRenameRequest(BaseModel): + """Request to rename a saved dataset.""" + + name: str + + +class SetupConfig(BaseModel): + """Initial evaluation setup configuration.""" + + dataset_id: str + compliance_frameworks: list[ComplianceFramework] + cloud_restriction: str # "allowed" | "restricted" + run_presidio: bool = True + run_llm: bool = True + + +class SamplingMethod(str, Enum): + """Available sampling strategies.""" + + random = "random" + length = "length" + + +class SamplingConfig(BaseModel): + """Sampling configuration parameters.""" + + dataset_id: str + sample_size: int = 500 + method: SamplingMethod = SamplingMethod.random + + +class AnalysisStatus(BaseModel): + """Current progress of PII analysis.""" + + presidio_progress: int + llm_progress: int + presidio_complete: bool + llm_complete: bool + presidio_stats: Optional[dict] = None + llm_stats: Optional[dict] = None + comparison: Optional[dict] = None + + +class EntityAction(BaseModel): + """An entity review action (confirm/reject/add).""" + + entity: Entity + source: str # "presidio" | "llm" | "manual" + + +class DecisionType(str, Enum): + """Final evaluation decision.""" + + approve = "approve" + iterate = "iterate" + + +class DecisionRequest(BaseModel): + """Request to approve or iterate on the evaluation.""" + + decision: DecisionType + notes: str = "" + selected_improvements: list[str] = [] + + +class LLMConfig(BaseModel): + """LLM Judge configuration — only deployment is chosen in the UI.""" + + deployment_name: str = "gpt-4o" + + +class LLMAnalyzeRequest(BaseModel): + """Request body for starting LLM analysis.""" + + dataset_id: str diff --git a/evaluation/ai-assistant/backend/pyproject.toml b/evaluation/ai-assistant/backend/pyproject.toml new file mode 100644 index 0000000000..0436fc6aed --- /dev/null +++ b/evaluation/ai-assistant/backend/pyproject.toml @@ -0,0 +1,22 @@ +[tool.poetry] +name = "presidio-evaluation-backend" +version = "0.1.0" +description = "Backend API for Presidio Evaluation Flow" +package-mode = false + +[tool.poetry.dependencies] +python = ">=3.10,<3.14" +fastapi = ">=0.115.0" +uvicorn = { version = ">=0.32.0", extras = ["standard"] } +pydantic = ">=2.0.0" +python-multipart = ">=0.0.9" +pandas = ">=2.0.0" +scikit-learn = ">=1.3.0" +langextract = ">=0.1.0" +openai = ">=1.0.0" +presidio-analyzer = { path = "../../../presidio-analyzer", develop = true, extras = ["transformers"] } +azure-identity = ">=1.15.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/evaluation/ai-assistant/backend/routers/EntityComparison.tsx b/evaluation/ai-assistant/backend/routers/EntityComparison.tsx new file mode 100644 index 0000000000..7e1fa3f582 --- /dev/null +++ b/evaluation/ai-assistant/backend/routers/EntityComparison.tsx @@ -0,0 +1,372 @@ +import { useState } from 'react'; +import { Card } from './ui/card'; +import { Button } from './ui/button'; +import { Badge } from './ui/badge'; +import { Input } from './ui/input'; +import { Label } from './ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible'; +import { CheckCircle, XCircle, Edit, AlertTriangle, Check, X, ChevronDown, FileText } from 'lucide-react'; +import type { Entity } from '../types'; + +interface EntityComparisonProps { + recordId: string; + recordText: string; + presidioEntities: Entity[]; + llmEntities: Entity[]; + datasetEntities?: Entity[]; + onConfirm: (recordId: string, entity: Entity, source: 'presidio' | 'llm' | 'manual' | 'dataset') => void; + onReject: (recordId: string, entity: Entity, source: 'presidio' | 'llm' | 'dataset') => void; + onAddManual: (recordId: string, entity: Entity) => void; +} + +type EntityStatus = 'match' | 'conflict' | 'presidio-only' | 'llm-only' | 'predefined-only' + | 'presidio+predefined' | 'presidio+llm' | 'predefined+llm' | 'pending'; + +interface AnnotatedEntity extends Entity { + status: EntityStatus; + sources: ('presidio' | 'llm' | 'predefined')[]; + confirmed?: boolean; +} + +export function EntityComparison({ + recordId, + recordText, + presidioEntities = [], + llmEntities = [], + datasetEntities = [], + onConfirm, + onReject, + onAddManual, +}: EntityComparisonProps) { + const [showAddManual, setShowAddManual] = useState(false); + const [manualEntity, setManualEntity] = useState({ text: '', entity_type: '', start: 0, end: 0 }); + const [confirmedEntities, setConfirmedEntities] = useState>(new Set()); + const [rejectedEntities, setRejectedEntities] = useState>(new Set()); + const [expandedContexts, setExpandedContexts] = useState>(new Set()); + + // Combine and classify entities from all three sources + const annotatedEntities: AnnotatedEntity[] = []; + + // Two spans overlap if one starts before the other ends + const spansOverlap = (a: Entity, b: Entity) => + a.start < b.end && b.start < a.end; + + // Build a unified list: for each unique span, track which sources detected it + interface SpanEntry { entity: Entity; sources: Set<'presidio' | 'llm' | 'predefined'>; types: Map } + const spans: SpanEntry[] = []; + + const addToSpans = (entity: Entity, source: 'presidio' | 'llm' | 'predefined') => { + const existing = spans.find(s => spansOverlap(s.entity, entity)); + if (existing) { + existing.sources.add(source); + existing.types.set(source, entity.entity_type); + // Prefer the entity with more text or higher score + if (entity.text.length > existing.entity.text.length) { + existing.entity = { ...entity }; + } + } else { + const types = new Map(); + types.set(source, entity.entity_type); + spans.push({ entity: { ...entity }, sources: new Set([source]), types }); + } + }; + + presidioEntities.forEach(e => addToSpans(e, 'presidio')); + datasetEntities.forEach(e => addToSpans(e, 'predefined')); + llmEntities.forEach(e => addToSpans(e, 'llm')); + + spans.forEach(({ entity, sources, types }) => { + const sourceList = Array.from(sources) as ('presidio' | 'llm' | 'predefined')[]; + const uniqueTypes = new Set(types.values()); + const allAgree = uniqueTypes.size === 1; + + let status: EntityStatus; + if (sourceList.length >= 2 && allAgree) { + status = 'match'; + } else if (sourceList.length >= 2 && !allAgree) { + status = 'conflict'; + } else if (sourceList.length === 1) { + const s = sourceList[0]; + status = s === 'presidio' ? 'presidio-only' : s === 'llm' ? 'llm-only' : 'predefined-only'; + } else { + status = 'pending'; + } + + // For two-source non-match conflicts, use specific labels + if (sourceList.length === 2 && status !== 'match') { + const key = sourceList.sort().join('+'); + if (key === 'predefined+presidio') status = 'presidio+predefined'; + else if (key === 'llm+presidio') status = 'presidio+llm'; + else if (key === 'llm+predefined') status = 'predefined+llm'; + } + + annotatedEntities.push({ ...entity, status, sources: sourceList }); + }); + + const getEntityKey = (entity: Entity) => `${entity.text}-${entity.start}-${entity.end}`; + + const getContextForEntity = (entity: Entity) => { + const CONTEXT_CHARS = 150; + // Use indexOf for robust highlighting regardless of position accuracy + const idx = recordText.indexOf(entity.text); + const entityStart = idx >= 0 ? idx : entity.start; + const entityEnd = idx >= 0 ? idx + entity.text.length : entity.end; + + const start = Math.max(0, entityStart - CONTEXT_CHARS); + const end = Math.min(recordText.length, entityEnd + CONTEXT_CHARS); + + const before = recordText.substring(start, entityStart); + const entityText = recordText.substring(entityStart, entityEnd); + const after = recordText.substring(entityEnd, end); + + return { + before: (start > 0 ? '...' : '') + before, + entity: entityText, + after: after + (end < recordText.length ? '...' : ''), + }; + }; + + const toggleContext = (key: string) => { + setExpandedContexts(prev => { + const newSet = new Set(prev); + if (newSet.has(key)) { + newSet.delete(key); + } else { + newSet.add(key); + } + return newSet; + }); + }; + + const handleConfirmEntity = (entity: AnnotatedEntity) => { + const key = getEntityKey(entity); + setConfirmedEntities(new Set([...confirmedEntities, key])); + setRejectedEntities(prev => { + const newSet = new Set(prev); + newSet.delete(key); + return newSet; + }); + onConfirm(recordId, entity, entity.sources[0] === 'predefined' ? 'dataset' : entity.sources[0]); + }; + + const handleRejectEntity = (entity: AnnotatedEntity) => { + const key = getEntityKey(entity); + setRejectedEntities(new Set([...rejectedEntities, key])); + setConfirmedEntities(prev => { + const newSet = new Set(prev); + newSet.delete(key); + return newSet; + }); + onReject(recordId, entity, entity.sources[0] === 'predefined' ? 'dataset' : entity.sources[0]); + }; + + const handleAddManualEntity = () => { + if (manualEntity.text && manualEntity.entity_type) { + onAddManual(recordId, manualEntity); + setManualEntity({ text: '', entity_type: '', start: 0, end: 0 }); + setShowAddManual(false); + } + }; + + const getStatusBadge = (status: EntityStatus, confirmed?: boolean, rejected?: boolean) => { + if (confirmed) { + return Confirmed; + } + if (rejected) { + return Rejected; + } + + switch (status) { + case 'match': + return ✓ Match; + case 'conflict': + return ⚠ Conflict; + case 'presidio-only': + return Presidio; + case 'llm-only': + return LLM Judge; + case 'predefined-only': + return Predefined; + case 'presidio+predefined': + return Presidio + Predefined; + case 'presidio+llm': + return Presidio + LLM Judge; + case 'predefined+llm': + return Predefined + LLM Judge; + default: + return Pending; + } + }; + + return ( + +
+ {/* Record Text */} +
+ +
+ {recordText} +
+
+ + {/* Entities List */} +
+
+ + +
+ + {/* Manual Add Form */} + {showAddManual && ( +
+
+
+ + setManualEntity({ ...manualEntity, text: e.target.value })} + placeholder="Enter entity text..." + /> +
+
+ + +
+
+
+ + +
+
+ )} + + {/* Entity Cards */} +
+ {annotatedEntities.map((entity, index) => { + const key = getEntityKey(entity); + const isConfirmed = confirmedEntities.has(key); + const isRejected = rejectedEntities.has(key); + + return ( +
+
+
+
+ {entity.text} + {entity.entity_type} + {getStatusBadge(entity.status, isConfirmed, isRejected)} +
+ +
+ Position: {entity.start}-{entity.end} + {entity.score && Confidence: {(entity.score * 100).toFixed(0)}%} + {entity.status === 'conflict' && ( +
+ + Type mismatch between Presidio and LLM +
+ )} +
+
+ + {!isConfirmed && !isRejected && ( +
+ + +
+ )} +
+ + {/* Context Collapsible */} + toggleContext(key)}> + + + + +
+
+ + Surrounding Context: +
+
+ {getContextForEntity(entity).before} + {getContextForEntity(entity).entity} + {getContextForEntity(entity).after} +
+
+
+
+
+ ); + })} +
+
+ + {/* Summary */} +
+
+
+ {confirmedEntities.size} Confirmed +
+
+
+ {rejectedEntities.size} Rejected +
+
+
+ {annotatedEntities.length - confirmedEntities.size - rejectedEntities.size} Pending +
+
+
+ + ); +} diff --git a/evaluation/ai-assistant/backend/routers/__init__.py b/evaluation/ai-assistant/backend/routers/__init__.py new file mode 100644 index 0000000000..1d3b629809 --- /dev/null +++ b/evaluation/ai-assistant/backend/routers/__init__.py @@ -0,0 +1 @@ +"""API route handlers.""" diff --git a/evaluation/ai-assistant/backend/routers/decision.py b/evaluation/ai-assistant/backend/routers/decision.py new file mode 100644 index 0000000000..05f39b59b3 --- /dev/null +++ b/evaluation/ai-assistant/backend/routers/decision.py @@ -0,0 +1,71 @@ +from fastapi import APIRouter +from models import DecisionRequest, DecisionType + +router = APIRouter(prefix="/api/decision", tags=["decision"]) + +# In-memory state +_decision_state: dict = {} + + +@router.post("") +async def save_decision(request: DecisionRequest): + """Save an approve or iterate decision.""" + _decision_state.update(request.model_dump()) + + if request.decision == DecisionType.approve: + return { + "status": "approved", + "message": "Configuration approved! Ready for full dataset anonymization.", + "artifacts": [ + "Approved Presidio Configuration", + "Golden Dataset", + "Evaluation Report", + "Audit Trail", + ], + } + + return { + "status": "iterating", + "message": ( + f"Iteration started with " + f"{len(request.selected_improvements)} improvements." + ), + "improvements": request.selected_improvements, + "next_step": "/sampling", + } + + +@router.get("/improvements") +async def list_improvements(): + """List suggested configuration improvements.""" + return [ + { + "id": "threshold", + "label": "Lower CREDIT_CARD confidence threshold to 0.60", + "impact": "+12 detections", + }, + { + "id": "medical", + "label": "Add MEDICAL_CONDITION custom recognizer", + "impact": "+9 detections", + }, + { + "id": "ssn", + "label": "Expand SSN pattern variations", + "impact": "+8 detections", + }, + { + "id": "insurance", + "label": "Add INSURANCE_POLICY recognizer", + "impact": "+6 detections", + }, + ] + + +@router.post("/save-artifacts") +async def save_artifacts(): + """Persist evaluation artifacts for audit.""" + return { + "status": "saved", + "message": "Evaluation artifacts saved for audit and compliance.", + } diff --git a/evaluation/ai-assistant/backend/routers/evaluation.py b/evaluation/ai-assistant/backend/routers/evaluation.py new file mode 100644 index 0000000000..a0c2f8159e --- /dev/null +++ b/evaluation/ai-assistant/backend/routers/evaluation.py @@ -0,0 +1,405 @@ +import csv +import json +import os +from collections import Counter + +from fastapi import APIRouter, HTTPException, Query +from mock_data import ENTITY_MISSES, EVALUATION_RUNS +from models import Entity, EntityMiss, EvaluationRun, MissType, RiskLevel +from routers.upload import _resolve_path, _uploaded + +router = APIRouter(prefix="/api/evaluation", tags=["evaluation"]) + + +def _parse_entities(raw: str | list | None) -> list[Entity]: + if not raw: + return [] + if isinstance(raw, str): + try: + raw = json.loads(raw) + except json.JSONDecodeError: + return [] + if not isinstance(raw, list): + return [] + entities: list[Entity] = [] + for item in raw: + if isinstance(item, dict): + try: + entities.append(Entity(**item)) + except Exception: + continue + return entities + + +def _entity_key(entity: Entity) -> tuple[str, int, int, str]: + return (entity.entity_type, entity.start, entity.end, entity.text) + + +def _risk_level(entity_type: str) -> RiskLevel: + high = { + "CREDIT_CARD", + "CRYPTO", + "IBAN_CODE", + "MEDICAL_RECORD", + "PASSPORT", + "SSN", + "US_BANK_NUMBER", + "US_DRIVER_LICENSE", + "US_ITIN", + "US_PASSPORT", + "US_SSN", + } + medium = { + "DATE_TIME", + "EMAIL_ADDRESS", + "LOCATION", + "ORGANIZATION", + "PERSON", + "PHONE_NUMBER", + "URL", + } + if entity_type in high: + return RiskLevel.high + if entity_type in medium: + return RiskLevel.medium + return RiskLevel.low + + +def _pct(numerator: int, denominator: int) -> float: + if denominator == 0: + return 0.0 + return round((numerator / denominator) * 100, 1) + + +def _evaluate_config(rows: list[dict], text_column: str, config_name: str) -> dict: + misses: list[dict] = [] + per_type = Counter() + predicted_per_type = Counter() + tp_per_type = Counter() + tp_total = 0 + fp_total = 0 + fn_total = 0 + + for index, row in enumerate(rows): + record_id = f"rec-{index + 1:04d}" + record_text = (row.get(text_column) or "").strip() + gold_entities = _parse_entities(row.get("final_entities")) + predicted_entities = _parse_entities(row.get(f"config_{config_name}")) + + gold_map = {_entity_key(entity): entity for entity in gold_entities} + predicted_map = {_entity_key(entity): entity for entity in predicted_entities} + + for entity in gold_map.values(): + per_type[entity.entity_type] += 1 + for entity in predicted_map.values(): + predicted_per_type[entity.entity_type] += 1 + + # Find positions (start, end) that have at least one TP match + tp_positions: set[tuple[int, int]] = set() + for key, entity in predicted_map.items(): + if key in gold_map: + tp_positions.add((entity.start, entity.end)) + + for key, entity in predicted_map.items(): + if key in gold_map: + tp_total += 1 + tp_per_type[entity.entity_type] += 1 + misses.append( + EntityMiss( + record_id=record_id, + record_text=record_text, + missed_entity=entity, + miss_type=MissType.true_positive, + entity_type=entity.entity_type, + risk_level=_risk_level(entity.entity_type), + ).model_dump() + ) + else: + # Skip FP if another entity at the same position is a TP + if (entity.start, entity.end) in tp_positions: + continue + fp_total += 1 + misses.append( + EntityMiss( + record_id=record_id, + record_text=record_text, + missed_entity=entity, + miss_type=MissType.false_positive, + entity_type=entity.entity_type, + risk_level=_risk_level(entity.entity_type), + ).model_dump() + ) + + for key, entity in gold_map.items(): + if key not in predicted_map: + # Skip FN if another entity at the same position is a TP + if (entity.start, entity.end) in tp_positions: + continue + fn_total += 1 + misses.append( + EntityMiss( + record_id=record_id, + record_text=record_text, + missed_entity=entity, + miss_type=MissType.false_negative, + entity_type=entity.entity_type, + risk_level=_risk_level(entity.entity_type), + ).model_dump() + ) + + entity_types = sorted(set(per_type.keys()) | set(predicted_per_type.keys())) + by_entity_type = [] + for entity_type in entity_types: + tp = tp_per_type[entity_type] + pred = predicted_per_type[entity_type] + gold = per_type[entity_type] + fp = pred - tp + fn = gold - tp + precision = _pct(tp, pred) + recall = _pct(tp, gold) + f1 = round((2 * precision * recall / (precision + recall)), 1) if precision + recall else 0.0 + by_entity_type.append({ + "type": entity_type, + "precision": precision, + "recall": recall, + "f1": f1, + "true_positives": tp, + "false_positives": fp, + "false_negatives": fn, + }) + + overall_precision = _pct(tp_total, tp_total + fp_total) + overall_recall = _pct(tp_total, tp_total + fn_total) + overall_f1 = round((2 * overall_precision * overall_recall / (overall_precision + overall_recall)), 1) if overall_precision + overall_recall else 0.0 + risk_counts = Counter(miss["risk_level"] for miss in misses) + + return { + "config_name": config_name, + "overall": { + "precision": overall_precision, + "recall": overall_recall, + "f1_score": overall_f1, + "true_positives": tp_total, + "false_positives": fp_total, + "false_negatives": fn_total, + }, + "by_entity_type": by_entity_type, + "misses": misses, + "summary": { + "total_misses": sum(1 for m in misses if m["miss_type"] != "true-positive"), + "false_positives": fp_total, + "false_negatives": fn_total, + "high_risk": risk_counts[RiskLevel.high], + "medium_risk": risk_counts[RiskLevel.medium], + "low_risk": risk_counts[RiskLevel.low], + }, + } + + +@router.get("/summary") +async def get_evaluation_summary( + dataset_id: str, + config_names: list[str] | None = Query(default=None), +): + ds = _uploaded.get(dataset_id) + if ds is None: + raise HTTPException(status_code=404, detail="Dataset not found") + + resolved = ds.stored_path if ds.stored_path and os.path.isfile(ds.stored_path) else _resolve_path(ds.path) + if not os.path.isfile(resolved): + raise HTTPException(status_code=404, detail="Dataset file not found on disk") + + if ds.format != "csv": + raise HTTPException(status_code=400, detail="Only CSV evaluation is currently supported") + + with open(resolved, encoding="utf-8") as f: + rows = list(csv.DictReader(f)) + + available_configs = list(ds.ran_configs or []) + if not available_configs and rows: + available_configs = [ + key.replace("config_", "") + for key in rows[0].keys() + if key.startswith("config_") + ] + + default_config = available_configs[-1] if available_configs else None + selected_configs = [c for c in (config_names or ([default_config] if default_config else [])) if c in available_configs] + + has_final_entities = False + + for row in rows: + gold_entities = _parse_entities(row.get("final_entities")) + if gold_entities: + has_final_entities = True + + if not has_final_entities: + raise HTTPException(status_code=400, detail="No reviewed final entities found for this dataset") + + per_config = [_evaluate_config(rows, ds.text_column, config_name) for config_name in selected_configs] + + return { + "dataset_id": dataset_id, + "available_configs": available_configs, + "selected_configs": selected_configs, + "default_config": default_config, + "per_config": per_config, + } + + +@router.get("/runs", response_model=list[EvaluationRun]) +async def get_evaluation_runs(): + """List all evaluation runs.""" + return EVALUATION_RUNS + + +@router.get("/latest", response_model=EvaluationRun) +async def get_latest_run(): + """Return the most recent evaluation run.""" + return EVALUATION_RUNS[-1] + + +@router.get("/misses", response_model=list[EntityMiss]) +async def get_entity_misses( + miss_type: str | None = None, + entity_type: str | None = None, + risk_level: str | None = None, +): + """Return entity misses with optional filtering.""" + results = ENTITY_MISSES + if miss_type and miss_type != "all": + results = [m for m in results if m.miss_type == miss_type] + if entity_type and entity_type != "all": + results = [m for m in results if m.entity_type == entity_type] + if risk_level and risk_level != "all": + results = [m for m in results if m.risk_level == risk_level] + return results + + +@router.get("/metrics") +async def get_metrics(): + """Return overall and per-entity-type metrics for the latest run.""" + latest = EVALUATION_RUNS[-1] + return { + "overall": { + "precision": 94, + "recall": 88, + "f1_score": 91, + "false_negatives": 40, + }, + "by_entity_type": [ + {"type": "PERSON", "precision": 96, "recall": 92, "f1": 94}, + {"type": "EMAIL", "precision": 98, "recall": 95, "f1": 96}, + {"type": "PHONE", "precision": 93, "recall": 89, "f1": 91}, + {"type": "SSN", "precision": 97, "recall": 84, "f1": 90}, + {"type": "CREDIT_CARD", "precision": 71, "recall": 65, "f1": 68}, + {"type": "MEDICAL_RECORD", "precision": 89, "recall": 81, "f1": 85}, + ], + "errors": { + "false_negatives": { + "total": 40, + "by_type": [ + {"type": "CREDIT_CARD", "count": 12}, + {"type": "MEDICAL_CONDITION", "count": 9}, + {"type": "SSN", "count": 8}, + {"type": "Other", "count": 11}, + ], + }, + "false_positives": { + "total": 19, + "by_type": [ + {"type": "DATE", "count": 7}, + {"type": "PERSON", "count": 5}, + {"type": "PHONE_NUMBER", "count": 4}, + {"type": "Other", "count": 3}, + ], + }, + }, + "run": latest.model_dump(), + } + + +@router.get("/patterns") +async def get_error_patterns(): + """Return common error patterns and insights.""" + return { + "frequent_misses": [ + { + "type": "CREDIT_CARD", + "count": 12, + "pattern": "Partial card numbers, low confidence scores", + }, + { + "type": "MEDICAL_CONDITION", + "count": 9, + "pattern": "Medical terminology not in baseline recognizers", + }, + { + "type": "SSN", + "count": 8, + "pattern": "Non-standard formatting (spaces instead of dashes)", + }, + { + "type": "INSURANCE_POLICY", + "count": 6, + "pattern": "Custom format not covered by patterns", + }, + ], + "common_patterns": [ + { + "name": "Low Confidence Threshold", + "description": ( + "Credit card patterns are being detected but filtered out " + "due to confidence scores below threshold (typically 0.65-0.75)" + ), + }, + { + "name": "Format Variations", + "description": ( + "SSN and phone numbers with non-standard separators " + "(spaces, periods) are being missed" + ), + }, + { + "name": "Domain-Specific Terms", + "description": ( + "Medical conditions and insurance policy numbers " + "require custom recognizers for your domain" + ), + }, + ], + "insights": [ + { + "title": "Adjust Confidence Thresholds", + "description": ( + "Consider lowering the confidence threshold for CREDIT_CARD " + "entities from 0.70 to 0.60. This would capture 12 additional " + "credit card numbers that are currently being missed." + ), + }, + { + "title": "Add Custom Recognizers", + "description": ( + "Create domain-specific recognizers for MEDICAL_CONDITION " + "(9 misses) and INSURANCE_POLICY (6 misses) to better handle " + "your healthcare dataset." + ), + }, + { + "title": "Expand Pattern Variations", + "description": ( + "Update SSN and PHONE_NUMBER patterns to handle " + "alternative separators (spaces, periods, no " + "separators). This addresses 8 SSN misses." + ), + }, + { + "title": "Strong Areas", + "description": ( + "EMAIL (98% precision, 95% recall) and PERSON " + "(96% precision, 92% recall) recognizers are " + "performing well and don't require tuning." + ), + }, + ], + } diff --git a/evaluation/ai-assistant/backend/routers/llm.py b/evaluation/ai-assistant/backend/routers/llm.py new file mode 100644 index 0000000000..1d7105e658 --- /dev/null +++ b/evaluation/ai-assistant/backend/routers/llm.py @@ -0,0 +1,221 @@ +"""LLM Judge router — configure and run LLM-based PII detection.""" + +from __future__ import annotations + +import asyncio +import logging +import time + +import llm_service +from fastapi import APIRouter, HTTPException +from models import LLMAnalyzeRequest, LLMConfig +from settings import MODEL_CHOICES, load_from_env + +from routers.upload import _ensure_records_loaded + +router = APIRouter(prefix="/api/llm", tags=["llm"]) +logger = logging.getLogger(__name__) + +# Currently selected deployment (persisted across page reloads while server lives) +_selected_deployment: str | None = None + +# Active dataset for current analysis run +_active_dataset_id: str | None = None + +# In-memory state for the current LLM analysis run +_state: dict = { + "progress": 0, + "total": 0, + "running": False, + "error": None, + "results": {}, # record_id -> list[Entity dict] + "start_time": None, + "elapsed_ms": None, +} + + +def _env_is_ready() -> bool: + """Check if .env has the required Azure endpoint.""" + env = load_from_env() + return bool(env.azure_endpoint) + + +# ── Model catalogue ────────────────────────────────────── + + +@router.get("/models") +async def list_models(): + """Return available model choices for the UI dropdown.""" + return MODEL_CHOICES + + +# ── Settings ───────────────────────────────────────────── + + +@router.get("/settings") +async def get_settings(): + """Return current LLM Judge configuration (no secrets).""" + env = load_from_env() + return { + "env_ready": bool(env.azure_endpoint), + "has_endpoint": bool(env.azure_endpoint), + "has_api_key": bool(env.azure_api_key), + "auth_method": "api_key" if env.azure_api_key else "default_credential", + "deployment_name": _selected_deployment or env.deployment_name, + "configured": llm_service.is_configured(), + } + + +@router.post("/configure") +async def configure_llm(config: LLMConfig): + """Configure the LLM recognizer using .env credentials + chosen deployment.""" + global _selected_deployment + + env = load_from_env() + if not env.azure_endpoint: + raise HTTPException( + status_code=400, + detail=( + "PRESIDIO_EVAL_AZURE_ENDPOINT must be set in backend/.env " + "before configuring the LLM Judge." + ), + ) + + # Validate the chosen deployment is in our allowed list + allowed_ids = {m["id"] for m in MODEL_CHOICES} + deployment = config.deployment_name + if deployment not in allowed_ids: + raise HTTPException( + status_code=400, + detail=f"Deployment '{deployment}' is not in the allowed list.", + ) + + try: + result = llm_service.configure( + azure_endpoint=env.azure_endpoint, + api_key=env.azure_api_key, # None → DefaultAzureCredential + deployment_name=deployment, + api_version=env.api_version, + ) + except llm_service.LLMServiceError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + _selected_deployment = deployment + return result + + +@router.post("/disconnect") +async def disconnect_llm(): + """Disconnect the LLM recognizer and reset analysis state.""" + global _selected_deployment, _active_dataset_id + llm_service.disconnect() + _selected_deployment = None + _active_dataset_id = None + _state["progress"] = 0 + _state["total"] = 0 + _state["running"] = False + _state["error"] = None + _state["results"] = {} + _state["start_time"] = None + _state["elapsed_ms"] = None + return {"status": "disconnected"} + + +# ── Status / analysis ──────────────────────────────────── + + +@router.get("/status") +async def get_llm_status(): + """Return LLM configuration and analysis status.""" + entity_count = sum(len(ents) for ents in _state["results"].values()) + return { + "configured": llm_service.is_configured(), + "running": _state["running"], + "progress": _state["progress"], + "total": _state["total"], + "error": _state["error"], + "entity_count": entity_count, + "elapsed_ms": _state["elapsed_ms"], + } + + +@router.post("/analyze") +async def start_llm_analysis(req: LLMAnalyzeRequest): + """Run LLM analysis on all records of a dataset.""" + global _active_dataset_id + + if not llm_service.is_configured(): + raise HTTPException( + status_code=400, + detail="LLM not configured. POST /api/llm/configure first.", + ) + + if _state["running"]: + raise HTTPException(status_code=409, detail="Analysis already running.") + + records = _ensure_records_loaded(req.dataset_id) + if not records: + raise HTTPException( + status_code=400, + detail=f"No records found for dataset '{req.dataset_id}'.", + ) + + _active_dataset_id = req.dataset_id + _state["progress"] = 0 + _state["total"] = len(records) + _state["running"] = True + _state["error"] = None + _state["results"] = {} + _state["start_time"] = time.time() + _state["elapsed_ms"] = None + + asyncio.create_task(_run_analysis()) + return {"status": "started", "total": _state["total"]} + + +async def _run_analysis(): + """Background task: analyse each record via the LLM.""" + loop = asyncio.get_event_loop() + records = _ensure_records_loaded(_active_dataset_id) if _active_dataset_id else [] + try: + for record in records: + try: + entities = await loop.run_in_executor( + None, llm_service.analyze_text, record.text + ) + entity_dicts = [e.model_dump() for e in entities] + _state["results"][record.id] = entity_dicts + if entity_dicts: + summary = "; ".join( + f"{e['entity_type']}('{e['text']}' @{e['start']}-{e['end']})" + for e in entity_dicts + ) + logger.info("[LLM] %s: %s", record.id, summary) + else: + logger.info("[LLM] %s: no entities", record.id) + except Exception: + logger.exception("LLM analysis failed for record %s", record.id) + _state["results"][record.id] = [] + _state["progress"] += 1 + except Exception as exc: + logger.exception("LLM analysis task failed") + _state["error"] = str(exc) + finally: + _state["running"] = False + if _state["start_time"]: + _state["elapsed_ms"] = round((time.time() - _state["start_time"]) * 1000) + + +@router.get("/results") +async def get_llm_results(): + """Return LLM entities for all analysed records.""" + return _state["results"] + + +@router.get("/results/{record_id}") +async def get_llm_record_results(record_id: str): + """Return LLM entities for a specific record.""" + entities = _state["results"].get(record_id) + if entities is None: + raise HTTPException(status_code=404, detail="Record not found in results.") + return entities diff --git a/evaluation/ai-assistant/backend/routers/presidio_service.py b/evaluation/ai-assistant/backend/routers/presidio_service.py new file mode 100644 index 0000000000..8017f967aa --- /dev/null +++ b/evaluation/ai-assistant/backend/routers/presidio_service.py @@ -0,0 +1,405 @@ +"""Presidio Analyzer router — simple in-process engine, no multiprocessing.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import re +import shutil +import time +from pathlib import Path + +from fastapi import APIRouter, HTTPException, UploadFile, File, Form +from fastapi.responses import FileResponse +from pydantic import BaseModel + +from routers.upload import _ensure_records_loaded + +router = APIRouter(prefix="/api/presidio", tags=["presidio"]) +logger = logging.getLogger(__name__) +_NAME_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9 _.\-]*$') + +# --------------------------------------------------------------------------- +# Named configs storage +# --------------------------------------------------------------------------- +_CONFIGS_FILE = Path(__file__).resolve().parent.parent / "data" / "presidio_configs.json" + +_BUILTIN_CONFIGS: list[dict] = [ + {"name": "default_spacy", "path": None}, +] + + +def _load_saved_configs() -> list[dict]: + """Return list of {name, path} dicts (builtins + user-saved).""" + user_configs: list[dict] = [] + if _CONFIGS_FILE.exists(): + try: + user_configs = json.loads(_CONFIGS_FILE.read_text()) + except Exception: + user_configs = [] + return _BUILTIN_CONFIGS + user_configs + + +def _save_user_configs(configs: list[dict]) -> None: + _CONFIGS_FILE.parent.mkdir(parents=True, exist_ok=True) + _CONFIGS_FILE.write_text(json.dumps(configs, indent=2)) + + +def _get_user_configs() -> list[dict]: + if _CONFIGS_FILE.exists(): + try: + return json.loads(_CONFIGS_FILE.read_text()) + except Exception: + return [] + return [] + + +# --------------------------------------------------------------------------- +# Per-config results accumulator (replaces run snapshots) +# --------------------------------------------------------------------------- +_DATA_DIR = Path(__file__).resolve().parent.parent / "data" + +# config_name -> {rec_id -> entities} +_all_config_results: dict[str, dict[str, list]] = {} + + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- +_engine = None # lazy-loaded AnalyzerEngine singleton + +_state: dict = { + "configured": False, + "loading": False, + "config_name": None, + "config_path": None, + "running": False, + "progress": 0, + "total": 0, + "error": None, + "results": {}, + "start_time": None, + "elapsed_ms": None, +} + + +# --------------------------------------------------------------------------- +# Pydantic models +# --------------------------------------------------------------------------- +class PresidioConfigureRequest(BaseModel): + config_name: str | None = None + config_path: str | None = None + + +class PresidioAnalyzeRequest(BaseModel): + dataset_id: str + + +class PresidioSaveConfigRequest(BaseModel): + name: str + path: str + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# Config CRUD +# --------------------------------------------------------------------------- +@router.get("/configs") +async def list_configs(): + return _load_saved_configs() + + +@router.post("/configs") +async def save_config(req: PresidioSaveConfigRequest): + name = req.name.strip() + path = req.path.strip() + if not name: + raise HTTPException(status_code=400, detail="Config name is required.") + if not _NAME_RE.match(name): + raise HTTPException(status_code=400, detail="Name may only contain letters, numbers, hyphens, underscores, dots, and spaces.") + if not path: + raise HTTPException(status_code=400, detail="Config path is required.") + if not os.path.isabs(path): + raise HTTPException(status_code=400, detail="Config path must be absolute.") + if not os.path.isfile(path): + raise HTTPException(status_code=400, detail=f"Config file not found: {path}") + + # Copy the config file into our data/ folder so we own the copy + _DATA_DIR.mkdir(parents=True, exist_ok=True) + safe_name = name.replace(" ", "_").replace("/", "_") + dest = _DATA_DIR / f"config-{safe_name}.yml" + shutil.copy2(path, dest) + abs_path = str(dest.resolve()) + + user_configs = _get_user_configs() + # Replace if same name exists + user_configs = [c for c in user_configs if c["name"] != name] + user_configs.append({"name": name, "path": abs_path}) + _save_user_configs(user_configs) + return {"status": "saved", "configs": _load_saved_configs()} + + +@router.delete("/configs/{config_name}") +async def delete_config(config_name: str): + user_configs = _get_user_configs() + # Find the config to delete (so we can clean up its file) + to_delete = [c for c in user_configs if c["name"] == config_name] + remaining = [c for c in user_configs if c["name"] != config_name] + if len(remaining) == len(user_configs): + raise HTTPException(status_code=404, detail="Config not found.") + # Remove the stored config file if it lives in our data/ folder + for cfg in to_delete: + cfg_path = cfg.get("path", "") + if cfg_path and _DATA_DIR.resolve() in Path(cfg_path).resolve().parents: + try: + os.remove(cfg_path) + except OSError: + pass + _save_user_configs(remaining) + return {"status": "deleted", "configs": _load_saved_configs()} + + +@router.post("/configs/upload") +async def upload_config( + file: UploadFile = File(...), + name: str = Form(...), +): + """Upload a YAML config file and save it with a given name.""" + if not file.filename or not file.filename.lower().endswith((".yml", ".yaml")): + raise HTTPException(status_code=400, detail="Only .yml / .yaml files are accepted.") + name = name.strip() + if not name: + raise HTTPException(status_code=400, detail="Config name is required.") + if not _NAME_RE.match(name): + raise HTTPException(status_code=400, detail="Name may only contain letters, numbers, hyphens, underscores, dots, and spaces.") + + content = await file.read() + if len(content) > 1 * 1024 * 1024: # 1 MB limit + raise HTTPException(status_code=400, detail="Config file too large (max 1 MB).") + + # Save the uploaded file to data/ folder + _DATA_DIR.mkdir(parents=True, exist_ok=True) + safe_name = name.replace(" ", "_").replace("/", "_") + dest = _DATA_DIR / f"config-{safe_name}.yml" + dest.write_bytes(content) + abs_path = str(dest.resolve()) + + # Save to config registry + user_configs = _get_user_configs() + user_configs = [c for c in user_configs if c["name"] != name] + user_configs.append({"name": name, "path": abs_path}) + _save_user_configs(user_configs) + + return {"status": "saved", "path": abs_path, "configs": _load_saved_configs()} + + +# --------------------------------------------------------------------------- +# Configure / Load engine +# --------------------------------------------------------------------------- +@router.post("/configure") +async def configure_presidio(req: PresidioConfigureRequest): + global _engine + + if req.config_path: + if not os.path.isabs(req.config_path): + raise HTTPException(status_code=400, detail="Config path must be an absolute path.") + if not os.path.isfile(req.config_path): + raise HTTPException(status_code=400, detail=f"Config file not found: {req.config_path}") + + # Reset + _engine = None + _state["loading"] = True + _state["configured"] = False + _state["error"] = None + _state["config_name"] = req.config_name or ("default" if not req.config_path else None) + _state["config_path"] = req.config_path + + # Load the engine in a background thread so we don't block the event loop + asyncio.create_task(_load_engine(req.config_path)) + + return {"status": "loading", "config_path": req.config_path or "default"} + + +async def _load_engine(config_path: str | None): + """Initialise AnalyzerEngine in a thread (model download can be slow).""" + global _engine + loop = asyncio.get_event_loop() + try: + _engine = await loop.run_in_executor(None, _create_engine, config_path) + _state["configured"] = True + _state["loading"] = False + logger.info("Presidio AnalyzerEngine ready (config=%s)", config_path or "default") + except Exception as exc: + logger.exception("Failed to initialise Presidio engine") + _state["error"] = str(exc) + _state["loading"] = False + + +def _create_engine(config_path: str | None): + """Synchronous factory — runs inside run_in_executor.""" + try: + from presidio_analyzer import AnalyzerEngine + from presidio_analyzer.analyzer_engine_provider import AnalyzerEngineProvider + except ImportError as exc: + raise RuntimeError( + "presidio-analyzer is not installed. " + "Run: cd backend && poetry install" + ) from exc + + if config_path: + provider = AnalyzerEngineProvider(analyzer_engine_conf_file=config_path) + return provider.create_engine() + return AnalyzerEngine() + + +@router.get("/status") +async def get_presidio_status(): + entity_count = sum(len(ents) for ents in _state["results"].values()) + return { + "configured": _state["configured"], + "loading": _state["loading"], + "config_name": _state["config_name"], + "config_path": _state["config_path"] or "default", + "running": _state["running"], + "progress": _state["progress"], + "total": _state["total"], + "error": _state["error"], + "entity_count": entity_count, + "elapsed_ms": _state["elapsed_ms"], + } + + +@router.post("/analyze") +async def start_presidio_analysis(req: PresidioAnalyzeRequest): + if not _state["configured"] or _engine is None: + raise HTTPException(status_code=400, detail="Presidio not configured. POST /api/presidio/configure first.") + + if _state["running"]: + raise HTTPException(status_code=409, detail="Analysis already running.") + + records = _ensure_records_loaded(req.dataset_id) + if not records: + raise HTTPException(status_code=400, detail=f"No records found for dataset '{req.dataset_id}'.") + + _state["progress"] = 0 + _state["total"] = len(records) + _state["running"] = True + _state["error"] = None + _state["results"] = {} + _state["start_time"] = time.time() + _state["elapsed_ms"] = None + + asyncio.create_task(_run_analysis(req.dataset_id)) + return {"status": "started", "total": _state["total"]} + + +async def _run_analysis(dataset_id: str): + """Analyse each record using the engine in a thread.""" + loop = asyncio.get_event_loop() + records = _ensure_records_loaded(dataset_id) + + try: + for record in records: + entities = await loop.run_in_executor(None, _analyze_record, record.text) + _state["results"][record.id] = entities + _state["progress"] += 1 + if entities: + summary = "; ".join( + f"{e['entity_type']}('{e['text']}' @{e['start']}-{e['end']} score={e['score']})" + for e in entities + ) + logger.info("[Presidio] %s: %s", record.id, summary) + else: + logger.info("[Presidio] %s: no entities", record.id) + except Exception as exc: + logger.exception("Presidio analysis task failed") + _state["error"] = str(exc) + finally: + _state["running"] = False + if _state["start_time"]: + _state["elapsed_ms"] = round((time.time() - _state["start_time"]) * 1000) + # Accumulate results under the config name + if _state["error"] is None and _state["results"] and _state["config_name"]: + _all_config_results[_state["config_name"]] = dict(_state["results"]) + + +def _analyze_record(text: str) -> list[dict]: + """Synchronous single-record analysis — runs inside run_in_executor.""" + results = _engine.analyze(text=text, language="en") + return [ + { + "text": text[r.start : r.end], + "entity_type": r.entity_type, + "start": r.start, + "end": r.end, + "score": round(r.score, 4), + } + for r in results + ] + + +@router.get("/results") +async def get_presidio_results(): + return _state["results"] + + +@router.get("/results/{record_id}") +async def get_presidio_record_results(record_id: str): + entities = _state["results"].get(record_id) + if entities is None: + raise HTTPException(status_code=404, detail="Record not found in results.") + return entities + + +@router.post("/disconnect") +async def disconnect_presidio(): + global _engine + _engine = None + _state["configured"] = False + _state["loading"] = False + _state["config_name"] = None + _state["config_path"] = None + _state["running"] = False + _state["progress"] = 0 + _state["total"] = 0 + _state["error"] = None + _state["results"] = {} + _state["start_time"] = None + _state["elapsed_ms"] = None + _all_config_results.clear() + return {"status": "disconnected"} + + +@router.get("/configs/{config_name}/download") +async def download_config(config_name: str): + """Download the YAML file for a named config.""" + all_configs = _load_saved_configs() + cfg = next((c for c in all_configs if c["name"] == config_name), None) + if cfg is None: + raise HTTPException(status_code=404, detail="Config not found") + cfg_path = cfg.get("path") + # For built-in configs without a file (e.g. default_spacy), serve the + # bundled default.yaml shipped with presidio-analyzer. + if not cfg_path or not Path(cfg_path).is_file(): + try: + import presidio_analyzer + pkg_dir = Path(presidio_analyzer.__file__).parent + default_yaml = pkg_dir / "conf" / "default.yaml" + if default_yaml.is_file(): + return FileResponse( + str(default_yaml), + media_type="application/x-yaml", + filename=f"{config_name}.yml", + ) + except ImportError: + pass + raise HTTPException(status_code=404, detail="Config file not found on disk") + return FileResponse( + cfg_path, + media_type="application/x-yaml", + filename=f"{config_name}.yml", + ) diff --git a/evaluation/ai-assistant/backend/routers/review.py b/evaluation/ai-assistant/backend/routers/review.py new file mode 100644 index 0000000000..e67f8d910e --- /dev/null +++ b/evaluation/ai-assistant/backend/routers/review.py @@ -0,0 +1,360 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from models import Entity, EntityAction, Record +from routers.upload import _records as uploaded_records, _uploaded, _save_registry +from routers.presidio_service import _state as presidio_state, _all_config_results + +router = APIRouter(prefix="/api/review", tags=["review"]) + +# In-memory golden set: record_id -> confirmed entities +_golden_set: dict[str, list[Entity]] = {} +_reviewed: set[str] = set() +# Active dataset for review +_active_dataset_id: str | None = None + + +def set_active_dataset(dataset_id: str) -> None: + """Set the dataset used for review (called by other routers).""" + global _active_dataset_id + _active_dataset_id = dataset_id + + +def _get_review_records() -> list[Record]: + """Get records for the active dataset.""" + if _active_dataset_id and _active_dataset_id in uploaded_records: + return uploaded_records[_active_dataset_id] + # Return first available dataset if no active one set + for records in uploaded_records.values(): + return records + return [] + + +@router.get("/records", response_model=list[Record]) +async def get_review_records(): + """List records for human review.""" + return _get_review_records() + + +@router.post("/records/{record_id}/confirm") +async def confirm_entity(record_id: str, action: EntityAction): + """Confirm an entity and add it to the golden set.""" + _golden_set.setdefault(record_id, []).append(action.entity) + _reviewed.add(record_id) + return {"status": "confirmed", "record_id": record_id} + + +def _spans_overlap(a: Entity, b: Entity) -> bool: + """Return True if two entity spans overlap.""" + return a.start < b.end and b.start < a.end + + +@router.post("/records/{record_id}/reject") +async def reject_entity(record_id: str, action: EntityAction): + """Reject an entity and remove it from the golden set.""" + entities = _golden_set.get(record_id, []) + _golden_set[record_id] = [ + e + for e in entities + if not _spans_overlap(e, action.entity) + ] + _reviewed.add(record_id) + return {"status": "rejected", "record_id": record_id} + + +@router.post("/records/{record_id}/manual") +async def add_manual_entity(record_id: str, action: EntityAction): + """Manually add an entity to the golden set.""" + entity = action.entity.model_copy(update={"score": 1.0}) + _golden_set.setdefault(record_id, []).append(entity) + _reviewed.add(record_id) + return {"status": "added", "record_id": record_id} + + +@router.get("/progress") +async def get_review_progress(): + """Return review completion progress.""" + total = len(_get_review_records()) + reviewed = len(_reviewed) + return { + "total": total, + "reviewed": reviewed, + "progress": (reviewed / total * 100) if total else 0, + "golden_set_size": sum(len(v) for v in _golden_set.values()), + } + + +@router.get("/golden-set") +async def get_golden_set(): + """Return the current golden entity set.""" + return _golden_set + + +class SaveFinalEntitiesRequest(BaseModel): + dataset_id: str + golden_set: dict[str, list[dict]] + + +@router.post("/save-final-entities") +async def save_final_entities(req: SaveFinalEntitiesRequest): + """Persist the human-approved golden set as final_entities in the stored dataset file.""" + import csv + import io + import json + import os + + ds = _uploaded.get(req.dataset_id) + if ds is None: + raise HTTPException(status_code=404, detail="Dataset not found") + + # Build a lookup: record_id -> list[Entity dict] + golden = {rid: [Entity(**e) for e in ents] for rid, ents in req.golden_set.items()} + + # Update in-memory records + records = uploaded_records.get(req.dataset_id, []) + for rec in records: + rec.final_entities = golden.get(rec.id, []) + + # Write back to the stored copy + stored = ds.stored_path if ds.stored_path and os.path.isfile(ds.stored_path) else None + if not stored: + raise HTTPException(status_code=400, detail="No stored file to update") + + # Grab raw Presidio results (if available) + presidio_results = presidio_state.get("results", {}) + + # Build per-config results from accumulated in-memory analyses + config_results: dict[str, dict[str, list]] = dict(_all_config_results) + # Also include the current in-memory analysis (may not be accumulated yet) + current_config = presidio_state.get("config_name") + if current_config and presidio_results: + config_results[current_config] = presidio_results + + # Recover any existing config columns from the file that are missing + # from in-memory (e.g. first config cleared by disconnect()) + if ds.format == "csv": + with open(stored, encoding="utf-8") as hf: + existing_reader = csv.DictReader(hf) + existing_cols = existing_reader.fieldnames or [] + existing_config_cols = [c for c in existing_cols if c.startswith("config_")] + if existing_config_cols: + existing_rows = list(existing_reader) + for col in existing_config_cols: + cname = col[len("config_"):] + if cname not in config_results: + config_results[cname] = {} + for i, row in enumerate(existing_rows): + rec_id = f"rec-{i + 1:04d}" + val = row.get(col, "[]") + try: + config_results[cname][rec_id] = json.loads(val) if val else [] + except (json.JSONDecodeError, TypeError): + config_results[cname][rec_id] = [] + elif ds.format == "json": + with open(stored, encoding="utf-8") as hf: + existing_data = json.load(hf) + if existing_data: + for key in list(existing_data[0].keys()): + if key.startswith("config_"): + cname = key[len("config_"):] + if cname not in config_results: + config_results[cname] = {} + for i, obj in enumerate(existing_data): + rec_id = f"rec-{i + 1:04d}" + config_results[cname][rec_id] = obj.get(key, []) + + config_names = list(config_results.keys()) + + if ds.format == "csv": + buf = io.StringIO() + # Build fieldnames from the CSV header (minus old presidio_analyzer_entities, + # previous config_* columns, and final_entities) + # + one column per config (prefixed with config_) + final_entities + with open(stored, encoding="utf-8") as hf: + header_reader = csv.DictReader(hf) + csv_columns = header_reader.fieldnames or [] + base_cols = [ + c for c in csv_columns + if c not in ("presidio_analyzer_entities", "final_entities") + and not c.startswith("config_") + ] + config_col_names = [f"config_{cname}" for cname in config_names] + fieldnames = base_cols + config_col_names + ["final_entities"] + writer = csv.DictWriter(buf, fieldnames=fieldnames) + writer.writeheader() + + # Re-read original data to preserve all columns + with open(stored, encoding="utf-8") as f: + reader = csv.DictReader(f) + orig_rows = list(reader) + + for i, row in enumerate(orig_rows): + rec_id = f"rec-{i + 1:04d}" + # Remove old columns if present + row.pop("presidio_analyzer_entities", None) + # Remove old config columns (both prefixed and non-prefixed) + for key in list(row.keys()): + if key.startswith("config_") or key in config_results: + row.pop(key, None) + # Add per-config analyzer results with config_ prefix + for cname in config_names: + run_results = config_results[cname] + row[f"config_{cname}"] = json.dumps(run_results.get(rec_id, [])) + # Human-reviewed final entities + ents = golden.get(rec_id, []) + row["final_entities"] = json.dumps([e.model_dump() for e in ents]) + writer.writerow(row) + + with open(stored, "w", encoding="utf-8") as f: + f.write(buf.getvalue()) + else: + # JSON format + with open(stored, encoding="utf-8") as f: + data = json.load(f) + + for i, obj in enumerate(data): + rec_id = f"rec-{i + 1:04d}" + obj.pop("presidio_analyzer_entities", None) + # Remove old config columns (both prefixed and non-prefixed) + for key in list(obj.keys()): + if key.startswith("config_") or key in config_results: + obj.pop(key, None) + for cname in config_names: + run_results = config_results[cname] + obj[f"config_{cname}"] = run_results.get(rec_id, []) + ents = golden.get(rec_id, []) + obj["final_entities"] = [e.model_dump() for e in ents] + + with open(stored, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + # Update dataset metadata + _uploaded[req.dataset_id] = ds.model_copy( + update={"has_final_entities": True, "ran_configs": config_names} + ) + _save_registry() + + return {"status": "saved", "records_updated": len(golden)} + + +class SaveConfigResultsRequest(BaseModel): + dataset_id: str + + +@router.post("/save-config-results") +async def save_config_results(req: SaveConfigResultsRequest): + """Persist all accumulated config columns into the stored dataset file + without touching final_entities. Used when a second config is run + in second-phase mode (skipping Human Review).""" + import csv + import io + import json + import os + + ds = _uploaded.get(req.dataset_id) + if ds is None: + raise HTTPException(status_code=404, detail="Dataset not found") + + stored = ds.stored_path if ds.stored_path and os.path.isfile(ds.stored_path) else None + if not stored: + raise HTTPException(status_code=400, detail="No stored file to update") + + presidio_results = presidio_state.get("results", {}) + config_results: dict[str, dict[str, list]] = dict(_all_config_results) + current_config = presidio_state.get("config_name") + if current_config and presidio_results: + config_results[current_config] = presidio_results + + # Also load any existing config columns already saved in the file + # so that a second-config run preserves the first config's results + if ds.format == "csv": + with open(stored, encoding="utf-8") as hf: + existing_reader = csv.DictReader(hf) + existing_cols = existing_reader.fieldnames or [] + existing_config_cols = [c for c in existing_cols if c.startswith("config_")] + if existing_config_cols: + existing_rows = list(existing_reader) + for col in existing_config_cols: + cname = col[len("config_"):] + if cname not in config_results: + config_results[cname] = {} + for i, row in enumerate(existing_rows): + rec_id = f"rec-{i + 1:04d}" + val = row.get(col, "[]") + try: + config_results[cname][rec_id] = json.loads(val) if val else [] + except (json.JSONDecodeError, TypeError): + config_results[cname][rec_id] = [] + elif ds.format == "json": + with open(stored, encoding="utf-8") as hf: + existing_data = json.load(hf) + if existing_data: + for key in list(existing_data[0].keys()): + if key.startswith("config_"): + cname = key[len("config_"):] + if cname not in config_results: + config_results[cname] = {} + for i, obj in enumerate(existing_data): + rec_id = f"rec-{i + 1:04d}" + config_results[cname][rec_id] = obj.get(key, []) + + config_names = list(config_results.keys()) + + if not config_names: + return {"status": "noop", "message": "No config results to save"} + + if ds.format == "csv": + buf = io.StringIO() + with open(stored, encoding="utf-8") as hf: + csv_columns = csv.DictReader(hf).fieldnames or [] + base_cols = [ + c for c in csv_columns + if c not in ("presidio_analyzer_entities",) + and not c.startswith("config_") + ] + config_col_names = [f"config_{cname}" for cname in config_names] + # Insert config columns before final_entities if it exists + if "final_entities" in base_cols: + base_cols.remove("final_entities") + fieldnames = base_cols + config_col_names + ["final_entities"] + else: + fieldnames = base_cols + config_col_names + writer = csv.DictWriter(buf, fieldnames=fieldnames) + writer.writeheader() + + with open(stored, encoding="utf-8") as f: + orig_rows = list(csv.DictReader(f)) + + for i, row in enumerate(orig_rows): + rec_id = f"rec-{i + 1:04d}" + row.pop("presidio_analyzer_entities", None) + for key in list(row.keys()): + if key.startswith("config_") or key in config_results: + row.pop(key, None) + for cname in config_names: + run_results = config_results[cname] + row[f"config_{cname}"] = json.dumps(run_results.get(rec_id, [])) + writer.writerow(row) + + with open(stored, "w", encoding="utf-8") as f: + f.write(buf.getvalue()) + else: + with open(stored, encoding="utf-8") as f: + data = json.load(f) + for i, obj in enumerate(data): + rec_id = f"rec-{i + 1:04d}" + obj.pop("presidio_analyzer_entities", None) + for key in list(obj.keys()): + if key.startswith("config_") or key in config_results: + obj.pop(key, None) + for cname in config_names: + run_results = config_results[cname] + obj[f"config_{cname}"] = run_results.get(rec_id, []) + with open(stored, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + _uploaded[req.dataset_id] = ds.model_copy( + update={"ran_configs": config_names} + ) + _save_registry() + + return {"status": "saved", "configs": config_names} diff --git a/evaluation/ai-assistant/backend/routers/sampling.py b/evaluation/ai-assistant/backend/routers/sampling.py new file mode 100644 index 0000000000..066b0f5646 --- /dev/null +++ b/evaluation/ai-assistant/backend/routers/sampling.py @@ -0,0 +1,90 @@ +import pandas as pd +from fastapi import APIRouter, HTTPException +from models import Record, SamplingConfig, SamplingMethod + +from routers.upload import _records as uploaded_records + +router = APIRouter(prefix="/api/sampling", tags=["sampling"]) + +# Sampled records available for downstream steps +sampled_records: list[Record] = [] + + +def _sample_random(df: pd.DataFrame, n: int) -> pd.DataFrame: + return df.sample(n=n, random_state=42) + + +def _sample_length(df: pd.DataFrame, n: int) -> pd.DataFrame: + """Stratified sampling by text length buckets (short / medium / long).""" + lengths = df["text"].str.len() + terciles = lengths.quantile([1 / 3, 2 / 3]) + df = df.copy() + df["_len_bucket"] = pd.cut( + lengths, + bins=[-1, terciles.iloc[0], terciles.iloc[1], lengths.max() + 1], + labels=["short", "medium", "long"], + ) + per_bucket = max(1, n // 3) + parts: list[pd.DataFrame] = [] + for bucket in ["short", "medium", "long"]: + group = df[df["_len_bucket"] == bucket] + take = min(per_bucket, len(group)) + parts.append(group.sample(n=take, random_state=42)) + collected = pd.concat(parts) + # fill any remaining quota from the full set + if len(collected) < n: + remaining = df.drop(collected.index) + extra = min(n - len(collected), len(remaining)) + if extra > 0: + collected = pd.concat( + [collected, remaining.sample(n=extra, random_state=42)] + ) + return collected.drop(columns=["_len_bucket"]) + + +_SAMPLERS = { + SamplingMethod.random: _sample_random, + SamplingMethod.length: _sample_length, +} + + +@router.post("") +async def configure_sampling(config: SamplingConfig): + """Sample records from the loaded dataset.""" + global sampled_records + + records = uploaded_records.get(config.dataset_id) + if not records: + raise HTTPException( + status_code=404, + detail=f"Dataset '{config.dataset_id}' not found.", + ) + + total = len(records) + sample_size = min(config.sample_size, total) + + if sample_size <= 0: + raise HTTPException( + status_code=400, + detail="Sample size must be greater than 0.", + ) + + df = pd.DataFrame([r.model_dump() for r in records]) + sampler = _SAMPLERS[config.method] + sampled_df = sampler(df, sample_size) + sampled_records = [ + Record(**row) for row in sampled_df.to_dict(orient="records") + ] + + return { + "sample_size": len(sampled_records), + "total_records": total, + "method": config.method.value, + "status": "ready", + } + + +@router.get("/records") +async def get_sampled_records(): + """Return the current set of sampled records.""" + return sampled_records diff --git a/evaluation/ai-assistant/backend/routers/upload.py b/evaluation/ai-assistant/backend/routers/upload.py new file mode 100644 index 0000000000..dcbf238c94 --- /dev/null +++ b/evaluation/ai-assistant/backend/routers/upload.py @@ -0,0 +1,510 @@ +"""Dataset load router — reads CSV and JSON files from a local path.""" + +from __future__ import annotations + +import csv +import io +import json +import os +import re +import shutil +import uuid + +from fastapi import APIRouter, HTTPException, UploadFile, File, Form +from fastapi.responses import FileResponse +from models import ( + DatasetLoadRequest, + DatasetRenameRequest, + Entity, + Record, + UploadedDataset, +) + +router = APIRouter(prefix="/api/datasets", tags=["datasets"]) + +_NAME_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9 _.\-]*$') + + +def _validate_name(name: str) -> None: + if not _NAME_RE.match(name): + raise HTTPException( + status_code=400, + detail="Name may only contain letters, numbers, hyphens, underscores, dots, and spaces.", + ) + + +# In-memory store for loaded datasets +_uploaded: dict[str, UploadedDataset] = {} +_records: dict[str, list[Record]] = {} + +MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB + +# Persistence file for saved datasets (next to this file → backend/datasets.json) +_DATASETS_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), "datasets.json") + +# Local copy folder (gitignored) +_DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data") +os.makedirs(_DATA_DIR, exist_ok=True) + + +def _save_registry() -> None: + """Persist the dataset registry to disk.""" + data = [ds.model_dump() for ds in _uploaded.values()] + with open(_DATASETS_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + + +def _load_registry() -> None: + """Load previously saved datasets from disk on startup.""" + if not os.path.isfile(_DATASETS_FILE): + return + try: + with open(_DATASETS_FILE, encoding="utf-8") as f: + data = json.load(f) + for item in data: + ds = UploadedDataset(**item) + _uploaded[ds.id] = ds + except Exception: + pass # ignore corrupt file + + +# Project root (evaluation/ai-assistant/) — used to resolve relative paths +_PROJECT_ROOT = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "..") +) + + +def _resolve_path(path: str) -> str: + """Resolve a path; relative paths are resolved against the project root.""" + if os.path.isabs(path): + return path + return os.path.normpath(os.path.join(_PROJECT_ROOT, path)) + + +def _ensure_stored_copy(dataset_id: str) -> UploadedDataset: + """Ensure a dataset has a managed copy under backend/data.""" + ds = _uploaded.get(dataset_id) + if ds is None: + raise HTTPException(status_code=404, detail="Dataset not found") + + if ds.stored_path and os.path.isfile(ds.stored_path): + return ds + + resolved = _resolve_path(ds.path) + if not os.path.isfile(resolved): + raise HTTPException( + status_code=404, + detail=f"Source file no longer exists: {ds.path}", + ) + + ext = os.path.splitext(resolved)[1] or ".csv" + safe_name = ds.name.replace(" ", "_").replace("/", "_") + stored_filename = f"{safe_name}_{dataset_id}{ext}" + stored_path = os.path.join(_DATA_DIR, stored_filename) + + shutil.copy2(resolved, stored_path) + updated = ds.model_copy(update={"stored_path": stored_path}) + _uploaded[dataset_id] = updated + _save_registry() + return updated + + +# Load on import so saved datasets are available immediately +_load_registry() + + +def _parse_entities(raw: str | list | None) -> list[Entity]: + """Parse entities from a JSON string or list.""" + if not raw: + return [] + if isinstance(raw, str): + try: + raw = json.loads(raw) + except json.JSONDecodeError: + return [] + if not isinstance(raw, list): + return [] + + entities: list[Entity] = [] + for item in raw: + if isinstance(item, dict): + try: + entities.append(Entity(**item)) + except Exception: + continue + return entities + + +def _parse_csv( + content: str, + text_column: str, + entities_column: str | None, +) -> tuple[list[Record], list[str], bool]: + """Parse CSV content into records.""" + reader = csv.DictReader(io.StringIO(content)) + fieldnames = list(reader.fieldnames or []) + if text_column not in fieldnames: + raise HTTPException( + status_code=400, + detail=f"CSV must have a '{text_column}' column. Found: {fieldnames}", + ) + + has_entities = entities_column is not None and entities_column in fieldnames + has_final = "final_entities" in fieldnames + records: list[Record] = [] + for i, row in enumerate(reader): + text = row.get(text_column, "").strip() + if not text: + continue + entities = ( + _parse_entities(row.get(entities_column)) + if has_entities and entities_column + else [] + ) + final_ents = ( + _parse_entities(row.get("final_entities")) + if has_final + else None + ) + records.append( + Record( + id=f"rec-{i + 1:04d}", + text=text, + dataset_entities=entities if entities else (final_ents or []), + final_entities=final_ents if final_ents else None, + ) + ) + return records, fieldnames, has_entities, has_final + + +def _parse_json( + content: str, + text_column: str, + entities_column: str | None, +) -> tuple[list[Record], list[str], bool]: + """Parse JSON content (array of objects or JSONL) into records.""" + # Try parsing as a JSON array first + try: + data = json.loads(content) + except json.JSONDecodeError: + # Fall back to JSONL + data = [] + for line in content.splitlines(): + line = line.strip() + if not line: + continue + try: + data.append(json.loads(line)) + except json.JSONDecodeError: + continue + + if not isinstance(data, list) or not data: + raise HTTPException( + status_code=400, + detail="JSON file must contain an array of objects or be in JSONL format.", + ) + + records: list[Record] = [] + columns: set[str] = set() + has_entities = False + + has_final = False + for i, obj in enumerate(data): + if not isinstance(obj, dict) or text_column not in obj: + continue + + columns.update(obj.keys()) + text = str(obj[text_column]).strip() + if not text: + continue + + ent_raw = obj.get(entities_column) if entities_column else None + entities = _parse_entities(ent_raw) + if entities: + has_entities = True + + final_raw = obj.get("final_entities") + final_ents = _parse_entities(final_raw) if final_raw else None + if final_ents: + has_final = True + + records.append( + Record( + id=f"rec-{i + 1:04d}", + text=text, + dataset_entities=entities if entities else (final_ents or []), + final_entities=final_ents if final_ents else None, + ) + ) + + if not records: + raise HTTPException( + status_code=400, + detail=( + f"No valid records found. Each object must " + f"have a '{text_column}' field." + ), + ) + return records, sorted(columns), has_entities, has_final + + +@router.post("/columns") +async def get_csv_columns(file: UploadFile = File(...)): + """Read the header row of a CSV file and return the column names.""" + if not file.filename or not file.filename.lower().endswith(".csv"): + raise HTTPException(status_code=400, detail="Only .csv files are accepted.") + # Read just enough to get the header (first 64 KB) + head = (await file.read(65_536)).decode("utf-8", errors="replace") + reader = csv.DictReader(io.StringIO(head)) + columns = list(reader.fieldnames or []) + if not columns: + raise HTTPException(status_code=400, detail="Could not detect columns in the CSV file.") + return {"columns": columns} + + +@router.post("/columns-from-path") +async def get_csv_columns_from_path(req: dict): + """Read the header row of a CSV at the given absolute path.""" + file_path = os.path.expanduser(req.get("path", "")) + if not file_path or not os.path.isabs(file_path): + raise HTTPException(status_code=400, detail="Path must be absolute.") + if not os.path.isfile(file_path): + raise HTTPException(status_code=400, detail=f"File not found: {file_path}") + with open(file_path, encoding="utf-8") as f: + head = f.read(65_536) + reader = csv.DictReader(io.StringIO(head)) + columns = list(reader.fieldnames or []) + if not columns: + raise HTTPException(status_code=400, detail="Could not detect columns in the CSV file.") + return {"columns": columns} + + +@router.get("/saved") +async def list_saved_datasets(): + """Return all saved datasets (for the dropdown).""" + return list(_uploaded.values()) + + +@router.post("/load") +async def load_dataset(req: DatasetLoadRequest): + """Load a CSV or JSON file from a local absolute path.""" + if req.format not in ("csv", "json"): + raise HTTPException( + status_code=400, + detail=( + f"Unsupported format '{req.format}'. " + f"Only 'csv' and 'json' are supported." + ), + ) + + file_path = os.path.expanduser(req.path) + if not os.path.isabs(file_path): + raise HTTPException(status_code=400, detail="Path must be absolute.") + if not os.path.isfile(file_path): + raise HTTPException(status_code=400, detail=f"File not found: {file_path}") + + file_size = os.path.getsize(file_path) + if file_size > MAX_FILE_SIZE: + raise HTTPException(status_code=400, detail="File too large (max 50 MB)") + + with open(file_path, encoding="utf-8") as f: + content = f.read() + + if req.format == "csv": + records, columns, has_entities, has_final = _parse_csv( + content, req.text_column, req.entities_column + ) + else: + records, columns, has_entities, has_final = _parse_json( + content, req.text_column, req.entities_column + ) + + if not records: + raise HTTPException(status_code=400, detail="No valid records found in file") + + uid = uuid.uuid4().hex[:8] + dataset_id = f"upload-{uid}" + filename = os.path.basename(file_path) + + display_name = req.name.strip() if req.name and req.name.strip() else filename + _validate_name(display_name) + # Ensure unique name + existing_names = {ds.name.lower() for ds in _uploaded.values()} + if display_name.lower() in existing_names: + raise HTTPException(status_code=409, detail=f"A dataset named '{display_name}' already exists. Please choose a different name.") + # Copy file to local data/ folder for persistence + ext = os.path.splitext(filename)[1] + safe_ds_name = display_name.replace(" ", "_").replace("/", "_") + stored_filename = f"{safe_ds_name}_{uid}{ext}" + stored_path = os.path.join(_DATA_DIR, stored_filename) + shutil.copy2(file_path, stored_path) + + description = req.description.strip() if req.description else "" + dataset = UploadedDataset( + id=dataset_id, + filename=filename, + name=display_name, + description=description, + path=file_path, + stored_path=stored_path, + format=req.format, + record_count=len(records), + has_entities=has_entities, + has_final_entities=has_final, + text_column=req.text_column, + entities_column=req.entities_column, + ) + + _uploaded[dataset_id] = dataset + _records[dataset_id] = records + _save_registry() + + return dataset + + +@router.get("/{dataset_id}") +async def get_dataset(dataset_id: str): + """Return metadata for a single dataset.""" + ds = _uploaded.get(dataset_id) + if not ds: + raise HTTPException(status_code=404, detail="Dataset not found") + return ds + + +@router.patch("/{dataset_id}/rename") +async def rename_dataset(dataset_id: str, req: DatasetRenameRequest): + """Rename a saved dataset.""" + if dataset_id not in _uploaded: + raise HTTPException(status_code=404, detail="Dataset not found") + new_name = req.name.strip() + if not new_name: + raise HTTPException(status_code=400, detail="Name cannot be empty") + _validate_name(new_name) + existing_names = {ds.name.lower() for did, ds in _uploaded.items() if did != dataset_id} + if new_name.lower() in existing_names: + raise HTTPException(status_code=409, detail=f"A dataset named '{new_name}' already exists. Please choose a different name.") + _uploaded[dataset_id] = _uploaded[dataset_id].model_copy( + update={"name": new_name} + ) + _save_registry() + return _uploaded[dataset_id] + + +@router.post("/upload") +async def upload_dataset( + file: UploadFile = File(...), + text_column: str = Form("text"), + entities_column: str = Form(""), + name: str = Form(""), + description: str = Form(""), +): + """Upload a CSV file directly and load it as a dataset.""" + if not file.filename or not file.filename.lower().endswith(".csv"): + raise HTTPException(status_code=400, detail="Only .csv files are accepted.") + + content_bytes = await file.read() + if len(content_bytes) > MAX_FILE_SIZE: + raise HTTPException(status_code=400, detail="File too large (max 50 MB)") + + content = content_bytes.decode("utf-8") + ent_col = entities_column.strip() or None + records, columns, has_entities, has_final = _parse_csv( + content, text_column.strip() or "text", ent_col + ) + if not records: + raise HTTPException(status_code=400, detail="No valid records found in file") + + uid = uuid.uuid4().hex[:8] + dataset_id = f"upload-{uid}" + filename = file.filename + + display_name = name.strip() if name and name.strip() else filename + _validate_name(display_name) + existing_names = {ds.name.lower() for ds in _uploaded.values()} + if display_name.lower() in existing_names: + raise HTTPException( + status_code=409, + detail=f"A dataset named '{display_name}' already exists. Please choose a different name.", + ) + + # Save file to data/ folder + safe_ds_name = display_name.replace(" ", "_").replace("/", "_") + stored_filename = f"{safe_ds_name}_{uid}.csv" + stored_path = os.path.join(_DATA_DIR, stored_filename) + with open(stored_path, "w", encoding="utf-8") as f: + f.write(content) + + dataset = UploadedDataset( + id=dataset_id, + filename=filename, + name=display_name, + description=description.strip() if description else "", + path=stored_path, + stored_path=stored_path, + format="csv", + record_count=len(records), + has_entities=has_entities, + has_final_entities=has_final, + text_column=text_column.strip() or "text", + entities_column=ent_col, + ) + + _uploaded[dataset_id] = dataset + _records[dataset_id] = records + _save_registry() + + return dataset + + +@router.delete("/{dataset_id}") +async def delete_dataset(dataset_id: str): + """Remove a saved dataset from the registry.""" + if dataset_id not in _uploaded: + raise HTTPException(status_code=404, detail="Dataset not found") + del _uploaded[dataset_id] + _records.pop(dataset_id, None) + _save_registry() + return {"ok": True} + + +def _ensure_records_loaded(dataset_id: str) -> list[Record]: + """Reload records from the stored copy (or original file) if not in memory.""" + if dataset_id in _records: + return _records[dataset_id] + ds = _ensure_stored_copy(dataset_id) + resolved = ds.stored_path if ds.stored_path and os.path.isfile(ds.stored_path) else _resolve_path(ds.path) + with open(resolved, encoding="utf-8") as f: + content = f.read() + if ds.format == "csv": + records, _, _, _ = _parse_csv(content, ds.text_column, ds.entities_column) + else: + records, _, _, _ = _parse_json(content, ds.text_column, ds.entities_column) + _records[dataset_id] = records + return records + + +@router.get("/{dataset_id}/records") +async def get_dataset_records(dataset_id: str): + """Return parsed records for a loaded dataset.""" + return _ensure_records_loaded(dataset_id) + + +@router.get("/{dataset_id}/preview") +async def preview_dataset(dataset_id: str, limit: int = 5): + """Return a small preview of the loaded dataset.""" + records = _ensure_records_loaded(dataset_id) + return records[:limit] + + +@router.get("/{dataset_id}/download") +async def download_dataset(dataset_id: str): + """Download the raw CSV/JSON file for a dataset.""" + ds = _ensure_stored_copy(dataset_id) + resolved = ds.stored_path if ds.stored_path else _resolve_path(ds.path) + if not os.path.isfile(resolved): + raise HTTPException(status_code=404, detail="Dataset file not found on disk") + return FileResponse( + resolved, + media_type="text/csv" if ds.format == "csv" else "application/json", + filename=f"{ds.name}.{ds.format}", + ) diff --git a/evaluation/ai-assistant/backend/settings.py b/evaluation/ai-assistant/backend/settings.py new file mode 100644 index 0000000000..585a093e4f --- /dev/null +++ b/evaluation/ai-assistant/backend/settings.py @@ -0,0 +1,66 @@ +"""Centralised settings loaded from .env with PRESIDIO_EVAL_ prefix.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Optional + +from pydantic import BaseModel + +_ENV_FILE = Path(__file__).resolve().parent / ".env" + +# ── Available model choices shown in the UI dropdown ── +MODEL_CHOICES: list[dict[str, str]] = [ + {"id": "gpt-5.1", "label": "GPT-5.1", "provider": "Azure OpenAI"}, + { + "id": "gpt-5.2-chat", + "label": "GPT-5.2 Chat", + "provider": "Azure OpenAI", + }, + {"id": "gpt-5.4", "label": "GPT-5.4", "provider": "Azure OpenAI"}, +] + + +class EvalSettings(BaseModel): + """Runtime settings, sourced from env vars or interactive input.""" + + azure_endpoint: str = "" + azure_api_key: Optional[str] = None + deployment_name: str = "gpt-5.4" + api_version: str = "2024-02-15-preview" + + +def _load_dotenv() -> None: + """Read .env into os.environ (simple key=value parser, no dependency).""" + if not _ENV_FILE.is_file(): + return + with open(_ENV_FILE) as fh: + for line in fh: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip() + # Only set if not already in os.environ (explicit env wins) + if key not in os.environ: + os.environ[key] = value + + +def load_from_env() -> EvalSettings: + """Build settings from PRESIDIO_EVAL_* environment variables.""" + _load_dotenv() + api_key = os.environ.get("PRESIDIO_EVAL_AZURE_API_KEY") or None + return EvalSettings( + azure_endpoint=os.environ.get("PRESIDIO_EVAL_AZURE_ENDPOINT", ""), + azure_api_key=api_key, + deployment_name=os.environ.get( + "PRESIDIO_EVAL_DEPLOYMENT_NAME", "gpt-4o" + ), + api_version=os.environ.get( + "PRESIDIO_EVAL_API_VERSION", "2024-02-15-preview" + ), + ) diff --git a/evaluation/ai-assistant/data/example_pii_dataset.csv b/evaluation/ai-assistant/data/example_pii_dataset.csv new file mode 100644 index 0000000000..2bf008d8aa --- /dev/null +++ b/evaluation/ai-assistant/data/example_pii_dataset.csv @@ -0,0 +1,4 @@ +text +"Dear Mr. James Wilson, your appointment at Springfield Clinic is confirmed for 04/12/2025. Please bring your insurance card (ID: INS-77234) and a photo ID. For questions, call 312-555-0198 or email james.wilson@outlook.com." +"The account holder, Maria Garcia (DOB: 11/23/1990), reported unauthorized charges on her Visa card ending in 4829. Her address on file is 742 Elm Street, Austin, TX 78701. Case reference: CR-2025-08813." +"Hi, this is a message for David Chen at extension 4021. Your lab results from Quest Diagnostics (order #QD-998471) are ready. Please contact Dr. Patel at 617-555-0342 to discuss. Your patient ID is PT-228109." diff --git a/evaluation/ai-assistant/data/sample_medical_records.csv b/evaluation/ai-assistant/data/sample_medical_records.csv new file mode 100644 index 0000000000..30d69426b5 --- /dev/null +++ b/evaluation/ai-assistant/data/sample_medical_records.csv @@ -0,0 +1,6 @@ +text +"SSN 123-45-6789 belongs to Maria Garcia, born on January 12, 1990." +Credit card number 4111-1111-1111-1111 was used by Robert Johnson on 12/01/2024. +"Patient Margaret O'Brien, age 101, was transferred from Cedar Hills Nursing Home on 02/28/2026. Her son Thomas O'Brien (phone 617-555-0198) is the emergency contact." +"Discharge summary for veteran William R. Thompson III, SSN 987-65-4320, admitted 01/05/2026 to VA Medical Center. Diagnosis: PTSD (ICD-10 F43.10). Pharmacy called in Sertraline 50mg to CVS #4892, phone 703-555-2244." +"Home health visit on 03/01/2026 for 97-year-old Clara Johansson at 88 Maple Drive, Apt 4B, Minneapolis MN 55401. Caregiver Ana Rodriguez (ana.r@homecare.net, 612-555-8834) reported patient fell on 02/27/2026. BP 155/95, pulse 88. Referred to Dr. Karen Wu, orthopedics." diff --git a/evaluation/ai-assistant/index.html b/evaluation/ai-assistant/index.html new file mode 100644 index 0000000000..613054d3f1 --- /dev/null +++ b/evaluation/ai-assistant/index.html @@ -0,0 +1,12 @@ + + + + + + Presidio Evaluation Flow + + +
+ + + diff --git a/evaluation/ai-assistant/package-lock.json b/evaluation/ai-assistant/package-lock.json new file mode 100644 index 0000000000..88219ba1a2 --- /dev/null +++ b/evaluation/ai-assistant/package-lock.json @@ -0,0 +1,3990 @@ +{ + "name": "presidio-evaluation-flow", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "presidio-evaluation-flow", + "version": "1.0.0", + "dependencies": { + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.3", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-progress": "^1.1.2", + "@radix-ui/react-radio-group": "^1.2.3", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-slider": "^1.2.3", + "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-tabs": "^1.1.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.487.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router": "^7.13.0", + "recharts": "^2.15.2", + "sonner": "^2.0.3", + "tailwind-merge": "^3.2.0", + "tw-animate-css": "^1.3.8" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.12", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.7.0", + "tailwindcss": "^4.1.12", + "typescript": "^5.7.0", + "vite": "^6.3.5" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.8.tgz", + "integrity": "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", + "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "tailwindcss": "4.2.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001777", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", + "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.307", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.487.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.487.0.tgz", + "integrity": "sha512-aKqhOQ+YmFnwq8dWgGjOuLc8V1R9/c/yOd+zDY4+ohsR2Jo05lSGc3WsstYPIzcTpeosN7LoCkLReUUITvaIvw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", + "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/evaluation/ai-assistant/package.json b/evaluation/ai-assistant/package.json new file mode 100644 index 0000000000..17e0d57aef --- /dev/null +++ b/evaluation/ai-assistant/package.json @@ -0,0 +1,41 @@ +{ + "name": "presidio-evaluation-flow", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.3", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-progress": "^1.1.2", + "@radix-ui/react-radio-group": "^1.2.3", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-slider": "^1.2.3", + "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-tabs": "^1.1.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.487.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router": "^7.13.0", + "recharts": "^2.15.2", + "sonner": "^2.0.3", + "tailwind-merge": "^3.2.0", + "tw-animate-css": "^1.3.8" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.12", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.7.0", + "tailwindcss": "^4.1.12", + "typescript": "^5.7.0", + "vite": "^6.3.5" + } +} diff --git a/evaluation/ai-assistant/postcss.config.mjs b/evaluation/ai-assistant/postcss.config.mjs new file mode 100644 index 0000000000..b1c6ea436a --- /dev/null +++ b/evaluation/ai-assistant/postcss.config.mjs @@ -0,0 +1 @@ +export default {} diff --git a/evaluation/ai-assistant/run.md b/evaluation/ai-assistant/run.md new file mode 100644 index 0000000000..1b42c432bf --- /dev/null +++ b/evaluation/ai-assistant/run.md @@ -0,0 +1,23 @@ +# Running the App + +## Backend + +**Important:** Run `poetry run` from the `backend/` directory (not the repo root) so Poetry uses the correct virtualenv with `presidio-analyzer` installed. + +```bash +cd evaluation/ai-assistant/backend +poetry install # first time only — installs presidio-analyzer + deps +poetry run uvicorn main:app --reload --reload-dir . --port 8000 --log-level info +``` + +> `--reload-dir .` scopes file watching to the backend directory only, preventing restarts when spaCy downloads models into site-packages. + +## Frontend + +```bash +cd evaluation/ai-assistant +npm install +npm run dev +``` + +Opens at http://localhost:5173 (proxies `/api` to backend). diff --git a/evaluation/ai-assistant/src/app/App.tsx b/evaluation/ai-assistant/src/app/App.tsx new file mode 100644 index 0000000000..7b834a756b --- /dev/null +++ b/evaluation/ai-assistant/src/app/App.tsx @@ -0,0 +1,12 @@ +import { RouterProvider } from 'react-router'; +import { router } from './routes.tsx'; +import { Toaster } from './components/ui/sonner'; + +export default function App() { + return ( + <> + + + + ); +} diff --git a/evaluation/ai-assistant/src/app/components/EntityComparison.tsx b/evaluation/ai-assistant/src/app/components/EntityComparison.tsx new file mode 100644 index 0000000000..255d1b8226 --- /dev/null +++ b/evaluation/ai-assistant/src/app/components/EntityComparison.tsx @@ -0,0 +1,875 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { Card } from './ui/card'; +import { Button } from './ui/button'; +import { Badge } from './ui/badge'; +import { Input } from './ui/input'; +import { Label } from './ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible'; +import { CheckCircle, XCircle, Edit, Check, X, ChevronDown, FileText, Move, Undo2, Plus } from 'lucide-react'; +import type { Entity } from '../types'; + +interface EntityComparisonProps { + recordId: string; + recordText: string; + presidioEntities: Entity[]; + llmEntities: Entity[]; + datasetEntities?: Entity[]; + allConfirmed?: boolean; + autoConfirmDataset?: boolean; + onConfirm: (recordId: string, entity: Entity, source: 'presidio' | 'llm' | 'dataset' | 'manual') => void; + onReject: (recordId: string, entity: Entity, source: 'presidio' | 'llm' | 'dataset' | 'manual') => void; + onAddManual: (recordId: string, entity: Entity) => void; + onUndo?: (recordId: string, entity: Entity) => void; +} + +type EntitySource = 'presidio' | 'llm' | 'dataset' | 'manual'; +type EntityStatus = 'match' | 'presidio-only' | 'llm-only' | 'dataset-only' | 'manual' | 'pending'; + +interface AnnotatedEntity extends Entity { + status: EntityStatus; + sources: EntitySource[]; + confirmed?: boolean; +} + +export function EntityComparison({ + recordId, + recordText, + presidioEntities = [], + llmEntities = [], + datasetEntities = [], + allConfirmed = false, + autoConfirmDataset = false, + onConfirm, + onReject, + onAddManual, + onUndo, +}: EntityComparisonProps) { + const [showAddManual, setShowAddManual] = useState(false); + const [manualEntity, setManualEntity] = useState({ text: '', entity_type: '', start: 0, end: 0 }); + const [confirmedEntities, setConfirmedEntities] = useState>(new Set()); + const [rejectedEntities, setRejectedEntities] = useState>(new Set()); + const [expandedContexts, setExpandedContexts] = useState>(new Set()); + const [editedTypes, setEditedTypes] = useState>({}); + // Boundary adjustment state: key -> { start, end } + const [adjustingKeys, setAdjustingKeys] = useState>(new Set()); + const [adjustedBounds, setAdjustedBounds] = useState>({}); + // Manually added entities (tracked locally for rendering) + const [manualEntities, setManualEntities] = useState([]); + const [customTypes, setCustomTypes] = useState([]); + const [newTypeName, setNewTypeName] = useState(''); + const [showNewTypeInput, setShowNewTypeInput] = useState(null); // tracks which dropdown: 'manual' | 'edit' | null + + // Drag state for boundary adjustment handles + const dragRef = useRef<{ side: 'start' | 'end'; entityKey: string; entity: Entity } | null>(null); + // Drag state for manual entity text selection + const manualDragRef = useRef<{ active: boolean; anchorIdx: number } | null>(null); + + // Reset per-record state when navigating to a different record + useEffect(() => { + setManualEntities([]); + setConfirmedEntities(new Set()); + setRejectedEntities(new Set()); + setExpandedContexts(new Set()); + setEditedTypes({}); + setAdjustingKeys(new Set()); + setAdjustedBounds({}); + setShowAddManual(false); + setManualEntity({ text: '', entity_type: '', start: 0, end: 0 }); + }, [recordId]); + + useEffect(() => { + const handleGlobalMouseUp = () => { + dragRef.current = null; + manualDragRef.current = null; + }; + window.addEventListener('mouseup', handleGlobalMouseUp); + return () => window.removeEventListener('mouseup', handleGlobalMouseUp); + }, []); + + const handleDragMove = useCallback((e: React.MouseEvent) => { + if (!dragRef.current) return; + e.preventDefault(); + const target = document.elementFromPoint(e.clientX, e.clientY); + if (!target) return; + const charIdx = target.getAttribute('data-char-idx'); + if (charIdx === null) return; + const idx = Number(charIdx); + const { side, entityKey, entity } = dragRef.current; + setAdjustedBounds(prev => { + const cur = prev[entityKey] || { start: entity.start, end: entity.end }; + if (side === 'start') { + const newStart = Math.max(0, Math.min(idx, cur.end)); + return { ...prev, [entityKey]: { ...cur, start: newStart } }; + } else { + const newEnd = Math.max(cur.start, Math.min(idx + 1, recordText.length)); + return { ...prev, [entityKey]: { ...cur, end: newEnd } }; + } + }); + }, [recordText.length]); + + // Combine and classify entities from all three sources + const annotatedEntities: AnnotatedEntity[] = []; + + // Two spans overlap if one starts before the other ends + const spansOverlap = (a: Entity, b: Entity) => + a.start < b.end && b.start < a.end; + + // Build a unified list: for each unique span, track which sources detected it + interface SpanEntry { entity: Entity; sources: Set; types: Map } + const spans: SpanEntry[] = []; + + const addToSpans = (entity: Entity, source: EntitySource) => { + const existing = spans.find(s => spansOverlap(s.entity, entity)); + if (existing) { + existing.sources.add(source); + existing.types.set(source, entity.entity_type); + // Prefer the entity with more text or higher score + if (entity.text.length > existing.entity.text.length) { + existing.entity = { ...entity }; + } + } else { + const types = new Map(); + types.set(source, entity.entity_type); + spans.push({ entity: { ...entity }, sources: new Set([source]), types }); + } + }; + + presidioEntities.forEach(e => addToSpans(e, 'presidio')); + llmEntities.forEach(e => addToSpans(e, 'llm')); + datasetEntities.forEach(e => addToSpans(e, 'dataset')); + // Manual entities are always added as separate entries (never merged with existing spans) + manualEntities.forEach(e => { + const types = new Map(); + types.set('manual', e.entity_type); + spans.push({ entity: { ...e }, sources: new Set(['manual']), types }); + }); + + const statusForSource = (src: EntitySource): EntityStatus => { + if (src === 'presidio') return 'presidio-only'; + if (src === 'llm') return 'llm-only'; + if (src === 'manual') return 'manual'; + return 'dataset-only'; + }; + + spans.forEach(({ entity, sources, types }) => { + const sourceList = Array.from(sources) as EntitySource[]; + const uniqueTypes = new Set(types.values()); + const allAgree = uniqueTypes.size === 1; + + if (sourceList.length >= 2 && allAgree) { + // All active sources agree on type → single "Match" card + annotatedEntities.push({ ...entity, status: 'match', sources: sourceList }); + } else if (sourceList.length >= 2 && !allAgree) { + // Sources disagree on type → group by type so sources that agree are merged + const typeToSources = new Map(); + for (const src of sourceList) { + const srcType = types.get(src) || entity.entity_type; + if (!typeToSources.has(srcType)) typeToSources.set(srcType, []); + typeToSources.get(srcType)!.push(src); + } + for (const [type, srcs] of typeToSources) { + const status = srcs.length >= 2 ? 'match' as EntityStatus : statusForSource(srcs[0]); + annotatedEntities.push({ ...entity, entity_type: type, status, sources: srcs }); + } + } else if (sourceList.length === 1) { + const s = sourceList[0]; + annotatedEntities.push({ ...entity, status: statusForSource(s), sources: sourceList }); + } else { + annotatedEntities.push({ ...entity, status: 'pending', sources: sourceList }); + } + }); + + const getEntityKey = (entity: AnnotatedEntity) => `${entity.entity_type}-${entity.text}-${entity.start}-${entity.end}-${entity.sources.join(',')}`; + + + + // When parent signals all entities are confirmed, mark them all + useEffect(() => { + if (allConfirmed && annotatedEntities.length > 0) { + setConfirmedEntities(new Set(annotatedEntities.map(e => getEntityKey(e)))); + setRejectedEntities(new Set()); + } + }, [allConfirmed, recordId]); + + // Auto-confirm only golden-dataset entities on second-config runs + useEffect(() => { + if (!autoConfirmDataset || annotatedEntities.length === 0) return; + const datasetKeys = annotatedEntities + .filter(e => e.sources.includes('dataset')) + .map(e => getEntityKey(e)); + if (datasetKeys.length > 0) { + setConfirmedEntities(prev => new Set([...prev, ...datasetKeys])); + } + }, [autoConfirmDataset, recordId]); + + const getContextForEntity = (entity: Entity, adjustedKey?: string) => { + const CONTEXT_CHARS = 150; + // Use adjusted bounds if available + const adj = adjustedKey ? adjustedBounds[adjustedKey] : undefined; + const useStart = adj ? adj.start : entity.start; + const useEnd = adj ? adj.end : entity.end; + + // Use indexOf for robust highlighting regardless of position accuracy + const idx = adj ? -1 : recordText.indexOf(entity.text); + const entityStart = idx >= 0 ? idx : useStart; + const entityEnd = idx >= 0 ? idx + entity.text.length : useEnd; + + const start = Math.max(0, entityStart - CONTEXT_CHARS); + const end = Math.min(recordText.length, entityEnd + CONTEXT_CHARS); + + const before = recordText.substring(start, entityStart); + const entityText = recordText.substring(entityStart, entityEnd); + const after = recordText.substring(entityEnd, end); + + return { + before: (start > 0 ? '...' : '') + before, + entity: entityText, + after: after + (end < recordText.length ? '...' : ''), + }; + }; + + const toggleContext = (key: string) => { + setExpandedContexts(prev => { + const newSet = new Set(prev); + if (newSet.has(key)) { + newSet.delete(key); + } else { + newSet.add(key); + } + return newSet; + }); + }; + + const handleConfirmEntity = (entity: AnnotatedEntity) => { + const key = getEntityKey(entity); + // Build final entity with optional type edit + boundary adjustment + let finalEntity: Entity = { ...entity }; + if (editedTypes[key]) { + finalEntity.entity_type = editedTypes[key]; + } + const adj = adjustedBounds[key]; + if (adj) { + // Record original span for audit + finalEntity.original_start = entity.start; + finalEntity.original_end = entity.end; + finalEntity.original_text = entity.text; + // Apply adjusted boundaries + finalEntity.start = adj.start; + finalEntity.end = adj.end; + finalEntity.text = recordText.substring(adj.start, adj.end); + } + setConfirmedEntities(new Set([...confirmedEntities, key])); + setRejectedEntities(prev => { + const newSet = new Set(prev); + newSet.delete(key); + return newSet; + }); + // Close adjustment panel + setAdjustingKeys(prev => { + const newSet = new Set(prev); + newSet.delete(key); + return newSet; + }); + onConfirm(recordId, finalEntity, entity.sources[0]); + }; + + const handleRejectEntity = (entity: AnnotatedEntity) => { + const key = getEntityKey(entity); + setRejectedEntities(new Set([...rejectedEntities, key])); + setConfirmedEntities(prev => { + const newSet = new Set(prev); + newSet.delete(key); + return newSet; + }); + onReject(recordId, entity, entity.sources[0]); + }; + + const handleUndoEntity = (entity: AnnotatedEntity) => { + const key = getEntityKey(entity); + setConfirmedEntities(prev => { + const newSet = new Set(prev); + newSet.delete(key); + return newSet; + }); + setRejectedEntities(prev => { + const newSet = new Set(prev); + newSet.delete(key); + return newSet; + }); + onUndo?.(recordId, entity); + }; + + const handleAddManualEntity = () => { + if (manualEntity.text && manualEntity.entity_type) { + const entity: Entity = { ...manualEntity, score: 1.0 }; + setManualEntities(prev => [...prev, entity]); + // Auto-confirm manual entities + const manualKey = `${entity.text}-${entity.start}-${entity.end}-manual`; + setConfirmedEntities(prev => new Set([...prev, manualKey])); + onAddManual(recordId, entity); + setManualEntity({ text: '', entity_type: '', start: 0, end: 0 }); + setShowAddManual(false); + } + }; + + const DEFAULT_ENTITY_TYPES = ['PERSON', 'EMAIL', 'PHONE_NUMBER', 'SSN', 'CREDIT_CARD', 'DATE_OF_BIRTH', + 'MEDICAL_RECORD', 'IP_ADDRESS', 'ADDRESS', 'MEDICAL_CONDITION']; + + // Include any entity types found in the data that aren't in the default list + const dataTypes = annotatedEntities.map(e => e.entity_type).filter(Boolean); + const ENTITY_TYPES = Array.from(new Set([...DEFAULT_ENTITY_TYPES, ...dataTypes, ...customTypes])).sort(); + + const addCustomType = (target: string) => { + const name = newTypeName.trim().toUpperCase().replace(/\s+/g, '_'); + if (name && !ENTITY_TYPES.includes(name)) { + setCustomTypes(prev => [...prev, name]); + } + if (name) { + if (target === 'manual') { + setManualEntity(prev => ({ ...prev, entity_type: name })); + } + } + setNewTypeName(''); + setShowNewTypeInput(null); + return name; + }; + + const getStatusBadge = (entity: AnnotatedEntity, confirmed?: boolean, rejected?: boolean) => { + if (confirmed) { + return Confirmed; + } + if (rejected) { + return Rejected; + } + + const badges: React.ReactNode[] = []; + if (entity.sources.includes('manual')) { + badges.push(Manual); + } + if (entity.sources.includes('dataset')) { + badges.push(Golden Dataset); + } + if (entity.sources.includes('presidio')) { + badges.push(Presidio Analyzer); + } + if (entity.sources.includes('llm')) { + badges.push(Presidio LLM Recognizer); + } + if (badges.length === 0) { + badges.push(Pending); + } + return <>{badges}; + }; + + return ( + +
+ {/* Record Text */} +
+ +
+ {recordText} +
+
+ + {/* Entities List */} +
+
+ + +
+ + {/* Manual Add Form */} + {showAddManual && ( +
+
+ + Manual Entity Annotation +
+ + {/* Interactive text marker */} +
+ +
{ + if (!manualDragRef.current?.active) return; + e.preventDefault(); + const target = document.elementFromPoint(e.clientX, e.clientY); + if (!target) return; + const charIdx = target.getAttribute('data-manual-char-idx'); + if (charIdx === null) return; + const idx = Number(charIdx); + const anchor = manualDragRef.current.anchorIdx; + const newStart = Math.min(anchor, idx); + const newEnd = Math.max(anchor, idx) + 1; + setManualEntity(prev => ({ + ...prev, + text: recordText.substring(newStart, newEnd), + start: newStart, + end: newEnd, + })); + }} + onMouseUp={() => { manualDragRef.current = null; }} + style={{ cursor: manualDragRef.current?.active ? 'text' : undefined }} + > + {recordText.split('').map((ch, i) => { + const isHighlighted = manualEntity.text && i >= manualEntity.start && i < manualEntity.end; + return ( + { + e.preventDefault(); + manualDragRef.current = { active: true, anchorIdx: i }; + setManualEntity(prev => ({ + ...prev, + text: recordText[i], + start: i, + end: i + 1, + })); + }} + > + {ch} + + ); + })} +
+
+ +
+
+ + { + const newText = e.target.value; + const idx = recordText.indexOf(newText); + setManualEntity({ + text: newText, + entity_type: manualEntity.entity_type, + start: idx >= 0 ? idx : manualEntity.start, + end: idx >= 0 ? idx + newText.length : manualEntity.end, + }); + }} + placeholder="Select text above or type here..." + className="text-sm" + /> +
+
+ + {showNewTypeInput === 'manual' ? ( +
+ setNewTypeName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') addCustomType('manual'); + if (e.key === 'Escape') { setNewTypeName(''); setShowNewTypeInput(null); } + }} + className="text-sm h-8 uppercase" + /> + + +
+ ) : ( + + )} +
+
+ {manualEntity.text && ( +
+ Position: {manualEntity.start}-{manualEntity.end} + {manualEntity.text && ( + Preview: “{manualEntity.text} + )} +
+ )} +
+ + +
+
+ )} + + {/* Entity Cards */} +
+ {annotatedEntities.map((entity, index) => { + const key = getEntityKey(entity); + const isConfirmed = confirmedEntities.has(key); + const isRejected = rejectedEntities.has(key); + + return ( +
+
+
+
+ {entity.text} + {!isConfirmed && !isRejected ? ( + showNewTypeInput === `edit-${key}` ? ( +
+ setNewTypeName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + const name = newTypeName.trim().toUpperCase().replace(/\s+/g, '_'); + if (name && !ENTITY_TYPES.includes(name)) setCustomTypes(prev => [...prev, name]); + if (name) setEditedTypes(prev => ({ ...prev, [key]: name })); + setNewTypeName(''); setShowNewTypeInput(null); + } + if (e.key === 'Escape') { setNewTypeName(''); setShowNewTypeInput(null); } + }} + className="text-xs h-7 w-[140px] uppercase" + /> + + +
+ ) : ( + + ) + ) : ( + {editedTypes[key] || entity.entity_type} + )} + {getStatusBadge(entity, isConfirmed, isRejected)} +
+ +
+ Position: {adjustedBounds[key] ? `${adjustedBounds[key].start}-${adjustedBounds[key].end}` : `${entity.start}-${entity.end}`} + {entity.score && Confidence: {(entity.score * 100).toFixed(0)}%} + {adjustedBounds[key] && ( + (original: {entity.start}-{entity.end}) + )} +
+ +
+ + {!isConfirmed && !isRejected ? ( +
+ + + +
+ ) : ( + + )} +
+ + {/* Boundary Adjustment Panel */} + {adjustingKeys.has(key) && !isConfirmed && !isRejected && (() => { + const bounds = adjustedBounds[key] || { start: entity.start, end: entity.end }; + const clampedStart = Math.max(0, Math.min(bounds.start, recordText.length)); + const clampedEnd = Math.max(clampedStart, Math.min(bounds.end, recordText.length)); + const previewText = recordText.substring(clampedStart, clampedEnd); + const PREVIEW_CTX = 80; + const hasChanged = bounds.start !== entity.start || bounds.end !== entity.end; + return ( +
+
+ + Adjust Entity Boundaries +
+
+
+ + { + const newStart = Math.max(0, Math.min(Number(e.target.value), recordText.length)); + setAdjustedBounds(prev => ({ + ...prev, + [key]: { ...prev[key], start: newStart, end: Math.max(newStart, prev[key]?.end ?? entity.end) }, + })); + }} + className="h-8 text-sm font-mono mt-1" + /> +
+
+ + { + const newEnd = Math.max(0, Math.min(Number(e.target.value), recordText.length)); + setAdjustedBounds(prev => ({ + ...prev, + [key]: { ...prev[key], start: Math.min(prev[key]?.start ?? entity.start, newEnd), end: newEnd }, + })); + }} + className="h-8 text-sm font-mono mt-1" + /> +
+
+ {/* Live preview with draggable boundaries */} +
+ +
{ dragRef.current = null; }} + style={{ cursor: dragRef.current ? 'col-resize' : undefined }} + > + {(() => { + const previewStartIdx = Math.max(0, clampedStart - PREVIEW_CTX); + const previewEndIdx = Math.min(recordText.length, clampedEnd + PREVIEW_CTX); + const chars: React.ReactNode[] = []; + if (previewStartIdx > 0) { + chars.push(); + } + for (let i = previewStartIdx; i < previewEndIdx; i++) { + const ch = recordText[i]; + const isHighlighted = i >= clampedStart && i < clampedEnd; + const isLeftEdge = i === clampedStart; + const isRightEdge = i === clampedEnd - 1; + chars.push( + + {isLeftEdge && ( + { + e.preventDefault(); + e.stopPropagation(); + dragRef.current = { side: 'start', entityKey: key, entity }; + }} + title="Drag to adjust start" + /> + )} + {ch} + {isRightEdge && ( + { + e.preventDefault(); + e.stopPropagation(); + dragRef.current = { side: 'end', entityKey: key, entity }; + }} + title="Drag to adjust end" + /> + )} + + ); + } + if (previewEndIdx < recordText.length) { + chars.push(); + } + return chars; + })()} +
+
+ {/* Adjusted entity text */} + {hasChanged && ( +
+ New text: “{previewText}” + (was: “{entity.text}”) +
+ )} +
+ +
+
+ ); + })()} + + {/* Context Collapsible */} + toggleContext(key)}> + + + + +
+
+ + Surrounding Context: +
+
+ {getContextForEntity(entity, key).before} + {getContextForEntity(entity, key).entity} + {getContextForEntity(entity, key).after} +
+
+
+
+
+ ); + })} +
+
+ + {/* Summary */} +
+
+
+ {confirmedEntities.size} Confirmed +
+
+
+ {rejectedEntities.size} Rejected +
+
+
+ {annotatedEntities.length - confirmedEntities.size - rejectedEntities.size} Pending +
+
+
+ + ); +} diff --git a/evaluation/ai-assistant/src/app/components/FileDropzone.tsx b/evaluation/ai-assistant/src/app/components/FileDropzone.tsx new file mode 100644 index 0000000000..dd67eefa7a --- /dev/null +++ b/evaluation/ai-assistant/src/app/components/FileDropzone.tsx @@ -0,0 +1,87 @@ +import { useCallback, useState, useRef } from 'react'; +import { Upload } from 'lucide-react'; + +interface FileDropzoneProps { + accept: string; // e.g. ".csv" or ".yml,.yaml" + label: string; // e.g. "Drop CSV file here" + onFile: (file: File) => void; + disabled?: boolean; + className?: string; +} + +export function FileDropzone({ accept, label, onFile, disabled, className = '' }: FileDropzoneProps) { + const [dragOver, setDragOver] = useState(false); + const [fileName, setFileName] = useState(null); + const inputRef = useRef(null); + + const exts = accept.split(',').map(e => e.trim().toLowerCase()); + + const isValidFile = (file: File) => { + const name = file.name.toLowerCase(); + return exts.some(ext => name.endsWith(ext)); + }; + + const handleFile = useCallback((file: File) => { + if (!isValidFile(file)) return; + setFileName(file.name); + onFile(file); + }, [onFile, exts]); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + if (disabled) return; + const file = e.dataTransfer.files[0]; + if (file) handleFile(file); + }, [disabled, handleFile]); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + if (!disabled) setDragOver(true); + }, [disabled]); + + const handleDragLeave = useCallback(() => setDragOver(false), []); + + const handleClick = useCallback(() => { + if (!disabled) inputRef.current?.click(); + }, [disabled]); + + const handleInputChange = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) handleFile(file); + // Reset so same file can be re-selected + e.target.value = ''; + }, [handleFile]); + + return ( +
+ + + {fileName ? ( +

{fileName}

+ ) : ( +

{label}

+ )} +

+ {accept.replace(/\./g, '').toUpperCase()} only +

+
+ ); +} diff --git a/evaluation/ai-assistant/src/app/components/Layout.tsx b/evaluation/ai-assistant/src/app/components/Layout.tsx new file mode 100644 index 0000000000..75eabcf122 --- /dev/null +++ b/evaluation/ai-assistant/src/app/components/Layout.tsx @@ -0,0 +1,84 @@ +import { Outlet, useLocation } from 'react-router'; +import { Shield } from 'lucide-react'; +import { Progress } from './ui/progress'; + +const steps = [ + { path: '/', label: 'Setup' }, + { path: '/anonymization', label: 'Analysis' }, + { path: '/human-review', label: 'Human Review' }, + { path: '/evaluation', label: 'Evaluation' }, + { path: '/decision', label: 'Insights' }, +]; + +export function Layout() { + const location = useLocation(); + + const currentStepIndex = steps.findIndex(step => step.path === location.pathname); + const progress = currentStepIndex >= 0 ? ((currentStepIndex + 1) / steps.length) * 100 : 0; + + return ( +
+ {/* Header */} +
+
+
+
+ +
+

Presidio Evaluation Flow

+

Human-in-the-loop PII detection evaluation

+
+
+
+
+ + {/* Progress bar */} +
+
+ + Step {currentStepIndex + 1} of {steps.length} + + + {steps[currentStepIndex]?.label || 'Unknown'} + +
+ +
+ + {/* Step indicators */} +
+
+ {steps.map((step, index) => ( +
+
+ + {step.label} + +
+ ))} +
+
+
+ + {/* Main content */} +
+ +
+
+ ); +} diff --git a/evaluation/ai-assistant/src/app/components/ReadingView.tsx b/evaluation/ai-assistant/src/app/components/ReadingView.tsx new file mode 100644 index 0000000000..571d3db237 --- /dev/null +++ b/evaluation/ai-assistant/src/app/components/ReadingView.tsx @@ -0,0 +1,908 @@ +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { Card } from './ui/card'; +import { Button } from './ui/button'; +import { Badge } from './ui/badge'; +import { Input } from './ui/input'; +import { Label } from './ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; +import { Check, X, CheckCheck, Plus } from 'lucide-react'; +import type { Entity } from '../types'; + +type EntitySource = 'presidio' | 'llm' | 'dataset' | 'manual'; + +interface AnnotatedEntity extends Entity { + sources: EntitySource[]; +} + +interface ReadingViewProps { + recordId: string; + recordText: string; + presidioEntities: Entity[]; + llmEntities: Entity[]; + datasetEntities?: Entity[]; + allConfirmed?: boolean; + autoConfirmDataset?: boolean; + onConfirm: (recordId: string, entity: Entity, source: EntitySource) => void; + onReject: (recordId: string, entity: Entity, source: EntitySource) => void; + onAddManual: (recordId: string, entity: Entity) => void; + onUndo?: (recordId: string, entity: Entity) => void; +} + +// Color palette by entity type +const ENTITY_TYPE_COLORS: Record = { + PERSON: { bg: 'bg-green-200', text: 'text-green-900', border: 'border-green-400' }, + DATE: { bg: 'bg-purple-200', text: 'text-purple-900', border: 'border-purple-400' }, + DATE_TIME: { bg: 'bg-purple-200', text: 'text-purple-900', border: 'border-purple-400' }, + DATE_OF_BIRTH: { bg: 'bg-purple-200', text: 'text-purple-900', border: 'border-purple-400' }, + LOCATION: { bg: 'bg-blue-200', text: 'text-blue-900', border: 'border-blue-400' }, + ADDRESS: { bg: 'bg-blue-200', text: 'text-blue-900', border: 'border-blue-400' }, + PHONE_NUMBER: { bg: 'bg-pink-200', text: 'text-pink-900', border: 'border-pink-400' }, + EMAIL: { bg: 'bg-orange-200', text: 'text-orange-900', border: 'border-orange-400' }, + EMAIL_ADDRESS: { bg: 'bg-orange-200', text: 'text-orange-900', border: 'border-orange-400' }, + CREDIT_CARD: { bg: 'bg-red-200', text: 'text-red-900', border: 'border-red-400' }, + SSN: { bg: 'bg-rose-200', text: 'text-rose-900', border: 'border-rose-400' }, + PERSON_ID: { bg: 'bg-fuchsia-200', text: 'text-fuchsia-900', border: 'border-fuchsia-400' }, + IP_ADDRESS: { bg: 'bg-teal-200', text: 'text-teal-900', border: 'border-teal-400' }, + MEDICAL_RECORD: { bg: 'bg-amber-200', text: 'text-amber-900', border: 'border-amber-400' }, + MEDICAL_CONDITION: { bg: 'bg-yellow-200', text: 'text-yellow-900', border: 'border-yellow-400' }, +}; + +const FALLBACK_COLOR = { bg: 'bg-slate-200', text: 'text-slate-900', border: 'border-slate-400' }; + +function getEntityColor(type: string) { + return ENTITY_TYPE_COLORS[type] || FALLBACK_COLOR; +} + +const DEFAULT_ENTITY_TYPES = [ + 'PERSON', 'EMAIL', 'PHONE_NUMBER', 'SSN', 'CREDIT_CARD', 'DATE_OF_BIRTH', + 'MEDICAL_RECORD', 'IP_ADDRESS', 'ADDRESS', 'MEDICAL_CONDITION', +]; + +export function ReadingView({ + recordId, + recordText, + presidioEntities = [], + llmEntities = [], + datasetEntities = [], + allConfirmed = false, + autoConfirmDataset = false, + onConfirm, + onReject, + onAddManual, + onUndo, +}: ReadingViewProps) { + const [manualEntities, setManualEntities] = useState([]); + const [confirmedEntities, setConfirmedEntities] = useState>(new Set()); + const [rejectedEntities, setRejectedEntities] = useState>(new Set()); + + // Context menu state + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + type: 'entity' | 'text'; + entities?: AnnotatedEntity[]; + textSelection?: { start: number; end: number; text: string }; + } | null>(null); + + // Add entity form (for adding via context menu) + const [addEntityForm, setAddEntityForm] = useState<{ + text: string; + start: number; + end: number; + entity_type: string; + } | null>(null); + + // Adjust entity form + const [adjustEntity, setAdjustEntity] = useState<{ + entity: AnnotatedEntity; + start: number; + end: number; + } | null>(null); + + const [customTypes, setCustomTypes] = useState([]); + const [newTypeName, setNewTypeName] = useState(''); + const [showNewTypeInput, setShowNewTypeInput] = useState(false); + const [selectedEntityType, setSelectedEntityType] = useState(null); + + const menuRef = useRef(null); + const textContainerRef = useRef(null); + const manualDragRef = useRef<{ active: boolean; anchorIdx: number } | null>(null); + const dragRef = useRef<{ side: 'start' | 'end' } | null>(null); + + // Reset state per record + useEffect(() => { + setManualEntities([]); + setConfirmedEntities(new Set()); + setRejectedEntities(new Set()); + setContextMenu(null); + setAddEntityForm(null); + setAdjustEntity(null); + }, [recordId]); + + // Clear drag on global mouseup + useEffect(() => { + const handleGlobalMouseUp = () => { + manualDragRef.current = null; + dragRef.current = null; + }; + window.addEventListener('mouseup', handleGlobalMouseUp); + return () => window.removeEventListener('mouseup', handleGlobalMouseUp); + }, []); + + // Handle drag-move for boundary adjustment handles + const handleDragMove = useCallback((e: React.MouseEvent) => { + if (!dragRef.current || !adjustEntity) return; + e.preventDefault(); + const target = document.elementFromPoint(e.clientX, e.clientY); + if (!target) return; + const charIdx = target.getAttribute('data-adj-char-idx'); + if (charIdx === null) return; + const idx = Number(charIdx); + const { side } = dragRef.current; + setAdjustEntity(prev => { + if (!prev) return null; + if (side === 'start') { + const newStart = Math.max(0, Math.min(idx, prev.end)); + return { ...prev, start: newStart }; + } else { + const newEnd = Math.max(prev.start, Math.min(idx + 1, recordText.length)); + return { ...prev, end: newEnd }; + } + }); + }, [adjustEntity, recordText.length]); + + // Entity key helper + const getEntityKey = (entity: AnnotatedEntity) => + `${entity.entity_type}-${entity.text}-${entity.start}-${entity.end}-${entity.sources.join(',')}`; + + // Merge entities from all sources + const annotatedEntities = useMemo(() => { + const result: AnnotatedEntity[] = []; + const addEntity = (entity: Entity, source: EntitySource) => { + // Check if we already have an entity at this exact position with same type + const existing = result.find( + e => e.start === entity.start && e.end === entity.end && e.entity_type === entity.entity_type + ); + if (existing) { + if (!existing.sources.includes(source)) { + existing.sources.push(source); + } + } else { + result.push({ ...entity, sources: [source] }); + } + }; + + presidioEntities.forEach(e => addEntity(e, 'presidio')); + llmEntities.forEach(e => addEntity(e, 'llm')); + (datasetEntities || []).forEach(e => addEntity(e, 'dataset')); + manualEntities.forEach(e => addEntity(e, 'manual')); + + return result.sort((a, b) => a.start - b.start); + }, [presidioEntities, llmEntities, datasetEntities, manualEntities]); + + // Entities at exact same position but different types + const exactMatchGroups = useMemo(() => { + const groups = new Map(); + for (const entity of annotatedEntities) { + const posKey = `${entity.start}-${entity.end}`; + if (!groups.has(posKey)) groups.set(posKey, []); + groups.get(posKey)!.push(entity); + } + // Only keep groups with more than one entity type + const multiGroups = new Map(); + groups.forEach((entities, key) => { + if (entities.length > 1) multiGroups.set(key, entities); + }); + return multiGroups; + }, [annotatedEntities]); + + // Active entities (not rejected) + const activeEntities = useMemo( + () => annotatedEntities.filter(e => !rejectedEntities.has(getEntityKey(e))), + [annotatedEntities, rejectedEntities] + ); + + // All entity types present + const ENTITY_TYPES = useMemo(() => { + const types = annotatedEntities.map(e => e.entity_type).filter(Boolean); + return Array.from(new Set([...DEFAULT_ENTITY_TYPES, ...types, ...customTypes])).sort(); + }, [annotatedEntities, customTypes]); + + // Bulk confirm + useEffect(() => { + if (allConfirmed && annotatedEntities.length > 0) { + setConfirmedEntities(new Set(annotatedEntities.map(e => getEntityKey(e)))); + setRejectedEntities(new Set()); + } + }, [allConfirmed, recordId]); + + // Auto-confirm only golden-dataset entities on second-config runs + useEffect(() => { + if (!autoConfirmDataset || annotatedEntities.length === 0) return; + const datasetKeys = annotatedEntities + .filter(e => e.sources.includes('dataset')) + .map(e => getEntityKey(e)); + if (datasetKeys.length > 0) { + setConfirmedEntities(prev => new Set([...prev, ...datasetKeys])); + } + }, [autoConfirmDataset, recordId]); + + // Close context menu on outside click + useEffect(() => { + const handleClick = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setContextMenu(null); + } + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, []); + + // Build text segments: non-entity text interspersed with entity highlights + const segments = useMemo(() => { + const segs: Array<{ + text: string; + start: number; + end: number; + entities?: AnnotatedEntity[]; + }> = []; + + let pos = 0; + for (const entity of activeEntities) { + if (entity.start > pos) { + segs.push({ text: recordText.substring(pos, entity.start), start: pos, end: entity.start }); + } + // Group entities at same position + const entitiesAtPos = activeEntities.filter( + e => e.start === entity.start && e.end === entity.end + ); + // Only add once per position + if (entity.start >= pos) { + segs.push({ + text: recordText.substring(entity.start, entity.end), + start: entity.start, + end: entity.end, + entities: entitiesAtPos, + }); + pos = entity.end; + } + } + if (pos < recordText.length) { + segs.push({ text: recordText.substring(pos), start: pos, end: recordText.length }); + } + return segs; + }, [activeEntities, recordText]); + + const handleContextMenu = useCallback((e: React.MouseEvent, entities?: AnnotatedEntity[], textRange?: { start: number; end: number }) => { + e.preventDefault(); + e.stopPropagation(); + + if (entities && entities.length > 0) { + setContextMenu({ + x: e.clientX, + y: e.clientY, + type: 'entity', + entities, + }); + } else if (textRange) { + const selectedText = recordText.substring(textRange.start, textRange.end); + setContextMenu({ + x: e.clientX, + y: e.clientY, + type: 'text', + textSelection: { start: textRange.start, end: textRange.end, text: selectedText }, + }); + } + }, [recordText]); + + const handleTextContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const selection = window.getSelection(); + if (selection && selection.toString().trim()) { + // User has selected text + const range = selection.getRangeAt(0); + const container = textContainerRef.current; + if (!container) return; + + // Find character indices from data attributes + const startEl = range.startContainer.parentElement; + const endEl = range.endContainer.parentElement; + const startIdx = startEl?.closest('[data-char-start]')?.getAttribute('data-char-start'); + const endIdx = endEl?.closest('[data-char-end]')?.getAttribute('data-char-end'); + + if (startIdx !== null && startIdx !== undefined && endIdx !== null && endIdx !== undefined) { + setContextMenu({ + x: e.clientX, + y: e.clientY, + type: 'text', + textSelection: { + start: Number(startIdx), + end: Number(endIdx), + text: selection.toString(), + }, + }); + } + } else { + // No selection — show generic add option at click position + const target = (e.target as HTMLElement).closest('[data-char-start]'); + if (target) { + const charStart = Number(target.getAttribute('data-char-start')); + const charEnd = Number(target.getAttribute('data-char-end')); + setContextMenu({ + x: e.clientX, + y: e.clientY, + type: 'text', + textSelection: { + start: charStart, + end: charEnd, + text: recordText.substring(charStart, charEnd), + }, + }); + } + } + }, [recordText]); + + const handleRejectEntity = (entity: AnnotatedEntity) => { + const key = getEntityKey(entity); + setRejectedEntities(prev => new Set([...prev, key])); + setConfirmedEntities(prev => { + const s = new Set(prev); + s.delete(key); + return s; + }); + onReject(recordId, entity, entity.sources[0]); + setContextMenu(null); + }; + + const handleConfirmEntity = (entity: AnnotatedEntity) => { + const key = getEntityKey(entity); + setConfirmedEntities(prev => new Set([...prev, key])); + setRejectedEntities(prev => { + const s = new Set(prev); + s.delete(key); + return s; + }); + onConfirm(recordId, entity, entity.sources[0]); + setContextMenu(null); + }; + + const handleStartAdjust = (entity: AnnotatedEntity) => { + setAdjustEntity({ entity, start: entity.start, end: entity.end }); + setContextMenu(null); + }; + + const handleSaveAdjust = () => { + if (!adjustEntity) return; + const { entity, start, end } = adjustEntity; + + // Reject the original + const key = getEntityKey(entity); + setRejectedEntities(prev => new Set([...prev, key])); + onReject(recordId, entity, entity.sources[0]); + + // Add as a new manual entity with adjusted bounds + const adjusted: Entity = { + text: recordText.substring(start, end), + entity_type: entity.entity_type, + start, + end, + score: 1.0, + original_start: entity.start, + original_end: entity.end, + original_text: entity.text, + }; + setManualEntities(prev => [...prev, adjusted]); + const manualKey = `${adjusted.text}-${adjusted.start}-${adjusted.end}-manual`; + setConfirmedEntities(prev => new Set([...prev, manualKey])); + onAddManual(recordId, adjusted); + setAdjustEntity(null); + }; + + const handleStartAdd = () => { + if (!contextMenu?.textSelection) return; + setAddEntityForm({ + text: contextMenu.textSelection.text, + start: contextMenu.textSelection.start, + end: contextMenu.textSelection.end, + entity_type: '', + }); + setContextMenu(null); + }; + + const handleSaveAdd = () => { + if (!addEntityForm || !addEntityForm.entity_type) return; + const entity: Entity = { ...addEntityForm, score: 1.0 }; + setManualEntities(prev => [...prev, entity]); + const manualKey = `${entity.text}-${entity.start}-${entity.end}-manual`; + setConfirmedEntities(prev => new Set([...prev, manualKey])); + onAddManual(recordId, entity); + setAddEntityForm(null); + }; + + const handleUndoEntity = (entity: AnnotatedEntity) => { + const key = getEntityKey(entity); + setConfirmedEntities(prev => { + const s = new Set(prev); + s.delete(key); + return s; + }); + setRejectedEntities(prev => { + const s = new Set(prev); + s.delete(key); + return s; + }); + onUndo?.(recordId, entity); + setContextMenu(null); + }; + + const addCustomType = () => { + const name = newTypeName.trim().toUpperCase().replace(/\s+/g, '_'); + if (name && !ENTITY_TYPES.includes(name)) { + setCustomTypes(prev => [...prev, name]); + } + if (name && addEntityForm) { + setAddEntityForm(prev => prev ? { ...prev, entity_type: name } : null); + } + setNewTypeName(''); + setShowNewTypeInput(false); + }; + + // Entity type legend from active entities + const entityTypeLegend = useMemo(() => { + const types = new Set(activeEntities.map(e => e.entity_type)); + return Array.from(types).sort(); + }, [activeEntities]); + + return ( + +
+ {/* Entity Type Legend */} + {entityTypeLegend.length > 0 && ( +
+ Entity types: + {entityTypeLegend.map(type => { + const color = getEntityColor(type); + const isSelected = selectedEntityType === type; + return ( + setSelectedEntityType(prev => prev === type ? null : type)} + > + {type} + + ); + })} +
+ )} + + {/* Record Text with Inline Highlights */} +
{ + // Only fire if not on an entity span + const target = e.target as HTMLElement; + if (!target.closest('[data-entity]')) { + handleTextContextMenu(e); + } + }} + > + {segments.map((seg, i) => { + if (seg.entities && seg.entities.length > 0) { + const primaryEntity = seg.entities[0]; + const isHighlighted = selectedEntityType === null ? false : seg.entities.some(e => e.entity_type === selectedEntityType); + const color = isHighlighted ? { bg: 'bg-yellow-200', text: 'text-yellow-900', border: 'border-yellow-400' } : { bg: 'bg-slate-200', text: 'text-slate-600', border: 'border-slate-300' }; + const isConfirmed = seg.entities.some(e => confirmedEntities.has(getEntityKey(e))); + const isRejected = seg.entities.every(e => rejectedEntities.has(getEntityKey(e))); + const hasExactMatch = seg.entities.length > 1; + + return ( + handleContextMenu(e, seg.entities)} + > + {seg.text} + + {primaryEntity.entity_type} + {hasExactMatch && ` +${seg.entities.length - 1}`} + + + ); + } + return ( + + {seg.text} + + ); + })} +
+ + {/* Rejected entities (shown faded below) */} + {annotatedEntities.some(e => rejectedEntities.has(getEntityKey(e))) && ( +
+ Rejected:{' '} + {annotatedEntities + .filter(e => rejectedEntities.has(getEntityKey(e))) + .map((e, i) => ( + + {e.text} + ({e.entity_type}) + + + ))} +
+ )} + + {/* Context Menu */} + {contextMenu && ( +
+ {contextMenu.type === 'entity' && contextMenu.entities && ( + <> + {/* Show entity info */} +
+ {contextMenu.entities.length === 1 + ? `${contextMenu.entities[0].entity_type}: "${contextMenu.entities[0].text}"` + : `${contextMenu.entities.length} entities at this position`} +
+ + {/* Per-entity actions for each entity at this position */} + {contextMenu.entities.map((entity, i) => { + const color = getEntityColor(entity.entity_type); + const key = getEntityKey(entity); + const isConfirmed = confirmedEntities.has(key); + const isRejected = rejectedEntities.has(key); + return ( +
+ {i > 0 &&
} +
+ + {entity.entity_type} + {entity.sources.join(', ')} +
+ {!isConfirmed && ( + + )} + {!isRejected && ( + + )} + + {(isConfirmed || isRejected) && ( + + )} +
+ ); + })} + + )} + + {contextMenu.type === 'text' && ( + <> +
+ "{contextMenu.textSelection?.text}" +
+ + + )} +
+ )} + + {/* Add Entity Form (appears after "Add as entity" from context menu) */} + {addEntityForm && ( +
+
Add New Entity
+ + {/* Interactive text marker */} +
+ +
{ + if (!manualDragRef.current?.active) return; + e.preventDefault(); + const target = document.elementFromPoint(e.clientX, e.clientY); + if (!target) return; + const charIdx = target.getAttribute('data-add-char-idx'); + if (charIdx === null) return; + const idx = Number(charIdx); + const anchor = manualDragRef.current.anchorIdx; + const newStart = Math.min(anchor, idx); + const newEnd = Math.max(anchor, idx) + 1; + setAddEntityForm(prev => prev ? { + ...prev, + text: recordText.substring(newStart, newEnd), + start: newStart, + end: newEnd, + } : null); + }} + onMouseUp={() => { manualDragRef.current = null; }} + > + {recordText.split('').map((ch, i) => { + const isHighlighted = addEntityForm.text && i >= addEntityForm.start && i < addEntityForm.end; + return ( + { + e.preventDefault(); + manualDragRef.current = { active: true, anchorIdx: i }; + setAddEntityForm(prev => prev ? { + ...prev, + text: recordText[i], + start: i, + end: i + 1, + } : null); + }} + > + {ch} + + ); + })} +
+
+ +
+
+ + { + const newText = e.target.value; + const idx = recordText.indexOf(newText); + setAddEntityForm(prev => prev ? { + ...prev, + text: newText, + start: idx >= 0 ? idx : prev.start, + end: idx >= 0 ? idx + newText.length : prev.end, + } : null); + }} + placeholder="Select text above or type here..." + className="text-sm" + /> +
+
+ + {showNewTypeInput ? ( +
+ setNewTypeName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') addCustomType(); + if (e.key === 'Escape') { setNewTypeName(''); setShowNewTypeInput(false); } + }} + className="text-sm h-8 uppercase" + /> + + +
+ ) : ( + + )} +
+
+
+ Position: {addEntityForm.start}-{addEntityForm.end} +
+
+ + +
+
+ )} + + {/* Adjust Entity Form */} + {adjustEntity && ( +
+
Adjust Entity Boundaries
+
+
+ + setAdjustEntity(prev => prev ? { ...prev, start: Math.max(0, Number(e.target.value)) } : null)} + className="h-8 text-sm font-mono" + /> +
+
+ + setAdjustEntity(prev => prev ? { ...prev, end: Math.min(recordText.length, Number(e.target.value)) } : null)} + className="h-8 text-sm font-mono" + /> +
+
+ {/* Live preview with draggable boundaries */} +
+ +
{ dragRef.current = null; }} + style={{ cursor: dragRef.current ? 'col-resize' : undefined }} + > + {(() => { + const PREVIEW_CTX = 80; + const clampedStart = Math.max(0, Math.min(adjustEntity.start, recordText.length)); + const clampedEnd = Math.max(clampedStart, Math.min(adjustEntity.end, recordText.length)); + const previewStartIdx = Math.max(0, clampedStart - PREVIEW_CTX); + const previewEndIdx = Math.min(recordText.length, clampedEnd + PREVIEW_CTX); + const hasChanged = adjustEntity.start !== adjustEntity.entity.start || adjustEntity.end !== adjustEntity.entity.end; + const chars: React.ReactNode[] = []; + if (previewStartIdx > 0) { + chars.push(); + } + for (let i = previewStartIdx; i < previewEndIdx; i++) { + const ch = recordText[i]; + const isHighlighted = i >= clampedStart && i < clampedEnd; + const isLeftEdge = i === clampedStart; + const isRightEdge = i === clampedEnd - 1; + chars.push( + + {isLeftEdge && ( + { + e.preventDefault(); + e.stopPropagation(); + dragRef.current = { side: 'start' }; + }} + title="Drag to adjust start" + /> + )} + {ch} + {isRightEdge && ( + { + e.preventDefault(); + e.stopPropagation(); + dragRef.current = { side: 'end' }; + }} + title="Drag to adjust end" + /> + )} + + ); + } + if (previewEndIdx < recordText.length) { + chars.push(); + } + return chars; + })()} +
+
+
+ Original: "{adjustEntity.entity.text}" → New: "{recordText.substring(adjustEntity.start, adjustEntity.end)}" +
+
+ + +
+
+ )} + + {/* Summary */} +
+
+
+ {confirmedEntities.size} Confirmed +
+
+
+ {rejectedEntities.size} Rejected +
+
+
+ {annotatedEntities.length - confirmedEntities.size - rejectedEntities.size} Pending +
+
+
+ + ); +} diff --git a/evaluation/ai-assistant/src/app/components/ui/alert.tsx b/evaluation/ai-assistant/src/app/components/ui/alert.tsx new file mode 100644 index 0000000000..9c35976c71 --- /dev/null +++ b/evaluation/ai-assistant/src/app/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "./utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Alert, AlertTitle, AlertDescription }; diff --git a/evaluation/ai-assistant/src/app/components/ui/badge.tsx b/evaluation/ai-assistant/src/app/components/ui/badge.tsx new file mode 100644 index 0000000000..2ccc2c4440 --- /dev/null +++ b/evaluation/ai-assistant/src/app/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "./utils"; + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span"; + + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/evaluation/ai-assistant/src/app/components/ui/button.tsx b/evaluation/ai-assistant/src/app/components/ui/button.tsx new file mode 100644 index 0000000000..40ef7aa92e --- /dev/null +++ b/evaluation/ai-assistant/src/app/components/ui/button.tsx @@ -0,0 +1,58 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "./utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background text-foreground hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9 rounded-md", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot : "button"; + + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/evaluation/ai-assistant/src/app/components/ui/card.tsx b/evaluation/ai-assistant/src/app/components/ui/card.tsx new file mode 100644 index 0000000000..5f9d58a566 --- /dev/null +++ b/evaluation/ai-assistant/src/app/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react"; + +import { cn } from "./utils"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +

+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +

+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +

+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +}; diff --git a/evaluation/ai-assistant/src/app/components/ui/checkbox.tsx b/evaluation/ai-assistant/src/app/components/ui/checkbox.tsx new file mode 100644 index 0000000000..6ac41930b4 --- /dev/null +++ b/evaluation/ai-assistant/src/app/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { CheckIcon } from "lucide-react"; + +import { cn } from "./utils"; + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ); +} + +export { Checkbox }; diff --git a/evaluation/ai-assistant/src/app/components/ui/collapsible.tsx b/evaluation/ai-assistant/src/app/components/ui/collapsible.tsx new file mode 100644 index 0000000000..849e7b66f5 --- /dev/null +++ b/evaluation/ai-assistant/src/app/components/ui/collapsible.tsx @@ -0,0 +1,31 @@ +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; + +function Collapsible({ + ...props +}: React.ComponentProps) { + return ; +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/evaluation/ai-assistant/src/app/components/ui/input.tsx b/evaluation/ai-assistant/src/app/components/ui/input.tsx new file mode 100644 index 0000000000..19b0c619d2 --- /dev/null +++ b/evaluation/ai-assistant/src/app/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; + +import { cn } from "./utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/evaluation/ai-assistant/src/app/components/ui/label.tsx b/evaluation/ai-assistant/src/app/components/ui/label.tsx new file mode 100644 index 0000000000..13e0c439b4 --- /dev/null +++ b/evaluation/ai-assistant/src/app/components/ui/label.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; + +import { cn } from "./utils"; + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Label }; diff --git a/evaluation/ai-assistant/src/app/components/ui/progress.tsx b/evaluation/ai-assistant/src/app/components/ui/progress.tsx new file mode 100644 index 0000000000..9ee2189801 --- /dev/null +++ b/evaluation/ai-assistant/src/app/components/ui/progress.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; +import * as ProgressPrimitive from "@radix-ui/react-progress"; + +import { cn } from "./utils"; + +function Progress({ + className, + value, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { Progress }; diff --git a/evaluation/ai-assistant/src/app/components/ui/radio-group.tsx b/evaluation/ai-assistant/src/app/components/ui/radio-group.tsx new file mode 100644 index 0000000000..2c44952370 --- /dev/null +++ b/evaluation/ai-assistant/src/app/components/ui/radio-group.tsx @@ -0,0 +1,43 @@ +import * as React from "react"; +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; +import { CircleIcon } from "lucide-react"; + +import { cn } from "./utils"; + +function RadioGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ); +} + +export { RadioGroup, RadioGroupItem }; diff --git a/evaluation/ai-assistant/src/app/components/ui/select.tsx b/evaluation/ai-assistant/src/app/components/ui/select.tsx new file mode 100644 index 0000000000..0fbf60267d --- /dev/null +++ b/evaluation/ai-assistant/src/app/components/ui/select.tsx @@ -0,0 +1,187 @@ +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { + CheckIcon, + ChevronDownIcon, + ChevronUpIcon, +} from "lucide-react"; + +import { cn } from "./utils"; + +function Select({ + ...props +}: React.ComponentProps) { + return ; +} + +function SelectGroup({ + ...props +}: React.ComponentProps) { + return ; +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return ; +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default"; +}) { + return ( + + {children} + + + + + ); +} + +function SelectContent({ + className, + children, + position = "popper", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ); +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +}; diff --git a/evaluation/ai-assistant/src/app/components/ui/slider.tsx b/evaluation/ai-assistant/src/app/components/ui/slider.tsx new file mode 100644 index 0000000000..122ca12048 --- /dev/null +++ b/evaluation/ai-assistant/src/app/components/ui/slider.tsx @@ -0,0 +1,61 @@ +import * as React from "react"; +import * as SliderPrimitive from "@radix-ui/react-slider"; + +import { cn } from "./utils"; + +function Slider({ + className, + defaultValue, + value, + min = 0, + max = 100, + ...props +}: React.ComponentProps) { + const _values = React.useMemo( + () => + Array.isArray(value) + ? value + : Array.isArray(defaultValue) + ? defaultValue + : [min, max], + [value, defaultValue, min, max], + ); + + return ( + + + + + {Array.from({ length: _values.length }, (_, index) => ( + + ))} + + ); +} + +export { Slider }; diff --git a/evaluation/ai-assistant/src/app/components/ui/sonner.tsx b/evaluation/ai-assistant/src/app/components/ui/sonner.tsx new file mode 100644 index 0000000000..d941907157 --- /dev/null +++ b/evaluation/ai-assistant/src/app/components/ui/sonner.tsx @@ -0,0 +1,19 @@ +import { Toaster as Sonner, type ToasterProps } from "sonner"; + +const Toaster = ({ ...props }: ToasterProps) => { + return ( + + ); +}; + +export { Toaster }; diff --git a/evaluation/ai-assistant/src/app/components/ui/tabs.tsx b/evaluation/ai-assistant/src/app/components/ui/tabs.tsx new file mode 100644 index 0000000000..fab7626f5b --- /dev/null +++ b/evaluation/ai-assistant/src/app/components/ui/tabs.tsx @@ -0,0 +1,64 @@ +import * as React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; + +import { cn } from "./utils"; + +function Tabs({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function TabsList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/evaluation/ai-assistant/src/app/components/ui/textarea.tsx b/evaluation/ai-assistant/src/app/components/ui/textarea.tsx new file mode 100644 index 0000000000..8f1810e1fb --- /dev/null +++ b/evaluation/ai-assistant/src/app/components/ui/textarea.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; + +import { cn } from "./utils"; + +function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { + return ( +