diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3a55015f5..af325c01c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/backend/app/operations_case_ingestion.py b/backend/app/operations_case_ingestion.py index ea92c4f22..f6b1bbed8 100644 --- a/backend/app/operations_case_ingestion.py +++ b/backend/app/operations_case_ingestion.py @@ -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, @@ -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 + ], ) diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py index 74ceece09..4238ecc8d 100644 --- a/backend/app/operations_dashboard.py +++ b/backend/app/operations_dashboard.py @@ -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 + """, + *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 근거를 연결하세요." + 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, diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py index 825c35b77..fd7dd8d2f 100644 --- a/backend/app/post_content_worker.py +++ b/backend/app/post_content_worker.py @@ -46,7 +46,9 @@ _INCOMPLETE_FAILURE_CODE = "post_content_ingestion_incomplete" _ATTEMPT_LIMIT_FAILURE_CODE = "post_content_ingestion_attempt_limit" _SOURCE_BODY_MISSING_FAILURE_CODE = "post_content_source_body_missing" -_UNEXPECTED_FAILURE_DETAIL = "post-content provider operation failed; retry the ingestion job" +_UNEXPECTED_FAILURE_DETAIL = ( + "post-content provider operation failed; retry the ingestion job" +) async def _operations_evidence_sources( @@ -68,6 +70,22 @@ def can_see(row: asyncpg.Record) -> bool: async with pool.acquire() as conn: sources = await gather_chat_sources(conn, post_id, can_see, vision_client) + if not sources: + return () + source_times = { + str(row["post_id"]): ( + row["observed_at"], + "event_occurred_at" + if row["event_occurred_at"] is not None + else "created_at", + ) + for row in await conn.fetch( + "select post_id, event_occurred_at, " + "coalesce(event_occurred_at, created_at) as observed_at " + "from source_post where post_id = any($1::uuid[])", + [source.post_id for source in sources], + ) + } return tuple( OperationsEvidenceSource( source.post_id, @@ -78,6 +96,8 @@ def can_see(row: asyncpg.Record) -> bool: if source.evidence_facts else "" ), + source_times[source.post_id][0], + source_times[source.post_id][1], ) for source in sources ) @@ -312,7 +332,9 @@ async def process_post_content_job( structure_client = structure_factory() with use_llm_metadata(metadata): vision_client = vision_factory() - normalized = await asyncio.to_thread(normalize_post_body, raw_body, vision_client) + normalized = await asyncio.to_thread( + normalize_post_body, raw_body, vision_client + ) async with pool.acquire() as conn: await persist_post_content( conn, @@ -360,7 +382,9 @@ async def process_post_content_job( complete = await post_content_is_complete( conn, post_id, - embedding_model_code=getattr(embedding_client, "resolved_model", None), + embedding_model_code=getattr( + embedding_client, "resolved_model", None + ), require_embedding=require_orchestrator_evidence, require_structure=require_orchestrator_evidence, ) @@ -410,7 +434,9 @@ async def consume_post_content_stream_once( from there on the next poll. """ try: - batches = await client.xread({POST_CONTENT_STREAM_KEY: last_id}, count=10, block=1000) + batches = await client.xread( + {POST_CONTENT_STREAM_KEY: last_id}, count=10, block=1000 + ) except Exception: # Keep idle polls silent, but retain a diagnostic span for broker failures. with traced( @@ -480,6 +506,7 @@ async def run_post_content_worker( ) except (redis.RedisError, OSError) as exc: _logger.warning( - "post-content Valkey poll failed; retrying (error_type=%s)", type(exc).__name__ + "post-content Valkey poll failed; retrying (error_type=%s)", + type(exc).__name__, ) await asyncio.sleep(_BROKER_RECOVERY_DELAY_SECONDS) diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md index 21d923fd3..cacb982e2 100644 --- a/docs/adr/0206-evidence-operations-dashboard.md +++ b/docs/adr/0206-evidence-operations-dashboard.md @@ -137,8 +137,28 @@ treated as a negative case. responses, source-digest invalidation, and unavailable orchestrator states. - Backend integration tests cover ABAC filtering, event-time fallback, event versus post counts, external-information percentage, multi-project - membership, and explicit missing facts. + membership, explicit missing facts, observed lifecycle endpoints, exact + elapsed duration, open cases with nullable elapsed time, reversed endpoint + rejection, and evidence-post authorization. - Frontend tests cover period submission, navigation, empty/error states, evidence links, keyboard semantics, and non-color status copy. - Storybook interaction tests and authenticated browser screenshots audit the rendered desktop and narrow layouts. + +## References + +Institute of Electrical and Electronics Engineers. (2023). *IEEE standard for +eXtensible Event Stream (XES) for achieving interoperability in event logs and +event streams* (IEEE Std 1849-2023). IEEE Standards Association. +https://standards.ieee.org/ieee/1849/10907/ + +van der Aalst, W. M. P., Adriansyah, A., de Medeiros, A. K. A., Arcieri, F., +Baier, T., Blickle, T., Bose, J. C., van den Brand, P., Brandtjen, R., Buijs, +J., Burattin, A., Carmona, J., Castellanos, M., Claes, J., Cook, J., Costantini, +N., Curbera, F., Damiani, E., de Leoni, M., ... Wynn, M. (2012). Process mining +manifesto. In F. Daniel, K. Barkaoui, & S. Dustdar (Eds.), *Business process +management workshops* (pp. 169–194). Springer. +https://doi.org/10.1007/978-3-642-28108-2_19 + +World Wide Web Consortium. (2022). *Time ontology in OWL*. +https://www.w3.org/TR/owl-time/ diff --git a/docs/product-requirements.md b/docs/product-requirements.md index 75cba0410..36e5fcb44 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -83,12 +83,18 @@ content never becomes an external query or citation. - Show persisted operational cases, actions, commitments, delivery status, and similar-VOC evidence with extractive citations. +- For claim investigation, rebid response, and handover, persist closed-vocabulary + milestones only when an authorized source span supports them. Report + open/resolved/evidence-missing counts and exact elapsed time only between two + observed endpoints; never invent an endpoint or delay threshold. - Preserve controls during loading and retry; discard responses from an earlier navigation scope. - Distinguish pending, unavailable, failed, incomplete, and succeeded states. Acceptance: each state tells the user the next valid action and never displays -stale evidence from a previously opened post. +stale evidence from a previously opened post. An open lifecycle has a cited +start, a missing end, and nullable elapsed time; a resolved lifecycle links both +endpoint sources and names the source clock used for each instant. ### PRD-FR-6 — Measurement boundary diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 5ed4f5387..098b2b031 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -8,8 +8,8 @@ | Requirement | Evidence contract | Delivery state | |---|---|---| -| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification with cited spans; Event Lineage context | Candidate API/UI reports separate per-kind Event and distinct-post counts; every required answer is cited or stored as an explicit missing fact with a collection action; authenticated runtime acceptance pending | -| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus persisted summary actions/roles | Candidate API/UI reports separate per-kind Event and distinct-post counts; every required answer is cited or stored as an explicit missing fact; corpus backfill pending | +| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification and `claim_received` → `cause_confirmed` milestones with cited spans and observed source clocks | Stacked candidate reports open/resolved/evidence-missing counts and exact elapsed time only for paired observed endpoints; every required answer and endpoint is cited or explicitly missing; authenticated runtime acceptance and corpus re-analysis pending | +| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus separate rebid-response and handover milestone pairs | Stacked candidate reports open/resolved/evidence-missing rebid and handover lifecycles without a delay threshold or invented elapsed endpoint; authenticated runtime acceptance and corpus re-analysis pending | | External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate GNB destination filters the Dashboard to external evidence; no separate Board by product decision; authenticated runtime acceptance pending | | Project-specific journey | Explicit source/semantic project membership plus event-time ordering | Candidate API preserves every explicit project membership and the UI orders each journey chronologically; authenticated runtime acceptance pending | | Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending | @@ -377,7 +377,7 @@ this file per §3.5 of the prior snapshot). | #272 | Verify Global Ask KG/ontology/semantic claims with public SearXNG evidence | Ask stack | | #274 | Persist and explain Event Lineage channel evidence | #387 | | #277 | TEPP: persist accepted receipts, poll completed results, keep measurement authority distinct | #468, #417 | -| #280 | Full project-lifecycle history and handover intervals | Tracked with issue #284; no active delivery PR confirmed | +| #280 | Full project-lifecycle history and handover intervals | The current stacked candidate covers observed claim, rebid-response, and handover endpoint pairs; cross-record business-case identity remains unavailable unless an explicit source identifier is persisted, so project/similarity proximity is not used as a substitute | | #284 | Authoritative lifecycle ingestion and idempotent reconciliation | No active delivery PR confirmed | | #289 | Activate the optional lineage LLM channel through a bounded asynchronous rebuild | #434 | | #336 | Replace pseudo-CalDAV feed with a Naruon-owned calendar projection | Contract on `main` (#355); operator consume wiring in historical branch `feat/naruon-calendar-buyer-wiring-v2170` | diff --git a/frontend/src/App.css b/frontend/src/App.css index f3f8fd08b..123d01254 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1301,6 +1301,14 @@ .dashboard-metrics dd { margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: 700; } .dashboard-case-metrics .dashboard-metrics { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.dashboard-lifecycle-summary { margin: 1.5rem 0; } +.dashboard-lifecycle-summary > p { color: var(--color-text); } +.dashboard-lifecycle-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0; border: 1px solid var(--color-border); } +.dashboard-lifecycle-metrics > div { padding: 1rem; border-right: 1px solid var(--color-border); } +.dashboard-lifecycle-metrics > div:last-child { border-right: 0; } +.dashboard-lifecycle-metrics dt { font-weight: 700; } +.dashboard-lifecycle-metrics dd { margin: 0.5rem 0 0; color: var(--color-text); } + .dashboard-case-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(22rem, 100%), 1fr)); @@ -1345,6 +1353,15 @@ border-left: 3px solid var(--color-dashboard-positive); } +.dashboard-case-lifecycles { display: grid; gap: 1rem; } +.dashboard-lifecycle-row { padding: 1rem; border: 1px solid var(--color-border); background: var(--color-background); } +.dashboard-lifecycle-row header { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; } +.dashboard-lifecycle-row h4, .dashboard-lifecycle-row p { margin: 0; } +.dashboard-lifecycle-row ol { display: grid; gap: 0.5rem; margin: 1rem 0; padding: 0; list-style: none; } +.dashboard-lifecycle-row li { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 0.5rem; border-top: 1px solid var(--color-border); padding-top: 0.5rem; } +.dashboard-lifecycle-row time { grid-column: 1 / -1; color: var(--color-text); font-variant-numeric: tabular-nums; } +.dashboard-next-action { font-weight: 700; } + .dashboard-case-card dl { margin: 0; } .dashboard-case-card dl div { display: grid; grid-template-columns: 8rem 1fr; gap: var(--space-control-gap); padding: 0.5rem 0; border-top: 1px solid var(--color-border); } .dashboard-case-card dd { margin: 0; font-weight: 600; } @@ -1461,6 +1478,11 @@ .operations-dashboard-heading { align-items: start; flex-direction: column; } .dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .dashboard-case-metrics .dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .dashboard-lifecycle-metrics { grid-template-columns: 1fr; } + .dashboard-lifecycle-metrics > div { border-right: 0; border-bottom: 1px solid var(--color-border); } + .dashboard-lifecycle-metrics > div:last-child { border-bottom: 0; } + .dashboard-lifecycle-row li { grid-template-columns: 1fr; } + .dashboard-lifecycle-row li button { width: 100%; } .dashboard-case-grid { grid-template-columns: 1fr; } .dashboard-topic-context > header { align-items: start; flex-direction: column; } .dashboard-topic-provenance dl div { grid-template-columns: 1fr; gap: 0.25rem; } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 1eb85a5d5..fc76a6b6d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -55,6 +55,29 @@ export interface OperationsDashboardFact { relation_predicate_iri?: string; } +export interface OperationsDashboardMilestone { + milestone_type_code: string; + milestone_type_label: string; + evidence_text: string; + evidence_post_id: string; + observed_at: string; + time_axis_code: "event_occurred_at" | "created_at"; + time_axis_label: string; +} + +export interface OperationsDashboardLifecycle { + lifecycle_kind_code: string; + lifecycle_kind_label: string; + status_code: "resolved" | "open" | "evidence_missing"; + status_label: string; + started_at: string | null; + resolved_at: string | null; + elapsed_seconds: number | null; + start_milestone: OperationsDashboardMilestone | null; + end_milestone: OperationsDashboardMilestone | null; + next_action_text: string; +} + export interface OperationsDashboardCase { post_id: string; case_kind_code: string; @@ -67,6 +90,8 @@ export interface OperationsDashboardCase { occurred_at: string; facts: OperationsDashboardFact[]; missing_facts: Array<{ fact_type_code: string; fact_type_label: string }>; + milestones: OperationsDashboardMilestone[]; + lifecycles: OperationsDashboardLifecycle[]; ontology_class_iri?: string; provenance_relation_iri?: string; semantic_projection?: Record; @@ -86,6 +111,13 @@ export interface OperationsDashboardResponse { event_count: number; post_count: number; }>; + lifecycle_metrics: Array<{ + lifecycle_kind_code: string; + lifecycle_kind_label: string; + open_case_count: number; + resolved_case_count: number; + evidence_missing_case_count: number; + }>; topic_context: TopicContextDashboard; cases: OperationsDashboardCase[]; } diff --git a/frontend/src/components/OperationsDashboard.stories.tsx b/frontend/src/components/OperationsDashboard.stories.tsx index 6d813019f..affe0f78e 100644 --- a/frontend/src/components/OperationsDashboard.stories.tsx +++ b/frontend/src/components/OperationsDashboard.stories.tsx @@ -18,6 +18,11 @@ export const EvidenceReady: Story = { { case_kind_code: "external_information", case_kind_label: "발주 공고 · 시장 동향", event_count: 9, post_count: 9 }, { case_kind_code: "repeat_issue", case_kind_label: "반복 이슈", event_count: 2, post_count: 2 }, ], + lifecycle_metrics: [ + { lifecycle_kind_code: "claim_investigation", lifecycle_kind_label: "클레임 원인 규명", open_case_count: 1, resolved_case_count: 0, evidence_missing_case_count: 0 }, + { lifecycle_kind_code: "rebid_response", lifecycle_kind_label: "재입찰 대응", open_case_count: 0, resolved_case_count: 1, evidence_missing_case_count: 0 }, + { lifecycle_kind_code: "handover_gap", lifecycle_kind_label: "인수인계 공백", open_case_count: 0, resolved_case_count: 0, evidence_missing_case_count: 1 }, + ], topic_context: { status_code: "unavailable", reason_code: "tepp_topic_posterior_not_persisted", next_action: "TEPP posterior topic 계약 결과를 먼저 완료하세요.", model_run: null, topics: [], @@ -28,10 +33,10 @@ export const EvidenceReady: Story = { }, failed_analysis_count: 0, cases: [ - { post_id: "synthetic-post-1", case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 역추적", project_name: "Synthetic Transformer Renewal", summary_text: "사양 변경 이후 원인 수주와 Pool을 확인", evidence_text: "Revision B originated in order SO-100 from pool SP-20.", evidence_post_id: "synthetic-post-1", occurred_at: "2026-08-04T00:00:00Z", facts: [{ fact_type_code: "originating_order", fact_type_label: "원인 수주", value_text: "SO-100 · SP-20", evidence_text: "order SO-100 from pool SP-20", evidence_post_id: "synthetic-post-1" }], missing_facts: [{ fact_type_code: "order", fact_type_label: "발생 수주" }, { fact_type_code: "specification_change", fact_type_label: "사양 변경" }, { fact_type_code: "sales_pool", fact_type_label: "수주 Pool" }] }, - { post_id: "synthetic-post-2", case_kind_code: "rebid_handover", case_kind_label: "재입찰 · 인수인계", project_name: "Synthetic Transformer Renewal", summary_text: "담당자 교체 전 협의와 후속 결정을 연결", evidence_text: "The account owner and design lead agreed to submit the revised proposal.", evidence_post_id: "synthetic-post-2", occurred_at: "2026-08-11T00:00:00Z", facts: [{ fact_type_code: "decision", fact_type_label: "이어진 결정", value_text: "수정 제안 제출", evidence_text: "submit the revised proposal", evidence_post_id: "synthetic-post-2" }], missing_facts: [{ fact_type_code: "discussion", fact_type_label: "협의 내용" }, { fact_type_code: "counterparty", fact_type_label: "협의 상대" }, { fact_type_code: "our_owner", fact_type_label: "우리측 담당자" }] }, - { post_id: "synthetic-post-3", case_kind_code: "external_information", case_kind_label: "외부 정보", project_name: "Synthetic Transformer Renewal", summary_text: "시장 공고를 영업 기회와 연결", evidence_text: "The public procurement notice opened on August 15.", evidence_post_id: "synthetic-post-3", occurred_at: "2026-08-15T00:00:00Z", facts: [{ fact_type_code: "external_relation", fact_type_label: "업무 관계", value_text: "갱신 제안 준비", evidence_text: "procurement notice", evidence_post_id: "synthetic-post-3", relation_target_kind_code: "project", relation_target_kind_label: "프로젝트" }], missing_facts: [] }, - { post_id: "synthetic-post-4", case_kind_code: "repeat_issue", case_kind_label: "반복 이슈 반영", project_name: "Synthetic Transformer Renewal", summary_text: "동일 유형 이슈를 설계 개선으로 환류", evidence_text: "The same enclosure issue recurred after Revision B.", evidence_post_id: "synthetic-post-4", occurred_at: "2026-08-18T00:00:00Z", facts: [{ fact_type_code: "improvement_action", fact_type_label: "개선 과제", value_text: "표준 사양 개정", evidence_text: "Update the standard enclosure specification.", evidence_post_id: "synthetic-post-4" }], missing_facts: [{ fact_type_code: "issue_pattern", fact_type_label: "반복 유형" }] }, + { post_id: "synthetic-post-1", case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 역추적", project_name: "Synthetic Transformer Renewal", summary_text: "사양 변경 이후 원인 수주와 Pool을 확인", evidence_text: "Revision B originated in order SO-100 from pool SP-20.", evidence_post_id: "synthetic-post-1", occurred_at: "2026-08-04T00:00:00Z", facts: [{ fact_type_code: "originating_order", fact_type_label: "원인 수주", value_text: "SO-100 · SP-20", evidence_text: "order SO-100 from pool SP-20", evidence_post_id: "synthetic-post-1" }], missing_facts: [{ fact_type_code: "order", fact_type_label: "발생 수주" }, { fact_type_code: "specification_change", fact_type_label: "사양 변경" }, { fact_type_code: "sales_pool", fact_type_label: "수주 Pool" }], milestones: [], lifecycles: [] }, + { post_id: "synthetic-post-2", case_kind_code: "rebid_handover", case_kind_label: "재입찰 · 인수인계", project_name: "Synthetic Transformer Renewal", summary_text: "담당자 교체 전 협의와 후속 결정을 연결", evidence_text: "The account owner and design lead agreed to submit the revised proposal.", evidence_post_id: "synthetic-post-2", occurred_at: "2026-08-11T00:00:00Z", facts: [{ fact_type_code: "decision", fact_type_label: "이어진 결정", value_text: "수정 제안 제출", evidence_text: "submit the revised proposal", evidence_post_id: "synthetic-post-2" }], missing_facts: [{ fact_type_code: "discussion", fact_type_label: "협의 내용" }, { fact_type_code: "counterparty", fact_type_label: "협의 상대" }, { fact_type_code: "our_owner", fact_type_label: "우리측 담당자" }], milestones: [], lifecycles: [] }, + { post_id: "synthetic-post-3", case_kind_code: "external_information", case_kind_label: "외부 정보", project_name: "Synthetic Transformer Renewal", summary_text: "시장 공고를 영업 기회와 연결", evidence_text: "The public procurement notice opened on August 15.", evidence_post_id: "synthetic-post-3", occurred_at: "2026-08-15T00:00:00Z", facts: [{ fact_type_code: "external_relation", fact_type_label: "업무 관계", value_text: "갱신 제안 준비", evidence_text: "procurement notice", evidence_post_id: "synthetic-post-3", relation_target_kind_code: "project", relation_target_kind_label: "프로젝트" }], missing_facts: [], milestones: [], lifecycles: [] }, + { post_id: "synthetic-post-4", case_kind_code: "repeat_issue", case_kind_label: "반복 이슈 반영", project_name: "Synthetic Transformer Renewal", summary_text: "동일 유형 이슈를 설계 개선으로 환류", evidence_text: "The same enclosure issue recurred after Revision B.", evidence_post_id: "synthetic-post-4", occurred_at: "2026-08-18T00:00:00Z", facts: [{ fact_type_code: "improvement_action", fact_type_label: "개선 과제", value_text: "표준 사양 개정", evidence_text: "Update the standard enclosure specification.", evidence_post_id: "synthetic-post-4" }], missing_facts: [{ fact_type_code: "issue_pattern", fact_type_label: "반복 유형" }], milestones: [], lifecycles: [] }, ], }, onOpenPost: () => undefined, @@ -40,6 +45,7 @@ export const EvidenceReady: Story = { const canvas = within(canvasElement); await expect(canvas.getByText("9건 · 22.5%")).toBeInTheDocument(); await expect(canvas.getByText("7 Event · 5글")).toBeVisible(); + await expect(canvas.getByText("3일 3시간 30분 0초")).toBeVisible(); await expect(canvas.getAllByRole("button", { name: "분류 근거 글 열기" })[0]).toBeVisible(); }, }; diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx index 9328c1235..dbb9bb5d3 100644 --- a/frontend/src/components/OperationsDashboard.test.tsx +++ b/frontend/src/components/OperationsDashboard.test.tsx @@ -21,6 +21,9 @@ const data: OperationsDashboardResponse = { { case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 규명", event_count: 3, post_count: 2 }, { case_kind_code: "rebid_handover", case_kind_label: "재입찰 · 인수인계", event_count: 2, post_count: 2 }, ], + lifecycle_metrics: [ + { lifecycle_kind_code: "claim_investigation", lifecycle_kind_label: "클레임 원인 규명", open_case_count: 1, resolved_case_count: 0, evidence_missing_case_count: 0 }, + ], topic_context: { status_code: "unavailable", reason_code: "tepp_topic_posterior_not_persisted", @@ -37,6 +40,11 @@ const data: OperationsDashboardResponse = { project_name: "Synthetic Grid Upgrade", summary_text: "사양 변경 이후 원인 수주를 확인했습니다.", evidence_text: "Revision B changed the enclosure.", evidence_post_id: "evidence-post-1", occurred_at: "2026-08-12T00:00:00Z", facts: [{ fact_type_code: "originating_order", fact_type_label: "원인 수주", value_text: "ORDER-100", evidence_text: "Original order ORDER-100", evidence_post_id: "evidence-post-2" }], missing_facts: [{ fact_type_code: "sales_pool", fact_type_label: "수주 Pool" }], + milestones: [ + { milestone_type_code: "claim_received", milestone_type_label: "클레임 접수", evidence_text: "Claim received", evidence_post_id: "evidence-post-1", observed_at: "2026-08-01T09:00:00Z", time_axis_code: "event_occurred_at", time_axis_label: "Event 발생일" }, + { milestone_type_code: "cause_confirmed", milestone_type_label: "원인 확정", evidence_text: "Cause confirmed", evidence_post_id: "evidence-post-2", observed_at: "2026-08-03T12:30:00Z", time_axis_code: "created_at", time_axis_label: "기록 생성일" }, + ], + lifecycles: [{ lifecycle_kind_code: "claim_investigation", lifecycle_kind_label: "클레임 원인 규명", status_code: "resolved", status_label: "종료 확인", started_at: "2026-08-01T09:00:00Z", resolved_at: "2026-08-03T12:30:00Z", elapsed_seconds: 185400, start_milestone: { milestone_type_code: "claim_received", milestone_type_label: "클레임 접수", evidence_text: "Claim received", evidence_post_id: "evidence-post-1", observed_at: "2026-08-01T09:00:00Z", time_axis_code: "event_occurred_at", time_axis_label: "Event 발생일" }, end_milestone: { milestone_type_code: "cause_confirmed", milestone_type_label: "원인 확정", evidence_text: "Cause confirmed", evidence_post_id: "evidence-post-2", observed_at: "2026-08-03T12:30:00Z", time_axis_code: "created_at", time_axis_label: "기록 생성일" }, next_action_text: "시작·종료 Event 근거를 열어 경과 시간을 검토하세요." }], }], }; @@ -48,10 +56,14 @@ describe("OperationsDashboardView", () => { expect(screen.getByText("5건 · 25.0%")).toBeInTheDocument(); expect(screen.getByText("원인 수주")).toBeInTheDocument(); expect(screen.getByText(/수주 Pool: 권한 범위 내 근거가 없습니다/)).toBeInTheDocument(); + expect(screen.getByText("2일 3시간 30분 0초")).toBeInTheDocument(); + expect(screen.getByText(/진행 중 1건 · 종료 확인 0건/)).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "분류 근거 글 열기" })); expect(onOpenPost).toHaveBeenCalledWith("evidence-post-1"); await userEvent.click(screen.getByRole("button", { name: "원인 수주 근거 열기" })); expect(onOpenPost).toHaveBeenCalledWith("evidence-post-2"); + await userEvent.click(screen.getByRole("button", { name: "클레임 접수 근거 열기" })); + expect(onOpenPost).toHaveBeenCalledWith("evidence-post-1"); }); it("shows an actionable empty external-information state", () => { diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx index e36adfdbf..f7c93dacb 100644 --- a/frontend/src/components/OperationsDashboard.tsx +++ b/frontend/src/components/OperationsDashboard.tsx @@ -1,6 +1,13 @@ import { useEffect, useState } from "react"; import { fetchOperationsDashboard, type OperationsDashboardResponse } from "../api"; +function formatElapsed(seconds: number): string { + const days = Math.floor(seconds / 86_400); + const hours = Math.floor((seconds % 86_400) / 3_600); + const minutes = Math.floor((seconds % 3_600) / 60); + return `${days}일 ${hours}시간 ${minutes}분 ${seconds % 60}초`; +} + const dimensionLabels = { business_unit: "사업부", process_unit: "PU", @@ -98,6 +105,20 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost ) : null} + {!externalOnly ? ( +
+

