fix(#190): доработки матрицы причин по ревью Task 14
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
cd7b687049
commit
d191fd3039
@ -33,11 +33,16 @@ _AFTER_CONFIRM = frozenset({QualityOutcome.DELETED_AFTER_CONFIRM, QualityOutcome
|
|||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class QualityOutcomeFactRow:
|
class QualityOutcomeFactRow:
|
||||||
"""Минимум полей `quality_outcome_fact`, нужных агрегации (вызывающий сервис строит его из SELECT)."""
|
"""Минимум полей `quality_outcome_fact`, нужных агрегации (вызывающий сервис строит его из SELECT).
|
||||||
|
|
||||||
|
`received_at` — дата приёма результата (`quality_intake_fact.intake_at`, design §7: период по дате приёма, когорта).
|
||||||
|
Поля нет в `quality_outcome_fact`: Task 15 подставляет его join'ом intake <-> outcome по `document_id`.
|
||||||
|
"""
|
||||||
|
|
||||||
document_id: uuid.UUID
|
document_id: uuid.UUID
|
||||||
outcome: QualityOutcome
|
outcome: QualityOutcome
|
||||||
outcome_at: datetime.datetime
|
outcome_at: datetime.datetime
|
||||||
|
received_at: datetime.datetime
|
||||||
primary_cause: QualityCause | None
|
primary_cause: QualityCause | None
|
||||||
|
|
||||||
|
|
||||||
@ -59,7 +64,7 @@ class MatrixResult:
|
|||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class MonthPoint:
|
class MonthPoint:
|
||||||
"""Точка ряда: `month` — первое число месяца `outcome_at`; `share` — доля причины от знаменателя месяца."""
|
"""Точка ряда: `month` — первое число месяца `received_at`; `share` — доля причины от знаменателя месяца."""
|
||||||
|
|
||||||
month: datetime.date
|
month: datetime.date
|
||||||
count: int
|
count: int
|
||||||
@ -67,12 +72,26 @@ class MonthPoint:
|
|||||||
share: float
|
share: float
|
||||||
|
|
||||||
|
|
||||||
|
def _tie_rank(outcome: QualityOutcome) -> int:
|
||||||
|
"""Приоритет при равном `outcome_at`: `*_AFTER_CONFIRM` (2) > подтверждение (1) > удаление/замена до него (0)."""
|
||||||
|
if outcome in _AFTER_CONFIRM:
|
||||||
|
return 2
|
||||||
|
return 1 if outcome in _IN_DENOMINATOR else 0
|
||||||
|
|
||||||
|
|
||||||
def _latest_per_document(outcomes: Sequence[QualityOutcomeFactRow]) -> list[QualityOutcomeFactRow]:
|
def _latest_per_document(outcomes: Sequence[QualityOutcomeFactRow]) -> list[QualityOutcomeFactRow]:
|
||||||
"""Последний по `outcome_at` факт каждого документа (design §4.2); при равенстве времени — поздний во входе."""
|
"""Последний по `outcome_at` факт каждого документа (design §4.2).
|
||||||
|
|
||||||
|
При равном `outcome_at` побеждает исход с большим `_tie_rank` (результат не зависит от порядка входа: «после
|
||||||
|
подтверждения» — позднее по смыслу); при равенстве и ранга — поздний во входе.
|
||||||
|
"""
|
||||||
latest: dict[uuid.UUID, QualityOutcomeFactRow] = {}
|
latest: dict[uuid.UUID, QualityOutcomeFactRow] = {}
|
||||||
for row in outcomes:
|
for row in outcomes:
|
||||||
current = latest.get(row.document_id)
|
current = latest.get(row.document_id)
|
||||||
if current is None or row.outcome_at >= current.outcome_at:
|
if current is None or (row.outcome_at, _tie_rank(row.outcome)) >= (
|
||||||
|
current.outcome_at,
|
||||||
|
_tie_rank(current.outcome),
|
||||||
|
):
|
||||||
latest[row.document_id] = row
|
latest[row.document_id] = row
|
||||||
return list(latest.values())
|
return list(latest.values())
|
||||||
|
|
||||||
@ -98,6 +117,8 @@ def compute_matrix(outcomes: Sequence[QualityOutcomeFactRow]) -> MatrixResult:
|
|||||||
before += 1
|
before += 1
|
||||||
elif row.outcome in _AFTER_CONFIRM:
|
elif row.outcome in _AFTER_CONFIRM:
|
||||||
after += 1
|
after += 1
|
||||||
|
else:
|
||||||
|
raise ValueError(f"неизвестный исход {row.outcome!r} документа {row.document_id}")
|
||||||
denominator = sum(counts.values())
|
denominator = sum(counts.values())
|
||||||
return MatrixResult(
|
return MatrixResult(
|
||||||
counts=counts,
|
counts=counts,
|
||||||
@ -113,9 +134,10 @@ def _next_month(month: datetime.date) -> datetime.date:
|
|||||||
|
|
||||||
|
|
||||||
def compute_monthly_series(outcomes: Sequence[QualityOutcomeFactRow], cause: QualityCause) -> list[MonthPoint]:
|
def compute_monthly_series(outcomes: Sequence[QualityOutcomeFactRow], cause: QualityCause) -> list[MonthPoint]:
|
||||||
"""§14.3 / design §7: ряд по месяцам `outcome_at` для выбранной причины.
|
"""§14.3 / design §7: ряд по месяцам даты приёма (`received_at`, когорта) для выбранной причины.
|
||||||
|
|
||||||
В ряд входят только документы со знаменателем (`AUTO_CONFIRMED` / `CONFIRMED`, последний факт документа).
|
В ряд входят только документы со знаменателем (`AUTO_CONFIRMED` / `CONFIRMED`, последний по `outcome_at` факт
|
||||||
|
документа); месяц берётся по `received_at`, не по `outcome_at`.
|
||||||
Месяцы между первым и последним месяцем данных без документов заполняются нулями (непрерывная ось).
|
Месяцы между первым и последним месяцем данных без документов заполняются нулями (непрерывная ось).
|
||||||
"""
|
"""
|
||||||
count_by_month: dict[datetime.date, int] = {}
|
count_by_month: dict[datetime.date, int] = {}
|
||||||
@ -123,7 +145,7 @@ def compute_monthly_series(outcomes: Sequence[QualityOutcomeFactRow], cause: Qua
|
|||||||
for row in _latest_per_document(outcomes):
|
for row in _latest_per_document(outcomes):
|
||||||
if row.outcome not in _IN_DENOMINATOR:
|
if row.outcome not in _IN_DENOMINATOR:
|
||||||
continue
|
continue
|
||||||
month = datetime.date(row.outcome_at.year, row.outcome_at.month, 1)
|
month = datetime.date(row.received_at.year, row.received_at.month, 1)
|
||||||
total_by_month[month] = total_by_month.get(month, 0) + 1
|
total_by_month[month] = total_by_month.get(month, 0) + 1
|
||||||
if _cause(row) is cause:
|
if _cause(row) is cause:
|
||||||
count_by_month[month] = count_by_month.get(month, 0) + 1
|
count_by_month[month] = count_by_month.get(month, 0) + 1
|
||||||
|
|||||||
@ -17,17 +17,20 @@ def make_outcome_row(
|
|||||||
primary_cause: QualityCause | None = QualityCause.AS_IS,
|
primary_cause: QualityCause | None = QualityCause.AS_IS,
|
||||||
outcome: QualityOutcome = QualityOutcome.CONFIRMED,
|
outcome: QualityOutcome = QualityOutcome.CONFIRMED,
|
||||||
outcome_at: datetime.datetime | None = None,
|
outcome_at: datetime.datetime | None = None,
|
||||||
|
received_at: datetime.datetime | None = None,
|
||||||
document_id: uuid.UUID | None = None,
|
document_id: uuid.UUID | None = None,
|
||||||
) -> QualityOutcomeFactRow:
|
) -> QualityOutcomeFactRow:
|
||||||
|
resolved_outcome_at = outcome_at or datetime.datetime(2026, 10, 15, tzinfo=datetime.UTC)
|
||||||
return QualityOutcomeFactRow(
|
return QualityOutcomeFactRow(
|
||||||
document_id=document_id or uuid.uuid4(),
|
document_id=document_id or uuid.uuid4(),
|
||||||
outcome=outcome,
|
outcome=outcome,
|
||||||
outcome_at=outcome_at or datetime.datetime(2026, 10, 15, tzinfo=datetime.UTC),
|
outcome_at=resolved_outcome_at,
|
||||||
|
received_at=received_at or resolved_outcome_at,
|
||||||
primary_cause=primary_cause,
|
primary_cause=primary_cause,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_compute_matrix_priority_service_over_our_error():
|
def test_compute_matrix_groups_by_stored_primary_cause():
|
||||||
# атрибуция уже в факте — матрица только группирует
|
# атрибуция уже в факте — матрица только группирует
|
||||||
matrix = compute_matrix(
|
matrix = compute_matrix(
|
||||||
[
|
[
|
||||||
@ -88,10 +91,10 @@ def test_monthly_series_fills_gaps_and_ignores_removed():
|
|||||||
return datetime.datetime(2026, month, 5, tzinfo=datetime.UTC)
|
return datetime.datetime(2026, month, 5, tzinfo=datetime.UTC)
|
||||||
|
|
||||||
outcomes = [
|
outcomes = [
|
||||||
make_outcome_row(primary_cause=QualityCause.OUR_ERROR, outcome_at=at(8)),
|
make_outcome_row(primary_cause=QualityCause.OUR_ERROR, received_at=at(8), outcome_at=at(11)),
|
||||||
make_outcome_row(primary_cause=QualityCause.AS_IS, outcome_at=at(8)),
|
make_outcome_row(primary_cause=QualityCause.AS_IS, received_at=at(8), outcome_at=at(9)),
|
||||||
make_outcome_row(primary_cause=QualityCause.OUR_ERROR, outcome_at=at(10)),
|
make_outcome_row(primary_cause=QualityCause.OUR_ERROR, received_at=at(10), outcome_at=at(10)),
|
||||||
make_outcome_row(primary_cause=None, outcome=QualityOutcome.DELETED, outcome_at=at(12)),
|
make_outcome_row(primary_cause=None, outcome=QualityOutcome.DELETED, received_at=at(12), outcome_at=at(12)),
|
||||||
]
|
]
|
||||||
series = compute_monthly_series(outcomes, QualityCause.OUR_ERROR)
|
series = compute_monthly_series(outcomes, QualityCause.OUR_ERROR)
|
||||||
assert [(p.month.month, p.count, p.denominator, p.share) for p in series] == [
|
assert [(p.month.month, p.count, p.denominator, p.share) for p in series] == [
|
||||||
@ -104,8 +107,37 @@ def test_monthly_series_fills_gaps_and_ignores_removed():
|
|||||||
def test_monthly_series_empty_and_year_rollover():
|
def test_monthly_series_empty_and_year_rollover():
|
||||||
assert compute_monthly_series([], QualityCause.AS_IS) == []
|
assert compute_monthly_series([], QualityCause.AS_IS) == []
|
||||||
outcomes = [
|
outcomes = [
|
||||||
make_outcome_row(outcome_at=datetime.datetime(2026, 12, 31, tzinfo=datetime.UTC)),
|
make_outcome_row(received_at=datetime.datetime(2026, 12, 31, tzinfo=datetime.UTC)),
|
||||||
make_outcome_row(outcome_at=datetime.datetime(2027, 1, 1, tzinfo=datetime.UTC)),
|
make_outcome_row(received_at=datetime.datetime(2027, 1, 1, tzinfo=datetime.UTC)),
|
||||||
]
|
]
|
||||||
months = [p.month for p in compute_monthly_series(outcomes, QualityCause.AS_IS)]
|
months = [p.month for p in compute_monthly_series(outcomes, QualityCause.AS_IS)]
|
||||||
assert months == [datetime.date(2026, 12, 1), datetime.date(2027, 1, 1)]
|
assert months == [datetime.date(2026, 12, 1), datetime.date(2027, 1, 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_matrix_equal_outcome_at_after_confirm_wins_regardless_of_order():
|
||||||
|
doc = uuid.uuid4()
|
||||||
|
same_time = datetime.datetime(2026, 10, 20, tzinfo=datetime.UTC)
|
||||||
|
confirmed = make_outcome_row(document_id=doc, outcome_at=same_time)
|
||||||
|
deleted = make_outcome_row(
|
||||||
|
document_id=doc, primary_cause=None, outcome=QualityOutcome.DELETED_AFTER_CONFIRM, outcome_at=same_time
|
||||||
|
)
|
||||||
|
for ordered in ([confirmed, deleted], [deleted, confirmed]):
|
||||||
|
matrix = compute_matrix(ordered)
|
||||||
|
assert matrix.denominator == 0
|
||||||
|
assert matrix.removed_after_confirm == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_matrix_unknown_outcome_raises():
|
||||||
|
row = make_outcome_row()
|
||||||
|
object.__setattr__(row, "outcome", "UNKNOWN")
|
||||||
|
with pytest.raises(ValueError, match="неизвестный исход"):
|
||||||
|
compute_matrix([row])
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_series_uses_received_month_not_outcome_month():
|
||||||
|
row = make_outcome_row(
|
||||||
|
received_at=datetime.datetime(2026, 8, 20, tzinfo=datetime.UTC),
|
||||||
|
outcome_at=datetime.datetime(2026, 10, 2, tzinfo=datetime.UTC),
|
||||||
|
)
|
||||||
|
series = compute_monthly_series([row], QualityCause.AS_IS)
|
||||||
|
assert [(p.month, p.count, p.denominator) for p in series] == [(datetime.date(2026, 8, 1), 1, 1)]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user