Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,18 @@ HTML-wrapped, base64-image-embedded version of the existing
people through the live `/extract-keymen` endpoint
(`test_extract_keymen_normalizes_html_and_embedded_image_content`).

## Evidence-operations lifecycle projection

ADR 0206's Dashboard persists a semantic classification separately from its
facts and observed milestones. `operations_case_milestone` binds a closed XES-
style activity code to an exact evidence span, evidence-post digest, observed
instant, and named source clock; `operations_case_missing_milestone` records an
unsupported required endpoint without fabricating one. The Dashboard pairs
only the three declared start/end definitions for claim investigation, rebid
response, and handover. Both endpoints yield `end - start`; a cited start plus
a missing end is open with nullable elapsed time. API projection rechecks
current ABAC for focal and evidence posts before returning either span.

## Phase 6d: external search verification for Ontology relation inferences

The brief requires an external web/internal search agent to check the
Expand Down
34 changes: 32 additions & 2 deletions backend/app/operations_case_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ async def persist_operations_cases(
) -> None:
"""Atomically replace one post's normalized case analysis."""
async with conn.transaction():
await conn.execute("delete from operations_case_analysis where post_id = $1", post_id)
await conn.execute(
"delete from operations_case_analysis where post_id = $1", post_id
)
await conn.execute(
"insert into operations_case_analysis (post_id, source_body_sha256, orchestrator_session_id) values ($1, $2, $3)",
post_id,
Expand Down Expand Up @@ -64,5 +66,33 @@ async def persist_operations_cases(
if case.missing_fact_type_codes:
await conn.executemany(
"insert into operations_case_missing_fact (post_id, case_kind_code, fact_type_code) values ($1, $2, $3)",
[(post_id, case.case_kind_code, code) for code in case.missing_fact_type_codes],
[
(post_id, case.case_kind_code, code)
for code in case.missing_fact_type_codes
],
)
if case.milestones:
await conn.executemany(
"insert into operations_case_milestone (post_id, case_kind_code, milestone_type_code, evidence_text, evidence_post_id, evidence_input_sha256, observed_at, time_axis_code) values ($1, $2, $3, $4, $5, $6, $7, $8)",
[
(
post_id,
case.case_kind_code,
milestone.milestone_type_code,
milestone.evidence_text,
milestone.evidence_post_id,
milestone.evidence_input_sha256,
milestone.observed_at,
milestone.time_axis_code,
)
for milestone in case.milestones
],
)
if case.missing_milestone_type_codes:
await conn.executemany(
"insert into operations_case_missing_milestone (post_id, case_kind_code, milestone_type_code) values ($1, $2, $3)",
[
(post_id, case.case_kind_code, code)
for code in case.missing_milestone_type_codes
],
)
197 changes: 168 additions & 29 deletions backend/app/operations_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from datetime import date
from datetime import date, datetime
import json
from typing import Any, Protocol

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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)
Expand All @@ -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 (
Expand All @@ -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}
Comment thread
seonghobae marked this conversation as resolved.
)
select (select count(*) from visible_post) as total_post_count,
(select count(*)
Expand Down Expand Up @@ -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,
(
Expand All @@ -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
""",
Expand All @@ -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
""",
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 milestone_type_code (operations_dashboard.py:303), so a claim case returns cause_confirmed before claim_received, but the test mock returns them claim_received-first, so the asserted array order will not match production. No functional impact: the frontend derives endpoints via by-type lookup in _project_lifecycles, not from the raw milestones array order.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

""",
*args,
)
topic_context = (
{
"status_code": "not_applicable",
Expand Down Expand Up @@ -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]] = {}
Expand All @@ -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,
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 evidence_post under visible_evidence (operations_dashboard.py:290-294) while the missing branch does not. When a milestone's evidence post is no longer visible, it vanishes from the observed set and is absent from missing_milestones, so _project_lifecycles reports evidence_missing even when the start endpoint exists, and next_action can name the wrong milestone. This is the intended ABAC recheck, but the status/next-action copy can mislead for partially hidden cases.

Open in Devin Review

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,
Expand Down
Loading