diff --git a/backend/app/main.py b/backend/app/main.py index 69b8d1944..122165990 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -166,6 +166,11 @@ fetch_occupation_ratings, fetch_rating_source_occupations, ) +from backend.app.project_history import ( + ProjectHistoryNotFound, + ProjectHistoryRequestError, + fetch_project_history_projection, +) from backend.app.post_summary_ingestion import ( fetch_persisted_summary, persist_post_summary, @@ -2588,6 +2593,33 @@ async def search_occupational_constructs( ) from None return search_page_to_payload(page) +@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 ProjectHistoryRequestError 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( diff --git a/backend/app/project_history.py b/backend/app/project_history.py new file mode 100644 index 000000000..9389ef5fd --- /dev/null +++ b/backend/app/project_history.py @@ -0,0 +1,246 @@ +"""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") +_ASCII_EDGE_WHITESPACE = r"E' \t\n\r\f\v'" +_PROJECT_MATCH = """ +( + lower(btrim(normalize(coalesce(post.source_project_code, ''), NFKC), {whitespace})) = $1 + or lower(btrim(normalize(coalesce(post.source_project_name, ''), NFKC), {whitespace})) = $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), {whitespace})) = $1 + or lower(btrim(normalize(mention.project_name, NFKC), {whitespace})) = $1 + ) + ) +) +""".format(whitespace=_ASCII_EDGE_WHITESPACE) +_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), {whitespace})) = $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), {whitespace})) = $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), {whitespace})) = $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), {whitespace})) = $2 +order by post_id, match_kind_code, matched_value +""".format(whitespace=_ASCII_EDGE_WHITESPACE) +_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.""" + + +class ProjectHistoryRequestError(ValueError): + """The caller supplied a project-history parameter outside its contract.""" + + +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 ProjectHistoryRequestError("project history limit is outside the supported bound") + try: + normalized_key = normalize_project_key(project_key) + except ValueError as exc: + raise ProjectHistoryRequestError(str(exc)) from exc + 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/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 783b28413..9534ca50a 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,25 @@ # Product & Technical Gap Baseline +> Exact-head loop overlay: 2026-08-28 KST. Protected `main` was +> `bbb191924e9881a5201f1ecf63c854d92992cc1c`; seven PRs and nine issues were +> open. PR #763 was `b51d3bd8872b` and PR #762 was `e6ca33dba1b5`; both were +> mergeable, normal squash auto-merge was enabled, exact-head Checks were still +> running, and no qualifying independent approval existed. PRs #702 +> (`93e7b81d096d`), #679 (`135dfe7c4266`), #672 (`a3e87a89185f`), #667 +> (`0c0f4af572a9`), and #640 (`bd73e0a43ae1`) remained draft and dirty against +> `main`. Central ruleset 18156473 and repository no-force-push ruleset +> 21065108 remain active. This overlay supersedes every older queue count below. +> Checks from older heads, stacked bases, or merged PRs are not transferred. +> +> Current-runtime boundary: the official Compose project was healthy at the +> HTTP health route, but its PostgreSQL schema did not yet contain +> `source_post_voice`; therefore no current Voice-history aggregate, +> authenticated project-history API result, or rendered authenticated UI result +> is claimed. Older aggregate observations below remain dated supporting +> evidence, not confirmation of this exact head. The checked repository names +> are `ContextualWisdomLab/LineageWeave`, `RankWeave`, `ThreadWeave`, `TEPP`, +> and lowercase canonical `ContextualWisdomLab/disksage`. + > Voice-of-X delivery snapshot: 2026-08-27 KST. Protected `main` was > `ff7431bd1851c03e737808d22c6a2d43968582f9`; PR #713 was > `850494c3861703862a76cfe564381a41243c6c2d`; stacked PR #717 was @@ -781,6 +801,9 @@ post-merge reruns (not transferable evidence for later heads): | ---: | --- | --- | | #644 | Native workspace surface split: 9 conditionally rendered components as lazy() dynamic imports behind a SurfaceBoundary error boundary (fallback spinner, alert + admin-guidance recovery). Build emits 9 chunks (1.5-37 kB) with main bundle 543 kB; 460 frontend tests, tsc, Storybook build green. PublicClaimVerification stays eager for synchronous Ask rendering | ADR 0220-adjacent | | #643 | Shared token-backed StatusNotice with success/unavailable/retry states (ADR 0220), WorkspaceCalendar auth-unavailable copy, 5-locale i18n; Full suite 22m54s green | ADR 0220 | +| #643 | Shared StatusNotice (ADR 0220): success/unavailable/retry states, WorkspaceCalendar auth-unavailable copy, 5-locale i18n; CI Full suite 22m54s green | ADR 0220 | +| #644 | Native workspace surface split: 9 conditionally rendered components as lazy() dynamic imports behind a SurfaceBoundary error boundary; build emits 9 chunks (1.5-37 kB), main bundle 543 kB; 470 frontend tests, tsc, Storybook green | — | +| #762 | Evidence-bound project history (ADR 0243): /api/projects/{key}/history endpoint, project_history.py projection, fetchProjectHistory client, standalone ProjectHistoryTimeline component; supersedes #668 (3-way merge kept only the additive +2279/-0, dropping the branch's 8k shared-file reverts; popup UI hookup deferred as a scoped follow-up) | ADR 0243 | | #761 | Temporal imported-primary Voice history (ADR 0252): migration 0243 (`effective_to` + GiST primary-period exclusion + synchronize trigger), refined 0237 `least()` effective_from backfill, `effective_from/effective_to` dataclass/export + `coalesce($2,$3)` cutoff predicate. Completes the half-shipped main layer that queried `voice.effective_to` against a missing column. CI Full suite 19m13s green | ADR 0252 | | #629 | Provider work released before embedding pool bound; landing reads bounded (k6-verified concurrency); merged with strix-only infra timeout (Full suite + all other gates green) | — | | #750 | Leftover-map unexplained leftover share persisted (`report_leftover_map_unexplained_share`, share `s = U² / R²`) | ADR 0233 | 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 45eb70945..5fa3d8a5e 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -38,3 +38,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/api.ts b/frontend/src/api.ts index 427c52f37..ec08090bc 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -533,6 +533,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..c58d54efb --- /dev/null +++ b/frontend/src/components/ProjectHistoryTimeline.test.tsx @@ -0,0 +1,153 @@ +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); + }); + + it("preserves the selected tab when a parent recreates an equal events array", () => { + const { rerender } = render( + , + ); + fireEvent.click(screen.getByRole("tab", { name: /Specification changed/ })); + + rerender( + , + ); + + expect(screen.getByRole("tab", { name: /Specification changed/ })).toHaveAttribute( + "aria-selected", + "true", + ); + }); +}); diff --git a/frontend/src/components/ProjectHistoryTimeline.tsx b/frontend/src/components/ProjectHistoryTimeline.tsx new file mode 100644 index 000000000..e0c2a1f2e --- /dev/null +++ b/frontend/src/components/ProjectHistoryTimeline.tsx @@ -0,0 +1,336 @@ +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 resetEventId = initialEventId(projection.events, projection.focus_event_id); + const [selectedEventId, setSelectedEventId] = useState(() => resetEventId); + 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(resetEventId); + }, [projection.normalized_project_key, projection.focus_event_id, resetEventId]); + + 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 5c3247c76..2b4e92d2e 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -76,6 +76,9 @@ describe("i18n", () => { "Title overlap", "Rankings combine newest-first and title-overlap evidence and are not calibrated scores. Open a ranked post to see its evidence.", "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.", @@ -131,6 +134,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 7266711d9..ca9a053ab 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -191,6 +191,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": "관찰된 고객 증거", @@ -759,6 +762,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": "观测到的客户证据", @@ -1344,6 +1350,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": "観測された顧客証拠", @@ -1908,6 +1917,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..281586b03 --- /dev/null +++ b/lineageweave/project_history.py @@ -0,0 +1,410 @@ +"""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} +_ASCII_EDGE_WHITESPACE = " \t\n\r\f\v" + + +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(_ASCII_EDGE_WHITESPACE).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..c4886becf --- /dev/null +++ b/tests/test_project_history_ingestion.py @@ -0,0 +1,180 @@ +"""Authorization-bound project-history query tests.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone + +from backend.app.project_history import ( + ProjectHistoryRequestError, + 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_project_history_query_uses_the_same_ascii_edge_whitespace_as_python() -> None: + """SQL and Python must normalize tab-delimited source keys identically.""" + + connection = _Connection() + asyncio.run( + fetch_project_history_projection( + connection, + project_key="\tP-100\n", + focus_post_id=None, + knowledge_cutoff=datetime(2026, 2, 1, tzinfo=timezone.utc), + corporate_entity_ids=[], + process_unit_ids=[], + ) + ) + event_query, event_args = connection.calls[0] + assert "btrim(normalize(coalesce(post.source_project_code, ''), NFKC), E'" in event_query + assert event_args[0] == "p-100" + + +def test_project_history_rejects_invalid_request_parameters_explicitly() -> None: + """Caller input errors use the request-error type, not internal ValueError.""" + + connection = _Connection() + try: + asyncio.run( + fetch_project_history_projection( + connection, + project_key=" ", + focus_post_id=None, + knowledge_cutoff=datetime(2026, 2, 1, tzinfo=timezone.utc), + corporate_entity_ids=[], + process_unit_ids=[], + ) + ) + except ProjectHistoryRequestError: + pass + else: + raise AssertionError("blank project key was accepted") + + +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