Commit 22063949 authored by JaeSeung Song's avatar JaeSeung Song
Browse files

Merge branch 'docs/update-readme' into 'main'

Add API for importing external report results

See merge request !6
parents 47a2974d 660ba3c8
Loading
Loading
Loading
Loading
+54 −0
Changes for README.md: 54 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
+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

@@ -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)
@@ -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:
+67 −1
Changes for api/services/analysis_service.py: 67 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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 []
@@ -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