Newer
Older
TIASDataHub / src / tiasdata / stats.py
"""目録の集計(`tiasdata stats`.SPEC_03「CLI」).

件数と所見の値(舌色の slug 等)だけを返す.対応表(`identity.sqlite`)は開かない.
"""

import json
import sqlite3
from collections import Counter
from dataclasses import dataclass

from tiasdata import catalog, kinds


@dataclass(frozen=True)
class AnnotationStats:
    """所見の種類ごとの集計.

    Attributes:
        total: 有効な所見(訂正されていないもの)の数.
        by_field: 値の項目 → 値 → 件数(例: `label` → `red` → 120).
        by_value: 値の組み合わせ(`項目=値` を `, ` でつないだもの)→ 件数.
    """

    total: int
    by_field: dict[str, dict[str, int]]
    by_value: dict[str, int]


@dataclass(frozen=True)
class CatalogStats:
    """目録の集計.

    Attributes:
        subjects: 症例の数.
        captures: 撮影の数.
        subjects_with_multiple_captures: 撮影を 2 つ以上持つ症例の数.
        assets_by_kind: ファイルの種類 → ファイル数.
        annotations: 所見の種類 → 集計(要確認の印 `review` は含めない).
        superseded_annotations: 訂正された所見の数.
        assets_without_capture: 撮影にひも付かないファイルの数.
        review_marks_on_captures: 撮影に付いた有効な要確認の印の数.
        review_marks_on_files: ファイルに付いた有効な要確認の印の数.

    要確認の印は件数(`review_marks_on_captures`・`review_marks_on_files`)だけを持つ.
    値は理由(reason)の全文等で,値ごとに数えても内訳にならないため.
    """

    subjects: int
    captures: int
    subjects_with_multiple_captures: int
    assets_by_kind: dict[str, int]
    annotations: dict[str, AnnotationStats]
    superseded_annotations: int
    assets_without_capture: int
    review_marks_on_captures: int
    review_marks_on_files: int


def summarize_annotation_values(values: dict[str, int]) -> AnnotationStats:
    """所見の値(JSON の文字列)ごとの件数を,項目ごと・組み合わせごとの件数にまとめる.

    Args:
        values: 値の JSON → 件数(同じ種類の所見).

    Returns:
        集計.項目・値は名前の順に並べる.
    """
    by_field: dict[str, Counter[str]] = {}
    by_value: Counter[str] = Counter()
    for value_json, count in values.items():
        value = json.loads(value_json)
        if not isinstance(value, dict):
            by_value[value_json] += count
            continue
        for field, field_value in value.items():
            by_field.setdefault(field, Counter())[_text(field_value)] += count
        by_value[", ".join(f"{k}={_text(v)}" for k, v in sorted(value.items()))] += count
    return AnnotationStats(
        total=sum(values.values()),
        by_field={field: dict(sorted(c.items())) for field, c in sorted(by_field.items())},
        by_value=dict(sorted(by_value.items())),
    )


def _text(value: object) -> str:
    return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)


def collect_stats(conn: sqlite3.Connection) -> CatalogStats:
    """目録を集計する.

    Args:
        conn: 目録への接続(スキーマ最新).

    Returns:
        集計.
    """
    on_captures, on_files = catalog.count_valid_annotations_by_target(conn, kinds.REVIEW_KIND)
    return CatalogStats(
        subjects=catalog.count_subjects(conn),
        captures=catalog.count_captures(conn),
        subjects_with_multiple_captures=catalog.count_subjects_with_multiple_captures(conn),
        assets_by_kind=catalog.count_assets_by_kind(conn),
        annotations={
            kind: summarize_annotation_values(values)
            for kind, values in catalog.count_annotation_values(conn).items()
            if kind != kinds.REVIEW_KIND
        },
        superseded_annotations=catalog.count_superseded_annotations(conn),
        assets_without_capture=catalog.count_assets_without_capture(conn),
        review_marks_on_captures=on_captures,
        review_marks_on_files=on_files,
    )