Loading README.md +54 −0 Changes for README.md: 54 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -208,10 +208,64 @@ POST /api/analysis/run GET /api/results/{analysis_id}/summary GET /api/results/{analysis_id}/details/{metric} GET /api/reports POST /api/reports/import GET /api/reports/{analysis_id}/pdf GET /api/reports/{analysis_id}/excel ``` ### Importing externally calculated results Use `POST /api/reports/import` when another service has already calculated the metric results. The endpoint stores the imported result, returns a new `analysis_id`, and makes the existing summary, PDF, and Excel endpoints available without rerunning the analysis pipeline. ```json { "source": "luc", "dataset_id": "stellio-air-quality-001", "report_name": "Luc data quality report", "score_scale": "ratio", "weighting_method": "custom", "metric_weights": { "completeness": 0.6, "consistency": 0.4 }, "data_profile": { "row_count": 7, "column_count": 4, "missing_total": 7 }, "results": [ { "metric": "completeness", "score": 0.75, "status": "evaluated", "summary": { "missing_cells": 7, "incomplete_entities": 4, "total_entities": 7 } }, { "metric": "consistency", "score": 0.5, "status": "evaluated", "summary": { "rule_count": 1, "passed_total": 4, "violations_total": 3, "evaluated_total": 7 } } ] } ``` Set `score_scale` to `ratio` for scores in the `0..1` range, or to `percent` for scores in the `0..100` range. Metrics with status `n/a` or `error` must use `null` for `score` and are excluded from the weighted overall score. For the complete request and response schemas, see the Swagger documentation at: ```text Loading api/routers/reports.py +107 −2 Changes for api/routers/reports.py: 107 added lines, 2 removed lines. Original line number Diff line number Diff line from __future__ import annotations from io import BytesIO from typing import Any import math from typing import Any, Literal from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field from pydantic import BaseModel, Field, model_validator from fastapi.encoders import jsonable_encoder from fastapi.responses import Response, StreamingResponse Loading @@ -18,6 +19,93 @@ class ReportRenameRequest(BaseModel): report_name: str = Field(..., min_length=1, max_length=255) SUPPORTED_METRICS = { "completeness", "accuracy", "consistency", "timeliness", "reliability", "uniqueness", } class ImportedMetricResult(BaseModel): metric: str score: float | None = None status: Literal["evaluated", "n/a", "error"] = "evaluated" reason: str | None = None summary: dict[str, Any] = Field(default_factory=dict) detail: dict[str, Any] = Field(default_factory=dict) class ReportImportRequest(BaseModel): source: str = Field(default="external", min_length=1, max_length=100) dataset_id: str | None = Field(default=None, max_length=128) dataset_filename: str | None = Field(default=None, max_length=255) report_name: str | None = Field(default=None, max_length=255) score_scale: Literal["percent", "ratio"] = "percent" weighting_method: Literal["equal", "custom"] = "equal" metric_weights: dict[str, float] = Field(default_factory=dict) data_profile: dict[str, Any] = Field(default_factory=dict) results: list[ImportedMetricResult] = Field(..., min_length=1) @model_validator(mode="after") def validate_import(self): metrics = [item.metric for item in self.results] unsupported = sorted(set(metrics) - SUPPORTED_METRICS) if unsupported: raise ValueError(f"Unsupported metrics: {', '.join(unsupported)}") if len(metrics) != len(set(metrics)): raise ValueError("Each metric may appear only once") upper_bound = 1.0 if self.score_scale == "ratio" else 100.0 for item in self.results: if item.status == "evaluated" and item.score is None: raise ValueError(f"score is required for evaluated metric: {item.metric}") if item.status != "evaluated" and item.score is not None: raise ValueError(f"score must be null for {item.status} metric: {item.metric}") if item.score is not None: if not math.isfinite(item.score) or not 0 <= item.score <= upper_bound: raise ValueError( f"score for {item.metric} must be between 0 and {upper_bound:g}" ) unknown_weights = sorted(set(self.metric_weights) - set(metrics)) if unknown_weights: raise ValueError( f"metric_weights contains metrics not present in results: {', '.join(unknown_weights)}" ) if any( not math.isfinite(weight) or weight < 0 for weight in self.metric_weights.values() ): raise ValueError("metric weights must be finite and cannot be negative") if self.weighting_method == "custom": missing_weights = sorted(set(metrics) - set(self.metric_weights)) if missing_weights: raise ValueError( f"custom weighting requires weights for: {', '.join(missing_weights)}" ) if sum(self.metric_weights.values()) <= 0: raise ValueError("custom metric weights must have a positive sum") if self.report_name is not None and not self.report_name.strip(): raise ValueError("report_name cannot be blank") if self.dataset_id is not None and not self.dataset_id.strip(): self.dataset_id = None return self class ReportImportResponse(BaseModel): analysis_id: str dataset_id: str status: Literal["imported"] metrics_imported: list[str] summary_url: str pdf_url: str excel_url: str def _get_summary_or_404(analysis_id: str) -> dict[str, Any]: try: summary = service.get_summary(analysis_id) Loading Loading @@ -164,6 +252,23 @@ def list_reports() -> list[dict[str, Any]]: raise HTTPException(status_code=500, detail=str(exc)) @router.post("/import", response_model=ReportImportResponse, status_code=201) def import_report(payload: ReportImportRequest) -> ReportImportResponse: """Import external metric results and make them available to existing reports.""" try: imported = service.import_report(payload.model_dump()) except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) analysis_id = imported["analysis_id"] return ReportImportResponse( **imported, summary_url=f"/api/results/{analysis_id}/summary", pdf_url=f"/api/reports/{analysis_id}/pdf", excel_url=f"/api/reports/{analysis_id}/excel", ) @router.patch("/{analysis_id}") def rename_report(analysis_id: str, payload: ReportRenameRequest) -> dict[str, Any]: try: Loading api/services/analysis_service.py +67 −1 Changes for api/services/analysis_service.py: 67 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -261,6 +261,64 @@ class AnalysisService: finally: db.close() def import_report(self, payload: dict) -> dict: """Import externally calculated metric results into the report workflow.""" analysis_id = str(uuid.uuid4())[:8] score_scale = payload.get("score_scale", "percent") imported_results: dict[str, dict] = {} for item in payload.get("results", []): metric = item["metric"] score = item.get("score") if score is not None: score = float(score) if score_scale == "ratio": score *= 100.0 imported_results[metric] = { "score": score, "status": item.get("status", "evaluated"), "reason": item.get("reason"), "summary": item.get("summary", {}) or {}, "detail": item.get("detail", {}) or {}, } dataset_id = (payload.get("dataset_id") or "").strip() or f"external-{analysis_id}" config = { "weighting_method": payload.get("weighting_method", "equal"), "metric_weights": ( payload.get("metric_weights", {}) or {} if payload.get("weighting_method") == "custom" else {} ), "__scores_are_quality_percent": True, "__import_source": payload.get("source", "external"), } stored_result = { "dataset_id": dataset_id, "dataset_filename": payload.get("dataset_filename"), "results": imported_results, "config": config, "data_profile": payload.get("data_profile", {}) or {}, } self._results[analysis_id] = stored_result try: self._save_history(analysis_id, stored_result) report_name = (payload.get("report_name") or "").strip() if report_name: self.rename_report(analysis_id, report_name) except Exception: self._results.pop(analysis_id, None) raise return { "analysis_id": analysis_id, "dataset_id": dataset_id, "status": "imported", "metrics_imported": list(imported_results.keys()), } def _find_accuracy_reference_dataset_id(self, config: dict) -> Optional[str]: """Find the first external reference dataset id declared in accuracy rules.""" acc_rules = (config or {}).get("acc_rules") or [] Loading Loading @@ -783,11 +841,19 @@ class AnalysisService: included_scores: dict[str, float] = {} excluded_metrics: list[str] = [] imported_quality_scores = bool(config.get("__scores_are_quality_percent")) for metric, data in results.items(): status = data.get("status") if imported_quality_scores: try: normalized_score = float(data.get("score")) except (TypeError, ValueError): normalized_score = None else: normalized_score = self._score_for_overall(metric, data.get("score")) if status == "n/a" or normalized_score is None: if status in {"n/a", "error"} or normalized_score is None: excluded_metrics.append(metric) continue Loading Loading
README.md +54 −0 Changes for README.md: 54 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -208,10 +208,64 @@ POST /api/analysis/run GET /api/results/{analysis_id}/summary GET /api/results/{analysis_id}/details/{metric} GET /api/reports POST /api/reports/import GET /api/reports/{analysis_id}/pdf GET /api/reports/{analysis_id}/excel ``` ### Importing externally calculated results Use `POST /api/reports/import` when another service has already calculated the metric results. The endpoint stores the imported result, returns a new `analysis_id`, and makes the existing summary, PDF, and Excel endpoints available without rerunning the analysis pipeline. ```json { "source": "luc", "dataset_id": "stellio-air-quality-001", "report_name": "Luc data quality report", "score_scale": "ratio", "weighting_method": "custom", "metric_weights": { "completeness": 0.6, "consistency": 0.4 }, "data_profile": { "row_count": 7, "column_count": 4, "missing_total": 7 }, "results": [ { "metric": "completeness", "score": 0.75, "status": "evaluated", "summary": { "missing_cells": 7, "incomplete_entities": 4, "total_entities": 7 } }, { "metric": "consistency", "score": 0.5, "status": "evaluated", "summary": { "rule_count": 1, "passed_total": 4, "violations_total": 3, "evaluated_total": 7 } } ] } ``` Set `score_scale` to `ratio` for scores in the `0..1` range, or to `percent` for scores in the `0..100` range. Metrics with status `n/a` or `error` must use `null` for `score` and are excluded from the weighted overall score. For the complete request and response schemas, see the Swagger documentation at: ```text Loading
api/routers/reports.py +107 −2 Changes for api/routers/reports.py: 107 added lines, 2 removed lines. Original line number Diff line number Diff line from __future__ import annotations from io import BytesIO from typing import Any import math from typing import Any, Literal from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field from pydantic import BaseModel, Field, model_validator from fastapi.encoders import jsonable_encoder from fastapi.responses import Response, StreamingResponse Loading @@ -18,6 +19,93 @@ class ReportRenameRequest(BaseModel): report_name: str = Field(..., min_length=1, max_length=255) SUPPORTED_METRICS = { "completeness", "accuracy", "consistency", "timeliness", "reliability", "uniqueness", } class ImportedMetricResult(BaseModel): metric: str score: float | None = None status: Literal["evaluated", "n/a", "error"] = "evaluated" reason: str | None = None summary: dict[str, Any] = Field(default_factory=dict) detail: dict[str, Any] = Field(default_factory=dict) class ReportImportRequest(BaseModel): source: str = Field(default="external", min_length=1, max_length=100) dataset_id: str | None = Field(default=None, max_length=128) dataset_filename: str | None = Field(default=None, max_length=255) report_name: str | None = Field(default=None, max_length=255) score_scale: Literal["percent", "ratio"] = "percent" weighting_method: Literal["equal", "custom"] = "equal" metric_weights: dict[str, float] = Field(default_factory=dict) data_profile: dict[str, Any] = Field(default_factory=dict) results: list[ImportedMetricResult] = Field(..., min_length=1) @model_validator(mode="after") def validate_import(self): metrics = [item.metric for item in self.results] unsupported = sorted(set(metrics) - SUPPORTED_METRICS) if unsupported: raise ValueError(f"Unsupported metrics: {', '.join(unsupported)}") if len(metrics) != len(set(metrics)): raise ValueError("Each metric may appear only once") upper_bound = 1.0 if self.score_scale == "ratio" else 100.0 for item in self.results: if item.status == "evaluated" and item.score is None: raise ValueError(f"score is required for evaluated metric: {item.metric}") if item.status != "evaluated" and item.score is not None: raise ValueError(f"score must be null for {item.status} metric: {item.metric}") if item.score is not None: if not math.isfinite(item.score) or not 0 <= item.score <= upper_bound: raise ValueError( f"score for {item.metric} must be between 0 and {upper_bound:g}" ) unknown_weights = sorted(set(self.metric_weights) - set(metrics)) if unknown_weights: raise ValueError( f"metric_weights contains metrics not present in results: {', '.join(unknown_weights)}" ) if any( not math.isfinite(weight) or weight < 0 for weight in self.metric_weights.values() ): raise ValueError("metric weights must be finite and cannot be negative") if self.weighting_method == "custom": missing_weights = sorted(set(metrics) - set(self.metric_weights)) if missing_weights: raise ValueError( f"custom weighting requires weights for: {', '.join(missing_weights)}" ) if sum(self.metric_weights.values()) <= 0: raise ValueError("custom metric weights must have a positive sum") if self.report_name is not None and not self.report_name.strip(): raise ValueError("report_name cannot be blank") if self.dataset_id is not None and not self.dataset_id.strip(): self.dataset_id = None return self class ReportImportResponse(BaseModel): analysis_id: str dataset_id: str status: Literal["imported"] metrics_imported: list[str] summary_url: str pdf_url: str excel_url: str def _get_summary_or_404(analysis_id: str) -> dict[str, Any]: try: summary = service.get_summary(analysis_id) Loading Loading @@ -164,6 +252,23 @@ def list_reports() -> list[dict[str, Any]]: raise HTTPException(status_code=500, detail=str(exc)) @router.post("/import", response_model=ReportImportResponse, status_code=201) def import_report(payload: ReportImportRequest) -> ReportImportResponse: """Import external metric results and make them available to existing reports.""" try: imported = service.import_report(payload.model_dump()) except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) analysis_id = imported["analysis_id"] return ReportImportResponse( **imported, summary_url=f"/api/results/{analysis_id}/summary", pdf_url=f"/api/reports/{analysis_id}/pdf", excel_url=f"/api/reports/{analysis_id}/excel", ) @router.patch("/{analysis_id}") def rename_report(analysis_id: str, payload: ReportRenameRequest) -> dict[str, Any]: try: Loading
api/services/analysis_service.py +67 −1 Changes for api/services/analysis_service.py: 67 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -261,6 +261,64 @@ class AnalysisService: finally: db.close() def import_report(self, payload: dict) -> dict: """Import externally calculated metric results into the report workflow.""" analysis_id = str(uuid.uuid4())[:8] score_scale = payload.get("score_scale", "percent") imported_results: dict[str, dict] = {} for item in payload.get("results", []): metric = item["metric"] score = item.get("score") if score is not None: score = float(score) if score_scale == "ratio": score *= 100.0 imported_results[metric] = { "score": score, "status": item.get("status", "evaluated"), "reason": item.get("reason"), "summary": item.get("summary", {}) or {}, "detail": item.get("detail", {}) or {}, } dataset_id = (payload.get("dataset_id") or "").strip() or f"external-{analysis_id}" config = { "weighting_method": payload.get("weighting_method", "equal"), "metric_weights": ( payload.get("metric_weights", {}) or {} if payload.get("weighting_method") == "custom" else {} ), "__scores_are_quality_percent": True, "__import_source": payload.get("source", "external"), } stored_result = { "dataset_id": dataset_id, "dataset_filename": payload.get("dataset_filename"), "results": imported_results, "config": config, "data_profile": payload.get("data_profile", {}) or {}, } self._results[analysis_id] = stored_result try: self._save_history(analysis_id, stored_result) report_name = (payload.get("report_name") or "").strip() if report_name: self.rename_report(analysis_id, report_name) except Exception: self._results.pop(analysis_id, None) raise return { "analysis_id": analysis_id, "dataset_id": dataset_id, "status": "imported", "metrics_imported": list(imported_results.keys()), } def _find_accuracy_reference_dataset_id(self, config: dict) -> Optional[str]: """Find the first external reference dataset id declared in accuracy rules.""" acc_rules = (config or {}).get("acc_rules") or [] Loading Loading @@ -783,11 +841,19 @@ class AnalysisService: included_scores: dict[str, float] = {} excluded_metrics: list[str] = [] imported_quality_scores = bool(config.get("__scores_are_quality_percent")) for metric, data in results.items(): status = data.get("status") if imported_quality_scores: try: normalized_score = float(data.get("score")) except (TypeError, ValueError): normalized_score = None else: normalized_score = self._score_for_overall(metric, data.get("score")) if status == "n/a" or normalized_score is None: if status in {"n/a", "error"} or normalized_score is None: excluded_metrics.append(metric) continue Loading