-
Notifications
You must be signed in to change notification settings - Fork 1
feat(dashboard): measure observed case lifecycles #653
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,7 @@ | |
|
|
||
| from __future__ import annotations | ||
|
|
||
| from datetime import date | ||
| from datetime import date, datetime | ||
| import json | ||
| from typing import Any, Protocol | ||
|
|
||
|
|
@@ -30,6 +30,19 @@ | |
| "issue_pattern": "반복 유형", | ||
| "improvement_action": "개선 조치", | ||
| } | ||
| MILESTONE_TYPE_LABELS = { | ||
| "claim_received": "클레임 접수", | ||
| "cause_confirmed": "원인 확정", | ||
| "rebid_response_requested": "재입찰 대응 요청", | ||
| "rebid_decision_recorded": "재입찰 의사결정", | ||
| "handover_started": "인수인계 시작", | ||
| "handover_accepted": "인수 확인", | ||
| } | ||
| LIFECYCLE_DEFINITIONS = ( | ||
| ("claim_investigation", "claim_investigation", "클레임 원인 규명", "claim_received", "cause_confirmed"), | ||
| ("rebid_response", "rebid_handover", "재입찰 대응", "rebid_response_requested", "rebid_decision_recorded"), | ||
| ("handover_gap", "rebid_handover", "인수인계 공백", "handover_started", "handover_accepted"), | ||
| ) | ||
| CASE_KIND_ONTOLOGY_CLASSES = { | ||
| "claim_investigation": str(LW.ClaimInvestigation), | ||
| "rebid_handover": str(LW.RebidHandover), | ||
|
|
@@ -114,14 +127,21 @@ async def fetch(self, query: str, *args: object) -> list[Any]: | |
| pass # pragma: no cover - structural Protocol member | ||
|
|
||
|
|
||
| def _visible_period_sql(alias: str = "post") -> str: | ||
| """Return the shared ABAC, eligibility, and event-clock predicate.""" | ||
| def _visible_scope_sql(alias: str = "post") -> str: | ||
| """Return the shared ABAC and source-eligibility predicate.""" | ||
| return f""" | ||
| ({alias}.visibility_code = 'public' | ||
| or ({alias}.corporate_entity_id::text = any($1::text[]) | ||
| and (cardinality($2::text[]) = 0 | ||
| or {alias}.process_unit_id::text = any($2::text[])))) | ||
| and {SOURCE_POST_ELIGIBILITY_SQL.format(alias=alias)} | ||
| """ | ||
|
|
||
|
|
||
| def _visible_period_sql(alias: str = "post") -> str: | ||
| """Return the shared visibility predicate plus the requested event interval.""" | ||
| return f""" | ||
| {_visible_scope_sql(alias)} | ||
| and ($3::date is null or (coalesce({alias}.event_occurred_at, {alias}.created_at) | ||
| at time zone 'Asia/Seoul')::date >= $3) | ||
| and ($4::date is null or (coalesce({alias}.event_occurred_at, {alias}.created_at) | ||
|
|
@@ -142,6 +162,7 @@ async def fetch_operations_dashboard( | |
| raise ValueError("period_start must not be after period_end") | ||
| args = (list(corporate_entity_ids), list(process_unit_ids), period_start, period_end, external_only) | ||
| visible = _visible_period_sql() | ||
| visible_evidence = _visible_scope_sql("evidence_post") | ||
| metrics = await conn.fetchrow( | ||
| f""" | ||
| with visible_post as ( | ||
|
|
@@ -158,6 +179,9 @@ async def fetch_operations_dashboard( | |
| select classification.post_id, classification.case_kind_code | ||
| from operations_case_classification classification | ||
| join visible_post on visible_post.post_id = classification.post_id | ||
| join source_post evidence_post | ||
| on evidence_post.post_id = classification.evidence_post_id | ||
| where {visible_evidence} | ||
| ) | ||
| select (select count(*) from visible_post) as total_post_count, | ||
| (select count(*) | ||
|
|
@@ -200,6 +224,8 @@ async def fetch_operations_dashboard( | |
| where summary_event.post_id = classification.post_id) as event_count | ||
| from operations_case_classification classification | ||
| join source_post post on post.post_id = classification.post_id | ||
| join source_post evidence_post | ||
| on evidence_post.post_id = classification.evidence_post_id | ||
| left join lateral ( | ||
| select array_agg(names.project_name order by names.project_name) as project_names, | ||
| ( | ||
|
|
@@ -220,8 +246,9 @@ async def fetch_operations_dashboard( | |
| ) names | ||
| where names.project_name is not null | ||
| ) project on true | ||
| where {visible} | ||
| and ($5::boolean is false or classification.case_kind_code = 'external_information') | ||
| where {visible} | ||
| and {visible_evidence} | ||
| and ($5::boolean is false or classification.case_kind_code = 'external_information') | ||
| order by coalesce(post.event_occurred_at, post.created_at) desc, | ||
| classification.post_id, classification.case_kind_code | ||
| """, | ||
|
|
@@ -234,7 +261,9 @@ async def fetch_operations_dashboard( | |
| fact.fact_ordinal, fact.relation_target_kind_code | ||
| from operations_case_fact fact | ||
| join source_post post on post.post_id = fact.post_id | ||
| join source_post evidence_post on evidence_post.post_id = fact.evidence_post_id | ||
| where {visible} | ||
| and {visible_evidence} | ||
| and ($5::boolean is false or fact.case_kind_code = 'external_information') | ||
| order by fact.post_id, fact.case_kind_code, fact.fact_ordinal | ||
| """, | ||
|
|
@@ -246,11 +275,35 @@ async def fetch_operations_dashboard( | |
| from operations_case_missing_fact missing | ||
| join source_post post on post.post_id = missing.post_id | ||
| where {visible} | ||
| and {visible_evidence} | ||
| and ($5::boolean is false or missing.case_kind_code = 'external_information') | ||
| order by missing.post_id, missing.case_kind_code, missing.fact_type_code | ||
| """, | ||
| *args, | ||
| ) | ||
| milestone_rows = await conn.fetch( | ||
| f""" | ||
| select milestone.post_id, milestone.case_kind_code, | ||
| milestone.milestone_type_code, milestone.evidence_text, | ||
| milestone.evidence_post_id, milestone.observed_at, | ||
| milestone.time_axis_code, false as is_missing | ||
| from operations_case_milestone milestone | ||
| join source_post post on post.post_id = milestone.post_id | ||
| join source_post evidence_post on evidence_post.post_id = milestone.evidence_post_id | ||
| where {visible} | ||
| and {visible_evidence} | ||
| and ($5::boolean is false or milestone.case_kind_code = 'external_information') | ||
| union all | ||
| select missing.post_id, missing.case_kind_code, | ||
| missing.milestone_type_code, null, null, null, null, true | ||
| from operations_case_missing_milestone missing | ||
| join source_post post on post.post_id = missing.post_id | ||
| where {visible} | ||
| and ($5::boolean is false or missing.case_kind_code = 'external_information') | ||
| order by post_id, case_kind_code, milestone_type_code | ||
|
Comment on lines
+296
to
+303
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Milestone array order in test differs from real DB The union query orders by Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| """, | ||
| *args, | ||
| ) | ||
| topic_context = ( | ||
| { | ||
| "status_code": "not_applicable", | ||
|
|
@@ -292,6 +345,30 @@ async def fetch_operations_dashboard( | |
| "fact_type_label": FACT_TYPE_LABELS[row["fact_type_code"]], | ||
| } | ||
| ) | ||
| milestones: dict[tuple[str, str], list[dict[str, Any]]] = {} | ||
| missing_milestones: dict[tuple[str, str], set[str]] = {} | ||
| for row in milestone_rows: | ||
| key = (str(row["post_id"]), row["case_kind_code"]) | ||
| if row["is_missing"]: | ||
| missing_milestones.setdefault(key, set()).add(row["milestone_type_code"]) | ||
| continue | ||
| milestones.setdefault(key, []).append( | ||
| { | ||
| "milestone_type_code": row["milestone_type_code"], | ||
| "milestone_type_label": MILESTONE_TYPE_LABELS[ | ||
| row["milestone_type_code"] | ||
| ], | ||
| "evidence_text": row["evidence_text"], | ||
| "evidence_post_id": str(row["evidence_post_id"]), | ||
| "observed_at": row["observed_at"].isoformat(), | ||
| "time_axis_code": row["time_axis_code"], | ||
| "time_axis_label": ( | ||
| "Event 발생일" | ||
| if row["time_axis_code"] == "event_occurred_at" | ||
| else "기록 생성일" | ||
| ), | ||
| } | ||
| ) | ||
| total = int(metrics["total_post_count"]) | ||
| external = int(metrics["external_post_count"]) | ||
| case_post_ids: dict[str, set[str]] = {} | ||
|
|
@@ -300,6 +377,50 @@ async def fetch_operations_dashboard( | |
| kind = row["case_kind_code"] | ||
| case_post_ids.setdefault(kind, set()).add(str(row["post_id"])) | ||
| case_event_counts[kind] = case_event_counts.get(kind, 0) + int(row["event_count"]) | ||
| projected_cases = [] | ||
| lifecycle_metrics = { | ||
| lifecycle_code: { | ||
| "lifecycle_kind_code": lifecycle_code, | ||
| "lifecycle_kind_label": label, | ||
| "open_case_count": 0, | ||
| "resolved_case_count": 0, | ||
| "evidence_missing_case_count": 0, | ||
| } | ||
| for lifecycle_code, _kind, label, _start, _end in LIFECYCLE_DEFINITIONS | ||
| } | ||
| for row in case_rows: | ||
| key = (str(row["post_id"]), row["case_kind_code"]) | ||
| case_milestones = milestones.get(key, []) | ||
| case_lifecycles = _project_lifecycles( | ||
| row["case_kind_code"], case_milestones, missing_milestones.get(key, set()) | ||
| ) | ||
| for lifecycle in case_lifecycles: | ||
| lifecycle_metrics[lifecycle["lifecycle_kind_code"]][ | ||
| f"{lifecycle['status_code']}_case_count" | ||
| ] += 1 | ||
| projected_cases.append( | ||
| { | ||
| "post_id": str(row["post_id"]), | ||
| "case_kind_code": row["case_kind_code"], | ||
| "case_kind_label": CASE_KIND_LABELS[row["case_kind_code"]], | ||
| "project_name": row["project_name"], | ||
| "project_names": list(row["project_names"]), | ||
| "summary_text": row["summary_text"], | ||
| "evidence_text": row["evidence_text"], | ||
| "evidence_post_id": str(row["evidence_post_id"]), | ||
| "ontology_class_iri": CASE_KIND_ONTOLOGY_CLASSES[row["case_kind_code"]], | ||
| "provenance_relation_iri": PROV_WAS_DERIVED_FROM, | ||
| "occurred_at": row["occurred_at"].isoformat(), | ||
| "facts": facts.get(key, []), | ||
| "missing_facts": missing_facts.get(key, []), | ||
| "milestones": case_milestones, | ||
| "lifecycles": case_lifecycles, | ||
| "semantic_projection": _operations_case_jsonld( | ||
| str(row["post_id"]), row["case_kind_code"], | ||
| str(row["evidence_post_id"]), facts.get(key, []), | ||
| ), | ||
| } | ||
| ) | ||
| return { | ||
| "period_label": _period_label(period_start, period_end), | ||
| "total_post_count": total, | ||
|
|
@@ -318,33 +439,51 @@ async def fetch_operations_dashboard( | |
| for kind, label in CASE_KIND_LABELS.items() | ||
| ], | ||
| "topic_context": topic_context, | ||
| "cases": [ | ||
| { | ||
| "post_id": str(row["post_id"]), | ||
| "case_kind_code": row["case_kind_code"], | ||
| "case_kind_label": CASE_KIND_LABELS[row["case_kind_code"]], | ||
| "project_name": row["project_name"], | ||
| "project_names": list(row["project_names"]), | ||
| "summary_text": row["summary_text"], | ||
| "evidence_text": row["evidence_text"], | ||
| "evidence_post_id": str(row["evidence_post_id"]), | ||
| "ontology_class_iri": CASE_KIND_ONTOLOGY_CLASSES[row["case_kind_code"]], | ||
| "provenance_relation_iri": PROV_WAS_DERIVED_FROM, | ||
| "occurred_at": row["occurred_at"].isoformat(), | ||
| "facts": facts.get((str(row["post_id"]), row["case_kind_code"]), []), | ||
| "missing_facts": missing_facts.get((str(row["post_id"]), row["case_kind_code"]), []), | ||
| "semantic_projection": _operations_case_jsonld( | ||
| str(row["post_id"]), | ||
| row["case_kind_code"], | ||
| str(row["evidence_post_id"]), | ||
| facts.get((str(row["post_id"]), row["case_kind_code"]), []), | ||
| ), | ||
| } | ||
| for row in case_rows | ||
| ], | ||
| "lifecycle_metrics": list(lifecycle_metrics.values()), | ||
| "cases": projected_cases, | ||
| } | ||
|
|
||
|
|
||
| def _project_lifecycles( | ||
| case_kind_code: str, | ||
| milestones: list[dict[str, Any]], | ||
| missing_milestones: set[str], | ||
| ) -> list[dict[str, Any]]: | ||
| """Pair observed endpoints and report exact elapsed time without thresholds.""" | ||
| by_type = {value["milestone_type_code"]: value for value in milestones} | ||
| result = [] | ||
| for lifecycle_code, required_kind, label, start_code, end_code in LIFECYCLE_DEFINITIONS: | ||
| if case_kind_code != required_kind: | ||
| continue | ||
| start = by_type.get(start_code) | ||
| end = by_type.get(end_code) | ||
| if start and end: | ||
| elapsed_seconds = int((datetime.fromisoformat(end["observed_at"]) - datetime.fromisoformat(start["observed_at"])).total_seconds()) | ||
| status_code = "resolved" | ||
| next_action = "시작·종료 Event 근거를 열어 경과 시간을 검토하세요." | ||
| elif start and end_code in missing_milestones: | ||
| elapsed_seconds = None | ||
| status_code = "open" | ||
| next_action = f"{MILESTONE_TYPE_LABELS[end_code]} Event 근거를 연결하세요." | ||
| else: | ||
| elapsed_seconds = None | ||
| status_code = "evidence_missing" | ||
| next_action = f"{MILESTONE_TYPE_LABELS[start_code]} Event 근거를 연결하세요." | ||
|
Comment on lines
+464
to
+471
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Hidden evidence posts silently downgrade lifecycle status The observed-milestone query joins Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| result.append({ | ||
| "lifecycle_kind_code": lifecycle_code, | ||
| "lifecycle_kind_label": label, | ||
| "status_code": status_code, | ||
| "status_label": {"resolved": "종료 확인", "open": "진행 중", "evidence_missing": "측정 근거 부족"}[status_code], | ||
| "started_at": start["observed_at"] if start else None, | ||
| "resolved_at": end["observed_at"] if end else None, | ||
| "elapsed_seconds": elapsed_seconds, | ||
| "start_milestone": start, | ||
| "end_milestone": end, | ||
| "next_action_text": next_action, | ||
| }) | ||
| return result | ||
|
|
||
|
|
||
| async def _fetch_topic_context_dashboard( | ||
| conn: _Connection, | ||
| visible_post_sql: str, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.