diff --git a/CHANGELOG.md b/CHANGELOG.md index 641306055..47f457c87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project are documented here. Format follows ### Added +- An evidence-bound project-history projection and post-detail timeline + (ADR 0243 / #280) reuse normalized project, responsibility, and Event + Lineage rows under RBAC/ABAC and cutoff filtering. Free text never creates a + lifecycle fact; unsupported authoritative ingestion remains issue #284. - Persist explicit paragraph, list, table, MathML formula, and caller-parsed conversation-turn semantic-unit kinds without inferring absent boundaries. - Event Lineage now persists each reconstructed connection's independent diff --git a/backend/app/main.py b/backend/app/main.py index 6457bbde1..101bf77e9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -147,6 +147,10 @@ fetch_post_evaluation, ingest_post_evaluation, ) +from backend.app.project_history import ( + ProjectHistoryNotFound, + fetch_project_history_projection, +) from backend.app.post_summary_ingestion import ( fetch_persisted_summary, persist_post_summary, @@ -2275,6 +2279,34 @@ async def read_ontology_neighborhood( return payload +@app.get("/api/projects/{project_key}/history") +async def read_project_history( + project_key: str, + focus_post_id: UUID | None = Query(None), + knowledge_cutoff: str | None = Query(None), + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Return one authorization-bounded project-history evidence projection.""" + + _require_post_read(account) + try: + cutoff = parse_as_of_clock(knowledge_cutoff) if knowledge_cutoff else datetime.now(timezone.utc) + async with pool.acquire() as conn: + return await fetch_project_history_projection( + conn, + project_key=project_key, + focus_post_id=str(focus_post_id) if focus_post_id else None, + knowledge_cutoff=cutoff, + corporate_entity_ids=sorted(account.corporate_entity_ids), + process_unit_ids=sorted(account.process_unit_ids), + ) + except ValueError as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc + except ProjectHistoryNotFound: + raise HTTPException(status.HTTP_404_NOT_FOUND, "project history not found") from None + + @app.get("/api/posts/{post_id}/counterparties") async def read_post_counterparties( post_id: str, diff --git a/backend/app/project_history.py b/backend/app/project_history.py new file mode 100644 index 000000000..c7252f57d --- /dev/null +++ b/backend/app/project_history.py @@ -0,0 +1,238 @@ +"""ABAC-safe PostgreSQL projection for customer-facing project histories.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import Any, Protocol + +from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL +from lineageweave.project_history import build_project_history_projection, normalize_project_key + +PROJECT_HISTORY_DEFAULT_LIMIT = 64 +PROJECT_HISTORY_MAXIMUM_LIMIT = 128 + + +class ProjectHistoryConnection(Protocol): + """Minimal asynchronous query port required by this repository.""" + + async def fetch(self, query: str, *args: object) -> Sequence[Mapping[str, Any]]: + """Execute a bounded read query and return mapping-like rows.""" + + pass + + +_ELIGIBILITY = SOURCE_POST_ELIGIBILITY_SQL.format(alias="post") +_PROJECT_MATCH = """ +( + lower(btrim(normalize(coalesce(post.source_project_code, ''), NFKC))) = $1 + or lower(btrim(normalize(coalesce(post.source_project_name, ''), NFKC))) = $1 + or exists ( + select 1 + from post_project_mention mention + where mention.post_id = post.post_id + and ( + lower(btrim(normalize(mention.project_key, NFKC))) = $1 + or lower(btrim(normalize(mention.project_name, NFKC))) = $1 + ) + ) +) +""" +_EVENT_SQL = f""" +select post.post_id, + post.post_title, + post.created_at, + post.event_occurred_at, + post.voc_type_code, + post.source_stage_code, + post.source_detail_state_code + from source_post post + where (post.visibility_code = 'public' + or (post.corporate_entity_id::text = any($2::text[]) + and (cardinality($3::text[]) = 0 + or post.process_unit_id::text = any($3::text[])))) + and {_ELIGIBILITY} + and post.created_at <= $4 + and {_PROJECT_MATCH} + order by coalesce(post.event_occurred_at, post.created_at), post.created_at, post.post_id + limit $5 +""" +_FOCUS_SQL = f""" +select post.post_id, + post.post_title, + post.created_at, + post.event_occurred_at, + post.voc_type_code, + post.source_stage_code, + post.source_detail_state_code + from source_post post + where (post.visibility_code = 'public' + or (post.corporate_entity_id::text = any($2::text[]) + and (cardinality($3::text[]) = 0 + or post.process_unit_id::text = any($3::text[])))) + and {_ELIGIBILITY} + and post.created_at <= $4 + and post.post_id = $5::uuid + and {_PROJECT_MATCH} + limit 1 +""" +_MATCH_SQL = """ +select post.post_id, + 'source_project_code'::text as match_kind_code, + post.source_project_code as matched_value, + null::numeric as confidence, + null::text as ontology_iri, + 'source_post.source_project_code'::text as provenance + from source_post post + where post.post_id = any($1::uuid[]) + and lower(btrim(normalize(coalesce(post.source_project_code, ''), NFKC))) = $2 +union all +select post.post_id, + 'source_project_name'::text, + post.source_project_name, + null::numeric, + null::text, + 'source_post.source_project_name'::text + from source_post post + where post.post_id = any($1::uuid[]) + and lower(btrim(normalize(coalesce(post.source_project_name, ''), NFKC))) = $2 +union all +select mention.post_id, + 'semantic_project_key'::text, + mention.project_key, + mention.confidence, + mention.ontology_iri, + 'post_project_mention.project_key'::text + from post_project_mention mention + where mention.post_id = any($1::uuid[]) + and lower(btrim(normalize(mention.project_key, NFKC))) = $2 +union all +select mention.post_id, + 'semantic_project_name'::text, + mention.project_name, + mention.confidence, + mention.ontology_iri, + 'post_project_mention.project_name'::text + from post_project_mention mention + where mention.post_id = any($1::uuid[]) + and lower(btrim(normalize(mention.project_name, NFKC))) = $2 +order by post_id, match_kind_code, matched_value +""" +_ROLE_SQL = """ +select role.post_id, + role.actor_name, + role.responsibility, + role.actor_type_code, + role.affiliated_organization_name, + role.cataloged_person_id, + role.cataloged_team_id, + role.cataloged_corporate_entity_id + from post_summary_role role + where role.post_id = any($1::uuid[]) + order by role.post_id, role.actor_type_code, role.actor_name, role.responsibility +""" +_EDGE_SQL = """ +select edge.parent_post_id, edge.child_post_id, edge.fused_score + from post_lineage_edge edge + where edge.parent_post_id = any($1::uuid[]) + and edge.child_post_id = any($1::uuid[]) + order by edge.child_post_id, edge.parent_post_id +""" + + +class ProjectHistoryNotFound(LookupError): + """No authorized project history matched the requested identity.""" + + +async def fetch_project_history_projection( + conn: ProjectHistoryConnection, + *, + project_key: str, + focus_post_id: str | None, + knowledge_cutoff: datetime, + corporate_entity_ids: Sequence[str], + process_unit_ids: Sequence[str], + limit: int = PROJECT_HISTORY_DEFAULT_LIMIT, +) -> dict[str, Any]: + """Return a bounded project history from authorized PostgreSQL evidence. + + The query applies source eligibility, cutoff, and ABAC before selecting + event IDs. All subsequent match, role, and lineage reads are constrained to + that visible ID set, so hidden rows cannot affect counts, transitions, or + prior-history paths. An authorized focus event remains in a truncated + projection even when it falls beyond the earliest page. + """ + + if limit < 1 or limit > PROJECT_HISTORY_MAXIMUM_LIMIT: + raise ValueError("project history limit is outside the supported bound") + normalized_key = normalize_project_key(project_key) + rows = list( + await conn.fetch( + _EVENT_SQL, + normalized_key, + list(corporate_entity_ids), + list(process_unit_ids), + knowledge_cutoff, + limit + 1, + ) + ) + truncated = len(rows) > limit + event_rows = rows[:limit] + transition_suppressed_event_ids: set[str] = set() + if not event_rows: + raise ProjectHistoryNotFound(project_key) + visible_ids = [str(row["post_id"]) for row in event_rows] + if focus_post_id is not None and focus_post_id not in set(visible_ids): + focus_rows = list( + await conn.fetch( + _FOCUS_SQL, + normalized_key, + list(corporate_entity_ids), + list(process_unit_ids), + knowledge_cutoff, + focus_post_id, + ) + ) + if not focus_rows: + raise ProjectHistoryNotFound(project_key) + truncated = True + event_rows = (event_rows[: limit - 1] if limit > 1 else []) + [focus_rows[0]] + transition_suppressed_event_ids.add(str(focus_rows[0]["post_id"])) + event_rows.sort( + key=lambda row: ( + row.get("event_occurred_at") or row["created_at"], + row["created_at"], + str(row["post_id"]), + ) + ) + visible_ids = [str(row["post_id"]) for row in event_rows] + + match_rows, role_rows, edge_rows = await _fetch_project_children( + conn, + visible_ids=visible_ids, + normalized_key=normalized_key, + ) + return build_project_history_projection( + project_key=project_key, + focus_event_id=focus_post_id, + event_rows=event_rows, + match_rows=match_rows, + role_rows=role_rows, + edge_rows=edge_rows, + truncated=truncated, + transition_suppressed_event_ids=transition_suppressed_event_ids, + ) + + +async def _fetch_project_children( + conn: ProjectHistoryConnection, + *, + visible_ids: Sequence[str], + normalized_key: str, +) -> tuple[list[Mapping[str, Any]], list[Mapping[str, Any]], list[Mapping[str, Any]]]: + """Fetch only child evidence whose endpoints are already authorized.""" + + matches = list(await conn.fetch(_MATCH_SQL, list(visible_ids), normalized_key)) + roles = list(await conn.fetch(_ROLE_SQL, list(visible_ids))) + edges = list(await conn.fetch(_EDGE_SQL, list(visible_ids))) + return matches, roles, edges diff --git a/docs/adr/0243-evidence-bound-project-history-projection.md b/docs/adr/0243-evidence-bound-project-history-projection.md new file mode 100644 index 000000000..295bc5e85 --- /dev/null +++ b/docs/adr/0243-evidence-bound-project-history-projection.md @@ -0,0 +1,57 @@ +# ADR 0243: Evidence-bound project history projection + +- Status: Accepted +- Date: 2026-08-26 +- Issues: #280, #284 +- Figma file ID: `SBpgot7uTvMxEaxUwvoc0S` + +## Context + +The PRD requires an operations analyst to find a project and inspect cited +evidence. Project evidence already exists in normalized `source_post`, +`post_project_mention`, `post_summary_role`, and `post_lineage_edge` rows. A +second project-history ledger would duplicate truth. Free-text lifecycle +classification would also turn words into unsupported business facts. + +## Decision + +`GET /api/projects/{project_key}/history` returns a read-only projection over +those existing rows. RBAC, corporate-entity scope, process-unit scope, source +eligibility, and knowledge cutoff are applied before child evidence is read. +Project identity uses exact NFKC-normalized source or semantic evidence; no +fuzzy match is allowed. + +The existing post-detail popup hosts the shared timeline; there is no new +navigation destination. Controlled VOC codes may label VOC evidence. Other +records remain `source_recorded`; source stage and detail-state codes are shown +without inferred lifecycle meaning. Adjacent responsibility rows describe +document evidence only. Persisted Event Lineage paths are labelled related and +non-causal. Dates use `source_post.event_occurred_at` when recorded and disclose +`source_post.created_at` as the fallback clock. + +Responsibility change is shown only when two displayed records are adjacent in +the authorized source ordering. If truncation retains a focus record but omits +intermediate records, that focus record has no responsibility-transition code; +the projection must not imply a direct handover or continuity across the gap. + +The projection is bounded and declares truncation. A missing or unauthorized +project is indistinguishable as HTTP 404. The Figma identifier records the +design authority; Storybook remains the executable state inventory. + +## Consequences + +- Users can move from one permitted post to project-wide evidence without a + duplicate store or invented handover interval. +- Issue #280 is satisfied only after protected-main API, UI, Storybook, and + screenshot evidence exists. +- Issue #284 remains open until an owned source adapter supplies authoritative, + versioned lifecycle events and idempotent reconciliation. This projection + must not impersonate that future write boundary. + +## References + +W3C. (2013). *PROV-O: The PROV ontology*. World Wide Web Consortium. +https://www.w3.org/TR/2013/REC-prov-o-20130430/ + +W3C. (2024). *Web Content Accessibility Guidelines (WCAG) 2.2*. World Wide Web +Consortium. https://www.w3.org/TR/WCAG22/ diff --git a/docs/adr/README.md b/docs/adr/README.md index 83e56345c..106632e6c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -17,6 +17,7 @@ decision from them. | [`ONTOLOGY_NAMESPACE_INVENTORY.md`](../doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md) | [0207](0207-repository-case-ontology-namespace-canonical.md), [0157](0157-public-ontology-namespace-identity.md) | | [`image-content-schema.md`](../image-content-schema.md) | [0066](0066-position-preserving-image-content.md) | | [`storybook-inventory.md`](../storybook-inventory.md) | [0118](0118-uiux-standard-guide-v3-design-overhaul.md), [0184](0184-ontology-provenance-explorer.md), [0222](0222-project-nodes-in-ontology-neighborhood.md) | +| Project-history timeline | [0243](0243-evidence-bound-project-history-projection.md) | | [`POSTGRESQL_CONCURRENCY_REFERENCES.md`](../doctoring/POSTGRESQL_CONCURRENCY_REFERENCES.md) | [0204](0204-analysis-run-short-transaction-delivery.md), [0213](0213-global-ask-embedding-pool-release.md) | | [`GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md`](../doctoring/GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md) | [0215](0215-global-ask-public-claim-verification.md) | | [`GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md`](../doctoring/GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md) | [0216](0216-global-ask-knowledge-cutoff.md) | diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 7704fa748..c6417633a 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,6 +1,6 @@ # Product & Technical Gap Baseline -> Dashboard delivery snapshot: 2026-08-26 07:15 KST. Protected `main` was +> Dashboard delivery snapshot: 2026-08-26 07:45 KST. Protected `main` was > `494b54e2245040bcf02b45376f221c37cd437e76`. This local branch is not > protected-main release evidence. @@ -11,7 +11,7 @@ | 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 implementation; 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 implementation; corpus backfill pending | | External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate implementation; no separate Board by product decision | -| Project-specific journey | Explicit source/semantic project membership plus event-time ordering | Candidate API and ordered journey UI implemented; authenticated runtime acceptance pending | +| Project-specific journey | ADR 0243; exact source/semantic project membership, source-post clock disclosure, responsibility evidence, and non-causal Event Lineage paths | PR #668 candidate API and post-detail timeline; exact-head review, protected merge, and 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 | | Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback; authenticated runtime acceptance pending | | Similar VOC, customer cohort, prior action | Persisted repeat-issue candidate semantics plus orchestrator pair adjudication and extractive evidence | Candidate live post endpoint and post-detail UI implemented; authenticated runtime acceptance pending | @@ -60,14 +60,14 @@ only aggregate, non-identifying evidence to this repository. ### Exact open-PR boundary -At this snapshot there were 11 open PRs and 10 open issues. PRs #660 and #659 -merged to protected `main`; PR #666 remains only non-default-branch stack -composition inside #663. Every remaining open head required refreshed hosted -gates and/or independent review after the base changed. These observations are -not merge readiness. Re-fetch exact heads, unresolved threads, checks, -approvals, rulesets, and merge SHA before any lifecycle claim. +At this snapshot there were 12 open PRs and 10 open issues. The exact-head +inventory in section 1 is authoritative for this snapshot. Every open head +remained blocked on hosted gates and/or independent review. These observations +are not merge readiness. Re-fetch exact heads, +unresolved threads, checks, approvals, rulesets, and merge SHA before any +lifecycle claim. -> Audit snapshot: 2026-08-26 07:15 KST (refreshed by the autonomous merge +> Audit snapshot: 2026-08-26 07:45 KST (refreshed by the autonomous merge > loop). This repository records synthetic fixtures and aggregate, > non-identifying runtime evidence only. Open PRs and local checks are not > protected-default-branch release evidence. Identifying post identifiers, @@ -77,24 +77,23 @@ approvals, rulesets, and merge SHA before any lifecycle claim. ## 1. Exact-head and governance evidence The protected default branch was `494b54e2245040bcf02b45376f221c37cd437e76` -when this baseline was refreshed. The live queue contained 11 open PRs and 10 -open issues. The exact-head inventory below supersedes older per-PR snapshots -elsewhere in this document; those older rows remain useful historical delivery -context only. +when this baseline was refreshed. The live queue contained 12 open PRs and 10 +open issues in the newer 07:45 KST snapshot whose inventory is retained below. | PR | Exact observed head | Merge/check state at this snapshot | | ---: | --- | --- | -| #667 | `3bc662d7` | refreshes protected-main and open-queue documentation evidence; base conflict remains to be repaired | -| #663 | `6fd2f701` | combined Project ontology candidate plus #666's non-default-branch removal of sampled region-coverage arithmetic; base conflict remains to be repaired | -| #658 | `f007a5ed` | evidence-honest Global Ask cutoff; hosted checks and independent review required | -| #657 | `2d9b43b7` | TEPP asynchronous lifecycle persistence while unpublished producer work stays unavailable; hosted checks and independent review required | -| #644 | `ed8d97f3` | native frontend surface code splitting; hosted checks and independent review required | -| #643 | `7fb4d18c` | shared token-backed status notice; hosted checks and independent review required | -| #640 | `2d50fa01` | dashboard case metrics and project journeys; base conflict remains to be repaired | -| #639 | `48065ad1` | restores Running action and Compose contracts; hosted checks and independent review required | -| #632 | `29aee18d` | graph-fact provenance, public verification, MCP admission, and k6 evidence; hosted checks and independent review required | -| #631 | `665046dc` (observed parent) | decomposes closed PR #490; this merge refresh advances its head and restarts hosted review evidence | -| #629 | `0138db5f` | provider-work release and bounded landing reads refreshed onto protected `main`; hosted checks and independent review restarted | +| #668 | `5ef8db83` | ADR 0243 evidence-bound project-history API and post-detail timeline; exact-head checks/review required | +| #667 | `3bf57fed` | gap-baseline refresh; exact-head checks/review required | +| #663 | `d5edd2b9` | project ontology traversal plus cutoff/snapshot-frozen project focus and labels; exact-head checks/review required | +| #658 | `fe830b0a` | evidence-honest Global Ask cutoff with revision-interval live-after semantics; exact-head checks/review required | +| #657 | `a59a2023` | TEPP asynchronous lifecycle evidence; exact-head checks/review required | +| #644 | `ed8d97f3` | native-surface code splitting with modal-focus regression coverage; exact-head checks/review required | +| #643 | `7fb4d18c` | accessible status-notice surfaces; exact-head checks/review required | +| #640 | `361641ec` | operations-dashboard contract alignment; exact-head checks/review required | +| #639 | `8da485d3` | exact-head checks/review required | +| #632 | `cad4debf` | active semantic provenance repair head; exact-head checks/review required | +| #631 | `e6b4f0c4` | documentation decomposition; exact-head checks/review required | +| #629 | `48496ff6` | provider-work release and bounded landing reads; exact-head checks/review required | No row above is merge evidence. Immediately before any lifecycle action, re-fetch the head, unresolved threads, formal reviews, rulesets, and same-head @@ -349,12 +348,20 @@ this file per §3.5 of the prior snapshot). | #79 | Milestone 2: port verified direct-PostgreSQL analysis into the protected architecture | analysis-run registry on `main`; remaining runtime bridge | | #87 | Milestone 2.1 normalized runtime-analysis schema bridge | related analysis-run work | | #269 | Authenticated Global Ask MCP browser-safe and admission-bounded | Ask stack | -| #271 | Evidence-honest knowledge-cutoff scope on Global Ask | #658; still open and not protected-main evidence | -| #272 | Verify Global Ask KG/ontology/semantic claims with public SearXNG evidence | #632 preserves internal provenance; public verification acceptance remains open | -| #277 | TEPP: persist accepted receipts, poll completed results, keep measurement authority distinct | #657 consumer lifecycle; executable producer route remains unavailable | -| #280 | Full project-lifecycle history and handover intervals | #640 adds case/project journeys and #663 adds evidence-backed Project exploration; authoritative lifecycle reconciliation remains #284 | -| #284 | Authoritative lifecycle ingestion and idempotent reconciliation | No active delivery PR confirmed | -| #338 | Evidence-bounded email/project lineage contract for Naruon consumption | Missing on protected `main`; #343 merged only into a non-default stack, while #355 is a distinct calendar-consumer contract and is not delivery evidence for email/project lineage | +| #271 | Evidence-honest knowledge-cutoff scope on Global Ask | Ask stack | +| #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 | Evidence-bound project history with responsibility changes and related prior paths | #668; read projection only, exact-head protected delivery pending | +| #284 | Authoritative lifecycle ingestion and idempotent reconciliation | No owned source-adapter contract or active delivery PR; ADR 0243 forbids the read projection from inventing this authority | +| #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` | +| #338 | Evidence-bounded email/project lineage contract for Naruon consumption | #355 | +| #341 | Heterogeneous ontology and provenance explorer separate from Event Lineage | Protected `main` via #349; issue closed | +| #358 | Batch reauthorize persisted post-Ask evidence without N+1 queries | Ask stack | +| #359 | Centralize Global Ask session storage access | Ask stack | +| #363 | Continue ontology neighborhoods beyond the bounded source window | Protected `main` via #349; issue closed | +| #372 | Reconcile lowercase and repository-case public namespace IRIs | Protected `main` via #616; issue closed, with term-kind hardening on #618 | | #611 | Decompose closed PR #490 ADR 0133–0137 evidence without transferring stale branch state | #631 supplies the current-main inventory only; focused implementation PRs and tests for every unmet criterion are still required | ## 5. Open product and technical gaps diff --git a/docs/screenshots/project-history-time-source-desktop.png b/docs/screenshots/project-history-time-source-desktop.png new file mode 100644 index 000000000..63a670cdc Binary files /dev/null and b/docs/screenshots/project-history-time-source-desktop.png differ diff --git a/docs/screenshots/project-history-time-source-mobile.png b/docs/screenshots/project-history-time-source-mobile.png new file mode 100644 index 000000000..ed52ca62e Binary files /dev/null and b/docs/screenshots/project-history-time-source-mobile.png differ diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 81ab3a8af..67ab2562b 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -34,3 +34,12 @@ https://storybook.js.org/docs/get-started/frameworks/react-vite World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines (WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/ +# Project history timeline + +`Projects/History Timeline` covers the evidence-bearing default +timeline and its exact-value table. Keyboard roving focus, the current event, +responsibility-evidence gaps, non-causal lineage paths, and source-record +actions are executable component-test states governed by ADR 0243. The +1440×1000 and 390×844 audits are retained in +`docs/screenshots/project-history-time-source-{desktop,mobile}.png`; both show +the customer-readable time source without exposing the stored basis code. diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 2dee4513d..f53507185 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -109,6 +109,7 @@ describe("App, authenticated", () => { pluralAffiliations?: boolean; deferMe?: boolean; deferPostOne?: boolean; + deferProjectHistory?: boolean; meFailed?: boolean; postBody?: string; manyCustomerHints?: number; @@ -121,7 +122,11 @@ describe("App, authenticated", () => { askImageCitation?: boolean; askDelivery?: boolean; lineageIsolationReason?: "comparison_candidates_available" | "no_comparison_group"; - }): ReturnType & { releaseMe: () => void; releasePostOne: () => void } { + }): ReturnType & { + releaseMe: () => void; + releasePostOne: () => void; + releaseProjectHistory: () => void; + } { const statusLabel: Record = { open: "Open", in_progress: "In progress", @@ -162,6 +167,13 @@ describe("App, authenticated", () => { }) : Promise.resolve(); + let releaseProjectHistory = () => {}; + const projectHistoryReady = options?.deferProjectHistory + ? new Promise((resolve) => { + releaseProjectHistory = resolve; + }) + : Promise.resolve(); + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); const method = init?.method ?? "GET"; @@ -1130,6 +1142,23 @@ describe("App, authenticated", () => { }), ); } + const projectHistoryUrl = new URL(url, "https://backend.test"); + if (projectHistoryUrl.pathname === "/api/projects/source-project/history") { + return projectHistoryReady.then(() => + jsonResponse({ + contract_version: 1, + project_key: "source-project", + normalized_project_key: "source-project", + project_name: "Semantic project", + focus_event_id: "post-1", + time_basis_code: "document_time", + event_count: 0, + distinct_observed_actor_count: 0, + truncated: false, + events: [], + }), + ); + } const postsUrl = new URL(url, "https://backend.test"); if (postsUrl.pathname === "/api/posts") { return Promise.resolve( @@ -1945,9 +1974,28 @@ describe("App, authenticated", () => { return Promise.reject(new Error(`unexpected fetch: ${method} ${url}`)); }); vi.stubGlobal("fetch", fetchMock); - return Object.assign(fetchMock, { releaseMe, releasePostOne }); + return Object.assign(fetchMock, { releaseMe, releasePostOne, releaseProjectHistory }); } + it("announces the next step while project history is loading", async () => { + const backend = stubBackend({ deferProjectHistory: true }); + setLocale("ko"); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "글 보기: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "프로젝트 이력 열기: source-project" })); + + expect(screen.getByText("프로젝트 이력을 불러오는 중입니다. 표시되면 타임라인을 확인하세요.")).toHaveAttribute( + "role", + "status", + ); + + backend.releaseProjectHistory(); + await waitFor(() => + expect(screen.queryByText("프로젝트 이력을 불러오는 중입니다. 표시되면 타임라인을 확인하세요.")).not.toBeInTheDocument(), + ); + }); + it("renders safe Ask Agent evidence under each cited post", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fbba1d9f2..44ca1a4c6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -33,6 +33,7 @@ import { fetchPostKeymen, fetchPostLineage, fetchPostFiveW1H, + fetchProjectHistory, fetchPostSummary, fetchPostTickets, fetchPostVocEvidence, @@ -88,6 +89,9 @@ import { fetchTenantConfig, } from "./api"; import { CitationChip } from "./components/CitationChip"; +import { ProjectHistoryTimeline } from "./components/ProjectHistoryTimeline"; +import { projectHistoryKeys } from "./projectHistory"; +import type { ProjectHistoryProjection } from "./projectHistory"; import { OrganizationAliasChip } from "./components/OrganizationAliasChip"; import { organizationAliasCaption } from "./components/organizationAliasCaption"; import { CutoffKnownBody } from "./components/CutoffKnownBody"; @@ -1829,9 +1833,30 @@ function PostDetailPopup({ const [focusPerson, setFocusPerson] = useState<{ personId: string; personName: string } | null>(null); const [focusEntity, setFocusEntity] = useState<{ entityId: string; entityName: string } | null>(null); const [focusTeam, setFocusTeam] = useState<{ teamId: string; teamName: string } | null>(null); + const [projectHistory, setProjectHistory] = useState(null); + const [projectHistoryError, setProjectHistoryError] = useState(null); + const [projectHistoryLoading, setProjectHistoryLoading] = useState(false); + const projectHistoryRequestRef = useRef(0); const contentReloadRef = useRef<() => void>(() => undefined); const dialogRef = useRef(null); + async function openProjectHistory(projectKey: string) { + const requestId = projectHistoryRequestRef.current + 1; + projectHistoryRequestRef.current = requestId; + const requestedPostId = postId; + setProjectHistory(null); + setProjectHistoryError(null); + setProjectHistoryLoading(true); + try { + const projection = await fetchProjectHistory(accessToken, projectKey, requestedPostId, knowledgeCutoff); + if (projectHistoryRequestRef.current === requestId) setProjectHistory(projection); + } catch (historyError) { + if (projectHistoryRequestRef.current === requestId) setProjectHistoryError(String(historyError)); + } finally { + if (projectHistoryRequestRef.current === requestId) setProjectHistoryLoading(false); + } + } + useEffect(() => { const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null; return () => { @@ -1841,7 +1866,11 @@ function PostDetailPopup({ useEffect(() => { dialogRef.current?.focus(); - }, [postId]); + setProjectHistory(null); + setProjectHistoryError(null); + setProjectHistoryLoading(false); + projectHistoryRequestRef.current += 1; + }, [postId, knowledgeCutoff]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -2323,6 +2352,38 @@ function PostDetailPopup({ ) : null} + {(post.project_evidence?.length || post.source_project_code || post.source_project_name) ? ( +
+