관측된 처리 구간

+

임의 지연 기준 없이, 시작·종료 Event가 모두 확인된 구간만 경과 시간을 계산합니다.

+
+ {data.lifecycle_metrics.map((metric) => ( +
+
{metric.lifecycle_kind_label}
+
진행 중 {metric.open_case_count}건 · 종료 확인 {metric.resolved_case_count}건 · 측정 근거 부족 {metric.evidence_missing_case_count}건
+
+ ))} +
+
+ ) : null} {!externalOnly ? ( ) : null} @@ -127,6 +148,26 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
{item.case_kind_label}{item.project_name ?? "프로젝트 연결 분석 중"}

{item.summary_text}

{item.evidence_text}
+ {item.lifecycles.length ? ( +
+ {item.lifecycles.map((lifecycle) => ( +
+

{lifecycle.lifecycle_kind_label}

{lifecycle.status_label}
+ {lifecycle.elapsed_seconds !== null ?

확정 경과 시간 {formatElapsed(lifecycle.elapsed_seconds)}

:

경과 시간은 종료 Event가 관측될 때 계산됩니다.

} +
    + {[lifecycle.start_milestone, lifecycle.end_milestone].filter((milestone) => milestone !== null).map((milestone) => ( +
  1. + + {milestone.milestone_type_label} · {milestone.time_axis_label} + +
  2. + ))} +
+

다음 조치: {lifecycle.next_action_text}

+
+ ))} +
+ ) : null}
{item.facts.map((fact) =>
{fact.fact_type_label}{fact.relation_target_kind_label ? ` · ${fact.relation_target_kind_label}` : ""}
{fact.value_text}
)}
{item.missing_facts.length ? (
diff --git a/lineageweave/operations_case_analysis.py b/lineageweave/operations_case_analysis.py index 3c054cc59..b27468706 100644 --- a/lineageweave/operations_case_analysis.py +++ b/lineageweave/operations_case_analysis.py @@ -5,6 +5,7 @@ import hashlib import json from dataclasses import dataclass +from datetime import datetime from typing import Protocol from .http_client import chat_completion_content, post_json @@ -14,20 +15,55 @@ ) FACT_TYPES = frozenset( { - "order", "specification_change", "originating_order", "sales_pool", - "discussion", "counterparty", "our_owner", "decision", "external_relation", - "issue_pattern", "improvement_action", + "order", + "specification_change", + "originating_order", + "sales_pool", + "discussion", + "counterparty", + "our_owner", + "decision", + "external_relation", + "issue_pattern", + "improvement_action", } ) EXTERNAL_RELATION_TARGET_KINDS = frozenset( {"order", "project", "sales", "business_management"} ) REQUIRED_FACT_TYPES = { - "claim_investigation": frozenset({"order", "specification_change", "originating_order", "sales_pool"}), - "rebid_handover": frozenset({"discussion", "counterparty", "our_owner", "decision"}), + "claim_investigation": frozenset( + {"order", "specification_change", "originating_order", "sales_pool"} + ), + "rebid_handover": frozenset( + {"discussion", "counterparty", "our_owner", "decision"} + ), "external_information": frozenset({"external_relation"}), "repeat_issue": frozenset({"issue_pattern", "improvement_action"}), } +MILESTONE_TYPES = frozenset( + { + "claim_received", + "cause_confirmed", + "rebid_response_requested", + "rebid_decision_recorded", + "handover_started", + "handover_accepted", + } +) +REQUIRED_MILESTONE_TYPES = { + "claim_investigation": frozenset({"claim_received", "cause_confirmed"}), + "rebid_handover": frozenset( + { + "rebid_response_requested", + "rebid_decision_recorded", + "handover_started", + "handover_accepted", + } + ), + "external_information": frozenset(), + "repeat_issue": frozenset(), +} @dataclass(frozen=True) @@ -42,6 +78,18 @@ class OperationsCaseFact: relation_target_kind_code: str | None = None +@dataclass(frozen=True) +class OperationsCaseMilestone: + """One semantically identified milestone bound to an observed source instant.""" + + milestone_type_code: str + evidence_text: str + evidence_post_id: str + evidence_input_sha256: str + observed_at: datetime + time_axis_code: str + + @dataclass(frozen=True) class OperationsCase: """One semantically classified operational case in a post.""" @@ -53,6 +101,8 @@ class OperationsCase: evidence_post_id: str = "" evidence_input_sha256: str = "" missing_fact_type_codes: tuple[str, ...] = () + milestones: tuple[OperationsCaseMilestone, ...] = () + missing_milestone_type_codes: tuple[str, ...] = () @dataclass(frozen=True) @@ -62,6 +112,8 @@ class OperationsEvidenceSource: post_id: str title: str text: str + observed_at: datetime | None = None + time_axis_code: str | None = None @property def input_sha256(self) -> str: @@ -109,7 +161,14 @@ def analyze( specification_change, originating_order, sales_pool; rebid_handover = discussion, counterparty, our_owner, decision; external_information = external_relation; repeat_issue = issue_pattern, improvement_action. Return [] only when the record supports none -of the case kinds. +of the case kinds. Each item must also contain milestones and +missing_milestone_type_codes. A milestone has milestone_type_code, +evidence_post_id, and a verbatim evidence_text; its instant is assigned from +that source record and must never be generated by the model. Required milestone +types are: claim_investigation = claim_received, cause_confirmed; +rebid_handover = rebid_response_requested, rebid_decision_recorded, +handover_started, handover_accepted; the other case kinds have no milestones. +Represent every required type exactly once as cited evidence or as missing. Stored context (hints, not proof): {context} Authorized numbered sources: @@ -141,19 +200,39 @@ def parse_operations_case_response( seen_case_kinds.add(item["case_kind_code"]) summary = item.get("summary_text") evidence = item.get("evidence_text") - evidence_post_id = item.get("evidence_post_id") or ("focal" if legacy_focal else None) + evidence_post_id = item.get("evidence_post_id") or ( + "focal" if legacy_focal else None + ) facts = item.get("facts") missing_fact_types = item.get("missing_fact_type_codes") + milestones = item.get("milestones") + missing_milestone_types = item.get("missing_milestone_type_codes") evidence_source = sources_by_id.get(evidence_post_id) - if not isinstance(summary, str) or not summary.strip() or not isinstance(evidence, str) or not evidence.strip() or evidence_source is None or evidence not in evidence_source.text or not isinstance(facts, list) or not isinstance(missing_fact_types, list): + if ( + not isinstance(summary, str) + or not summary.strip() + or not isinstance(evidence, str) + or not evidence.strip() + or evidence_source is None + or evidence not in evidence_source.text + or not isinstance(facts, list) + or not isinstance(missing_fact_types, list) + or not isinstance(milestones, list) + or not isinstance(missing_milestone_types, list) + ): return None parsed_facts: list[OperationsCaseFact] = [] for fact in facts: - if not isinstance(fact, dict) or fact.get("fact_type_code") not in FACT_TYPES: + if ( + not isinstance(fact, dict) + or fact.get("fact_type_code") not in FACT_TYPES + ): return None value = fact.get("value_text") fact_evidence = fact.get("evidence_text") - fact_post_id = fact.get("evidence_post_id") or ("focal" if legacy_focal else None) + fact_post_id = fact.get("evidence_post_id") or ( + "focal" if legacy_focal else None + ) fact_source = sources_by_id.get(fact_post_id) relation_target_kind = fact.get("relation_target_kind_code") if ( @@ -194,7 +273,82 @@ def parse_operations_case_response( or not required_types.issubset(supported_types.union(missing_types)) ): return None - cases.append(OperationsCase(item["case_kind_code"], summary.strip(), evidence, tuple(parsed_facts), evidence_source.post_id, evidence_source.input_sha256, tuple(missing_fact_types))) + parsed_milestones: list[OperationsCaseMilestone] = [] + for milestone in milestones: + if ( + not isinstance(milestone, dict) + or milestone.get("milestone_type_code") not in MILESTONE_TYPES + ): + return None + milestone_evidence = milestone.get("evidence_text") + milestone_post_id = milestone.get("evidence_post_id") or ( + "focal" if legacy_focal else None + ) + milestone_source = sources_by_id.get(milestone_post_id) + if ( + not isinstance(milestone_evidence, str) + or not milestone_evidence.strip() + or milestone_source is None + or milestone_evidence not in milestone_source.text + or milestone_source.observed_at is None + or milestone_source.time_axis_code + not in {"event_occurred_at", "created_at"} + ): + return None + parsed_milestones.append( + OperationsCaseMilestone( + milestone["milestone_type_code"], + milestone_evidence, + milestone_source.post_id, + milestone_source.input_sha256, + milestone_source.observed_at, + milestone_source.time_axis_code, + ) + ) + supported_milestone_types = { + value.milestone_type_code for value in parsed_milestones + } + required_milestones = REQUIRED_MILESTONE_TYPES[item["case_kind_code"]] + if ( + len(supported_milestone_types) != len(parsed_milestones) + or any( + not isinstance(code, str) or code not in MILESTONE_TYPES + for code in missing_milestone_types + ) + or len(set(missing_milestone_types)) != len(missing_milestone_types) + or supported_milestone_types.intersection(missing_milestone_types) + or supported_milestone_types.union(missing_milestone_types) + != required_milestones + ): + return None + milestone_by_type = { + value.milestone_type_code: value for value in parsed_milestones + } + for start_code, end_code in ( + ("claim_received", "cause_confirmed"), + ("rebid_response_requested", "rebid_decision_recorded"), + ("handover_started", "handover_accepted"), + ): + if ( + start_code in milestone_by_type + and end_code in milestone_by_type + and milestone_by_type[end_code].observed_at + < milestone_by_type[start_code].observed_at + ): + return None + cases.append( + OperationsCase( + item["case_kind_code"], + summary.strip(), + evidence, + tuple(parsed_facts), + evidence_source.post_id, + evidence_source.input_sha256, + tuple(missing_fact_types), + tuple(parsed_milestones), + tuple(missing_milestone_types), + ) + ) return tuple(cases) @@ -214,11 +368,30 @@ def analyze( """Classify cases and reject any uncited or malformed result.""" response = post_json( f"{self._base_url}/v1/chat/completions", - {"messages": [{"role": "user", "content": _PROMPT.format(context=context, sources="\n\n".join(f"[Source {index}] post_id={source.post_id}\nTitle: {source.title}\n{source.text}" for index, source in enumerate(sources, 1)))}], "mode": "auto", "reasoning_effort": "auto"}, + { + "messages": [ + { + "role": "user", + "content": _PROMPT.format( + context=context, + sources="\n\n".join( + f"[Source {index}] post_id={source.post_id}\nTitle: {source.title}\n{source.text}" + for index, source in enumerate(sources, 1) + ), + ), + } + ], + "mode": "auto", + "reasoning_effort": "auto", + }, headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, ) - parsed = parse_operations_case_response(chat_completion_content(response), sources) + parsed = parse_operations_case_response( + chat_completion_content(response), sources + ) if parsed is None: - raise ValueError("operations case response did not match the evidence contract") + raise ValueError( + "operations case response did not match the evidence contract" + ) return parsed diff --git a/migrations/0215_operations_case_milestone.sql b/migrations/0215_operations_case_milestone.sql new file mode 100644 index 000000000..614accb7c --- /dev/null +++ b/migrations/0215_operations_case_milestone.sql @@ -0,0 +1,36 @@ +-- ADR 0206: observed lifecycle milestones; no inferred timestamps or delay threshold. +create table if not exists operations_case_milestone ( + post_id uuid not null, + case_kind_code text not null, + milestone_type_code text not null check (milestone_type_code in ( + 'claim_received', 'cause_confirmed', + 'rebid_response_requested', 'rebid_decision_recorded', + 'handover_started', 'handover_accepted' + )), + evidence_text text not null check (btrim(evidence_text) <> ''), + evidence_post_id uuid not null references source_post(post_id) on delete restrict, + evidence_input_sha256 text not null check (evidence_input_sha256 ~ '^[0-9a-f]{64}$'), + observed_at timestamptz not null, + time_axis_code text not null check (time_axis_code in ('event_occurred_at', 'created_at')), + primary key (post_id, case_kind_code, milestone_type_code), + foreign key (post_id, case_kind_code) + references operations_case_classification(post_id, case_kind_code) + on delete cascade +); + +create table if not exists operations_case_missing_milestone ( + post_id uuid not null, + case_kind_code text not null, + milestone_type_code text not null check (milestone_type_code in ( + 'claim_received', 'cause_confirmed', + 'rebid_response_requested', 'rebid_decision_recorded', + 'handover_started', 'handover_accepted' + )), + primary key (post_id, case_kind_code, milestone_type_code), + foreign key (post_id, case_kind_code) + references operations_case_classification(post_id, case_kind_code) + on delete cascade +); + +create index if not exists operations_case_milestone_kind_time_idx + on operations_case_milestone (case_kind_code, milestone_type_code, observed_at, post_id); diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py index d5df88c2d..7b248c168 100644 --- a/tests/test_operations_case_analysis.py +++ b/tests/test_operations_case_analysis.py @@ -1,25 +1,75 @@ """Operational case semantic-response contract tests.""" import json +from datetime import UTC, datetime -from lineageweave.operations_case_analysis import OperationsEvidenceSource, parse_operations_case_response +from lineageweave.operations_case_analysis import ( + OperationsEvidenceSource, + parse_operations_case_response, +) def test_parses_multiple_cases_and_grounded_facts() -> None: """One record may support multiple case kinds without losing evidence.""" body = "The revised specification caused the claim. Mina agreed with Alex to rebid." payload = [ - {"case_kind_code": "claim_investigation", "summary_text": "Specification-linked claim", "evidence_text": "The revised specification caused the claim.", "facts": [{"fact_type_code": "specification_change", "value_text": "revised specification", "evidence_text": "The revised specification caused the claim."}], "missing_fact_type_codes": ["order", "originating_order", "sales_pool"]}, - {"case_kind_code": "rebid_handover", "summary_text": "Rebid agreement", "evidence_text": "Mina agreed with Alex to rebid.", "facts": [{"fact_type_code": "counterparty", "value_text": "Mina and Alex", "evidence_text": "Mina agreed with Alex to rebid."}], "missing_fact_type_codes": ["discussion", "our_owner", "decision"]}, + { + "case_kind_code": "claim_investigation", + "summary_text": "Specification-linked claim", + "evidence_text": "The revised specification caused the claim.", + "facts": [ + { + "fact_type_code": "specification_change", + "value_text": "revised specification", + "evidence_text": "The revised specification caused the claim.", + } + ], + "missing_fact_type_codes": ["order", "originating_order", "sales_pool"], + "milestones": [], + "missing_milestone_type_codes": ["claim_received", "cause_confirmed"], + }, + { + "case_kind_code": "rebid_handover", + "summary_text": "Rebid agreement", + "evidence_text": "Mina agreed with Alex to rebid.", + "facts": [ + { + "fact_type_code": "counterparty", + "value_text": "Mina and Alex", + "evidence_text": "Mina agreed with Alex to rebid.", + } + ], + "missing_fact_type_codes": ["discussion", "our_owner", "decision"], + "milestones": [], + "missing_milestone_type_codes": [ + "rebid_response_requested", + "rebid_decision_recorded", + "handover_started", + "handover_accepted", + ], + }, ] result = parse_operations_case_response(json.dumps(payload), body) assert result is not None - assert [case.case_kind_code for case in result] == ["claim_investigation", "rebid_handover"] + assert [case.case_kind_code for case in result] == [ + "claim_investigation", + "rebid_handover", + ] def test_rejects_uncited_model_claim() -> None: """A plausible answer absent from the source is not persisted.""" - payload = [{"case_kind_code": "external_information", "summary_text": "Market note", "evidence_text": "invented", "facts": [], "missing_fact_type_codes": ["external_relation"]}] + payload = [ + { + "case_kind_code": "external_information", + "summary_text": "Market note", + "evidence_text": "invented", + "facts": [], + "missing_fact_type_codes": ["external_relation"], + "milestones": [], + "missing_milestone_type_codes": [], + } + ] assert parse_operations_case_response(json.dumps(payload), "source body") is None @@ -31,17 +81,43 @@ def test_accepts_supported_no_case_result() -> None: def test_rejects_unknown_codes_and_malformed_json() -> None: """Closed vocabularies prevent provider prose from entering persistence.""" assert parse_operations_case_response("not json", "body") is None - assert parse_operations_case_response('[{"case_kind_code":"other"}]', "body") is None + assert ( + parse_operations_case_response('[{"case_kind_code":"other"}]', "body") is None + ) def test_rejects_duplicate_case_kinds_and_blank_evidence() -> None: """One normalized key has one grounded classification, never an empty span.""" duplicate = [ - {"case_kind_code": "repeat_issue", "summary_text": "First", "evidence_text": "body", "facts": [], "missing_fact_type_codes": ["issue_pattern", "improvement_action"]}, - {"case_kind_code": "repeat_issue", "summary_text": "Second", "evidence_text": "body", "facts": [], "missing_fact_type_codes": ["issue_pattern", "improvement_action"]}, + { + "case_kind_code": "repeat_issue", + "summary_text": "First", + "evidence_text": "body", + "facts": [], + "missing_fact_type_codes": ["issue_pattern", "improvement_action"], + "milestones": [], + "missing_milestone_type_codes": [], + }, + { + "case_kind_code": "repeat_issue", + "summary_text": "Second", + "evidence_text": "body", + "facts": [], + "missing_fact_type_codes": ["issue_pattern", "improvement_action"], + "milestones": [], + "missing_milestone_type_codes": [], + }, ] blank = [ - {"case_kind_code": "repeat_issue", "summary_text": "Blank", "evidence_text": "", "facts": [], "missing_fact_type_codes": ["issue_pattern", "improvement_action"]} + { + "case_kind_code": "repeat_issue", + "summary_text": "Blank", + "evidence_text": "", + "facts": [], + "missing_fact_type_codes": ["issue_pattern", "improvement_action"], + "milestones": [], + "missing_milestone_type_codes": [], + } ] assert parse_operations_case_response(json.dumps(duplicate), "body") is None assert parse_operations_case_response(json.dumps(blank), "body") is None @@ -51,21 +127,29 @@ def test_linked_fact_retains_its_authorized_source_post_and_input_digest() -> No """A linked specification fact is never attributed to the focal record.""" sources = ( OperationsEvidenceSource("focal", "Claim", "A claim was received."), - OperationsEvidenceSource("linked", "Specification", "Specification S2 replaced S1."), + OperationsEvidenceSource( + "linked", "Specification", "Specification S2 replaced S1." + ), ) - payload = [{ - "case_kind_code": "claim_investigation", - "summary_text": "Specification changed before the claim", - "evidence_post_id": "focal", - "evidence_text": "A claim was received.", - "facts": [{ - "fact_type_code": "specification_change", - "value_text": "S2 replaced S1", - "evidence_post_id": "linked", - "evidence_text": "Specification S2 replaced S1.", - }], - "missing_fact_type_codes": ["order", "originating_order", "sales_pool"], - }] + payload = [ + { + "case_kind_code": "claim_investigation", + "summary_text": "Specification changed before the claim", + "evidence_post_id": "focal", + "evidence_text": "A claim was received.", + "facts": [ + { + "fact_type_code": "specification_change", + "value_text": "S2 replaced S1", + "evidence_post_id": "linked", + "evidence_text": "Specification S2 replaced S1.", + } + ], + "missing_fact_type_codes": ["order", "originating_order", "sales_pool"], + "milestones": [], + "missing_milestone_type_codes": ["claim_received", "cause_confirmed"], + } + ] result = parse_operations_case_response(json.dumps(payload), sources) @@ -78,13 +162,17 @@ def test_linked_fact_retains_its_authorized_source_post_and_input_digest() -> No def test_requires_each_case_question_to_be_supported_or_explicitly_missing() -> None: """The provider cannot silently omit or both support and miss a required answer.""" - payload = [{ - "case_kind_code": "external_information", - "summary_text": "External notice", - "evidence_text": "A public notice was published.", - "facts": [], - "missing_fact_type_codes": [], - }] + payload = [ + { + "case_kind_code": "external_information", + "summary_text": "External notice", + "evidence_text": "A public notice was published.", + "facts": [], + "missing_fact_type_codes": [], + "milestones": [], + "missing_milestone_type_codes": [], + } + ] body = "A public notice was published." assert parse_operations_case_response(json.dumps(payload), body) is None @@ -102,6 +190,8 @@ def test_accepts_additional_grounded_fact_beyond_required_questions() -> None: {"fact_type_code": "discussion", "value_text": "Claim discussion", "evidence_text": "claim changed"}, ], "missing_fact_type_codes": ["order", "originating_order"], + "milestones": [], + "missing_milestone_type_codes": ["claim_received", "cause_confirmed"], }] result = parse_operations_case_response(json.dumps(payload), body) assert result is not None @@ -139,6 +229,8 @@ def test_accepts_grounded_nonrequired_fact_after_required_questions_are_complete }, ], "missing_fact_type_codes": [], + "milestones": [], + "missing_milestone_type_codes": [], }] assert parse_operations_case_response(json.dumps(payload), body) is not None @@ -158,6 +250,8 @@ def test_external_relation_requires_a_semantic_target_type() -> None: "evidence_text": body, "facts": [fact], "missing_fact_type_codes": [], + "milestones": [], + "missing_milestone_type_codes": [], }] result = parse_operations_case_response(json.dumps(payload), body) diff --git a/tests/test_operations_case_ingestion.py b/tests/test_operations_case_ingestion.py index e6df5b1b2..f24b1f3eb 100644 --- a/tests/test_operations_case_ingestion.py +++ b/tests/test_operations_case_ingestion.py @@ -1,9 +1,17 @@ """Operational case persistence tests.""" import asyncio +from datetime import UTC, datetime -from backend.app.operations_case_ingestion import persist_operations_cases, source_body_digest -from lineageweave.operations_case_analysis import OperationsCase, OperationsCaseFact +from backend.app.operations_case_ingestion import ( + persist_operations_cases, + source_body_digest, +) +from lineageweave.operations_case_analysis import ( + OperationsCase, + OperationsCaseFact, + OperationsCaseMilestone, +) class _Transaction: @@ -72,11 +80,59 @@ def test_persists_missing_required_facts_without_invented_evidence() -> None: ("order", "specification_change", "originating_order", "sales_pool"), ) - asyncio.run(persist_operations_cases(conn, "post-1", "source", "session-1", (case,))) + asyncio.run( + persist_operations_cases(conn, "post-1", "source", "session-1", (case,)) + ) + + assert conn.batches == [ + [ + ("post-1", "claim_investigation", "order"), + ("post-1", "claim_investigation", "specification_change"), + ("post-1", "claim_investigation", "originating_order"), + ("post-1", "claim_investigation", "sales_pool"), + ] + ] + + +def test_persists_observed_and_missing_milestones_separately() -> None: + """An observed source instant is never replaced by an invented endpoint.""" + conn = _Connection() + observed_at = datetime(2026, 8, 1, tzinfo=UTC) + case = OperationsCase( + "claim_investigation", + "Claim", + "source", + (), + "post-1", + "a" * 64, + ("order", "specification_change", "originating_order", "sales_pool"), + ( + OperationsCaseMilestone( + "claim_received", + "source", + "post-1", + "a" * 64, + observed_at, + "event_occurred_at", + ), + ), + ("cause_confirmed",), + ) + + asyncio.run( + persist_operations_cases(conn, "post-1", "source", "session-1", (case,)) + ) - assert conn.batches == [[ - ("post-1", "claim_investigation", "order"), - ("post-1", "claim_investigation", "specification_change"), - ("post-1", "claim_investigation", "originating_order"), - ("post-1", "claim_investigation", "sales_pool"), - ]] + assert conn.batches[-2] == [ + ( + "post-1", + "claim_investigation", + "claim_received", + "source", + "post-1", + "a" * 64, + observed_at, + "event_occurred_at", + ) + ] + assert conn.batches[-1] == [("post-1", "claim_investigation", "cause_confirmed")] diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py index 343df2b39..e5742d081 100644 --- a/tests/test_operations_dashboard.py +++ b/tests/test_operations_dashboard.py @@ -1,6 +1,6 @@ """Focused tests for the operational dashboard evidence projection.""" -from datetime import date, datetime, timezone +from datetime import UTC, date, datetime, timezone import pytest @@ -49,6 +49,29 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: "case_kind_code": "claim_investigation", "fact_type_code": "sales_pool", }] + if "operations_case_milestone milestone" in query: + return [ + { + "post_id": "00000000-0000-0000-0000-000000000001", + "case_kind_code": "claim_investigation", + "milestone_type_code": "claim_received", + "evidence_text": "The claim was received", + "evidence_post_id": "00000000-0000-0000-0000-000000000001", + "observed_at": datetime(2026, 8, 1, 9, tzinfo=timezone.utc), + "time_axis_code": "event_occurred_at", + "is_missing": False, + }, + { + "post_id": "00000000-0000-0000-0000-000000000001", + "case_kind_code": "claim_investigation", + "milestone_type_code": "cause_confirmed", + "evidence_text": "The cause was confirmed", + "evidence_post_id": "00000000-0000-0000-0000-000000000002", + "observed_at": datetime(2026, 8, 3, 12, 30, tzinfo=timezone.utc), + "time_axis_code": "created_at", + "is_missing": False, + }, + ] if "from topic_post_context_influence influence" in query: return [] return [ @@ -140,11 +163,61 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: "missing_facts": [ {"fact_type_code": "sales_pool", "fact_type_label": "수주 Pool"} ], + "milestones": [ + { + "milestone_type_code": "claim_received", + "milestone_type_label": "클레임 접수", + "evidence_text": "The claim was received", + "evidence_post_id": "00000000-0000-0000-0000-000000000001", + "observed_at": "2026-08-01T09:00:00+00:00", + "time_axis_code": "event_occurred_at", + "time_axis_label": "Event 발생일", + }, + { + "milestone_type_code": "cause_confirmed", + "milestone_type_label": "원인 확정", + "evidence_text": "The cause was confirmed", + "evidence_post_id": "00000000-0000-0000-0000-000000000002", + "observed_at": "2026-08-03T12:30:00+00:00", + "time_axis_code": "created_at", + "time_axis_label": "기록 생성일", + }, + ], + "lifecycles": [ + { + "lifecycle_kind_code": "claim_investigation", + "lifecycle_kind_label": "클레임 원인 규명", + "status_code": "resolved", + "status_label": "종료 확인", + "started_at": "2026-08-01T09:00:00+00:00", + "resolved_at": "2026-08-03T12:30:00+00:00", + "elapsed_seconds": 185400, + "start_milestone": { + "milestone_type_code": "claim_received", + "milestone_type_label": "클레임 접수", + "evidence_text": "The claim was received", + "evidence_post_id": "00000000-0000-0000-0000-000000000001", + "observed_at": "2026-08-01T09:00:00+00:00", + "time_axis_code": "event_occurred_at", + "time_axis_label": "Event 발생일", + }, + "end_milestone": { + "milestone_type_code": "cause_confirmed", + "milestone_type_label": "원인 확정", + "evidence_text": "The cause was confirmed", + "evidence_post_id": "00000000-0000-0000-0000-000000000002", + "observed_at": "2026-08-03T12:30:00+00:00", + "time_axis_code": "created_at", + "time_axis_label": "기록 생성일", + }, + "next_action_text": "시작·종료 Event 근거를 열어 경과 시간을 검토하세요.", + } + ], } ] assert result["topic_context"]["status_code"] == "unavailable" assert result["topic_context"]["reason_code"] == "tepp_topic_posterior_not_persisted" - assert len(conn.queries) == 6 + assert len(conn.queries) == 7 for query, args in conn.queries: assert "visibility_code = 'public'" in query assert "corporate_entity_id::text = any($1::text[])" in query @@ -158,7 +231,18 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: ) case_query = conn.queries[1][0] assert "order by primary_mention.confidence desc" in case_query - assert "coalesce(nullif(btrim(post.source_project_name), ''), project.primary_project_name)" in case_query + assert ( + "coalesce(nullif(btrim(post.source_project_name), ''), project.primary_project_name)" + in case_query + ) + for evidence_query in ( + conn.queries[0][0], + conn.queries[1][0], + conn.queries[2][0], + conn.queries[4][0], + ): + assert "join source_post evidence_post" in evidence_query + assert "evidence_post.corporate_entity_id::text = any($1::text[])" in evidence_query @pytest.mark.anyio @@ -278,7 +362,13 @@ async def fetchrow(self, query: str, *args: object) -> dict[str, int]: "fast_mlsirm_influence_persisted": False, } return dict.fromkeys( - ("total_post_count", "total_event_count", "external_post_count", "pending_analysis_count", "failed_analysis_count"), + ( + "total_post_count", + "total_event_count", + "external_post_count", + "pending_analysis_count", + "failed_analysis_count", + ), 0, ) @@ -313,6 +403,8 @@ async def fetchrow(self, query: str, *args: object) -> dict[str, object]: async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: self.queries.append((query, args)) + if "operations_case_milestone milestone" in query: + return [] if "from topic_post_context_influence influence" in query: return [] if "operations_case_fact fact" in query: diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py index 7bd661b44..46f9fa02d 100644 --- a/tests/test_post_content_worker.py +++ b/tests/test_post_content_worker.py @@ -4,6 +4,7 @@ import asyncio from contextlib import asynccontextmanager +from datetime import UTC, datetime from types import SimpleNamespace from backend.app import post_content_worker @@ -109,6 +110,39 @@ async def gather(_conn, _post_id, can_see, _vision): assert decisions == [True, False, False, True] +def test_operations_sources_bind_milestones_to_source_owned_clocks(monkeypatch) -> None: + """The source row, not model output, supplies each milestone instant.""" + observed_at = datetime(2026, 8, 1, 9, tzinfo=UTC) + + async def gather(*_args): + return [SimpleNamespace( + post_id="00000000-0000-0000-0000-000000000001", + post_title="Synthetic claim", + post_body="A claim was received.", + evidence_facts=(), + )] + + class SourceConnection(_Connection): + async def fetch(self, query: str, *_args: object): + assert "coalesce(event_occurred_at, created_at) as observed_at" in query + return [{ + "post_id": "00000000-0000-0000-0000-000000000001", + "event_occurred_at": observed_at, + "observed_at": observed_at, + }] + + monkeypatch.setattr(post_content_worker, "gather_chat_sources", gather) + sources = asyncio.run(post_content_worker._operations_evidence_sources( + _Pool(SourceConnection()), + "00000000-0000-0000-0000-000000000001", + {"corporate_entity_id": "corp", "process_unit_id": "pu"}, + SimpleNamespace(available=False), + )) + + assert sources[0].observed_at == observed_at + assert sources[0].time_axis_code == "event_occurred_at" + + def test_terminal_failed_job_ignores_a_stale_duplicate_wakeup() -> None: connection = _Connection(_row(FAILED, POST_CONTENT_MAX_ATTEMPTS)) diff --git a/tests/test_schema.py b/tests/test_schema.py index 5f7c730aa..0474ac74d 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -26,12 +26,16 @@ _ADMIN_DSN = os.environ.get( "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" ) -_MIGRATION_PATH = Path(__file__).resolve().parents[1] / "migrations" / "0001_initial_schema.sql" +_MIGRATION_PATH = ( + Path(__file__).resolve().parents[1] / "migrations" / "0001_initial_schema.sql" +) _MAJOR_EVENT_ACTION_MIGRATION = ( Path(__file__).resolve().parents[1] / "migrations" / "0100_major_event_action.sql" ) _PROJECT_MENTION_MIGRATION = ( - Path(__file__).resolve().parents[1] / "migrations" / "0031_semantic_project_mentions.sql" + Path(__file__).resolve().parents[1] + / "migrations" + / "0031_semantic_project_mentions.sql" ) _PROJECT_BOUND_ACTION_MIGRATION = ( Path(__file__).resolve().parents[1] @@ -74,7 +78,9 @@ / "0169_report_leftover_map_axis.sql" ) _CHANNEL_EVIDENCE_MIGRATION = ( - Path(__file__).resolve().parents[1] / "migrations" / "0174_post_lineage_edge_signal.sql" + Path(__file__).resolve().parents[1] + / "migrations" + / "0174_post_lineage_edge_signal.sql" ) _LEFTOVER_MAP_COVERAGE_MIGRATION = ( Path(__file__).resolve().parents[1] @@ -87,13 +93,24 @@ / "0182_report_leftover_map_unexplained.sql" ) _OPERATIONS_CASE_MIGRATION = ( - Path(__file__).resolve().parents[1] / "migrations" / "0208_operations_case_analysis.sql" + Path(__file__).resolve().parents[1] + / "migrations" + / "0208_operations_case_analysis.sql" ) _OPERATIONS_CASE_EVIDENCE_MIGRATION = ( - Path(__file__).resolve().parents[1] / "migrations" / "0209_operations_case_evidence_source.sql" + Path(__file__).resolve().parents[1] + / "migrations" + / "0209_operations_case_evidence_source.sql" ) _OPERATIONS_CASE_MISSING_MIGRATION = ( - Path(__file__).resolve().parents[1] / "migrations" / "0211_operations_case_missing_fact.sql" + Path(__file__).resolve().parents[1] + / "migrations" + / "0211_operations_case_missing_fact.sql" +) +_OPERATIONS_CASE_MILESTONE_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0215_operations_case_milestone.sql" ) _ANALYSIS_RUN_REGISTRY_MIGRATION = ( Path(__file__).resolve().parents[1] / "migrations" / "0018_analysis_run_registry.sql"