운영 지표와 지원 요청 저장소 추가
This commit is contained in:
parent
50fa4ad432
commit
e7ebb38177
20 changed files with 3038 additions and 39 deletions
163
apps/api/app/test_phase3_kpi_export.py
Normal file
163
apps/api/app/test_phase3_kpi_export.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.phase3_kpi_export import (
|
||||
KPI_REPORT_PATH,
|
||||
PHASE3_KPI_METRICS,
|
||||
PREPOST_CSV_PATH,
|
||||
ParticipantKeys,
|
||||
build_kpi_report,
|
||||
build_prepost_csv_rows,
|
||||
paired_prepost_summary,
|
||||
write_kpi_report,
|
||||
write_prepost_csv,
|
||||
)
|
||||
|
||||
|
||||
REQUIRED_METRIC_KEYS = {
|
||||
"denominator",
|
||||
"method",
|
||||
"numerator",
|
||||
"pass",
|
||||
"source_files",
|
||||
"threshold",
|
||||
"value",
|
||||
}
|
||||
|
||||
|
||||
def fixture_rows():
|
||||
return [
|
||||
{
|
||||
"learner_id": "11111111-1111-1111-1111-111111111111",
|
||||
"measure_name": "self_efficacy",
|
||||
"timepoint": "pre",
|
||||
"raw_score": 2,
|
||||
"min_score": 1,
|
||||
"max_score": 5,
|
||||
"collected_at": datetime(2026, 6, 27, 0, 0, tzinfo=UTC),
|
||||
"updated_at": datetime(2026, 6, 27, 0, 1, tzinfo=UTC),
|
||||
},
|
||||
{
|
||||
"learner_id": "11111111-1111-1111-1111-111111111111",
|
||||
"measure_name": "self_efficacy",
|
||||
"timepoint": "pre",
|
||||
"raw_score": 3,
|
||||
"min_score": 1,
|
||||
"max_score": 5,
|
||||
"collected_at": datetime(2026, 6, 27, 0, 2, tzinfo=UTC),
|
||||
"updated_at": datetime(2026, 6, 27, 0, 3, tzinfo=UTC),
|
||||
},
|
||||
{
|
||||
"learner_id": "11111111-1111-1111-1111-111111111111",
|
||||
"measure_name": "self_efficacy",
|
||||
"timepoint": "post",
|
||||
"raw_score": 4,
|
||||
"min_score": 1,
|
||||
"max_score": 5,
|
||||
"collected_at": datetime(2026, 6, 27, 1, 0, tzinfo=UTC),
|
||||
"updated_at": datetime(2026, 6, 27, 1, 0, tzinfo=UTC),
|
||||
},
|
||||
{
|
||||
"learner_id": "22222222-2222-2222-2222-222222222222",
|
||||
"measure_name": "self_efficacy",
|
||||
"timepoint": "pre",
|
||||
"raw_score": 4,
|
||||
"min_score": 1,
|
||||
"max_score": 5,
|
||||
"collected_at": datetime(2026, 6, 27, 0, 5, tzinfo=UTC),
|
||||
"updated_at": datetime(2026, 6, 27, 0, 5, tzinfo=UTC),
|
||||
},
|
||||
{
|
||||
"learner_id": "11111111-1111-1111-1111-111111111111",
|
||||
"measure_name": "skill_proficiency",
|
||||
"timepoint": "pre",
|
||||
"raw_score": 2,
|
||||
"min_score": 1,
|
||||
"max_score": 5,
|
||||
"collected_at": datetime(2026, 6, 27, 0, 10, tzinfo=UTC),
|
||||
"updated_at": datetime(2026, 6, 27, 0, 10, tzinfo=UTC),
|
||||
},
|
||||
{
|
||||
"learner_id": "11111111-1111-1111-1111-111111111111",
|
||||
"measure_name": "skill_proficiency",
|
||||
"timepoint": "post",
|
||||
"raw_score": 5,
|
||||
"min_score": 1,
|
||||
"max_score": 5,
|
||||
"collected_at": datetime(2026, 6, 27, 1, 10, tzinfo=UTC),
|
||||
"updated_at": datetime(2026, 6, 27, 1, 10, tzinfo=UTC),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class Phase3KpiExportTests(unittest.TestCase):
|
||||
def test_prepost_csv_rows_use_pseudonymous_participant_ids_and_latest_score(self) -> None:
|
||||
keys = ParticipantKeys()
|
||||
rows = build_prepost_csv_rows(fixture_rows(), participant_keys=keys)
|
||||
|
||||
self.assertEqual(len(keys), 2)
|
||||
self.assertTrue(all(row["participant_id"].startswith("P3-") for row in rows))
|
||||
blob = json.dumps(rows, ensure_ascii=False)
|
||||
self.assertNotIn("11111111-1111-1111-1111-111111111111", blob)
|
||||
self.assertNotIn("22222222-2222-2222-2222-222222222222", blob)
|
||||
p1_pre = [
|
||||
row for row in rows
|
||||
if row["participant_id"] == "P3-001"
|
||||
and row["measure_name"] == "self_efficacy"
|
||||
and row["timepoint"] == "pre"
|
||||
]
|
||||
self.assertEqual([{"participant_id": "P3-001", "measure_name": "self_efficacy", "timepoint": "pre", "score": "3", "collected_at": "2026-06-27T00:02:00Z"}], p1_pre)
|
||||
|
||||
def test_paired_prepost_summary_uses_normalized_scores(self) -> None:
|
||||
summary = paired_prepost_summary(fixture_rows(), "self_efficacy")
|
||||
|
||||
self.assertEqual(summary["participants_with_any_measure"], 2)
|
||||
self.assertEqual(summary["complete_pairs"], 1)
|
||||
self.assertEqual(summary["missing_pairs"], 1)
|
||||
self.assertEqual(summary["mean_pre"], 50.0)
|
||||
self.assertEqual(summary["mean_post"], 75.0)
|
||||
self.assertEqual(summary["mean_delta"], 25.0)
|
||||
|
||||
def test_kpi_report_contains_checker_required_metric_shape(self) -> None:
|
||||
report = build_kpi_report(
|
||||
fixture_rows(),
|
||||
pilot_id="phase3-pilot-draft",
|
||||
generated_at="2026-06-28T00:00:00Z",
|
||||
review_operator="operator",
|
||||
)
|
||||
|
||||
self.assertEqual(report["pilot_id"], "phase3-pilot-draft")
|
||||
self.assertEqual(report["cohort_size"], 2)
|
||||
self.assertTrue(set(PHASE3_KPI_METRICS).issubset(report["metrics"]))
|
||||
for metric in report["metrics"].values():
|
||||
self.assertTrue(REQUIRED_METRIC_KEYS.issubset(metric))
|
||||
self_efficacy = report["metrics"]["self_efficacy_prepost"]
|
||||
self.assertFalse(self_efficacy["pass"])
|
||||
self.assertEqual(self_efficacy["value"], 25.0)
|
||||
self.assertEqual(self_efficacy["source_files"], [PREPOST_CSV_PATH])
|
||||
|
||||
def test_writers_create_phase3_evidence_files(self) -> None:
|
||||
rows = build_prepost_csv_rows(fixture_rows(), participant_keys=ParticipantKeys())
|
||||
report = build_kpi_report(
|
||||
fixture_rows(),
|
||||
pilot_id="phase3-pilot-draft",
|
||||
generated_at="2026-06-28T00:00:00Z",
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
write_prepost_csv(rows, root / PREPOST_CSV_PATH)
|
||||
write_kpi_report(report, root / KPI_REPORT_PATH)
|
||||
|
||||
self.assertTrue((root / PREPOST_CSV_PATH).exists())
|
||||
self.assertTrue((root / KPI_REPORT_PATH).exists())
|
||||
csv_text = (root / PREPOST_CSV_PATH).read_text(encoding="utf-8")
|
||||
self.assertTrue(csv_text.startswith("participant_id,measure_name,timepoint,score,collected_at"))
|
||||
saved = json.loads((root / KPI_REPORT_PATH).read_text(encoding="utf-8"))
|
||||
self.assertEqual(saved["metrics"]["self_efficacy_prepost"]["complete_pairs"], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue