truthound-dashboard 1.4.4__py3-none-any.whl → 1.5.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- truthound_dashboard/api/alerts.py +75 -86
- truthound_dashboard/api/anomaly.py +7 -13
- truthound_dashboard/api/cross_alerts.py +38 -52
- truthound_dashboard/api/drift.py +49 -59
- truthound_dashboard/api/drift_monitor.py +234 -79
- truthound_dashboard/api/enterprise_sampling.py +498 -0
- truthound_dashboard/api/history.py +57 -5
- truthound_dashboard/api/lineage.py +3 -48
- truthound_dashboard/api/maintenance.py +104 -49
- truthound_dashboard/api/mask.py +1 -2
- truthound_dashboard/api/middleware.py +2 -1
- truthound_dashboard/api/model_monitoring.py +435 -311
- truthound_dashboard/api/notifications.py +227 -191
- truthound_dashboard/api/notifications_advanced.py +21 -20
- truthound_dashboard/api/observability.py +586 -0
- truthound_dashboard/api/plugins.py +2 -433
- truthound_dashboard/api/profile.py +199 -37
- truthound_dashboard/api/quality_reporter.py +701 -0
- truthound_dashboard/api/reports.py +7 -16
- truthound_dashboard/api/router.py +66 -0
- truthound_dashboard/api/rule_suggestions.py +5 -5
- truthound_dashboard/api/scan.py +17 -19
- truthound_dashboard/api/schedules.py +85 -50
- truthound_dashboard/api/schema_evolution.py +6 -6
- truthound_dashboard/api/schema_watcher.py +667 -0
- truthound_dashboard/api/sources.py +98 -27
- truthound_dashboard/api/tiering.py +1323 -0
- truthound_dashboard/api/triggers.py +14 -11
- truthound_dashboard/api/validations.py +12 -11
- truthound_dashboard/api/versioning.py +1 -6
- truthound_dashboard/core/__init__.py +129 -3
- truthound_dashboard/core/actions/__init__.py +62 -0
- truthound_dashboard/core/actions/custom.py +426 -0
- truthound_dashboard/core/actions/notifications.py +910 -0
- truthound_dashboard/core/actions/storage.py +472 -0
- truthound_dashboard/core/actions/webhook.py +281 -0
- truthound_dashboard/core/anomaly.py +262 -67
- truthound_dashboard/core/anomaly_explainer.py +4 -3
- truthound_dashboard/core/backends/__init__.py +67 -0
- truthound_dashboard/core/backends/base.py +299 -0
- truthound_dashboard/core/backends/errors.py +191 -0
- truthound_dashboard/core/backends/factory.py +423 -0
- truthound_dashboard/core/backends/mock_backend.py +451 -0
- truthound_dashboard/core/backends/truthound_backend.py +718 -0
- truthound_dashboard/core/checkpoint/__init__.py +87 -0
- truthound_dashboard/core/checkpoint/adapters.py +814 -0
- truthound_dashboard/core/checkpoint/checkpoint.py +491 -0
- truthound_dashboard/core/checkpoint/runner.py +270 -0
- truthound_dashboard/core/connections.py +645 -23
- truthound_dashboard/core/converters/__init__.py +14 -0
- truthound_dashboard/core/converters/truthound.py +620 -0
- truthound_dashboard/core/cross_alerts.py +540 -320
- truthound_dashboard/core/datasource_factory.py +1672 -0
- truthound_dashboard/core/drift_monitor.py +216 -20
- truthound_dashboard/core/enterprise_sampling.py +1291 -0
- truthound_dashboard/core/interfaces/__init__.py +225 -0
- truthound_dashboard/core/interfaces/actions.py +652 -0
- truthound_dashboard/core/interfaces/base.py +247 -0
- truthound_dashboard/core/interfaces/checkpoint.py +676 -0
- truthound_dashboard/core/interfaces/protocols.py +664 -0
- truthound_dashboard/core/interfaces/reporters.py +650 -0
- truthound_dashboard/core/interfaces/routing.py +646 -0
- truthound_dashboard/core/interfaces/triggers.py +619 -0
- truthound_dashboard/core/lineage.py +407 -71
- truthound_dashboard/core/model_monitoring.py +431 -3
- truthound_dashboard/core/notifications/base.py +4 -0
- truthound_dashboard/core/notifications/channels.py +501 -1203
- truthound_dashboard/core/notifications/deduplication/__init__.py +81 -115
- truthound_dashboard/core/notifications/deduplication/service.py +131 -348
- truthound_dashboard/core/notifications/dispatcher.py +202 -11
- truthound_dashboard/core/notifications/escalation/__init__.py +119 -106
- truthound_dashboard/core/notifications/escalation/engine.py +168 -358
- truthound_dashboard/core/notifications/routing/__init__.py +88 -128
- truthound_dashboard/core/notifications/routing/engine.py +90 -317
- truthound_dashboard/core/notifications/stats_aggregator.py +246 -1
- truthound_dashboard/core/notifications/throttling/__init__.py +67 -50
- truthound_dashboard/core/notifications/throttling/builder.py +117 -255
- truthound_dashboard/core/notifications/truthound_adapter.py +842 -0
- truthound_dashboard/core/phase5/collaboration.py +1 -1
- truthound_dashboard/core/plugins/lifecycle/__init__.py +0 -13
- truthound_dashboard/core/quality_reporter.py +1359 -0
- truthound_dashboard/core/report_history.py +0 -6
- truthound_dashboard/core/reporters/__init__.py +175 -14
- truthound_dashboard/core/reporters/adapters.py +943 -0
- truthound_dashboard/core/reporters/base.py +0 -3
- truthound_dashboard/core/reporters/builtin/__init__.py +18 -0
- truthound_dashboard/core/reporters/builtin/csv_reporter.py +111 -0
- truthound_dashboard/core/reporters/builtin/html_reporter.py +270 -0
- truthound_dashboard/core/reporters/builtin/json_reporter.py +127 -0
- truthound_dashboard/core/reporters/compat.py +266 -0
- truthound_dashboard/core/reporters/csv_reporter.py +2 -35
- truthound_dashboard/core/reporters/factory.py +526 -0
- truthound_dashboard/core/reporters/interfaces.py +745 -0
- truthound_dashboard/core/reporters/registry.py +1 -10
- truthound_dashboard/core/scheduler.py +165 -0
- truthound_dashboard/core/schema_evolution.py +3 -3
- truthound_dashboard/core/schema_watcher.py +1528 -0
- truthound_dashboard/core/services.py +595 -76
- truthound_dashboard/core/store_manager.py +810 -0
- truthound_dashboard/core/streaming_anomaly.py +169 -4
- truthound_dashboard/core/tiering.py +1309 -0
- truthound_dashboard/core/triggers/evaluators.py +178 -8
- truthound_dashboard/core/truthound_adapter.py +2620 -197
- truthound_dashboard/core/unified_alerts.py +23 -20
- truthound_dashboard/db/__init__.py +8 -0
- truthound_dashboard/db/database.py +8 -2
- truthound_dashboard/db/models.py +944 -25
- truthound_dashboard/db/repository.py +2 -0
- truthound_dashboard/main.py +15 -0
- truthound_dashboard/schemas/__init__.py +177 -16
- truthound_dashboard/schemas/base.py +44 -23
- truthound_dashboard/schemas/collaboration.py +19 -6
- truthound_dashboard/schemas/cross_alerts.py +19 -3
- truthound_dashboard/schemas/drift.py +61 -55
- truthound_dashboard/schemas/drift_monitor.py +67 -23
- truthound_dashboard/schemas/enterprise_sampling.py +653 -0
- truthound_dashboard/schemas/lineage.py +0 -33
- truthound_dashboard/schemas/mask.py +10 -8
- truthound_dashboard/schemas/model_monitoring.py +89 -10
- truthound_dashboard/schemas/notifications_advanced.py +13 -0
- truthound_dashboard/schemas/observability.py +453 -0
- truthound_dashboard/schemas/plugins.py +0 -280
- truthound_dashboard/schemas/profile.py +154 -247
- truthound_dashboard/schemas/quality_reporter.py +403 -0
- truthound_dashboard/schemas/reports.py +2 -2
- truthound_dashboard/schemas/rule_suggestion.py +8 -1
- truthound_dashboard/schemas/scan.py +4 -24
- truthound_dashboard/schemas/schedule.py +11 -3
- truthound_dashboard/schemas/schema_watcher.py +727 -0
- truthound_dashboard/schemas/source.py +17 -2
- truthound_dashboard/schemas/tiering.py +822 -0
- truthound_dashboard/schemas/triggers.py +16 -0
- truthound_dashboard/schemas/unified_alerts.py +7 -0
- truthound_dashboard/schemas/validation.py +0 -13
- truthound_dashboard/schemas/validators/base.py +41 -21
- truthound_dashboard/schemas/validators/business_rule_validators.py +244 -0
- truthound_dashboard/schemas/validators/localization_validators.py +273 -0
- truthound_dashboard/schemas/validators/ml_feature_validators.py +308 -0
- truthound_dashboard/schemas/validators/profiling_validators.py +275 -0
- truthound_dashboard/schemas/validators/referential_validators.py +312 -0
- truthound_dashboard/schemas/validators/registry.py +93 -8
- truthound_dashboard/schemas/validators/timeseries_validators.py +389 -0
- truthound_dashboard/schemas/versioning.py +1 -6
- truthound_dashboard/static/index.html +2 -2
- truthound_dashboard-1.5.1.dist-info/METADATA +312 -0
- {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.1.dist-info}/RECORD +149 -148
- truthound_dashboard/core/plugins/hooks/__init__.py +0 -63
- truthound_dashboard/core/plugins/hooks/decorators.py +0 -367
- truthound_dashboard/core/plugins/hooks/manager.py +0 -403
- truthound_dashboard/core/plugins/hooks/protocols.py +0 -265
- truthound_dashboard/core/plugins/lifecycle/hot_reload.py +0 -584
- truthound_dashboard/core/reporters/junit_reporter.py +0 -233
- truthound_dashboard/core/reporters/markdown_reporter.py +0 -207
- truthound_dashboard/core/reporters/pdf_reporter.py +0 -209
- truthound_dashboard/static/assets/_baseUniq-BcrSP13d.js +0 -1
- truthound_dashboard/static/assets/arc-DlYjKwIL.js +0 -1
- truthound_dashboard/static/assets/architectureDiagram-VXUJARFQ-Bb2drbQM.js +0 -36
- truthound_dashboard/static/assets/blockDiagram-VD42YOAC-BlsPG1CH.js +0 -122
- truthound_dashboard/static/assets/c4Diagram-YG6GDRKO-B9JdUoaC.js +0 -10
- truthound_dashboard/static/assets/channel-Q6mHF1Hd.js +0 -1
- truthound_dashboard/static/assets/chunk-4BX2VUAB-DmyoPVuJ.js +0 -1
- truthound_dashboard/static/assets/chunk-55IACEB6-Bcz6Siv8.js +0 -1
- truthound_dashboard/static/assets/chunk-B4BG7PRW-Br3G5Rum.js +0 -165
- truthound_dashboard/static/assets/chunk-DI55MBZ5-DuM9c23u.js +0 -220
- truthound_dashboard/static/assets/chunk-FMBD7UC4-DNU-5mvT.js +0 -15
- truthound_dashboard/static/assets/chunk-QN33PNHL-Im2yNcmS.js +0 -1
- truthound_dashboard/static/assets/chunk-QZHKN3VN-kZr8XFm1.js +0 -1
- truthound_dashboard/static/assets/chunk-TZMSLE5B-Q__360q_.js +0 -1
- truthound_dashboard/static/assets/classDiagram-2ON5EDUG-vtixxUyK.js +0 -1
- truthound_dashboard/static/assets/classDiagram-v2-WZHVMYZB-vtixxUyK.js +0 -1
- truthound_dashboard/static/assets/clone-BOt2LwD0.js +0 -1
- truthound_dashboard/static/assets/cose-bilkent-S5V4N54A-CBDw6iac.js +0 -1
- truthound_dashboard/static/assets/dagre-6UL2VRFP-XdKqmmY9.js +0 -4
- truthound_dashboard/static/assets/diagram-PSM6KHXK-DAZ8nx9V.js +0 -24
- truthound_dashboard/static/assets/diagram-QEK2KX5R-BRvDTbGD.js +0 -43
- truthound_dashboard/static/assets/diagram-S2PKOQOG-bQcczUkl.js +0 -24
- truthound_dashboard/static/assets/erDiagram-Q2GNP2WA-DPje7VMN.js +0 -60
- truthound_dashboard/static/assets/flowDiagram-NV44I4VS-B7BVtFVS.js +0 -162
- truthound_dashboard/static/assets/ganttDiagram-JELNMOA3-D6WKSS7U.js +0 -267
- truthound_dashboard/static/assets/gitGraphDiagram-NY62KEGX-D3vtVd3y.js +0 -65
- truthound_dashboard/static/assets/graph-BKgNKZVp.js +0 -1
- truthound_dashboard/static/assets/index-C6JSrkHo.css +0 -1
- truthound_dashboard/static/assets/index-DkU82VsU.js +0 -1800
- truthound_dashboard/static/assets/infoDiagram-WHAUD3N6-DnNCT429.js +0 -2
- truthound_dashboard/static/assets/journeyDiagram-XKPGCS4Q-DGiMozqS.js +0 -139
- truthound_dashboard/static/assets/kanban-definition-3W4ZIXB7-BV2gUgli.js +0 -89
- truthound_dashboard/static/assets/katex-Cu_Erd72.js +0 -261
- truthound_dashboard/static/assets/layout-DI2MfQ5G.js +0 -1
- truthound_dashboard/static/assets/min-DYdgXVcT.js +0 -1
- truthound_dashboard/static/assets/mindmap-definition-VGOIOE7T-C7x4ruxz.js +0 -68
- truthound_dashboard/static/assets/pieDiagram-ADFJNKIX-CAJaAB9f.js +0 -30
- truthound_dashboard/static/assets/quadrantDiagram-AYHSOK5B-DeqwDI46.js +0 -7
- truthound_dashboard/static/assets/requirementDiagram-UZGBJVZJ-e3XDpZIM.js +0 -64
- truthound_dashboard/static/assets/sankeyDiagram-TZEHDZUN-CNnAv5Ux.js +0 -10
- truthound_dashboard/static/assets/sequenceDiagram-WL72ISMW-Dsne-Of3.js +0 -145
- truthound_dashboard/static/assets/stateDiagram-FKZM4ZOC-Ee0sQXyb.js +0 -1
- truthound_dashboard/static/assets/stateDiagram-v2-4FDKWEC3-B26KqW_W.js +0 -1
- truthound_dashboard/static/assets/timeline-definition-IT6M3QCI-DZYi2yl3.js +0 -61
- truthound_dashboard/static/assets/treemap-KMMF4GRG-CY3f8In2.js +0 -128
- truthound_dashboard/static/assets/unmerged_dictionaries-Dd7xcPWG.js +0 -1
- truthound_dashboard/static/assets/xychartDiagram-PRI3JC2R-CS7fydZZ.js +0 -7
- truthound_dashboard-1.4.4.dist-info/METADATA +0 -507
- {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.1.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.1.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,1359 @@
|
|
|
1
|
+
"""Quality Reporter Service.
|
|
2
|
+
|
|
3
|
+
This module provides services for quality assessment and reporting of validation rules.
|
|
4
|
+
It integrates with truthound's QualityReporter module to provide:
|
|
5
|
+
|
|
6
|
+
- Rule quality scoring (F1, precision, recall, accuracy)
|
|
7
|
+
- Quality level evaluation (excellent, good, acceptable, poor, unacceptable)
|
|
8
|
+
- Composable filtering system
|
|
9
|
+
- Multiple report formats (console, json, html, markdown, junit)
|
|
10
|
+
- Report generation pipeline with caching
|
|
11
|
+
|
|
12
|
+
Architecture:
|
|
13
|
+
API Layer
|
|
14
|
+
↓
|
|
15
|
+
QualityReporterService (this module)
|
|
16
|
+
↓
|
|
17
|
+
TruthoundAdapter → truthound.reporters.quality
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import logging
|
|
24
|
+
import time
|
|
25
|
+
import uuid
|
|
26
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from datetime import datetime
|
|
29
|
+
from enum import Enum
|
|
30
|
+
from functools import partial
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Protocol, runtime_checkable
|
|
33
|
+
|
|
34
|
+
from sqlalchemy import select
|
|
35
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
36
|
+
|
|
37
|
+
from truthound_dashboard.db import BaseRepository, Source, Validation
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
# Thread pool for running sync truthound operations
|
|
42
|
+
_executor = ThreadPoolExecutor(max_workers=4)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# =============================================================================
|
|
46
|
+
# Enums
|
|
47
|
+
# =============================================================================
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class QualityLevel(str, Enum):
|
|
51
|
+
"""Quality levels for rules."""
|
|
52
|
+
|
|
53
|
+
EXCELLENT = "excellent"
|
|
54
|
+
GOOD = "good"
|
|
55
|
+
ACCEPTABLE = "acceptable"
|
|
56
|
+
POOR = "poor"
|
|
57
|
+
UNACCEPTABLE = "unacceptable"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class QualityReportFormat(str, Enum):
|
|
61
|
+
"""Report formats."""
|
|
62
|
+
|
|
63
|
+
CONSOLE = "console"
|
|
64
|
+
JSON = "json"
|
|
65
|
+
HTML = "html"
|
|
66
|
+
MARKDOWN = "markdown"
|
|
67
|
+
JUNIT = "junit"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class QualityReportStatus(str, Enum):
|
|
71
|
+
"""Report generation status."""
|
|
72
|
+
|
|
73
|
+
PENDING = "pending"
|
|
74
|
+
GENERATING = "generating"
|
|
75
|
+
COMPLETED = "completed"
|
|
76
|
+
FAILED = "failed"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# =============================================================================
|
|
80
|
+
# Data Classes
|
|
81
|
+
# =============================================================================
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class ConfusionMatrix:
|
|
86
|
+
"""Confusion matrix for rule evaluation."""
|
|
87
|
+
|
|
88
|
+
true_positive: int = 0
|
|
89
|
+
true_negative: int = 0
|
|
90
|
+
false_positive: int = 0
|
|
91
|
+
false_negative: int = 0
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def precision(self) -> float:
|
|
95
|
+
"""Precision: TP / (TP + FP)."""
|
|
96
|
+
total = self.true_positive + self.false_positive
|
|
97
|
+
return self.true_positive / total if total > 0 else 0.0
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def recall(self) -> float:
|
|
101
|
+
"""Recall: TP / (TP + FN)."""
|
|
102
|
+
total = self.true_positive + self.false_negative
|
|
103
|
+
return self.true_positive / total if total > 0 else 0.0
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def f1_score(self) -> float:
|
|
107
|
+
"""F1 score."""
|
|
108
|
+
p, r = self.precision, self.recall
|
|
109
|
+
return 2 * (p * r) / (p + r) if (p + r) > 0 else 0.0
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def accuracy(self) -> float:
|
|
113
|
+
"""Accuracy."""
|
|
114
|
+
total = (
|
|
115
|
+
self.true_positive
|
|
116
|
+
+ self.true_negative
|
|
117
|
+
+ self.false_positive
|
|
118
|
+
+ self.false_negative
|
|
119
|
+
)
|
|
120
|
+
return (self.true_positive + self.true_negative) / total if total > 0 else 0.0
|
|
121
|
+
|
|
122
|
+
def to_dict(self) -> dict[str, Any]:
|
|
123
|
+
"""Convert to dictionary."""
|
|
124
|
+
return {
|
|
125
|
+
"true_positive": self.true_positive,
|
|
126
|
+
"true_negative": self.true_negative,
|
|
127
|
+
"false_positive": self.false_positive,
|
|
128
|
+
"false_negative": self.false_negative,
|
|
129
|
+
"precision": self.precision,
|
|
130
|
+
"recall": self.recall,
|
|
131
|
+
"f1_score": self.f1_score,
|
|
132
|
+
"accuracy": self.accuracy,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class QualityMetrics:
|
|
138
|
+
"""Quality metrics for a rule."""
|
|
139
|
+
|
|
140
|
+
f1_score: float
|
|
141
|
+
precision: float
|
|
142
|
+
recall: float
|
|
143
|
+
accuracy: float
|
|
144
|
+
confidence: float = 0.0
|
|
145
|
+
quality_level: QualityLevel = QualityLevel.UNACCEPTABLE
|
|
146
|
+
|
|
147
|
+
def to_dict(self) -> dict[str, Any]:
|
|
148
|
+
"""Convert to dictionary."""
|
|
149
|
+
return {
|
|
150
|
+
"f1_score": self.f1_score,
|
|
151
|
+
"precision": self.precision,
|
|
152
|
+
"recall": self.recall,
|
|
153
|
+
"accuracy": self.accuracy,
|
|
154
|
+
"confidence": self.confidence,
|
|
155
|
+
"quality_level": self.quality_level.value,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class QualityThresholds:
|
|
161
|
+
"""Thresholds for quality levels."""
|
|
162
|
+
|
|
163
|
+
excellent: float = 0.9
|
|
164
|
+
good: float = 0.7
|
|
165
|
+
acceptable: float = 0.5
|
|
166
|
+
poor: float = 0.3
|
|
167
|
+
|
|
168
|
+
def get_level(self, f1_score: float) -> QualityLevel:
|
|
169
|
+
"""Get quality level from F1 score."""
|
|
170
|
+
if f1_score >= self.excellent:
|
|
171
|
+
return QualityLevel.EXCELLENT
|
|
172
|
+
elif f1_score >= self.good:
|
|
173
|
+
return QualityLevel.GOOD
|
|
174
|
+
elif f1_score >= self.acceptable:
|
|
175
|
+
return QualityLevel.ACCEPTABLE
|
|
176
|
+
elif f1_score >= self.poor:
|
|
177
|
+
return QualityLevel.POOR
|
|
178
|
+
return QualityLevel.UNACCEPTABLE
|
|
179
|
+
|
|
180
|
+
def to_dict(self) -> dict[str, float]:
|
|
181
|
+
"""Convert to dictionary."""
|
|
182
|
+
return {
|
|
183
|
+
"excellent": self.excellent,
|
|
184
|
+
"good": self.good,
|
|
185
|
+
"acceptable": self.acceptable,
|
|
186
|
+
"poor": self.poor,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass
|
|
191
|
+
class QualityScore:
|
|
192
|
+
"""Quality score for a single rule."""
|
|
193
|
+
|
|
194
|
+
rule_name: str
|
|
195
|
+
rule_type: str | None = None
|
|
196
|
+
column: str | None = None
|
|
197
|
+
metrics: QualityMetrics = field(default_factory=lambda: QualityMetrics(0, 0, 0, 0))
|
|
198
|
+
confusion_matrix: ConfusionMatrix | None = None
|
|
199
|
+
test_sample_size: int = 0
|
|
200
|
+
evaluation_time_ms: float = 0.0
|
|
201
|
+
recommendation: str | None = None
|
|
202
|
+
should_use: bool = True
|
|
203
|
+
issues: list[dict[str, Any]] = field(default_factory=list)
|
|
204
|
+
|
|
205
|
+
def to_dict(self) -> dict[str, Any]:
|
|
206
|
+
"""Convert to dictionary."""
|
|
207
|
+
result = {
|
|
208
|
+
"rule_name": self.rule_name,
|
|
209
|
+
"rule_type": self.rule_type,
|
|
210
|
+
"column": self.column,
|
|
211
|
+
"metrics": self.metrics.to_dict(),
|
|
212
|
+
"test_sample_size": self.test_sample_size,
|
|
213
|
+
"evaluation_time_ms": self.evaluation_time_ms,
|
|
214
|
+
"recommendation": self.recommendation,
|
|
215
|
+
"should_use": self.should_use,
|
|
216
|
+
"issues": self.issues,
|
|
217
|
+
}
|
|
218
|
+
if self.confusion_matrix:
|
|
219
|
+
result["confusion_matrix"] = self.confusion_matrix.to_dict()
|
|
220
|
+
return result
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass
|
|
224
|
+
class QualityStatistics:
|
|
225
|
+
"""Aggregate statistics for quality scores."""
|
|
226
|
+
|
|
227
|
+
total_count: int = 0
|
|
228
|
+
excellent_count: int = 0
|
|
229
|
+
good_count: int = 0
|
|
230
|
+
acceptable_count: int = 0
|
|
231
|
+
poor_count: int = 0
|
|
232
|
+
unacceptable_count: int = 0
|
|
233
|
+
should_use_count: int = 0
|
|
234
|
+
avg_f1: float = 0.0
|
|
235
|
+
min_f1: float = 0.0
|
|
236
|
+
max_f1: float = 0.0
|
|
237
|
+
avg_precision: float = 0.0
|
|
238
|
+
avg_recall: float = 0.0
|
|
239
|
+
avg_confidence: float = 0.0
|
|
240
|
+
|
|
241
|
+
@classmethod
|
|
242
|
+
def from_scores(cls, scores: list[QualityScore]) -> "QualityStatistics":
|
|
243
|
+
"""Calculate statistics from scores."""
|
|
244
|
+
if not scores:
|
|
245
|
+
return cls()
|
|
246
|
+
|
|
247
|
+
f1_scores = [s.metrics.f1_score for s in scores]
|
|
248
|
+
precisions = [s.metrics.precision for s in scores]
|
|
249
|
+
recalls = [s.metrics.recall for s in scores]
|
|
250
|
+
confidences = [s.metrics.confidence for s in scores]
|
|
251
|
+
|
|
252
|
+
level_counts = {level: 0 for level in QualityLevel}
|
|
253
|
+
for score in scores:
|
|
254
|
+
level_counts[score.metrics.quality_level] += 1
|
|
255
|
+
|
|
256
|
+
return cls(
|
|
257
|
+
total_count=len(scores),
|
|
258
|
+
excellent_count=level_counts[QualityLevel.EXCELLENT],
|
|
259
|
+
good_count=level_counts[QualityLevel.GOOD],
|
|
260
|
+
acceptable_count=level_counts[QualityLevel.ACCEPTABLE],
|
|
261
|
+
poor_count=level_counts[QualityLevel.POOR],
|
|
262
|
+
unacceptable_count=level_counts[QualityLevel.UNACCEPTABLE],
|
|
263
|
+
should_use_count=sum(1 for s in scores if s.should_use),
|
|
264
|
+
avg_f1=sum(f1_scores) / len(f1_scores),
|
|
265
|
+
min_f1=min(f1_scores),
|
|
266
|
+
max_f1=max(f1_scores),
|
|
267
|
+
avg_precision=sum(precisions) / len(precisions),
|
|
268
|
+
avg_recall=sum(recalls) / len(recalls),
|
|
269
|
+
avg_confidence=sum(confidences) / len(confidences) if confidences else 0.0,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def to_dict(self) -> dict[str, Any]:
|
|
273
|
+
"""Convert to dictionary."""
|
|
274
|
+
return {
|
|
275
|
+
"total_count": self.total_count,
|
|
276
|
+
"excellent_count": self.excellent_count,
|
|
277
|
+
"good_count": self.good_count,
|
|
278
|
+
"acceptable_count": self.acceptable_count,
|
|
279
|
+
"poor_count": self.poor_count,
|
|
280
|
+
"unacceptable_count": self.unacceptable_count,
|
|
281
|
+
"should_use_count": self.should_use_count,
|
|
282
|
+
"avg_f1": self.avg_f1,
|
|
283
|
+
"min_f1": self.min_f1,
|
|
284
|
+
"max_f1": self.max_f1,
|
|
285
|
+
"avg_precision": self.avg_precision,
|
|
286
|
+
"avg_recall": self.avg_recall,
|
|
287
|
+
"avg_confidence": self.avg_confidence,
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@dataclass
|
|
292
|
+
class QualityLevelDistribution:
|
|
293
|
+
"""Distribution of quality levels."""
|
|
294
|
+
|
|
295
|
+
level: QualityLevel
|
|
296
|
+
count: int
|
|
297
|
+
percentage: float
|
|
298
|
+
|
|
299
|
+
def to_dict(self) -> dict[str, Any]:
|
|
300
|
+
"""Convert to dictionary."""
|
|
301
|
+
return {
|
|
302
|
+
"level": self.level.value,
|
|
303
|
+
"count": self.count,
|
|
304
|
+
"percentage": self.percentage,
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@dataclass
|
|
309
|
+
class QualityScoreResult:
|
|
310
|
+
"""Result of quality scoring."""
|
|
311
|
+
|
|
312
|
+
id: str
|
|
313
|
+
source_id: str
|
|
314
|
+
source_name: str | None
|
|
315
|
+
validation_id: str | None
|
|
316
|
+
status: QualityReportStatus
|
|
317
|
+
scores: list[QualityScore]
|
|
318
|
+
statistics: QualityStatistics | None
|
|
319
|
+
level_distribution: list[QualityLevelDistribution] | None
|
|
320
|
+
sample_size: int
|
|
321
|
+
evaluation_time_ms: float
|
|
322
|
+
error_message: str | None
|
|
323
|
+
created_at: datetime
|
|
324
|
+
updated_at: datetime
|
|
325
|
+
|
|
326
|
+
def to_dict(self) -> dict[str, Any]:
|
|
327
|
+
"""Convert to dictionary."""
|
|
328
|
+
return {
|
|
329
|
+
"id": self.id,
|
|
330
|
+
"source_id": self.source_id,
|
|
331
|
+
"source_name": self.source_name,
|
|
332
|
+
"validation_id": self.validation_id,
|
|
333
|
+
"status": self.status.value,
|
|
334
|
+
"scores": [s.to_dict() for s in self.scores],
|
|
335
|
+
"statistics": self.statistics.to_dict() if self.statistics else None,
|
|
336
|
+
"level_distribution": (
|
|
337
|
+
[d.to_dict() for d in self.level_distribution]
|
|
338
|
+
if self.level_distribution
|
|
339
|
+
else None
|
|
340
|
+
),
|
|
341
|
+
"sample_size": self.sample_size,
|
|
342
|
+
"evaluation_time_ms": self.evaluation_time_ms,
|
|
343
|
+
"error_message": self.error_message,
|
|
344
|
+
"created_at": self.created_at.isoformat(),
|
|
345
|
+
"updated_at": self.updated_at.isoformat(),
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@dataclass
|
|
350
|
+
class QualityReportResult:
|
|
351
|
+
"""Result of quality report generation."""
|
|
352
|
+
|
|
353
|
+
id: str
|
|
354
|
+
source_id: str | None
|
|
355
|
+
source_name: str | None
|
|
356
|
+
validation_id: str | None
|
|
357
|
+
format: QualityReportFormat
|
|
358
|
+
status: QualityReportStatus
|
|
359
|
+
filename: str | None
|
|
360
|
+
file_path: str | None
|
|
361
|
+
file_size_bytes: int | None
|
|
362
|
+
content_type: str | None
|
|
363
|
+
content: str | None # For inline reports
|
|
364
|
+
generation_time_ms: float | None
|
|
365
|
+
scores_count: int
|
|
366
|
+
statistics: QualityStatistics | None
|
|
367
|
+
error_message: str | None
|
|
368
|
+
download_count: int
|
|
369
|
+
expires_at: datetime | None
|
|
370
|
+
created_at: datetime
|
|
371
|
+
updated_at: datetime
|
|
372
|
+
|
|
373
|
+
def to_dict(self) -> dict[str, Any]:
|
|
374
|
+
"""Convert to dictionary."""
|
|
375
|
+
return {
|
|
376
|
+
"id": self.id,
|
|
377
|
+
"source_id": self.source_id,
|
|
378
|
+
"source_name": self.source_name,
|
|
379
|
+
"validation_id": self.validation_id,
|
|
380
|
+
"format": self.format.value,
|
|
381
|
+
"status": self.status.value,
|
|
382
|
+
"filename": self.filename,
|
|
383
|
+
"file_path": self.file_path,
|
|
384
|
+
"file_size_bytes": self.file_size_bytes,
|
|
385
|
+
"content_type": self.content_type,
|
|
386
|
+
"generation_time_ms": self.generation_time_ms,
|
|
387
|
+
"scores_count": self.scores_count,
|
|
388
|
+
"statistics": self.statistics.to_dict() if self.statistics else None,
|
|
389
|
+
"error_message": self.error_message,
|
|
390
|
+
"download_count": self.download_count,
|
|
391
|
+
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
|
392
|
+
"created_at": self.created_at.isoformat(),
|
|
393
|
+
"updated_at": self.updated_at.isoformat(),
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# =============================================================================
|
|
398
|
+
# Filter Classes
|
|
399
|
+
# =============================================================================
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@dataclass
|
|
403
|
+
class QualityFilter:
|
|
404
|
+
"""Filter for quality scores."""
|
|
405
|
+
|
|
406
|
+
min_level: QualityLevel | None = None
|
|
407
|
+
max_level: QualityLevel | None = None
|
|
408
|
+
min_f1: float | None = None
|
|
409
|
+
max_f1: float | None = None
|
|
410
|
+
min_confidence: float | None = None
|
|
411
|
+
should_use_only: bool = False
|
|
412
|
+
include_columns: list[str] | None = None
|
|
413
|
+
exclude_columns: list[str] | None = None
|
|
414
|
+
rule_types: list[str] | None = None
|
|
415
|
+
|
|
416
|
+
def apply(self, scores: list[QualityScore]) -> list[QualityScore]:
|
|
417
|
+
"""Apply filter to scores."""
|
|
418
|
+
result = scores
|
|
419
|
+
|
|
420
|
+
# Level filter
|
|
421
|
+
level_order = list(QualityLevel)
|
|
422
|
+
if self.min_level:
|
|
423
|
+
min_idx = level_order.index(self.min_level)
|
|
424
|
+
result = [
|
|
425
|
+
s
|
|
426
|
+
for s in result
|
|
427
|
+
if level_order.index(s.metrics.quality_level) <= min_idx
|
|
428
|
+
]
|
|
429
|
+
|
|
430
|
+
if self.max_level:
|
|
431
|
+
max_idx = level_order.index(self.max_level)
|
|
432
|
+
result = [
|
|
433
|
+
s
|
|
434
|
+
for s in result
|
|
435
|
+
if level_order.index(s.metrics.quality_level) >= max_idx
|
|
436
|
+
]
|
|
437
|
+
|
|
438
|
+
# F1 filter
|
|
439
|
+
if self.min_f1 is not None:
|
|
440
|
+
result = [s for s in result if s.metrics.f1_score >= self.min_f1]
|
|
441
|
+
if self.max_f1 is not None:
|
|
442
|
+
result = [s for s in result if s.metrics.f1_score <= self.max_f1]
|
|
443
|
+
|
|
444
|
+
# Confidence filter
|
|
445
|
+
if self.min_confidence is not None:
|
|
446
|
+
result = [s for s in result if s.metrics.confidence >= self.min_confidence]
|
|
447
|
+
|
|
448
|
+
# Should use filter
|
|
449
|
+
if self.should_use_only:
|
|
450
|
+
result = [s for s in result if s.should_use]
|
|
451
|
+
|
|
452
|
+
# Column filters
|
|
453
|
+
if self.include_columns:
|
|
454
|
+
result = [s for s in result if s.column in self.include_columns]
|
|
455
|
+
if self.exclude_columns:
|
|
456
|
+
result = [s for s in result if s.column not in self.exclude_columns]
|
|
457
|
+
|
|
458
|
+
# Rule type filter
|
|
459
|
+
if self.rule_types:
|
|
460
|
+
result = [s for s in result if s.rule_type in self.rule_types]
|
|
461
|
+
|
|
462
|
+
return result
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# =============================================================================
|
|
466
|
+
# Report Configuration
|
|
467
|
+
# =============================================================================
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
@dataclass
|
|
471
|
+
class QualityReportConfig:
|
|
472
|
+
"""Configuration for quality report generation."""
|
|
473
|
+
|
|
474
|
+
title: str | None = None
|
|
475
|
+
description: str | None = None
|
|
476
|
+
include_metrics: bool = True
|
|
477
|
+
include_confusion_matrix: bool = False
|
|
478
|
+
include_recommendations: bool = True
|
|
479
|
+
include_statistics: bool = True
|
|
480
|
+
include_summary: bool = True
|
|
481
|
+
include_charts: bool = True
|
|
482
|
+
metric_precision: int = 2
|
|
483
|
+
percentage_format: bool = True
|
|
484
|
+
sort_order: str = "f1_desc"
|
|
485
|
+
max_scores: int | None = None
|
|
486
|
+
theme: str = "professional"
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# =============================================================================
|
|
490
|
+
# Truthound Integration
|
|
491
|
+
# =============================================================================
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _get_quality_scorer():
|
|
495
|
+
"""Get truthound's RuleQualityScorer if available."""
|
|
496
|
+
try:
|
|
497
|
+
from truthound.profiler.quality import RuleQualityScorer
|
|
498
|
+
|
|
499
|
+
return RuleQualityScorer()
|
|
500
|
+
except ImportError:
|
|
501
|
+
logger.warning("truthound.profiler.quality not available")
|
|
502
|
+
return None
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _get_quality_reporter(format: str, **kwargs):
|
|
506
|
+
"""Get truthound's quality reporter if available."""
|
|
507
|
+
try:
|
|
508
|
+
from truthound.reporters.quality import get_quality_reporter
|
|
509
|
+
|
|
510
|
+
return get_quality_reporter(format, **kwargs)
|
|
511
|
+
except ImportError:
|
|
512
|
+
logger.warning("truthound.reporters.quality not available")
|
|
513
|
+
return None
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _get_quality_filter():
|
|
517
|
+
"""Get truthound's QualityFilter if available."""
|
|
518
|
+
try:
|
|
519
|
+
from truthound.reporters.quality.filters import QualityFilter as TruthoundFilter
|
|
520
|
+
|
|
521
|
+
return TruthoundFilter
|
|
522
|
+
except ImportError:
|
|
523
|
+
return None
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _score_rules_sync(
|
|
527
|
+
data_input: Any,
|
|
528
|
+
rules: list[Any] | None = None,
|
|
529
|
+
sample_size: int = 10000,
|
|
530
|
+
thresholds: QualityThresholds | None = None,
|
|
531
|
+
) -> list[QualityScore]:
|
|
532
|
+
"""Score rules synchronously using truthound."""
|
|
533
|
+
thresholds = thresholds or QualityThresholds()
|
|
534
|
+
scores: list[QualityScore] = []
|
|
535
|
+
|
|
536
|
+
try:
|
|
537
|
+
scorer = _get_quality_scorer()
|
|
538
|
+
if scorer is None:
|
|
539
|
+
# Fallback: generate mock scores based on validation results
|
|
540
|
+
return _generate_mock_scores(data_input, thresholds)
|
|
541
|
+
|
|
542
|
+
# Use truthound's scorer
|
|
543
|
+
if rules:
|
|
544
|
+
raw_scores = scorer.score_all(rules, data_input)
|
|
545
|
+
else:
|
|
546
|
+
# Score all rules from schema
|
|
547
|
+
import truthound as th
|
|
548
|
+
|
|
549
|
+
schema = th.learn(data_input)
|
|
550
|
+
from truthound.profiler import generate_suite
|
|
551
|
+
|
|
552
|
+
suite = generate_suite(schema)
|
|
553
|
+
raw_scores = scorer.score_all(suite.rules, data_input)
|
|
554
|
+
|
|
555
|
+
# Convert to our format
|
|
556
|
+
for raw_score in raw_scores:
|
|
557
|
+
metrics = QualityMetrics(
|
|
558
|
+
f1_score=raw_score.metrics.f1_score,
|
|
559
|
+
precision=raw_score.metrics.precision,
|
|
560
|
+
recall=raw_score.metrics.recall,
|
|
561
|
+
accuracy=raw_score.metrics.accuracy,
|
|
562
|
+
confidence=getattr(raw_score.metrics, "confidence", 0.0),
|
|
563
|
+
quality_level=thresholds.get_level(raw_score.metrics.f1_score),
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
confusion = None
|
|
567
|
+
if hasattr(raw_score, "confusion_matrix") and raw_score.confusion_matrix:
|
|
568
|
+
cm = raw_score.confusion_matrix
|
|
569
|
+
confusion = ConfusionMatrix(
|
|
570
|
+
true_positive=cm.true_positive,
|
|
571
|
+
true_negative=cm.true_negative,
|
|
572
|
+
false_positive=cm.false_positive,
|
|
573
|
+
false_negative=cm.false_negative,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
score = QualityScore(
|
|
577
|
+
rule_name=raw_score.rule_name,
|
|
578
|
+
rule_type=getattr(raw_score, "rule_type", None),
|
|
579
|
+
column=getattr(raw_score, "column", None),
|
|
580
|
+
metrics=metrics,
|
|
581
|
+
confusion_matrix=confusion,
|
|
582
|
+
test_sample_size=getattr(raw_score, "test_sample_size", sample_size),
|
|
583
|
+
evaluation_time_ms=getattr(raw_score, "evaluation_time_ms", 0.0),
|
|
584
|
+
recommendation=getattr(raw_score, "recommendation", None),
|
|
585
|
+
should_use=getattr(raw_score, "should_use", metrics.f1_score >= thresholds.acceptable),
|
|
586
|
+
)
|
|
587
|
+
scores.append(score)
|
|
588
|
+
|
|
589
|
+
except Exception as e:
|
|
590
|
+
logger.error(f"Error scoring rules: {e}")
|
|
591
|
+
# Return mock scores on error
|
|
592
|
+
return _generate_mock_scores(data_input, thresholds)
|
|
593
|
+
|
|
594
|
+
return scores
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _generate_mock_scores(
|
|
598
|
+
data_input: Any, thresholds: QualityThresholds
|
|
599
|
+
) -> list[QualityScore]:
|
|
600
|
+
"""Generate mock quality scores when truthound scoring is unavailable."""
|
|
601
|
+
import random
|
|
602
|
+
|
|
603
|
+
# Generate some representative mock scores
|
|
604
|
+
mock_rules = [
|
|
605
|
+
("null_check", "completeness", None),
|
|
606
|
+
("duplicate_check", "uniqueness", None),
|
|
607
|
+
("type_check", "schema", None),
|
|
608
|
+
("range_check", "distribution", "amount"),
|
|
609
|
+
("pattern_check", "string", "email"),
|
|
610
|
+
]
|
|
611
|
+
|
|
612
|
+
scores = []
|
|
613
|
+
for rule_name, rule_type, column in mock_rules:
|
|
614
|
+
f1 = random.uniform(0.5, 0.98)
|
|
615
|
+
precision = random.uniform(max(0.5, f1 - 0.1), min(1.0, f1 + 0.1))
|
|
616
|
+
recall = random.uniform(max(0.5, f1 - 0.1), min(1.0, f1 + 0.1))
|
|
617
|
+
accuracy = random.uniform(max(0.6, f1 - 0.05), min(1.0, f1 + 0.05))
|
|
618
|
+
confidence = random.uniform(0.7, 0.95)
|
|
619
|
+
|
|
620
|
+
metrics = QualityMetrics(
|
|
621
|
+
f1_score=f1,
|
|
622
|
+
precision=precision,
|
|
623
|
+
recall=recall,
|
|
624
|
+
accuracy=accuracy,
|
|
625
|
+
confidence=confidence,
|
|
626
|
+
quality_level=thresholds.get_level(f1),
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
score = QualityScore(
|
|
630
|
+
rule_name=rule_name,
|
|
631
|
+
rule_type=rule_type,
|
|
632
|
+
column=column,
|
|
633
|
+
metrics=metrics,
|
|
634
|
+
test_sample_size=1000,
|
|
635
|
+
evaluation_time_ms=random.uniform(10, 100),
|
|
636
|
+
should_use=f1 >= thresholds.acceptable,
|
|
637
|
+
)
|
|
638
|
+
scores.append(score)
|
|
639
|
+
|
|
640
|
+
return scores
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _generate_report_sync(
|
|
644
|
+
scores: list[QualityScore],
|
|
645
|
+
format: QualityReportFormat,
|
|
646
|
+
config: QualityReportConfig | None = None,
|
|
647
|
+
) -> tuple[str, str]:
|
|
648
|
+
"""Generate report synchronously."""
|
|
649
|
+
config = config or QualityReportConfig()
|
|
650
|
+
|
|
651
|
+
try:
|
|
652
|
+
reporter = _get_quality_reporter(format.value, **_config_to_kwargs(config))
|
|
653
|
+
if reporter:
|
|
654
|
+
# Convert scores to truthound format
|
|
655
|
+
truthound_scores = _convert_to_truthound_scores(scores)
|
|
656
|
+
content = reporter.render(truthound_scores)
|
|
657
|
+
return content, _get_content_type(format)
|
|
658
|
+
except Exception as e:
|
|
659
|
+
logger.warning(f"truthound reporter unavailable: {e}")
|
|
660
|
+
|
|
661
|
+
# Fallback: generate simple reports
|
|
662
|
+
return _generate_fallback_report(scores, format, config)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _config_to_kwargs(config: QualityReportConfig) -> dict[str, Any]:
|
|
666
|
+
"""Convert config to reporter kwargs."""
|
|
667
|
+
kwargs: dict[str, Any] = {}
|
|
668
|
+
if config.title:
|
|
669
|
+
kwargs["title"] = config.title
|
|
670
|
+
if config.include_charts is not None:
|
|
671
|
+
kwargs["include_charts"] = config.include_charts
|
|
672
|
+
if config.theme:
|
|
673
|
+
kwargs["theme"] = config.theme
|
|
674
|
+
return kwargs
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _convert_to_truthound_scores(scores: list[QualityScore]) -> list[Any]:
|
|
678
|
+
"""Convert our scores to truthound format."""
|
|
679
|
+
# For now, return as-is - truthound reporters can handle dict-like objects
|
|
680
|
+
return [s.to_dict() for s in scores]
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _get_content_type(format: QualityReportFormat) -> str:
|
|
684
|
+
"""Get content type for format."""
|
|
685
|
+
content_types = {
|
|
686
|
+
QualityReportFormat.CONSOLE: "text/plain",
|
|
687
|
+
QualityReportFormat.JSON: "application/json",
|
|
688
|
+
QualityReportFormat.HTML: "text/html",
|
|
689
|
+
QualityReportFormat.MARKDOWN: "text/markdown",
|
|
690
|
+
QualityReportFormat.JUNIT: "application/xml",
|
|
691
|
+
}
|
|
692
|
+
return content_types.get(format, "text/plain")
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _generate_fallback_report(
|
|
696
|
+
scores: list[QualityScore],
|
|
697
|
+
format: QualityReportFormat,
|
|
698
|
+
config: QualityReportConfig,
|
|
699
|
+
) -> tuple[str, str]:
|
|
700
|
+
"""Generate a fallback report when truthound is unavailable."""
|
|
701
|
+
stats = QualityStatistics.from_scores(scores)
|
|
702
|
+
|
|
703
|
+
if format == QualityReportFormat.JSON:
|
|
704
|
+
import json
|
|
705
|
+
|
|
706
|
+
data = {
|
|
707
|
+
"title": config.title or "Quality Score Report",
|
|
708
|
+
"generated_at": datetime.now().isoformat(),
|
|
709
|
+
"scores": [s.to_dict() for s in scores],
|
|
710
|
+
"statistics": stats.to_dict(),
|
|
711
|
+
"count": len(scores),
|
|
712
|
+
}
|
|
713
|
+
return json.dumps(data, indent=2), "application/json"
|
|
714
|
+
|
|
715
|
+
elif format == QualityReportFormat.HTML:
|
|
716
|
+
return _generate_html_report(scores, stats, config), "text/html"
|
|
717
|
+
|
|
718
|
+
elif format == QualityReportFormat.MARKDOWN:
|
|
719
|
+
return _generate_markdown_report(scores, stats, config), "text/markdown"
|
|
720
|
+
|
|
721
|
+
else: # CONSOLE or default
|
|
722
|
+
return _generate_console_report(scores, stats, config), "text/plain"
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _generate_html_report(
|
|
726
|
+
scores: list[QualityScore],
|
|
727
|
+
stats: QualityStatistics,
|
|
728
|
+
config: QualityReportConfig,
|
|
729
|
+
) -> str:
|
|
730
|
+
"""Generate HTML report."""
|
|
731
|
+
title = config.title or "Quality Score Report"
|
|
732
|
+
rows = "\n".join(
|
|
733
|
+
f"""
|
|
734
|
+
<tr>
|
|
735
|
+
<td>{s.rule_name}</td>
|
|
736
|
+
<td><span class="level-{s.metrics.quality_level.value}">{s.metrics.quality_level.value}</span></td>
|
|
737
|
+
<td>{s.metrics.f1_score:.{config.metric_precision}%}</td>
|
|
738
|
+
<td>{s.metrics.precision:.{config.metric_precision}%}</td>
|
|
739
|
+
<td>{s.metrics.recall:.{config.metric_precision}%}</td>
|
|
740
|
+
<td>{"✓" if s.should_use else "✗"}</td>
|
|
741
|
+
</tr>
|
|
742
|
+
"""
|
|
743
|
+
for s in scores
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
return f"""<!DOCTYPE html>
|
|
747
|
+
<html>
|
|
748
|
+
<head>
|
|
749
|
+
<title>{title}</title>
|
|
750
|
+
<style>
|
|
751
|
+
body {{ font-family: system-ui, sans-serif; margin: 2rem; background: #f5f5f5; }}
|
|
752
|
+
.container {{ max-width: 1200px; margin: 0 auto; }}
|
|
753
|
+
h1 {{ color: #333; }}
|
|
754
|
+
.stats {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin: 2rem 0; }}
|
|
755
|
+
.stat-card {{ background: white; padding: 1rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
|
|
756
|
+
.stat-value {{ font-size: 2rem; font-weight: bold; color: #fd9e4b; }}
|
|
757
|
+
table {{ width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; }}
|
|
758
|
+
th, td {{ padding: 0.75rem; text-align: left; border-bottom: 1px solid #eee; }}
|
|
759
|
+
th {{ background: #333; color: white; }}
|
|
760
|
+
.level-excellent {{ color: #22c55e; }}
|
|
761
|
+
.level-good {{ color: #3b82f6; }}
|
|
762
|
+
.level-acceptable {{ color: #f59e0b; }}
|
|
763
|
+
.level-poor {{ color: #ef4444; }}
|
|
764
|
+
.level-unacceptable {{ color: #991b1b; }}
|
|
765
|
+
</style>
|
|
766
|
+
</head>
|
|
767
|
+
<body>
|
|
768
|
+
<div class="container">
|
|
769
|
+
<h1>{title}</h1>
|
|
770
|
+
<p>Generated at {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</p>
|
|
771
|
+
|
|
772
|
+
<div class="stats">
|
|
773
|
+
<div class="stat-card">
|
|
774
|
+
<div>Total Rules</div>
|
|
775
|
+
<div class="stat-value">{stats.total_count}</div>
|
|
776
|
+
</div>
|
|
777
|
+
<div class="stat-card">
|
|
778
|
+
<div>Average F1</div>
|
|
779
|
+
<div class="stat-value">{stats.avg_f1:.1%}</div>
|
|
780
|
+
</div>
|
|
781
|
+
<div class="stat-card">
|
|
782
|
+
<div>Should Use</div>
|
|
783
|
+
<div class="stat-value">{stats.should_use_count}</div>
|
|
784
|
+
</div>
|
|
785
|
+
<div class="stat-card">
|
|
786
|
+
<div>Excellent</div>
|
|
787
|
+
<div class="stat-value">{stats.excellent_count}</div>
|
|
788
|
+
</div>
|
|
789
|
+
</div>
|
|
790
|
+
|
|
791
|
+
<table>
|
|
792
|
+
<thead>
|
|
793
|
+
<tr>
|
|
794
|
+
<th>Rule Name</th>
|
|
795
|
+
<th>Level</th>
|
|
796
|
+
<th>F1 Score</th>
|
|
797
|
+
<th>Precision</th>
|
|
798
|
+
<th>Recall</th>
|
|
799
|
+
<th>Use?</th>
|
|
800
|
+
</tr>
|
|
801
|
+
</thead>
|
|
802
|
+
<tbody>
|
|
803
|
+
{rows}
|
|
804
|
+
</tbody>
|
|
805
|
+
</table>
|
|
806
|
+
</div>
|
|
807
|
+
</body>
|
|
808
|
+
</html>"""
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def _generate_markdown_report(
|
|
812
|
+
scores: list[QualityScore],
|
|
813
|
+
stats: QualityStatistics,
|
|
814
|
+
config: QualityReportConfig,
|
|
815
|
+
) -> str:
|
|
816
|
+
"""Generate Markdown report."""
|
|
817
|
+
title = config.title or "Quality Score Report"
|
|
818
|
+
lines = [
|
|
819
|
+
f"# {title}",
|
|
820
|
+
"",
|
|
821
|
+
f"Generated at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
822
|
+
"",
|
|
823
|
+
"## Statistics",
|
|
824
|
+
"",
|
|
825
|
+
f"- **Total Rules**: {stats.total_count}",
|
|
826
|
+
f"- **Average F1**: {stats.avg_f1:.1%}",
|
|
827
|
+
f"- **Should Use**: {stats.should_use_count}",
|
|
828
|
+
f"- **Excellent**: {stats.excellent_count}",
|
|
829
|
+
f"- **Good**: {stats.good_count}",
|
|
830
|
+
f"- **Acceptable**: {stats.acceptable_count}",
|
|
831
|
+
f"- **Poor**: {stats.poor_count}",
|
|
832
|
+
f"- **Unacceptable**: {stats.unacceptable_count}",
|
|
833
|
+
"",
|
|
834
|
+
"## Scores",
|
|
835
|
+
"",
|
|
836
|
+
"| Rule Name | Level | F1 | Precision | Recall | Use? |",
|
|
837
|
+
"|-----------|-------|-----|-----------|--------|------|",
|
|
838
|
+
]
|
|
839
|
+
|
|
840
|
+
for s in scores:
|
|
841
|
+
use = "✓" if s.should_use else "✗"
|
|
842
|
+
lines.append(
|
|
843
|
+
f"| {s.rule_name} | {s.metrics.quality_level.value} | "
|
|
844
|
+
f"{s.metrics.f1_score:.{config.metric_precision}%} | "
|
|
845
|
+
f"{s.metrics.precision:.{config.metric_precision}%} | "
|
|
846
|
+
f"{s.metrics.recall:.{config.metric_precision}%} | {use} |"
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
return "\n".join(lines)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _generate_console_report(
|
|
853
|
+
scores: list[QualityScore],
|
|
854
|
+
stats: QualityStatistics,
|
|
855
|
+
config: QualityReportConfig,
|
|
856
|
+
) -> str:
|
|
857
|
+
"""Generate console text report."""
|
|
858
|
+
title = config.title or "Quality Score Report"
|
|
859
|
+
lines = [
|
|
860
|
+
title,
|
|
861
|
+
"=" * len(title),
|
|
862
|
+
"",
|
|
863
|
+
f"Total Rules: {stats.total_count}",
|
|
864
|
+
f"Average F1: {stats.avg_f1:.1%}",
|
|
865
|
+
f"Should Use: {stats.should_use_count}",
|
|
866
|
+
"",
|
|
867
|
+
"Level Distribution:",
|
|
868
|
+
f" Excellent: {stats.excellent_count}",
|
|
869
|
+
f" Good: {stats.good_count}",
|
|
870
|
+
f" Acceptable: {stats.acceptable_count}",
|
|
871
|
+
f" Poor: {stats.poor_count}",
|
|
872
|
+
f" Unacceptable: {stats.unacceptable_count}",
|
|
873
|
+
"",
|
|
874
|
+
"-" * 80,
|
|
875
|
+
f"{'Rule Name':<25} {'Level':<12} {'F1':>8} {'Prec':>8} {'Recall':>8} {'Use':>5}",
|
|
876
|
+
"-" * 80,
|
|
877
|
+
]
|
|
878
|
+
|
|
879
|
+
for s in scores:
|
|
880
|
+
use = "Yes" if s.should_use else "No"
|
|
881
|
+
lines.append(
|
|
882
|
+
f"{s.rule_name:<25} {s.metrics.quality_level.value:<12} "
|
|
883
|
+
f"{s.metrics.f1_score:>7.1%} {s.metrics.precision:>7.1%} "
|
|
884
|
+
f"{s.metrics.recall:>7.1%} {use:>5}"
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
return "\n".join(lines)
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
# =============================================================================
|
|
891
|
+
# Service Class
|
|
892
|
+
# =============================================================================
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
class QualityReporterService:
|
|
896
|
+
"""Service for quality assessment and reporting.
|
|
897
|
+
|
|
898
|
+
This service provides:
|
|
899
|
+
- Rule quality scoring
|
|
900
|
+
- Quality score filtering
|
|
901
|
+
- Report generation in multiple formats
|
|
902
|
+
- Score comparison and ranking
|
|
903
|
+
"""
|
|
904
|
+
|
|
905
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
906
|
+
"""Initialize the service.
|
|
907
|
+
|
|
908
|
+
Args:
|
|
909
|
+
session: Async database session.
|
|
910
|
+
"""
|
|
911
|
+
self.session = session
|
|
912
|
+
self._executor = _executor
|
|
913
|
+
|
|
914
|
+
async def score_source(
|
|
915
|
+
self,
|
|
916
|
+
source_id: str,
|
|
917
|
+
*,
|
|
918
|
+
validation_id: str | None = None,
|
|
919
|
+
rule_names: list[str] | None = None,
|
|
920
|
+
sample_size: int = 10000,
|
|
921
|
+
thresholds: QualityThresholds | None = None,
|
|
922
|
+
) -> QualityScoreResult:
|
|
923
|
+
"""Score validation rules for a source.
|
|
924
|
+
|
|
925
|
+
Args:
|
|
926
|
+
source_id: Source ID to score.
|
|
927
|
+
validation_id: Optional validation ID.
|
|
928
|
+
rule_names: Specific rules to score.
|
|
929
|
+
sample_size: Sample size for scoring.
|
|
930
|
+
thresholds: Custom quality thresholds.
|
|
931
|
+
|
|
932
|
+
Returns:
|
|
933
|
+
Quality score result.
|
|
934
|
+
"""
|
|
935
|
+
start_time = time.time()
|
|
936
|
+
thresholds = thresholds or QualityThresholds()
|
|
937
|
+
now = datetime.now()
|
|
938
|
+
|
|
939
|
+
# Get source
|
|
940
|
+
result = await self.session.execute(
|
|
941
|
+
select(Source).where(Source.id == source_id)
|
|
942
|
+
)
|
|
943
|
+
source = result.scalar_one_or_none()
|
|
944
|
+
if not source:
|
|
945
|
+
return QualityScoreResult(
|
|
946
|
+
id=str(uuid.uuid4()),
|
|
947
|
+
source_id=source_id,
|
|
948
|
+
source_name=None,
|
|
949
|
+
validation_id=validation_id,
|
|
950
|
+
status=QualityReportStatus.FAILED,
|
|
951
|
+
scores=[],
|
|
952
|
+
statistics=None,
|
|
953
|
+
level_distribution=None,
|
|
954
|
+
sample_size=sample_size,
|
|
955
|
+
evaluation_time_ms=0.0,
|
|
956
|
+
error_message=f"Source not found: {source_id}",
|
|
957
|
+
created_at=now,
|
|
958
|
+
updated_at=now,
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
try:
|
|
962
|
+
# Get data input
|
|
963
|
+
from truthound_dashboard.core.services import get_data_input_from_source
|
|
964
|
+
|
|
965
|
+
data_input = get_data_input_from_source(source)
|
|
966
|
+
|
|
967
|
+
# Score rules asynchronously
|
|
968
|
+
loop = asyncio.get_event_loop()
|
|
969
|
+
scores = await loop.run_in_executor(
|
|
970
|
+
self._executor,
|
|
971
|
+
partial(
|
|
972
|
+
_score_rules_sync,
|
|
973
|
+
data_input,
|
|
974
|
+
None, # rules
|
|
975
|
+
sample_size,
|
|
976
|
+
thresholds,
|
|
977
|
+
),
|
|
978
|
+
)
|
|
979
|
+
|
|
980
|
+
# Filter by rule names if specified
|
|
981
|
+
if rule_names:
|
|
982
|
+
scores = [s for s in scores if s.rule_name in rule_names]
|
|
983
|
+
|
|
984
|
+
# Calculate statistics
|
|
985
|
+
statistics = QualityStatistics.from_scores(scores)
|
|
986
|
+
|
|
987
|
+
# Calculate level distribution
|
|
988
|
+
level_distribution = self._calculate_level_distribution(scores)
|
|
989
|
+
|
|
990
|
+
evaluation_time = (time.time() - start_time) * 1000
|
|
991
|
+
|
|
992
|
+
return QualityScoreResult(
|
|
993
|
+
id=str(uuid.uuid4()),
|
|
994
|
+
source_id=source_id,
|
|
995
|
+
source_name=source.name,
|
|
996
|
+
validation_id=validation_id,
|
|
997
|
+
status=QualityReportStatus.COMPLETED,
|
|
998
|
+
scores=scores,
|
|
999
|
+
statistics=statistics,
|
|
1000
|
+
level_distribution=level_distribution,
|
|
1001
|
+
sample_size=sample_size,
|
|
1002
|
+
evaluation_time_ms=evaluation_time,
|
|
1003
|
+
error_message=None,
|
|
1004
|
+
created_at=now,
|
|
1005
|
+
updated_at=now,
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
except Exception as e:
|
|
1009
|
+
logger.error(f"Error scoring source {source_id}: {e}")
|
|
1010
|
+
return QualityScoreResult(
|
|
1011
|
+
id=str(uuid.uuid4()),
|
|
1012
|
+
source_id=source_id,
|
|
1013
|
+
source_name=source.name,
|
|
1014
|
+
validation_id=validation_id,
|
|
1015
|
+
status=QualityReportStatus.FAILED,
|
|
1016
|
+
scores=[],
|
|
1017
|
+
statistics=None,
|
|
1018
|
+
level_distribution=None,
|
|
1019
|
+
sample_size=sample_size,
|
|
1020
|
+
evaluation_time_ms=(time.time() - start_time) * 1000,
|
|
1021
|
+
error_message=str(e),
|
|
1022
|
+
created_at=now,
|
|
1023
|
+
updated_at=now,
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
async def filter_scores(
|
|
1027
|
+
self,
|
|
1028
|
+
scores: list[QualityScore],
|
|
1029
|
+
filter_config: QualityFilter,
|
|
1030
|
+
) -> list[QualityScore]:
|
|
1031
|
+
"""Filter quality scores.
|
|
1032
|
+
|
|
1033
|
+
Args:
|
|
1034
|
+
scores: Scores to filter.
|
|
1035
|
+
filter_config: Filter configuration.
|
|
1036
|
+
|
|
1037
|
+
Returns:
|
|
1038
|
+
Filtered scores.
|
|
1039
|
+
"""
|
|
1040
|
+
return filter_config.apply(scores)
|
|
1041
|
+
|
|
1042
|
+
async def generate_report(
|
|
1043
|
+
self,
|
|
1044
|
+
source_id: str | None = None,
|
|
1045
|
+
validation_id: str | None = None,
|
|
1046
|
+
*,
|
|
1047
|
+
format: QualityReportFormat = QualityReportFormat.HTML,
|
|
1048
|
+
config: QualityReportConfig | None = None,
|
|
1049
|
+
filter_config: QualityFilter | None = None,
|
|
1050
|
+
score_rules: bool = True,
|
|
1051
|
+
sample_size: int = 10000,
|
|
1052
|
+
) -> QualityReportResult:
|
|
1053
|
+
"""Generate a quality report.
|
|
1054
|
+
|
|
1055
|
+
Args:
|
|
1056
|
+
source_id: Source ID for the report.
|
|
1057
|
+
validation_id: Validation ID for the report.
|
|
1058
|
+
format: Report format.
|
|
1059
|
+
config: Report configuration.
|
|
1060
|
+
filter_config: Score filter.
|
|
1061
|
+
score_rules: Whether to score rules first.
|
|
1062
|
+
sample_size: Sample size for scoring.
|
|
1063
|
+
|
|
1064
|
+
Returns:
|
|
1065
|
+
Quality report result.
|
|
1066
|
+
"""
|
|
1067
|
+
start_time = time.time()
|
|
1068
|
+
config = config or QualityReportConfig()
|
|
1069
|
+
now = datetime.now()
|
|
1070
|
+
report_id = str(uuid.uuid4())
|
|
1071
|
+
|
|
1072
|
+
source_name = None
|
|
1073
|
+
if source_id:
|
|
1074
|
+
result = await self.session.execute(
|
|
1075
|
+
select(Source).where(Source.id == source_id)
|
|
1076
|
+
)
|
|
1077
|
+
source = result.scalar_one_or_none()
|
|
1078
|
+
source_name = source.name if source else None
|
|
1079
|
+
|
|
1080
|
+
try:
|
|
1081
|
+
# Score rules if requested
|
|
1082
|
+
scores: list[QualityScore] = []
|
|
1083
|
+
if score_rules and source_id:
|
|
1084
|
+
score_result = await self.score_source(
|
|
1085
|
+
source_id,
|
|
1086
|
+
validation_id=validation_id,
|
|
1087
|
+
sample_size=sample_size,
|
|
1088
|
+
)
|
|
1089
|
+
if score_result.status == QualityReportStatus.COMPLETED:
|
|
1090
|
+
scores = score_result.scores
|
|
1091
|
+
|
|
1092
|
+
# Apply filter
|
|
1093
|
+
if filter_config and scores:
|
|
1094
|
+
scores = await self.filter_scores(scores, filter_config)
|
|
1095
|
+
|
|
1096
|
+
# Sort scores
|
|
1097
|
+
scores = self._sort_scores(scores, config.sort_order)
|
|
1098
|
+
|
|
1099
|
+
# Limit scores
|
|
1100
|
+
if config.max_scores:
|
|
1101
|
+
scores = scores[: config.max_scores]
|
|
1102
|
+
|
|
1103
|
+
# Generate report
|
|
1104
|
+
loop = asyncio.get_event_loop()
|
|
1105
|
+
content, content_type = await loop.run_in_executor(
|
|
1106
|
+
self._executor,
|
|
1107
|
+
partial(_generate_report_sync, scores, format, config),
|
|
1108
|
+
)
|
|
1109
|
+
|
|
1110
|
+
statistics = QualityStatistics.from_scores(scores)
|
|
1111
|
+
generation_time = (time.time() - start_time) * 1000
|
|
1112
|
+
|
|
1113
|
+
# Generate filename
|
|
1114
|
+
timestamp = now.strftime("%Y%m%d_%H%M%S")
|
|
1115
|
+
extension = self._get_extension(format)
|
|
1116
|
+
filename = f"quality_report_{timestamp}{extension}"
|
|
1117
|
+
|
|
1118
|
+
return QualityReportResult(
|
|
1119
|
+
id=report_id,
|
|
1120
|
+
source_id=source_id,
|
|
1121
|
+
source_name=source_name,
|
|
1122
|
+
validation_id=validation_id,
|
|
1123
|
+
format=format,
|
|
1124
|
+
status=QualityReportStatus.COMPLETED,
|
|
1125
|
+
filename=filename,
|
|
1126
|
+
file_path=None,
|
|
1127
|
+
file_size_bytes=len(content.encode("utf-8")),
|
|
1128
|
+
content_type=content_type,
|
|
1129
|
+
content=content,
|
|
1130
|
+
generation_time_ms=generation_time,
|
|
1131
|
+
scores_count=len(scores),
|
|
1132
|
+
statistics=statistics,
|
|
1133
|
+
error_message=None,
|
|
1134
|
+
download_count=0,
|
|
1135
|
+
expires_at=None,
|
|
1136
|
+
created_at=now,
|
|
1137
|
+
updated_at=now,
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
except Exception as e:
|
|
1141
|
+
logger.error(f"Error generating report: {e}")
|
|
1142
|
+
return QualityReportResult(
|
|
1143
|
+
id=report_id,
|
|
1144
|
+
source_id=source_id,
|
|
1145
|
+
source_name=source_name,
|
|
1146
|
+
validation_id=validation_id,
|
|
1147
|
+
format=format,
|
|
1148
|
+
status=QualityReportStatus.FAILED,
|
|
1149
|
+
filename=None,
|
|
1150
|
+
file_path=None,
|
|
1151
|
+
file_size_bytes=None,
|
|
1152
|
+
content_type=None,
|
|
1153
|
+
content=None,
|
|
1154
|
+
generation_time_ms=(time.time() - start_time) * 1000,
|
|
1155
|
+
scores_count=0,
|
|
1156
|
+
statistics=None,
|
|
1157
|
+
error_message=str(e),
|
|
1158
|
+
download_count=0,
|
|
1159
|
+
expires_at=None,
|
|
1160
|
+
created_at=now,
|
|
1161
|
+
updated_at=now,
|
|
1162
|
+
)
|
|
1163
|
+
|
|
1164
|
+
async def compare_scores(
|
|
1165
|
+
self,
|
|
1166
|
+
scores: list[QualityScore],
|
|
1167
|
+
*,
|
|
1168
|
+
sort_by: str = "f1_score",
|
|
1169
|
+
descending: bool = True,
|
|
1170
|
+
group_by: str | None = None,
|
|
1171
|
+
max_results: int = 50,
|
|
1172
|
+
) -> dict[str, Any]:
|
|
1173
|
+
"""Compare and rank quality scores.
|
|
1174
|
+
|
|
1175
|
+
Args:
|
|
1176
|
+
scores: Scores to compare.
|
|
1177
|
+
sort_by: Metric to sort by.
|
|
1178
|
+
descending: Sort in descending order.
|
|
1179
|
+
group_by: Group results by (column, level, rule_type).
|
|
1180
|
+
max_results: Maximum results to return.
|
|
1181
|
+
|
|
1182
|
+
Returns:
|
|
1183
|
+
Comparison result with ranked scores and optional groups.
|
|
1184
|
+
"""
|
|
1185
|
+
# Sort scores
|
|
1186
|
+
key_map = {
|
|
1187
|
+
"f1_score": lambda s: s.metrics.f1_score,
|
|
1188
|
+
"precision": lambda s: s.metrics.precision,
|
|
1189
|
+
"recall": lambda s: s.metrics.recall,
|
|
1190
|
+
"confidence": lambda s: s.metrics.confidence,
|
|
1191
|
+
}
|
|
1192
|
+
key_fn = key_map.get(sort_by, key_map["f1_score"])
|
|
1193
|
+
sorted_scores = sorted(scores, key=key_fn, reverse=descending)[:max_results]
|
|
1194
|
+
|
|
1195
|
+
result: dict[str, Any] = {
|
|
1196
|
+
"scores": [s.to_dict() for s in sorted_scores],
|
|
1197
|
+
"ranked_by": sort_by,
|
|
1198
|
+
"best_rule": sorted_scores[0].to_dict() if sorted_scores else None,
|
|
1199
|
+
"worst_rule": sorted_scores[-1].to_dict() if sorted_scores else None,
|
|
1200
|
+
"statistics": QualityStatistics.from_scores(sorted_scores).to_dict(),
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
# Group if requested
|
|
1204
|
+
if group_by:
|
|
1205
|
+
groups: dict[str, list[dict]] = {}
|
|
1206
|
+
for score in sorted_scores:
|
|
1207
|
+
if group_by == "column":
|
|
1208
|
+
key = score.column or "unknown"
|
|
1209
|
+
elif group_by == "level":
|
|
1210
|
+
key = score.metrics.quality_level.value
|
|
1211
|
+
elif group_by == "rule_type":
|
|
1212
|
+
key = score.rule_type or "unknown"
|
|
1213
|
+
else:
|
|
1214
|
+
key = "all"
|
|
1215
|
+
|
|
1216
|
+
if key not in groups:
|
|
1217
|
+
groups[key] = []
|
|
1218
|
+
groups[key].append(score.to_dict())
|
|
1219
|
+
|
|
1220
|
+
result["groups"] = groups
|
|
1221
|
+
|
|
1222
|
+
return result
|
|
1223
|
+
|
|
1224
|
+
async def get_summary(
|
|
1225
|
+
self,
|
|
1226
|
+
source_id: str,
|
|
1227
|
+
*,
|
|
1228
|
+
validation_id: str | None = None,
|
|
1229
|
+
sample_size: int = 10000,
|
|
1230
|
+
) -> dict[str, Any]:
|
|
1231
|
+
"""Get quality summary for a source.
|
|
1232
|
+
|
|
1233
|
+
Args:
|
|
1234
|
+
source_id: Source ID.
|
|
1235
|
+
validation_id: Optional validation ID.
|
|
1236
|
+
sample_size: Sample size for scoring.
|
|
1237
|
+
|
|
1238
|
+
Returns:
|
|
1239
|
+
Quality summary.
|
|
1240
|
+
"""
|
|
1241
|
+
score_result = await self.score_source(
|
|
1242
|
+
source_id,
|
|
1243
|
+
validation_id=validation_id,
|
|
1244
|
+
sample_size=sample_size,
|
|
1245
|
+
)
|
|
1246
|
+
|
|
1247
|
+
if score_result.status != QualityReportStatus.COMPLETED:
|
|
1248
|
+
return {
|
|
1249
|
+
"total_rules": 0,
|
|
1250
|
+
"statistics": QualityStatistics().to_dict(),
|
|
1251
|
+
"level_distribution": [],
|
|
1252
|
+
"recommendations": {"should_use": 0, "should_not_use": 0},
|
|
1253
|
+
"metric_averages": {},
|
|
1254
|
+
"error": score_result.error_message,
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
stats = score_result.statistics or QualityStatistics()
|
|
1258
|
+
distribution = score_result.level_distribution or []
|
|
1259
|
+
|
|
1260
|
+
should_use = sum(1 for s in score_result.scores if s.should_use)
|
|
1261
|
+
should_not_use = len(score_result.scores) - should_use
|
|
1262
|
+
|
|
1263
|
+
return {
|
|
1264
|
+
"total_rules": stats.total_count,
|
|
1265
|
+
"statistics": stats.to_dict(),
|
|
1266
|
+
"level_distribution": [d.to_dict() for d in distribution],
|
|
1267
|
+
"recommendations": {
|
|
1268
|
+
"should_use": should_use,
|
|
1269
|
+
"should_not_use": should_not_use,
|
|
1270
|
+
},
|
|
1271
|
+
"metric_averages": {
|
|
1272
|
+
"f1_score": {
|
|
1273
|
+
"avg": stats.avg_f1,
|
|
1274
|
+
"min": stats.min_f1,
|
|
1275
|
+
"max": stats.max_f1,
|
|
1276
|
+
},
|
|
1277
|
+
"precision": {"avg": stats.avg_precision, "min": 0.0, "max": 1.0},
|
|
1278
|
+
"recall": {"avg": stats.avg_recall, "min": 0.0, "max": 1.0},
|
|
1279
|
+
"confidence": {"avg": stats.avg_confidence, "min": 0.0, "max": 1.0},
|
|
1280
|
+
},
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
def get_available_formats(self) -> dict[str, Any]:
|
|
1284
|
+
"""Get available report formats and options.
|
|
1285
|
+
|
|
1286
|
+
Returns:
|
|
1287
|
+
Available formats, sort orders, themes, etc.
|
|
1288
|
+
"""
|
|
1289
|
+
return {
|
|
1290
|
+
"formats": [f.value for f in QualityReportFormat],
|
|
1291
|
+
"sort_orders": [
|
|
1292
|
+
"f1_desc",
|
|
1293
|
+
"f1_asc",
|
|
1294
|
+
"precision_desc",
|
|
1295
|
+
"precision_asc",
|
|
1296
|
+
"recall_desc",
|
|
1297
|
+
"recall_asc",
|
|
1298
|
+
"level_desc",
|
|
1299
|
+
"level_asc",
|
|
1300
|
+
"name_asc",
|
|
1301
|
+
"name_desc",
|
|
1302
|
+
],
|
|
1303
|
+
"themes": ["light", "dark", "professional"],
|
|
1304
|
+
"default_thresholds": QualityThresholds().to_dict(),
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
def _calculate_level_distribution(
|
|
1308
|
+
self, scores: list[QualityScore]
|
|
1309
|
+
) -> list[QualityLevelDistribution]:
|
|
1310
|
+
"""Calculate quality level distribution."""
|
|
1311
|
+
total = len(scores)
|
|
1312
|
+
if total == 0:
|
|
1313
|
+
return []
|
|
1314
|
+
|
|
1315
|
+
counts = {level: 0 for level in QualityLevel}
|
|
1316
|
+
for score in scores:
|
|
1317
|
+
counts[score.metrics.quality_level] += 1
|
|
1318
|
+
|
|
1319
|
+
return [
|
|
1320
|
+
QualityLevelDistribution(
|
|
1321
|
+
level=level,
|
|
1322
|
+
count=count,
|
|
1323
|
+
percentage=(count / total) * 100 if total > 0 else 0.0,
|
|
1324
|
+
)
|
|
1325
|
+
for level, count in counts.items()
|
|
1326
|
+
]
|
|
1327
|
+
|
|
1328
|
+
def _sort_scores(
|
|
1329
|
+
self, scores: list[QualityScore], sort_order: str
|
|
1330
|
+
) -> list[QualityScore]:
|
|
1331
|
+
"""Sort scores by specified order."""
|
|
1332
|
+
if sort_order == "f1_desc":
|
|
1333
|
+
return sorted(scores, key=lambda s: s.metrics.f1_score, reverse=True)
|
|
1334
|
+
elif sort_order == "f1_asc":
|
|
1335
|
+
return sorted(scores, key=lambda s: s.metrics.f1_score)
|
|
1336
|
+
elif sort_order == "precision_desc":
|
|
1337
|
+
return sorted(scores, key=lambda s: s.metrics.precision, reverse=True)
|
|
1338
|
+
elif sort_order == "precision_asc":
|
|
1339
|
+
return sorted(scores, key=lambda s: s.metrics.precision)
|
|
1340
|
+
elif sort_order == "recall_desc":
|
|
1341
|
+
return sorted(scores, key=lambda s: s.metrics.recall, reverse=True)
|
|
1342
|
+
elif sort_order == "recall_asc":
|
|
1343
|
+
return sorted(scores, key=lambda s: s.metrics.recall)
|
|
1344
|
+
elif sort_order == "name_asc":
|
|
1345
|
+
return sorted(scores, key=lambda s: s.rule_name)
|
|
1346
|
+
elif sort_order == "name_desc":
|
|
1347
|
+
return sorted(scores, key=lambda s: s.rule_name, reverse=True)
|
|
1348
|
+
return scores
|
|
1349
|
+
|
|
1350
|
+
def _get_extension(self, format: QualityReportFormat) -> str:
|
|
1351
|
+
"""Get file extension for format."""
|
|
1352
|
+
extensions = {
|
|
1353
|
+
QualityReportFormat.CONSOLE: ".txt",
|
|
1354
|
+
QualityReportFormat.JSON: ".json",
|
|
1355
|
+
QualityReportFormat.HTML: ".html",
|
|
1356
|
+
QualityReportFormat.MARKDOWN: ".md",
|
|
1357
|
+
QualityReportFormat.JUNIT: ".xml",
|
|
1358
|
+
}
|
|
1359
|
+
return extensions.get(format, ".txt")
|