{t("Project history")}

+
+ {projectHistoryKeys( + post.project_evidence, + post.source_project_code, + post.source_project_name, + ) + .map((projectKey) => ( + + ))} +
+ {projectHistoryError ?

{projectHistoryError}

: null} + {projectHistoryLoading ? ( +

{t("Loading project history. Review the timeline when it appears.")}

+ ) : null} + {projectHistory ? ( + onSelectPost?.(sourcePostId)} + /> + ) : null} +
+ ) : null} +

{t("Summary")}

{summary ? ( diff --git a/frontend/src/api.ts b/frontend/src/api.ts index fca5882d0..acbf9c3e9 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -495,6 +495,20 @@ export class BackendError extends Error { } } +export function fetchProjectHistory( + accessToken: string, + projectKey: string, + focusPostId: string, + knowledgeCutoff?: string | null, +): Promise { + const query = new URLSearchParams({ focus_post_id: focusPostId }); + if (knowledgeCutoff) query.set("knowledge_cutoff", knowledgeCutoff); + return backendFetch( + `/api/projects/${encodeURIComponent(projectKey)}/history?${query.toString()}`, + accessToken, + ); +} + async function backendFetch( path: string, accessToken: string, diff --git a/frontend/src/components/ProjectHistoryTimeline.css b/frontend/src/components/ProjectHistoryTimeline.css new file mode 100644 index 000000000..4ecd339d3 --- /dev/null +++ b/frontend/src/components/ProjectHistoryTimeline.css @@ -0,0 +1,241 @@ +.project-history { + display: grid; + gap: 1rem; + min-width: 0; +} + +.project-history-header, +.project-history-detail-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.project-history-header h3, +.project-history-detail-heading h4 { + margin: 0; +} + +.project-history-counts, +.project-history-time-basis, +.project-history-warning, +.project-history-boundary { + margin: 0; +} + +.project-history-warning, +.project-history-boundary { + border-inline-start: 0.25rem solid currentColor; + padding-inline-start: 0.75rem; +} + +.project-history-tabs { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(10rem, 1fr); + overflow-x: auto; + padding: 1.5rem 0 0.5rem; + position: relative; +} + +.project-history-tabs::before { + content: ""; + position: absolute; + inset-inline: 1rem; + top: 2rem; + border-top: 2px solid var(--color-border); +} + +.project-history-tab { + appearance: none; + background: transparent; + border: 0; + color: inherit; + display: grid; + gap: 0.35rem; + justify-items: center; + min-height: 7rem; + padding: 0; + position: relative; + text-align: center; +} + +.project-history-tab:focus-visible { + outline: 3px solid currentColor; + outline-offset: 0.25rem; +} + +.project-history-marker { + background: currentColor; + border: 0.25rem solid var(--color-background); + border-radius: 50%; + box-shadow: 0 0 0 2px currentColor; + height: 1rem; + width: 1rem; + z-index: 1; +} + +.project-history-tab-current .project-history-marker { + height: 1.25rem; + width: 1.25rem; +} + +.project-history-tab[aria-selected="true"] strong { + text-decoration: underline; + text-underline-offset: 0.25rem; +} + +.project-history-detail { + border: 1px solid var(--color-border); + border-radius: 0.75rem; + display: grid; + gap: 1rem; + padding: 1rem; +} + +.project-history-detail section { + display: grid; + gap: 0.5rem; +} + +.project-history-detail h5 { + margin: 0; +} + +.project-history-facts { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); + margin: 0; +} + +.project-history-facts div { + display: grid; + gap: 0.25rem; +} + +.project-history-facts dt { + font-weight: 700; +} + +.project-history-facts dd { + margin: 0; +} + +.project-history-transition { + font-weight: 700; +} + +.project-history-responsibilities, +.project-history-paths { + display: grid; + gap: 0.5rem; + list-style: none; + margin: 0; + padding: 0; +} + +.project-history-responsibilities li, +.project-history-paths li { + border: 1px solid var(--color-border); + border-radius: 0.5rem; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.75rem; +} + +.project-history-responsibilities li span:not(.project-history-truth) { + flex-basis: 100%; +} + +.project-history-paths p { + flex: 1 1 20rem; + margin: 0; +} + +.project-history-truth { + border: 1px solid currentColor; + border-radius: 999px; + font-size: 0.8rem; + padding: 0.1rem 0.5rem; +} + +.project-history-exact-values summary { + cursor: pointer; + font-weight: 700; +} + +.project-history-table-scroll { + overflow-x: auto; + padding-top: 0.75rem; +} + +.project-history-table-scroll table { + border-collapse: collapse; + min-width: 54rem; + width: 100%; +} + +.project-history-table-scroll th, +.project-history-table-scroll td { + border: 1px solid var(--color-border); + padding: 0.5rem; + text-align: start; + vertical-align: top; +} + +@media (max-width: 48rem) { + .project-history-tabs { + grid-auto-flow: row; + grid-auto-rows: auto; + overflow: visible; + padding: 0; + } + + .project-history-tabs::before { + border-inline-start: 2px solid var(--color-border); + border-top: 0; + inset-block: 1rem; + inset-inline-start: 0.75rem; + } + + .project-history-tab { + grid-template-columns: 1.5rem minmax(5rem, auto) 1fr; + justify-items: start; + min-height: auto; + padding: 0.5rem 0.5rem 0.5rem 0; + text-align: start; + } + + .project-history-tab > span:last-child { + grid-column: 3; + } + + .project-history-header, + .project-history-detail-heading { + align-items: stretch; + flex-direction: column; + } +} + +@media print { + .project-history-tabs, + .project-history-detail-heading button { + display: none; + } + + .project-history-exact-values, + .project-history-exact-values > * { + display: block !important; + } + + .project-history-table-scroll { + overflow: visible; + } + + .project-history-table-scroll table { + min-width: 0; + } +} diff --git a/frontend/src/components/ProjectHistoryTimeline.stories.tsx b/frontend/src/components/ProjectHistoryTimeline.stories.tsx new file mode 100644 index 000000000..ea9f96605 --- /dev/null +++ b/frontend/src/components/ProjectHistoryTimeline.stories.tsx @@ -0,0 +1,95 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import type { ProjectHistoryProjection } from "../projectHistory"; +import { ProjectHistoryTimeline } from "./ProjectHistoryTimeline"; + +const event = ( + eventId: string, + title: string, + occurredAt: string, + transition: "continuous" | "handoff" | "assignment_gap" | null, + actorName?: string, +) => ({ + event_id: eventId, + source_post_id: `post-${eventId}`, + event_title: title, + event_type_code: eventId === "voc" ? "voc_received" : "source_recorded", + event_type_basis_code: "controlled_source_code" as const, + occurred_at: occurredAt, + time_basis_code: "document_time" as const, + voc_type_code: eventId === "voc" ? "voc" : null, + source_stage_code: null, + source_detail_state_code: null, + project_matches: [], + observed_responsibilities: actorName + ? [ + { + actor_key: `actor:${actorName}`, + actor_name: actorName, + actor_type_code: "prov_person", + affiliated_organization_name: "Demo Corp", + responsibility: `Own ${title.toLowerCase()}`, + truth_status_code: "observed" as const, + provenance: "post_summary_role" as const, + }, + ] + : [], + responsibility_transition_code: transition, + related_prior_paths: [], +}); + +const projection: ProjectHistoryProjection = { + contract_version: 1, + project_key: "P-100", + normalized_project_key: "p-100", + project_name: "Northridge renewal", + focus_event_id: "voc", + time_basis_code: "document_time", + event_count: 5, + distinct_observed_actor_count: 3, + truncated: false, + events: [ + event("award", "Contract awarded", "2022-03-11T09:00:00Z", null, "Ada West"), + event( + "spec", + "Specification revision requested", + "2023-06-15T09:00:00Z", + "continuous", + "Ada West", + ), + event("delivery", "Delivery confirmed", "2024-02-20T09:00:00Z", "handoff", "Priya Nair"), + event("voc", "VOC received", "2026-07-30T09:00:00Z", "assignment_gap"), + event("rebid", "Rebid started", "2026-08-10T09:00:00Z", "assignment_gap", "Bid team"), + ], +}; + +projection.events[3].related_prior_paths = [ + { + source_event_id: "award", + target_event_id: "voc", + event_ids: ["award", "spec", "delivery", "voc"], + edges: [ + { parent_event_id: "award", child_event_id: "spec", fused_score: 0.91 }, + { parent_event_id: "spec", child_event_id: "delivery", fused_score: 0.82 }, + { parent_event_id: "delivery", child_event_id: "voc", fused_score: 0.73 }, + ], + minimum_fused_score: 0.73, + truth_status_code: "inferred", + source_relation_code: "post_lineage_edge", + provenance: "post_lineage_edge.fused_score", + }, +]; + +const meta = { + title: "Projects/History Timeline", + component: ProjectHistoryTimeline, + args: { + projection, + onOpenPost: () => undefined, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const AwardToRebid: Story = {}; diff --git a/frontend/src/components/ProjectHistoryTimeline.test.tsx b/frontend/src/components/ProjectHistoryTimeline.test.tsx new file mode 100644 index 000000000..41ddbaa9f --- /dev/null +++ b/frontend/src/components/ProjectHistoryTimeline.test.tsx @@ -0,0 +1,134 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { ProjectHistoryProjection } from "../projectHistory"; +import { ProjectHistoryTimeline } from "./ProjectHistoryTimeline"; + +const projection: ProjectHistoryProjection = { + contract_version: 1, + project_key: "P-100", + normalized_project_key: "p-100", + project_name: "Transformer renewal", + focus_event_id: "voc", + time_basis_code: "source_post_created_at_fallback", + event_count: 3, + distinct_observed_actor_count: 2, + truncated: false, + events: [ + { + event_id: "award", + source_post_id: "post-award", + event_title: "Contract awarded", + event_type_code: "source_recorded", + event_type_basis_code: "controlled_source_code", + occurred_at: "2022-03-11T09:00:00Z", + time_basis_code: "source_post_created_at_fallback", + voc_type_code: null, + source_stage_code: null, + source_detail_state_code: null, + project_matches: [], + observed_responsibilities: [ + { + actor_key: "person:sales", + actor_name: "Kim OO", + actor_type_code: "prov_person", + affiliated_organization_name: "Demo Corp", + responsibility: "Observed award owner", + truth_status_code: "observed", + provenance: "post_summary_role", + }, + ], + responsibility_transition_code: null, + related_prior_paths: [], + }, + { + event_id: "spec", + source_post_id: "post-spec", + event_title: "Specification changed", + event_type_code: "source_recorded", + event_type_basis_code: "controlled_source_code", + occurred_at: "2023-06-15T09:00:00Z", + time_basis_code: "source_post_created_at_fallback", + voc_type_code: null, + source_stage_code: null, + source_detail_state_code: null, + project_matches: [], + observed_responsibilities: [ + { + actor_key: "person:pm", + actor_name: "Park OO", + actor_type_code: "prov_person", + affiliated_organization_name: "Demo Corp", + responsibility: "Observed specification owner", + truth_status_code: "observed", + provenance: "post_summary_role", + }, + ], + responsibility_transition_code: "handoff", + related_prior_paths: [], + }, + { + event_id: "voc", + source_post_id: "post-voc", + event_title: "VOC received", + event_type_code: "voc_received", + event_type_basis_code: "controlled_source_code", + occurred_at: "2026-02-02T09:00:00Z", + time_basis_code: "document_time", + voc_type_code: "voc", + source_stage_code: "delivery", + source_detail_state_code: "delivered", + project_matches: [], + observed_responsibilities: [], + responsibility_transition_code: "assignment_gap", + related_prior_paths: [ + { + source_event_id: "award", + target_event_id: "voc", + event_ids: ["award", "spec", "voc"], + edges: [ + { parent_event_id: "award", child_event_id: "spec", fused_score: 0.91 }, + { parent_event_id: "spec", child_event_id: "voc", fused_score: 0.73 }, + ], + minimum_fused_score: 0.73, + truth_status_code: "inferred", + source_relation_code: "post_lineage_edge", + provenance: "post_lineage_edge.fused_score", + }, + ], + }, + ], +}; + +describe("ProjectHistoryTimeline", () => { + it("shows the focus event, evidence gap, and non-causal prior history", () => { + const onOpenPost = vi.fn(); + render(); + + const vocTab = screen.getByRole("tab", { name: /VOC received/ }); + expect(vocTab).toHaveAttribute("aria-selected", "true"); + expect(vocTab).toHaveAttribute("aria-current", "step"); + expect(screen.getAllByText(/evidence gap/i).length).toBeGreaterThan(0); + expect(screen.getByText(/related history, not causality/i)).toBeInTheDocument(); + expect(screen.getByText("Recorded event time")).toBeInTheDocument(); + expect(screen.queryByText("document_time")).not.toBeInTheDocument(); + expect(screen.getByText("delivery")).toBeInTheDocument(); + expect(screen.getByText("delivered")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /open source record: VOC received/i })); + expect(onOpenPost).toHaveBeenCalledWith("post-voc"); + }); + + it("uses roving keyboard selection and a labelled tabpanel", () => { + render(); + const vocTab = screen.getByRole("tab", { name: /VOC received/ }); + + fireEvent.keyDown(vocTab, { key: "ArrowLeft" }); + const specTab = screen.getByRole("tab", { name: /Specification changed/ }); + expect(specTab).toHaveFocus(); + expect(specTab).toHaveAttribute("aria-selected", "true"); + + const panel = screen.getByRole("tabpanel"); + expect(panel).toHaveAttribute("aria-labelledby", specTab.id); + }); +}); diff --git a/frontend/src/components/ProjectHistoryTimeline.tsx b/frontend/src/components/ProjectHistoryTimeline.tsx new file mode 100644 index 000000000..bc83a42da --- /dev/null +++ b/frontend/src/components/ProjectHistoryTimeline.tsx @@ -0,0 +1,337 @@ +import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react"; + +import { useLocale } from "../i18n"; +import { + type ProjectHistoryEvent, + type ProjectHistoryProjection, + projectHistoryEventTypeLabel, + projectHistoryMatchSourceLabel, + projectHistoryText, + projectHistoryTransitionLabel, +} from "../projectHistory"; +import "./ProjectHistoryTimeline.css"; + +function formatDate(value: string): string { + const parsed = new Date(value); + return Number.isNaN(parsed.valueOf()) ? value : parsed.toISOString().slice(0, 10); +} + +function minimumPathScore(event: ProjectHistoryEvent): number | null { + if (event.related_prior_paths.length === 0) return null; + return Math.min(...event.related_prior_paths.map((path) => path.minimum_fused_score)); +} + +function initialEventId(events: ProjectHistoryEvent[], focusEventId: string | null): string { + return ( + events.find((event) => event.event_id === focusEventId)?.event_id ?? + events[0]?.event_id ?? + "" + ); +} + +export function ProjectHistoryTimeline({ + projection, + onOpenPost, +}: { + projection: ProjectHistoryProjection; + onOpenPost: (postId: string) => void; +}) { + const locale = useLocale(); + const instanceId = useId(); + const panelId = `${instanceId}-project-history-panel`; + const headingId = `${instanceId}-project-history-heading`; + const [selectedEventId, setSelectedEventId] = useState(() => + initialEventId(projection.events, projection.focus_event_id), + ); + const tabRefs = useRef>([]); + const eventById = new Map(projection.events.map((event) => [event.event_id, event])); + const selectedEvent = + eventById.get(selectedEventId) ?? + eventById.get(initialEventId(projection.events, projection.focus_event_id)) ?? + null; + const selectedIndex = selectedEvent + ? projection.events.findIndex((event) => event.event_id === selectedEvent.event_id) + : -1; + const selectedTabId = selectedIndex >= 0 ? `${instanceId}-project-history-tab-${selectedIndex}` : undefined; + + useEffect(() => { + setSelectedEventId(initialEventId(projection.events, projection.focus_event_id)); + }, [projection.normalized_project_key, projection.focus_event_id, projection.events]); + + function selectAt(index: number) { + const bounded = Math.max(0, Math.min(index, projection.events.length - 1)); + const event = projection.events[bounded]; + if (!event) return; + setSelectedEventId(event.event_id); + tabRefs.current[bounded]?.focus(); + } + + function handleTabKey(event: KeyboardEvent, index: number) { + let target: number | null = null; + switch (event.key) { + case "ArrowLeft": + case "ArrowUp": + target = index === 0 ? projection.events.length - 1 : index - 1; + break; + case "ArrowRight": + case "ArrowDown": + target = index === projection.events.length - 1 ? 0 : index + 1; + break; + case "Home": + target = 0; + break; + case "End": + target = projection.events.length - 1; + break; + default: + return; + } + event.preventDefault(); + selectAt(target); + } + + return ( +
+
+
+

{projection.project_name}

+

{projectHistoryText(locale, "heading")}

+
+

+ {projectHistoryText(locale, "summaryCounts", { + events: projection.event_count, + actors: projection.distinct_observed_actor_count, + })} +

+
+ +

+ {projectHistoryText(locale, "documentTime")} +

+ {projection.truncated ? ( +

+ {projectHistoryText(locale, "truncated")} +

+ ) : null} + +
+ {projection.events.map((event, index) => { + const selected = event.event_id === selectedEvent?.event_id; + const current = event.event_id === projection.focus_event_id; + const tabId = `${instanceId}-project-history-tab-${index}`; + return ( + + ); + })} +
+ + {selectedEvent ? ( +
+
+
+

{projectHistoryText(locale, "eventDetail")}

+

{selectedEvent.event_title}

+
+ +
+ +
+
+
{projectHistoryText(locale, "eventDate")}
+
{formatDate(selectedEvent.occurred_at)}
+
+
+
{projectHistoryText(locale, "eventType")}
+
{projectHistoryEventTypeLabel(locale, selectedEvent.event_type_code)}
+
+
+
{projectHistoryText(locale, "timeBasisCode")}
+
+ {projectHistoryText( + locale, + selectedEvent.time_basis_code === "document_time" + ? "recordedEventTime" + : "sourceCreationTime", + )} +
+
+
+
{projectHistoryText(locale, "sourceStageCode")}
+
{selectedEvent.source_stage_code ?? projectHistoryText(locale, "notApplicable")}
+
+
+
{projectHistoryText(locale, "sourceDetailStateCode")}
+
+ {selectedEvent.source_detail_state_code ?? projectHistoryText(locale, "notApplicable")} +
+
+ {selectedEvent.responsibility_transition_code ? ( +
+
{projectHistoryText(locale, "columnTransition")}
+
+ {projectHistoryTransitionLabel( + locale, + selectedEvent.responsibility_transition_code, + )} +
+
+ ) : null} +
+ +
+
+ {projectHistoryText(locale, "responsibilityEvidence")} +
+ {selectedEvent.observed_responsibilities.length > 0 ? ( +
    + {selectedEvent.observed_responsibilities.map((responsibility) => ( +
  • + {responsibility.actor_name} + {responsibility.affiliated_organization_name + ? ` · ${responsibility.affiliated_organization_name}` + : ""} + {responsibility.responsibility} + + {projectHistoryText(locale, "observed")} + +
  • + ))} +
+ ) : ( +

{projectHistoryText(locale, "noResponsibilityEvidence")}

+ )} +
+ +
+
{projectHistoryText(locale, "priorHistory")}
+ {selectedEvent.related_prior_paths.length > 0 ? ( +
    + {selectedEvent.related_prior_paths.map((path) => ( +
  • +

    + {path.event_ids + .map((eventId) => eventById.get(eventId)?.event_title ?? eventId) + .join(" → ")} +

    + {path.minimum_fused_score.toFixed(3)} + + {projectHistoryText(locale, "inferred")} + +
  • + ))} +
+ ) : ( +

{projectHistoryText(locale, "noPriorHistory")}

+ )} +

+ {projectHistoryText(locale, "inferredBoundary")} +

+
+ + {selectedEvent.project_matches.length > 0 ? ( +
+
+ {projectHistoryText(locale, "projectEvidence")} +
+
    + {selectedEvent.project_matches.map((match) => ( +
  • + {match.matched_value} ·{" "} + {projectHistoryMatchSourceLabel(locale, match.provenance)} ·{" "} + {projectHistoryText(locale, match.truth_status_code)} +
  • + ))} +
+
+ ) : null} +
+ ) : null} + +
+ {projectHistoryText(locale, "exactValues")} +
+ + + + + + + + + + + + + {projection.events.map((event) => { + const pathScore = minimumPathScore(event); + return ( + + + + + + + + + ); + })} + +
{projectHistoryText(locale, "columnDate")}{projectHistoryText(locale, "columnEvent")}{projectHistoryText(locale, "columnType")}{projectHistoryText(locale, "columnTransition")}{projectHistoryText(locale, "columnActors")}{projectHistoryText(locale, "columnPathScore")}
{formatDate(event.occurred_at)}{event.event_title}{projectHistoryEventTypeLabel(locale, event.event_type_code)} + {projectHistoryTransitionLabel(locale, event.responsibility_transition_code)} + + {event.observed_responsibilities.length > 0 + ? event.observed_responsibilities.map((row) => row.actor_name).join(", ") + : projectHistoryText(locale, "notApplicable")} + + {pathScore === null + ? projectHistoryText(locale, "notApplicable") + : pathScore.toFixed(3)} +
+
+
+
+ ); +} diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index ae085d010..4a5a2111f 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -75,6 +75,9 @@ describe("i18n", () => { "Title overlap", "RankWeave fused newest-first and title-overlap ranks. This is not a calibrated score.", "Workspace navigation", + "Project history", + "Open project history: {name}", + "Loading project history. Review the timeline when it appears.", "Observed calendar events", "No observed calendar events are available.", "Open this observed occurrence. It is not a LineageWeave commitment.", @@ -130,6 +133,17 @@ describe("i18n", () => { expect(document.documentElement.lang).toBe(locale); }); + it.each([ + ["ko", "프로젝트 이력", "프로젝트 이력 열기: DEMO"], + ["zh", "项目历史", "打开项目历史:DEMO"], + ["ja", "プロジェクト履歴", "プロジェクト履歴を開く: DEMO"], + ["vi", "Lịch sử dự án", "Mở lịch sử dự án: DEMO"], + ] as const)("translates project history actions in %s", (locale, heading, action) => { + setLocale(locale); + expect(t("Project history")).toBe(heading); + expect(tf("Open project history: {name}", { name: "DEMO" })).toBe(action); + }); + it.each([ ["ko", "글"], ["zh", "文章"], diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 390be07d8..5a152a106 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -155,6 +155,9 @@ const TRANSLATIONS: Partial>> = { "Authorized customer scope": "권한이 있는 고객 범위", "Customer entities available to this account.": "이 계정에서 사용할 수 있는 고객 엔터티입니다.", "Loading customer master...": "고객 마스터를 불러오는 중...", + "Project history": "프로젝트 이력", + "Open project history: {name}": "프로젝트 이력 열기: {name}", + "Loading project history. Review the timeline when it appears.": "프로젝트 이력을 불러오는 중입니다. 표시되면 타임라인을 확인하세요.", "Customer master could not be loaded.": "고객 마스터를 불러오지 못했습니다.", "No customer entities are connected to this account.": "이 계정에 연결된 고객 엔터티가 없습니다.", "Observed customer evidence": "관찰된 고객 증거", @@ -682,6 +685,9 @@ const TRANSLATIONS: Partial>> = { "Authorized customer scope": "已授权的客户范围", "Customer entities available to this account.": "此账户可用的客户实体。", "Loading customer master...": "正在加载客户主数据...", + "Project history": "项目历史", + "Open project history: {name}": "打开项目历史:{name}", + "Loading project history. Review the timeline when it appears.": "正在加载项目历史。显示后请查看时间线。", "Customer master could not be loaded.": "无法加载客户主数据。", "No customer entities are connected to this account.": "此账户没有连接的客户实体。", "Observed customer evidence": "观测到的客户证据", @@ -1225,6 +1231,9 @@ const TRANSLATIONS: Partial>> = { "Authorized customer scope": "許可された顧客範囲", "Customer entities available to this account.": "このアカウントで利用できる顧客エンティティです。", "Loading customer master...": "顧客マスターを読み込んでいます...", + "Project history": "プロジェクト履歴", + "Open project history: {name}": "プロジェクト履歴を開く: {name}", + "Loading project history. Review the timeline when it appears.": "プロジェクト履歴を読み込んでいます。表示されたらタイムラインを確認してください。", "Customer master could not be loaded.": "顧客マスターを読み込めませんでした。", "No customer entities are connected to this account.": "このアカウントに接続された顧客エンティティはありません。", "Observed customer evidence": "観測された顧客証拠", @@ -1747,6 +1756,9 @@ const TRANSLATIONS: Partial>> = { "Authorized customer scope": "Phạm vi khách hàng được cấp quyền", "Customer entities available to this account.": "Các thực thể khách hàng mà tài khoản này được phép sử dụng.", "Loading customer master...": "Đang tải danh mục khách hàng...", + "Project history": "Lịch sử dự án", + "Open project history: {name}": "Mở lịch sử dự án: {name}", + "Loading project history. Review the timeline when it appears.": "Đang tải lịch sử dự án. Khi dòng thời gian xuất hiện, hãy xem lại.", "Customer master could not be loaded.": "Không thể tải danh mục khách hàng.", "No customer entities are connected to this account.": "Tài khoản này chưa được kết nối với thực thể khách hàng nào.", "Observed customer evidence": "Bằng chứng khách hàng được quan sát", diff --git a/frontend/src/projectHistory.test.ts b/frontend/src/projectHistory.test.ts new file mode 100644 index 000000000..10c0fbf6c --- /dev/null +++ b/frontend/src/projectHistory.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { + projectHistoryEventTypeLabel, + projectHistoryKeys, + projectHistoryMatchSourceLabel, +} from "./projectHistory"; + +describe("projectHistoryKeys", () => { + it("deduplicates compatibility- and case-normalized identities", () => { + expect( + projectHistoryKeys( + [ + { project_key: "P-100", project_name: "Project", evidence: "source", confidence: null, ontology_iri: "", extraction_method: "source_field_hint", resolution_status: "hint_only", provenance: "test" }, + { project_key: "p-100", project_name: "Project", evidence: "semantic", confidence: null, ontology_iri: "", extraction_method: "semantic", resolution_status: "resolved", provenance: "test" }, + ], + null, + null, + ), + ).toEqual(["P-100"]); + }); + + it("uses a source identity when project evidence is empty", () => { + expect(projectHistoryKeys([], " ", " P-200 ")).toEqual([" P-200 "]); + }); + + it("keeps a distinct explicit source identity beside semantic evidence", () => { + expect( + projectHistoryKeys( + [ + { + project_key: "semantic-project", + project_name: "Semantic project", + evidence: "semantic", + confidence: null, + ontology_iri: "", + extraction_method: "semantic", + resolution_status: "resolved", + provenance: "test", + }, + ], + "SOURCE-200", + "Source project", + ), + ).toEqual(["semantic-project", "SOURCE-200"]); + }); + + it("keeps storage fields and unknown event codes out of customer labels", () => { + expect(projectHistoryMatchSourceLabel("en", "source_post.source_project_name")).toBe( + "Source record", + ); + expect(projectHistoryMatchSourceLabel("en", "post_project_mention.project_name")).toBe( + "Supporting record", + ); + expect(projectHistoryMatchSourceLabel("en", "future_table.future_column")).toBe( + "Recorded evidence", + ); + expect(projectHistoryEventTypeLabel("en", "future_event_code")).toBe("Source record"); + }); +}); diff --git a/frontend/src/projectHistory.ts b/frontend/src/projectHistory.ts new file mode 100644 index 000000000..4dceb6fbd --- /dev/null +++ b/frontend/src/projectHistory.ts @@ -0,0 +1,421 @@ +import type { ProjectEvidence } from "./api"; +import type { Locale } from "./i18n"; + +export type ProjectHistoryTruthStatus = "observed" | "inferred"; +export type ResponsibilityTransitionCode = "continuous" | "handoff" | "assignment_gap"; +export type ProjectHistoryTimeBasis = "source_post_created_at_fallback" | "document_time"; + +export interface ProjectHistoryMatch { + match_kind_code: string; + matched_value: string; + truth_status_code: ProjectHistoryTruthStatus; + confidence: number | null; + ontology_iri: string | null; + provenance: string; +} + +export interface ProjectHistoryResponsibility { + actor_key: string; + actor_name: string; + actor_type_code: string; + affiliated_organization_name: string | null; + responsibility: string; + truth_status_code: "observed"; + provenance: string; +} + +export interface ProjectHistoryPathEdge { + parent_event_id: string; + child_event_id: string; + fused_score: number; +} + +export interface ProjectHistoryPriorPath { + source_event_id: string; + target_event_id: string; + event_ids: string[]; + edges: ProjectHistoryPathEdge[]; + minimum_fused_score: number; + truth_status_code: "inferred"; + source_relation_code: "post_lineage_edge"; + provenance: "post_lineage_edge.fused_score"; +} + +export interface ProjectHistoryEvent { + event_id: string; + source_post_id: string; + event_title: string; + event_type_code: string; + event_type_basis_code: "controlled_source_code"; + occurred_at: string; + time_basis_code: ProjectHistoryTimeBasis; + voc_type_code: string | null; + source_stage_code: string | null; + source_detail_state_code: string | null; + project_matches: ProjectHistoryMatch[]; + observed_responsibilities: ProjectHistoryResponsibility[]; + responsibility_transition_code: ResponsibilityTransitionCode | null; + related_prior_paths: ProjectHistoryPriorPath[]; +} + +export interface ProjectHistoryProjection { + contract_version: 1; + project_key: string; + normalized_project_key: string; + project_name: string; + focus_event_id: string; + time_basis_code: ProjectHistoryTimeBasis; + event_count: number; + distinct_observed_actor_count: number; + truncated: boolean; + events: ProjectHistoryEvent[]; +} + +function normalizeProjectIdentity(value: string): string { + return value.normalize("NFKC").trim().toLocaleLowerCase("en-US"); +} + +/** Return one display key per exact normalized project identity. */ +export function projectHistoryKeys( + evidence: ProjectEvidence[] | undefined, + sourceProjectCode: string | null | undefined, + sourceProjectName: string | null | undefined, +): string[] { + const sourceIdentity = sourceProjectCode?.trim() ? sourceProjectCode : sourceProjectName ?? ""; + const candidates = [...(evidence ?? []).map((project) => project.project_key), sourceIdentity]; + const seen = new Set(); + return candidates.filter((candidate) => { + const normalized = normalizeProjectIdentity(candidate); + if (!normalized || seen.has(normalized)) return false; + seen.add(normalized); + return true; + }); +} + +const MESSAGE_KEYS = [ + "heading", + "summaryCounts", + "documentTime", + "truncated", + "eventDetail", + "eventType", + "eventDate", + "timeBasisCode", + "recordedEventTime", + "sourceCreationTime", + "sourceStageCode", + "sourceDetailStateCode", + "responsibilityEvidence", + "noResponsibilityEvidence", + "continuous", + "handoff", + "assignmentGap", + "priorHistory", + "noPriorHistory", + "inferredBoundary", + "projectEvidence", + "sourceRecordEvidence", + "supportingRecordEvidence", + "recordedEvidence", + "observed", + "inferred", + "openSourceRecord", + "exactValues", + "exactTableLabel", + "columnDate", + "columnEvent", + "columnType", + "columnTransition", + "columnActors", + "columnPathScore", + "notApplicable", + "contractAwarded", + "specificationChanged", + "delivered", + "handoffRecorded", + "vocReceived", + "rebidStarted", + "sourceRecorded", +] as const; + +export const PROJECT_HISTORY_MESSAGE_KEYS = MESSAGE_KEYS; +export type ProjectHistoryMessageKey = (typeof MESSAGE_KEYS)[number]; + +type MessageParams = Record; + +const EN: Record = { + heading: "Project event timeline", + summaryCounts: "{events} events · {actors} observed actors", + documentTime: "Dates use the recorded event time when available and the source creation time otherwise.", + truncated: "This bounded timeline is truncated. The selected event remains included.", + eventDetail: "Event detail", + eventType: "Display event type", + eventDate: "Event date", + timeBasisCode: "Time source", + recordedEventTime: "Recorded event time", + sourceCreationTime: "Source creation time", + sourceStageCode: "Source stage code", + sourceDetailStateCode: "Source detail-state code", + responsibilityEvidence: "Observed responsibility evidence", + noResponsibilityEvidence: "No responsibility evidence is recorded for this event.", + continuous: "Responsibility evidence continued", + handoff: "Responsibility evidence changed", + assignmentGap: "Responsibility evidence gap", + priorHistory: "Related prior history", + noPriorHistory: "No visible prior lineage path is recorded for this event.", + inferredBoundary: "This is inferred related history, not causality or an authoritative assignment record.", + projectEvidence: "Project identity evidence", + sourceRecordEvidence: "Source record", + supportingRecordEvidence: "Supporting record", + recordedEvidence: "Recorded evidence", + observed: "Observed", + inferred: "Inferred", + openSourceRecord: "Open source record: {title}", + exactValues: "Exact values", + exactTableLabel: "Project history exact values", + columnDate: "Date", + columnEvent: "Event", + columnType: "Type", + columnTransition: "Responsibility evidence change", + columnActors: "Observed actors", + columnPathScore: "Minimum lineage score", + notApplicable: "Not applicable", + contractAwarded: "Contract awarded", + specificationChanged: "Specification changed", + delivered: "Delivered", + handoffRecorded: "Handoff recorded", + vocReceived: "VOC received", + rebidStarted: "Rebid started", + sourceRecorded: "Source record", +}; + +const MESSAGES: Record> = { + en: EN, + ko: { + heading: "프로젝트 이벤트 타임라인", + summaryCounts: "이벤트 {events}건 · 관찰된 담당자 {actors}명", + documentTime: "기록된 사건 시각을 우선 사용하고, 없으면 원천 생성 시각을 사용합니다.", + truncated: "이 제한된 타임라인은 일부만 표시합니다. 선택한 이벤트는 계속 포함됩니다.", + eventDetail: "이벤트 상세", + eventType: "표시용 이벤트 유형", + eventDate: "이벤트 날짜", + timeBasisCode: "시간 출처", + recordedEventTime: "기록된 사건 시각", + sourceCreationTime: "원천 생성 시각", + sourceStageCode: "원천 단계 코드", + sourceDetailStateCode: "원천 세부 상태 코드", + responsibilityEvidence: "관찰된 담당 근거", + noResponsibilityEvidence: "이 이벤트에는 기록된 담당 근거가 없습니다.", + continuous: "담당 근거 유지", + handoff: "담당 근거 변경", + assignmentGap: "담당 근거 공백", + priorHistory: "관련 과거 이력", + noPriorHistory: "이 이벤트로 이어지는 공개 가능한 이전 계보가 없습니다.", + inferredBoundary: "이는 추론된 관련 이력이며 인과관계나 권위 있는 인사 배정 기록이 아닙니다.", + projectEvidence: "프로젝트 식별 근거", + sourceRecordEvidence: "원천 기록", + supportingRecordEvidence: "뒷받침 기록", + recordedEvidence: "기록된 근거", + observed: "관찰됨", + inferred: "추론됨", + openSourceRecord: "원천 기록 열기: {title}", + exactValues: "정확한 값", + exactTableLabel: "프로젝트 이력 정확한 값", + columnDate: "날짜", + columnEvent: "이벤트", + columnType: "유형", + columnTransition: "담당 근거 변화", + columnActors: "관찰된 담당자", + columnPathScore: "최소 계보 점수", + notApplicable: "해당 없음", + contractAwarded: "수주 확정", + specificationChanged: "사양 변경", + delivered: "납품", + handoffRecorded: "인수인계 기록", + vocReceived: "VOC 접수", + rebidStarted: "재입찰 시작", + sourceRecorded: "원천 기록", + }, + zh: { + heading: "项目事件时间线", + summaryCounts: "{events} 个事件 · {actors} 名已观察责任人", + documentTime: "优先使用已记录的事件时间;若无,则使用来源创建时间。", + truncated: "此有界时间线已截断,但所选事件仍保留。", + eventDetail: "事件详情", + eventType: "显示事件类型", + eventDate: "事件日期", + timeBasisCode: "时间来源", + recordedEventTime: "已记录的事件时间", + sourceCreationTime: "来源创建时间", + sourceStageCode: "来源阶段代码", + sourceDetailStateCode: "来源详细状态代码", + responsibilityEvidence: "已观察的责任证据", + noResponsibilityEvidence: "此事件没有记录责任证据。", + continuous: "责任证据持续", + handoff: "责任证据变化", + assignmentGap: "责任证据缺口", + priorHistory: "相关既往历史", + noPriorHistory: "此事件没有可见的既往谱系路径。", + inferredBoundary: "这是推断的相关历史,并非因果关系或权威任命记录。", + projectEvidence: "项目身份依据", + sourceRecordEvidence: "来源记录", + supportingRecordEvidence: "支持记录", + recordedEvidence: "已记录依据", + observed: "已观察", + inferred: "已推断", + openSourceRecord: "打开源记录:{title}", + exactValues: "精确值", + exactTableLabel: "项目历史精确值", + columnDate: "日期", + columnEvent: "事件", + columnType: "类型", + columnTransition: "责任证据变化", + columnActors: "已观察责任人", + columnPathScore: "最低谱系分数", + notApplicable: "不适用", + contractAwarded: "合同授予", + specificationChanged: "规格变更", + delivered: "已交付", + handoffRecorded: "已记录交接", + vocReceived: "收到客户之声", + rebidStarted: "重新投标开始", + sourceRecorded: "源记录", + }, + ja: { + heading: "プロジェクトイベントのタイムライン", + summaryCounts: "イベント {events}件 · 観察された担当者 {actors}名", + documentTime: "記録されたイベント時刻を優先し、ない場合は原資料の作成時刻を使います。", + truncated: "この上限付きタイムラインは省略されていますが、選択イベントは保持されます。", + eventDetail: "イベント詳細", + eventType: "表示用イベント種別", + eventDate: "イベント日", + timeBasisCode: "時刻の出典", + recordedEventTime: "記録されたイベント時刻", + sourceCreationTime: "原資料の作成時刻", + sourceStageCode: "ソース段階コード", + sourceDetailStateCode: "ソース詳細状態コード", + responsibilityEvidence: "観察された担当根拠", + noResponsibilityEvidence: "このイベントには担当根拠が記録されていません。", + continuous: "担当根拠が継続", + handoff: "担当根拠が変更", + assignmentGap: "担当根拠の空白", + priorHistory: "関連する過去履歴", + noPriorHistory: "このイベントに至る可視の過去系譜はありません。", + inferredBoundary: "これは推論された関連履歴であり、因果関係や権威ある配属記録ではありません。", + projectEvidence: "プロジェクト識別根拠", + sourceRecordEvidence: "元レコード", + supportingRecordEvidence: "根拠レコード", + recordedEvidence: "記録された根拠", + observed: "観察済み", + inferred: "推論済み", + openSourceRecord: "原資料を開く: {title}", + exactValues: "正確な値", + exactTableLabel: "プロジェクト履歴の正確な値", + columnDate: "日付", + columnEvent: "イベント", + columnType: "種別", + columnTransition: "担当根拠の変化", + columnActors: "観察担当者", + columnPathScore: "最小系譜スコア", + notApplicable: "該当なし", + contractAwarded: "受注確定", + specificationChanged: "仕様変更", + delivered: "納品", + handoffRecorded: "引継ぎ記録", + vocReceived: "VOC受付", + rebidStarted: "再入札開始", + sourceRecorded: "原資料", + }, + vi: { + heading: "Dòng thời gian sự kiện dự án", + summaryCounts: "{events} sự kiện · {actors} người phụ trách được quan sát", + documentTime: "Ưu tiên thời gian sự kiện đã ghi; nếu thiếu thì dùng thời gian tạo nguồn.", + truncated: "Dòng thời gian có giới hạn này đã bị rút gọn nhưng vẫn giữ sự kiện đang chọn.", + eventDetail: "Chi tiết sự kiện", + eventType: "Loại sự kiện hiển thị", + eventDate: "Ngày sự kiện", + timeBasisCode: "Nguồn thời gian", + recordedEventTime: "Thời gian sự kiện đã ghi", + sourceCreationTime: "Thời gian tạo nguồn", + sourceStageCode: "Mã giai đoạn nguồn", + sourceDetailStateCode: "Mã trạng thái chi tiết nguồn", + responsibilityEvidence: "Bằng chứng trách nhiệm quan sát được", + noResponsibilityEvidence: "Không có bằng chứng trách nhiệm được ghi cho sự kiện này.", + continuous: "Bằng chứng trách nhiệm tiếp tục", + handoff: "Bằng chứng trách nhiệm thay đổi", + assignmentGap: "Khoảng trống bằng chứng trách nhiệm", + priorHistory: "Lịch sử trước đó có liên quan", + noPriorHistory: "Không có đường dẫn lịch sử trước đó khả kiến cho sự kiện này.", + inferredBoundary: "Đây là lịch sử liên quan được suy luận, không phải quan hệ nhân quả hay hồ sơ phân công có thẩm quyền.", + projectEvidence: "Bằng chứng nhận dạng dự án", + sourceRecordEvidence: "Bản ghi nguồn", + supportingRecordEvidence: "Bản ghi hỗ trợ", + recordedEvidence: "Bằng chứng đã ghi nhận", + observed: "Đã quan sát", + inferred: "Đã suy luận", + openSourceRecord: "Mở bản ghi nguồn: {title}", + exactValues: "Giá trị chính xác", + exactTableLabel: "Giá trị chính xác của lịch sử dự án", + columnDate: "Ngày", + columnEvent: "Sự kiện", + columnType: "Loại", + columnTransition: "Thay đổi bằng chứng trách nhiệm", + columnActors: "Người phụ trách được quan sát", + columnPathScore: "Điểm dòng dõi tối thiểu", + notApplicable: "Không áp dụng", + contractAwarded: "Đã trao hợp đồng", + specificationChanged: "Đã thay đổi đặc tả", + delivered: "Đã bàn giao sản phẩm", + handoffRecorded: "Đã ghi nhận bàn giao", + vocReceived: "Đã nhận ý kiến khách hàng", + rebidStarted: "Đã bắt đầu đấu thầu lại", + sourceRecorded: "Bản ghi nguồn", + }, +}; + +export function projectHistoryText( + locale: Locale, + key: ProjectHistoryMessageKey, + params: MessageParams = {}, +): string { + let value = MESSAGES[locale][key]; + for (const [name, replacement] of Object.entries(params)) { + value = value.replaceAll(`{${name}}`, String(replacement)); + } + return value; +} + +export function projectHistoryEventTypeLabel(locale: Locale, code: string): string { + const keyByCode: Record = { + contract_awarded: "contractAwarded", + specification_changed: "specificationChanged", + delivered: "delivered", + handoff_recorded: "handoffRecorded", + voc_received: "vocReceived", + rebid_started: "rebidStarted", + source_recorded: "sourceRecorded", + }; + const key = keyByCode[code]; + return projectHistoryText(locale, key ?? "sourceRecorded"); +} + +/** Return a customer label for a persisted project-match provenance field. */ +export function projectHistoryMatchSourceLabel(locale: Locale, provenance: string): string { + if (provenance.startsWith("source_post.")) { + return projectHistoryText(locale, "sourceRecordEvidence"); + } + if (provenance.startsWith("post_project_mention.")) { + return projectHistoryText(locale, "supportingRecordEvidence"); + } + return projectHistoryText(locale, "recordedEvidence"); +} + +export function projectHistoryTransitionLabel( + locale: Locale, + code: ResponsibilityTransitionCode | null, +): string { + if (code === "continuous") return projectHistoryText(locale, "continuous"); + if (code === "handoff") return projectHistoryText(locale, "handoff"); + if (code === "assignment_gap") return projectHistoryText(locale, "assignmentGap"); + return projectHistoryText(locale, "notApplicable"); +} diff --git a/lineageweave/project_history.py b/lineageweave/project_history.py new file mode 100644 index 000000000..264cfa4e5 --- /dev/null +++ b/lineageweave/project_history.py @@ -0,0 +1,409 @@ +"""Build evidence-bound project histories from already-authorized rows. + +Callers must apply RBAC, ABAC, source eligibility, and knowledge-cutoff +filtering before invoking this module. The pure projection layer then orders +visible source records, preserves explicit and semantic project evidence, +compares observed responsibility evidence, and exposes persisted lineage as +related history without promoting it to causality or an HR assignment ledger. +""" + +from __future__ import annotations + +from collections import deque +from collections.abc import Mapping, Sequence +from datetime import datetime, timezone +from decimal import Decimal +import math +from typing import Any +from unicodedata import normalize + +PROJECT_HISTORY_CONTRACT_VERSION = 1 +PROJECT_HISTORY_TIME_BASIS = "source_post_created_at_fallback" +PROJECT_HISTORY_DOCUMENT_TIME_BASIS = "document_time" +PROJECT_HISTORY_MAX_DEPTH = 8 +PROJECT_HISTORY_MAX_PATHS_PER_EVENT = 32 + +_VOC_CODES = frozenset({"voc", "vocc", "voco", "vom", "vop"}) +_TRUTH_ORDER = {"observed": 0, "inferred": 1} +_DISPLAY_NAME_ORDER = {"source_project_name": 0, "semantic_project_name": 1} + + +def normalize_project_key(value: str) -> str: + """Return the exact project-identity comparison key. + + Unicode compatibility normalization lets full-width and compatibility + forms match without introducing fuzzy identity. Empty and oversized keys + fail closed. + """ + + normalized = normalize("NFKC", value).strip().lower() + if not normalized: + raise ValueError("project key must not be empty") + if len(normalized.encode("utf-8")) > 256: + raise ValueError("project key exceeds 256 UTF-8 bytes") + return normalized + + +def classify_project_event( + *, + title: str, + source_stage_code: str | None, + source_detail_state_code: str | None, + voc_type_code: str | None, + is_focus: bool, +) -> str: + """Return a non-authoritative display classification for one source row. + + Only the persisted controlled VOC code is classified. Free-text titles, + stages, and detail states remain evidence fields; this projection never + guesses lifecycle semantics from words. ``is_focus`` is retained for + contract compatibility but never changes the truth status or creates an + event. + """ + + del is_focus + del title, source_stage_code, source_detail_state_code + if (voc_type_code or "").strip().lower() in _VOC_CODES: + return "voc_received" + return "source_recorded" + + +def responsibility_transition_code( + previous_actor_keys: Sequence[str], current_actor_keys: Sequence[str] +) -> str: + """Compare adjacent observed responsibility evidence. + + Missing evidence on either row is an ``assignment_gap`` evidence state, + not proof of an operational or HR vacancy. Equal non-empty actor sets are + continuous; different non-empty sets are a handoff. + """ + + previous = frozenset(key for key in previous_actor_keys if key) + current = frozenset(key for key in current_actor_keys if key) + if not previous or not current: + return "assignment_gap" + if previous == current: + return "continuous" + return "handoff" + + +def _as_utc(value: datetime) -> str: + """Serialize a source clock as canonical UTC RFC 3339 text.""" + + aware = value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc) + return aware.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _actor_key(role: Mapping[str, Any]) -> str: + """Return a stable key for one observed role actor.""" + + catalog_fields = ( + ("person", role.get("cataloged_person_id")), + ("team", role.get("cataloged_team_id")), + ("organization", role.get("cataloged_corporate_entity_id")), + ) + for prefix, value in catalog_fields: + if value: + return f"{prefix}:{value}" + parts = ( + str(role.get("actor_type_code") or "unknown"), + str(role.get("actor_name") or ""), + str(role.get("affiliated_organization_name") or ""), + ) + return "text:" + "\u001f".join(normalize("NFKC", part).strip().lower() for part in parts) + + +def _score(value: object) -> float: + """Return a finite JSON-compatible lineage score.""" + + if isinstance(value, bool) or not isinstance(value, (int, float, Decimal)): + raise ValueError("lineage score must be numeric") + result = float(value) + if math.isnan(result) or result in (float("inf"), float("-inf")): + raise ValueError("lineage score must be finite") + return result + + +def _prior_paths( + ordered_event_ids: Sequence[str], + edge_rows: Sequence[Mapping[str, Any]], + *, + maximum_depth: int, + maximum_paths_per_event: int, +) -> dict[str, list[dict[str, Any]]]: + """Return one deterministic shortest visible predecessor path per source event.""" + + event_index = {event_id: index for index, event_id in enumerate(ordered_event_ids)} + reverse_edges: dict[str, list[dict[str, Any]]] = {event_id: [] for event_id in ordered_event_ids} + for row in edge_rows: + parent = str(row["parent_post_id"]) + child = str(row["child_post_id"]) + if parent not in event_index or child not in event_index: + continue + if event_index[parent] >= event_index[child]: + continue + reverse_edges[child].append( + { + "parent_event_id": parent, + "child_event_id": child, + "fused_score": _score(row["fused_score"]), + } + ) + for edges in reverse_edges.values(): + edges.sort(key=lambda edge: (event_index[edge["parent_event_id"]], edge["parent_event_id"])) + + result: dict[str, list[dict[str, Any]]] = {} + for target in ordered_event_ids: + queue: deque[tuple[str, tuple[str, ...], tuple[dict[str, Any], ...]]] = deque( + [(target, (target,), ())] + ) + best_depth = {target: 0} + paths: list[dict[str, Any]] = [] + while queue and len(paths) < maximum_paths_per_event: + current, reverse_event_path, reverse_edge_path = queue.popleft() + depth = len(reverse_edge_path) + if depth >= maximum_depth: + continue + for edge in reverse_edges[current]: + parent = edge["parent_event_id"] + if parent in reverse_event_path: + continue + next_depth = depth + 1 + if best_depth.get(parent, maximum_depth + 1) <= next_depth: + continue + best_depth[parent] = next_depth + next_events = reverse_event_path + (parent,) + next_edges = reverse_edge_path + (edge,) + ordered_events = list(reversed(next_events)) + ordered_edges = list(reversed(next_edges)) + paths.append( + { + "source_event_id": parent, + "target_event_id": target, + "event_ids": ordered_events, + "edges": ordered_edges, + "minimum_fused_score": min(item["fused_score"] for item in ordered_edges), + "truth_status_code": "inferred", + "source_relation_code": "post_lineage_edge", + "provenance": "post_lineage_edge.fused_score", + } + ) + queue.append((parent, next_events, next_edges)) + if len(paths) >= maximum_paths_per_event: + break + paths.sort( + key=lambda path: ( + len(path["edges"]), + event_index[path["source_event_id"]], + tuple(path["event_ids"]), + ) + ) + result[target] = paths + return result + + +def build_project_history_projection( + *, + project_key: str, + focus_event_id: str | None, + event_rows: Sequence[Mapping[str, Any]], + match_rows: Sequence[Mapping[str, Any]], + role_rows: Sequence[Mapping[str, Any]], + edge_rows: Sequence[Mapping[str, Any]], + truncated: bool = False, + transition_suppressed_event_ids: set[str] | None = None, + maximum_depth: int = PROJECT_HISTORY_MAX_DEPTH, + maximum_paths_per_event: int = PROJECT_HISTORY_MAX_PATHS_PER_EVENT, +) -> dict[str, Any]: + """Build the versioned project-history projection. + + Inputs must already be visible, eligible, and within the requested cutoff. + Duplicate source rows and role rows are collapsed deterministically. An + observed source project name outranks an inferred semantic display name. + A transition is omitted for an event whose predecessor was excluded from + the supplied sequence, rather than treating the displayed rows as adjacent. + """ + + normalized_key = normalize_project_key(project_key) + if maximum_depth < 1 or maximum_depth > PROJECT_HISTORY_MAX_DEPTH: + raise ValueError("maximum_depth is outside the supported bound") + if maximum_paths_per_event < 1 or maximum_paths_per_event > PROJECT_HISTORY_MAX_PATHS_PER_EVENT: + raise ValueError("maximum_paths_per_event is outside the supported bound") + + deduplicated: dict[str, Mapping[str, Any]] = {} + for row in event_rows: + event_id = str(row["post_id"]) + current = deduplicated.get(event_id) + row_clock = row.get("event_occurred_at") or row["created_at"] + current_clock = ( + current.get("event_occurred_at") or current["created_at"] + if current is not None + else None + ) + if current is None or (row_clock, row["created_at"], event_id) < ( + current_clock, + current["created_at"], + event_id, + ): + deduplicated[event_id] = row + ordered_rows = sorted( + deduplicated.values(), + key=lambda row: ( + row.get("event_occurred_at") or row["created_at"], + row["created_at"], + str(row["post_id"]), + ), + ) + if not ordered_rows: + raise ValueError("project history requires at least one visible event") + ordered_ids = [str(row["post_id"]) for row in ordered_rows] + event_index = {event_id: index for index, event_id in enumerate(ordered_ids)} + effective_focus = focus_event_id or ordered_ids[-1] + if effective_focus not in event_index: + raise ValueError("focus event is not in the visible project history") + suppressed_transition_ids = transition_suppressed_event_ids or set() + + matches_by_event: dict[str, list[dict[str, Any]]] = {event_id: [] for event_id in ordered_ids} + display_names: list[tuple[int, int, str, str]] = [] + seen_matches: set[tuple[str, str, str]] = set() + for row in match_rows: + event_id = str(row["post_id"]) + if event_id not in matches_by_event: + continue + matched_value = str(row["matched_value"]) + if normalize_project_key(matched_value) != normalized_key: + continue + kind = str(row["match_kind_code"]) + key = (event_id, kind, matched_value) + if key in seen_matches: + continue + seen_matches.add(key) + confidence = row.get("confidence") + if confidence is not None: + confidence = _score(confidence) + truth = "observed" if kind.startswith("source_") else "inferred" + matches_by_event[event_id].append( + { + "match_kind_code": kind, + "matched_value": matched_value, + "truth_status_code": truth, + "confidence": confidence, + "ontology_iri": row.get("ontology_iri"), + "provenance": str(row["provenance"]), + } + ) + if kind in _DISPLAY_NAME_ORDER: + display_names.append( + ( + _DISPLAY_NAME_ORDER[kind], + event_index[event_id], + normalize_project_key(matched_value), + matched_value, + ) + ) + for matches in matches_by_event.values(): + matches.sort( + key=lambda item: ( + _TRUTH_ORDER[item["truth_status_code"]], + item["match_kind_code"], + item["matched_value"], + ) + ) + + roles_by_event: dict[str, list[dict[str, Any]]] = {event_id: [] for event_id in ordered_ids} + actor_keys_by_event: dict[str, list[str]] = {event_id: [] for event_id in ordered_ids} + distinct_actor_keys: set[str] = set() + seen_roles: set[tuple[str, str, str]] = set() + for row in role_rows: + event_id = str(row["post_id"]) + if event_id not in roles_by_event: + continue + actor_key = _actor_key(row) + responsibility = str(row["responsibility"]) + role_key = (event_id, actor_key, responsibility) + if role_key in seen_roles: + continue + seen_roles.add(role_key) + distinct_actor_keys.add(actor_key) + actor_keys_by_event[event_id].append(actor_key) + roles_by_event[event_id].append( + { + "actor_key": actor_key, + "actor_name": str(row["actor_name"]), + "actor_type_code": str(row["actor_type_code"]), + "affiliated_organization_name": row.get("affiliated_organization_name"), + "responsibility": responsibility, + "truth_status_code": "observed", + "provenance": "post_summary_role", + } + ) + for event_id, roles in roles_by_event.items(): + roles.sort(key=lambda role: (role["actor_type_code"], role["actor_name"], role["actor_key"])) + actor_keys_by_event[event_id] = sorted(set(actor_keys_by_event[event_id])) + + paths_by_event = _prior_paths( + ordered_ids, + edge_rows, + maximum_depth=maximum_depth, + maximum_paths_per_event=maximum_paths_per_event, + ) + + events: list[dict[str, Any]] = [] + previous_actor_keys: Sequence[str] | None = None + for row in ordered_rows: + event_id = str(row["post_id"]) + event_occurred_at = row.get("event_occurred_at") + time_basis_code = ( + PROJECT_HISTORY_DOCUMENT_TIME_BASIS + if event_occurred_at is not None + else PROJECT_HISTORY_TIME_BASIS + ) + current_actor_keys = actor_keys_by_event[event_id] + transition = ( + None + if previous_actor_keys is None or event_id in suppressed_transition_ids + else responsibility_transition_code(previous_actor_keys, current_actor_keys) + ) + events.append( + { + "event_id": event_id, + "source_post_id": event_id, + "event_title": str(row["post_title"]), + "event_type_code": classify_project_event( + title=str(row["post_title"]), + source_stage_code=row.get("source_stage_code"), + source_detail_state_code=row.get("source_detail_state_code"), + voc_type_code=row.get("voc_type_code"), + is_focus=event_id == effective_focus, + ), + "event_type_basis_code": "controlled_source_code", + "occurred_at": _as_utc(event_occurred_at or row["created_at"]), + "time_basis_code": time_basis_code, + "voc_type_code": row.get("voc_type_code"), + "source_stage_code": row.get("source_stage_code"), + "source_detail_state_code": row.get("source_detail_state_code"), + "project_matches": matches_by_event[event_id], + "observed_responsibilities": roles_by_event[event_id], + "responsibility_transition_code": transition, + "related_prior_paths": paths_by_event[event_id], + } + ) + previous_actor_keys = current_actor_keys + + project_name = min(display_names)[3] if display_names else project_key.strip() + return { + "contract_version": PROJECT_HISTORY_CONTRACT_VERSION, + "project_key": project_key.strip(), + "normalized_project_key": normalized_key, + "project_name": project_name, + "focus_event_id": effective_focus, + "time_basis_code": ( + PROJECT_HISTORY_DOCUMENT_TIME_BASIS + if all(event["time_basis_code"] == PROJECT_HISTORY_DOCUMENT_TIME_BASIS for event in events) + else PROJECT_HISTORY_TIME_BASIS + ), + "event_count": len(events), + "distinct_observed_actor_count": len(distinct_actor_keys), + "truncated": bool(truncated), + "events": events, + } diff --git a/tests/test_project_history.py b/tests/test_project_history.py new file mode 100644 index 000000000..8eedec3fe --- /dev/null +++ b/tests/test_project_history.py @@ -0,0 +1,70 @@ +"""RED contracts for the customer-facing project-history timeline.""" + +from __future__ import annotations + +import pytest + +from lineageweave.project_history import ( + _prior_paths, + classify_project_event, + normalize_project_key, + responsibility_transition_code, +) + + +def test_project_identity_is_exact_but_unicode_compatible() -> None: + """Compatibility forms may normalize; fuzzy project binding may not.""" + assert normalize_project_key(" P-100 ") == "p-100" + assert normalize_project_key("P-100-A") != normalize_project_key("P-100") + with pytest.raises(ValueError): + normalize_project_key(" ") + + +def test_event_display_classification_uses_only_controlled_evidence() -> None: + """Free text cannot manufacture a lifecycle event classification.""" + assert ( + classify_project_event( + title="Contract awarded", + source_stage_code=None, + source_detail_state_code=None, + voc_type_code=None, + is_focus=False, + ) + == "source_recorded" + ) + for is_focus in (False, True): + assert ( + classify_project_event( + title="Field complaint received", + source_stage_code=None, + source_detail_state_code=None, + voc_type_code="voc", + is_focus=is_focus, + ) + == "voc_received" + ) + + +def test_responsibility_transition_describes_document_evidence_only() -> None: + """Missing adjacent evidence is a visible evidence gap, not an HR fact.""" + assert responsibility_transition_code(["person:a"], ["person:a"]) == "continuous" + assert responsibility_transition_code(["person:a"], ["person:b"]) == "handoff" + assert responsibility_transition_code(["person:a"], []) == "assignment_gap" + + +def test_prior_paths_keep_the_first_deterministic_shortest_route_per_predecessor() -> None: + """A tied route reports one stable path for a prior event rather than duplicate history.""" + paths = _prior_paths( + ["award", "spec-a", "spec-b", "delivery"], + [ + {"parent_post_id": "award", "child_post_id": "spec-a", "fused_score": 0.9}, + {"parent_post_id": "award", "child_post_id": "spec-b", "fused_score": 0.8}, + {"parent_post_id": "spec-a", "child_post_id": "delivery", "fused_score": 0.7}, + {"parent_post_id": "spec-b", "child_post_id": "delivery", "fused_score": 0.6}, + ], + maximum_depth=8, + maximum_paths_per_event=32, + ) + + award_paths = [path for path in paths["delivery"] if path["source_event_id"] == "award"] + assert [path["event_ids"] for path in award_paths] == [["award", "spec-a", "delivery"]] diff --git a/tests/test_project_history_ingestion.py b/tests/test_project_history_ingestion.py new file mode 100644 index 000000000..5817211af --- /dev/null +++ b/tests/test_project_history_ingestion.py @@ -0,0 +1,137 @@ +"""Authorization-bound project-history query tests.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone + +from backend.app.project_history import fetch_project_history_projection + + +class _Connection: + """Record projection queries and return one synthetic visible event.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, tuple[object, ...]]] = [] + + async def fetch(self, query: str, *args: object): + """Return the minimum rows required by each bounded query.""" + + self.calls.append((" ".join(query.split()), args)) + if "select post.post_id, post.post_title" in " ".join(query.split()): + return [ + { + "post_id": "00000000-0000-0000-0000-000000000001", + "post_title": "Synthetic project record", + "created_at": datetime(2026, 1, 1, tzinfo=timezone.utc), + "event_occurred_at": datetime(2025, 12, 20, tzinfo=timezone.utc), + "voc_type_code": None, + "source_stage_code": "observed-stage", + "source_detail_state_code": None, + } + ] + return [] + + +def test_project_history_query_binds_corporate_and_process_scopes() -> None: + """Private project evidence must bind both dimensions before child reads.""" + + connection = _Connection() + result = asyncio.run( + fetch_project_history_projection( + connection, + project_key="P-100", + focus_post_id=None, + knowledge_cutoff=datetime(2026, 2, 1, tzinfo=timezone.utc), + corporate_entity_ids=["corp-1"], + process_unit_ids=["pu-1"], + ) + ) + event_query, event_args = connection.calls[0] + assert "post.process_unit_id::text = any($3::text[])" in event_query + assert "coalesce(post.event_occurred_at, post.created_at)" in event_query + assert event_args[1:3] == (["corp-1"], ["pu-1"]) + assert result["events"][0]["event_type_code"] == "source_recorded" + assert result["events"][0]["occurred_at"] == "2025-12-20T00:00:00Z" + assert result["events"][0]["time_basis_code"] == "document_time" + + +def test_truncated_focus_does_not_claim_a_responsibility_transition_across_omitted_events() -> None: + """A retained focus event loses its transition when hidden events break adjacency.""" + + class _TruncatedConnection: + async def fetch(self, query: str, *args: object): + compact_query = " ".join(query.split()) + early = { + "post_id": "00000000-0000-0000-0000-000000000001", + "post_title": "Early record", + "created_at": datetime(2026, 1, 1, tzinfo=timezone.utc), + "event_occurred_at": None, + "voc_type_code": None, + "source_stage_code": None, + "source_detail_state_code": None, + } + omitted = { + "post_id": "00000000-0000-0000-0000-000000000002", + "post_title": "Omitted record", + "created_at": datetime(2026, 1, 2, tzinfo=timezone.utc), + "event_occurred_at": None, + "voc_type_code": None, + "source_stage_code": None, + "source_detail_state_code": None, + } + focus = { + "post_id": "00000000-0000-0000-0000-000000000003", + "post_title": "Focus record", + "created_at": datetime(2026, 1, 3, tzinfo=timezone.utc), + "event_occurred_at": None, + "voc_type_code": None, + "source_stage_code": None, + "source_detail_state_code": None, + } + if "post.post_id = $5::uuid" in compact_query: + return [focus] + if "limit $5" in compact_query: + return [early, omitted, focus] + if "from post_summary_role" in compact_query: + return [ + { + "post_id": early["post_id"], + "actor_name": "Early owner", + "responsibility": "Own early work", + "actor_type_code": "prov_person", + "affiliated_organization_name": None, + "cataloged_person_id": None, + "cataloged_team_id": None, + "cataloged_corporate_entity_id": None, + }, + { + "post_id": focus["post_id"], + "actor_name": "Focus owner", + "responsibility": "Own focus work", + "actor_type_code": "prov_person", + "affiliated_organization_name": None, + "cataloged_person_id": None, + "cataloged_team_id": None, + "cataloged_corporate_entity_id": None, + }, + ] + return [] + + result = asyncio.run( + fetch_project_history_projection( + _TruncatedConnection(), + project_key="P-100", + focus_post_id="00000000-0000-0000-0000-000000000003", + knowledge_cutoff=datetime(2026, 2, 1, tzinfo=timezone.utc), + corporate_entity_ids=["corp-1"], + process_unit_ids=["pu-1"], + limit=2, + ) + ) + + assert [event["event_id"] for event in result["events"]] == [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000003", + ] + assert result["events"][-1]["responsibility_transition_code"] is None