diff --git a/AGENTS.md b/AGENTS.md index 6baad7d21..a01b63067 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -184,6 +184,11 @@ Do not invent a week, a theta, a cutoff body, a CalDAV event, or a customer. next read (ADR 0100). Do not invent a week, a theta, a cutoff body, a CalDAV event, a customer, or a cited post. +Ask Agent accepts an optional knowledge cutoff (ADR 0135). A dated +question uses retained revisions and never substitutes a live body. A +live query is never labeled as-of. Do not invent a cutoff body or a +TEPP theta. + ## Tests diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 70b7d86b6..6f77b917d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -281,10 +281,12 @@ rebuild), the post list with a named Weekly VOC ISO-8601 week filter Calendar commitments use the same Event Lineage focus path (ADR 0094). Customer master related posts use the same Event Lineage focus path (ADR 0095). Ask Agent cited posts use the same Event Lineage focus path -(ADR 0096). A linked Event Lineage node opened from a focused popup -keeps those flags (ADR 0097) and then focuses Keyman as the named next -read (ADR 0100). -The full detail popup includes Korean +(ADR 0096). A linked Event Lineage node opened from a focused popup keeps +those flags (ADR 0097). Ask Agent accepts an optional knowledge cutoff +and uses retained `source_post_revision` bodies for that clock (ADR 0135); +a live query is never labeled as-of. The full detail popup includes Korean +and focuses Keyman as the named next read (ADR 0100). The full detail +popup includes Korean summary/key-events/R&R, VOC evidence excerpts, an Event Lineage panel (direct vs. indirect links; a link opens that post), the Keyman affiliate tree (resolved ancestors plus unresolved org roots), Keyman + @@ -371,7 +373,9 @@ close the one product-brief item with a schema table (`issue_ticket`) but no implementation through Phase 4. Deliberately plain CRUD, not a pluggable-LLM channel like `keyman_ingestion.py` -- ticket status is a closed enum in `common_lookup_value`, and opening or updating a ticket -is a direct user action, not something extracted from text. +is a direct user action, not something extracted from text. Ticket writes +also require `post_admin` plus authorship or corporate affiliation with the +owning post; public visibility is read access only (ADR 0122). `frontend/src/App.tsx`'s `IssueTicketPanel` is the popup's real list/create/status-update UI for it. Status options show `common_lookup_value` labels (`Open` / `In progress` / `Closed`) diff --git a/CHANGELOG.d/2.18.0-project-history-truth-and-loading.md b/CHANGELOG.d/2.18.0-project-history-truth-and-loading.md new file mode 100644 index 000000000..ded0feb87 --- /dev/null +++ b/CHANGELOG.d/2.18.0-project-history-truth-and-loading.md @@ -0,0 +1,5 @@ +### Fixed + +- Project history now labels the actual source-post or document time basis, + keeps evidence-free responsibility gaps unknown, shows a loading status while + history is fetched, and uses the Stylelint-compatible `currentcolor` token. diff --git a/CHANGELOG.d/2.23.0-global-ask-knowledge-cutoff.md b/CHANGELOG.d/2.23.0-global-ask-knowledge-cutoff.md new file mode 100644 index 000000000..af811e0aa --- /dev/null +++ b/CHANGELOG.d/2.23.0-global-ask-knowledge-cutoff.md @@ -0,0 +1,10 @@ +# 2.23.0 Global Ask knowledge cutoff + +Ask Agent can name a dated question. Retrieval keeps only source posts +that existed by that clock and matches the covering +`source_post_revision` text. A missing historical body is an explicit +limitation. Live queries stay live-only. + +The Buyer now renders each missing historical-body limitation in a partial +answer. Commitment-derived ticket creation also enforces owning-post write +authorization before calling contextual-orchestrator. diff --git a/CHANGELOG.md b/CHANGELOG.md index e6fb6d1f8..f01138653 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.23.0] - 2026-08-20 + +### Added + +- Ask Agent now accepts an optional knowledge cutoff. A dated question + matches retained source-post revisions from that clock and never + substitutes a live body or live rewrite text. Fully, partly, and + live-only answers are named separately. No TEPP theta is invented. No + as-of label is applied to a live query (ADR 0135 / ADR 0016 / ADR 0025). + +### Fixed + +- Partial cutoff answers now show which historical bodies were unavailable, + and commitment-derived ticket writes enforce the owning-post authorization + boundary before provider work. + +## [2.18.0] - 2026-08-20 + +### Added + +- Added a Buyer Project history destination and post-detail entry point for + bounded, authorized exact-project chronology. The release remains pending + protected-main review and Checks (ADR 0111). + ## [2.19.0] - 2026-08-20 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 6f1ee6755..677d16396 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,6 +106,14 @@ From a GNB-focused popup, open a linked Event Lineage node: Event Lineage stays focused and names the new post as current (ADR 0097). A home-list DAG walk does not. Do not invent a theta. +## Ask Agent knowledge cutoff (v2.23.0) + +Open Ask Agent. Optionally set a knowledge cutoff. A dated question uses +retained source-post revisions from that clock. A live query stays +live-only and is never labeled as-of. A missing historical body is named +and the live rewrite is not used (ADR 0135). Do not invent a theta or a +cutoff body. + ## GNB Event Lineage focuses Keyman (v2.19.0) A GNB-origin popup (Weekly VOC, Calendar, Customer master, Ask Agent, or a diff --git a/backend/app/main.py b/backend/app/main.py index 3ae9d90d6..eabd4a638 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -23,7 +23,7 @@ import json from contextlib import asynccontextmanager from dataclasses import asdict -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Literal from uuid import UUID @@ -73,8 +73,12 @@ ChatSourceDocument, ContextualOrchestratorPostChatClient, NullPostChatClient, + ask_grounding_status, + ask_next_action, + cited_post_citations, cited_post_evidence, cited_post_summaries, + historical_body_limitations, render_global_ask_context, ) from lineageweave.post_content_normalization import normalize_post_body @@ -187,6 +191,16 @@ require_summary_source_body, ) from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL +from backend.app.project_history import ( + PROJECT_HISTORY_DEFAULT_LIMIT, + PROJECT_HISTORY_MAXIMUM_LIMIT, + PROJECT_INDEX_DEFAULT_LIMIT, + PROJECT_INDEX_MAXIMUM_LIMIT, + ProjectHistoryNotFound, + fetch_project_history_index, + fetch_project_history_projection, +) +from lineageweave.project_history import normalize_project_key from backend.app.demo_scope import ( fetch_demo_corporate_entity_ids, has_real_source_context, @@ -259,6 +273,22 @@ def _require_post_admin(account: CurrentAccount) -> None: raise HTTPException(status.HTTP_403_FORBIDDEN, "account lacks the post_admin permission") +def _require_ticket_post_access(account: CurrentAccount, post: asyncpg.Record) -> None: + """Require ticket mutation access to the owning post, not visibility alone. + + ``post_admin`` is necessary but intentionally not sufficient: a public post + can be read by every account, while ticket state is still a write to the + authoring account's corporate work area. + """ + is_author = str(post["author_account_id"]) == account.user_account_id + is_affiliated = str(post["corporate_entity_id"]) in account.corporate_entity_ids + if not (is_author or is_affiliated): + raise HTTPException( + status.HTTP_403_FORBIDDEN, + "account is not authorized to modify tickets on this post", + ) + + def _keyman_extraction_client(): """Live orchestrator client when configured; otherwise the unavailable null.""" settings = load_settings() @@ -2637,6 +2667,7 @@ class GlobalAskRequest(BaseModel): question: str session_id: str | None = None + knowledge_cutoff: str | None = None def global_ask_timeline(sources: list[ChatSourceDocument]) -> list[dict[str, str | None]]: @@ -2772,6 +2803,15 @@ async def ask_agent( UUID(request.session_id) except ValueError: raise HTTPException(status.HTTP_404_NOT_FOUND, "Global Ask session not found") from None + knowledge_cutoff = None + if request.knowledge_cutoff is not None and request.knowledge_cutoff.strip(): + try: + knowledge_cutoff = parse_as_of_clock(request.knowledge_cutoff) + except ValueError as exc: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + "knowledge_cutoff must be an ISO-8601 timestamp", + ) from exc client = _post_chat_client() if not client.available: raise HTTPException( @@ -2790,6 +2830,7 @@ async def ask_agent( lambda row: _can_see_post(account, row), account.corporate_entity_ids, question=question, + knowledge_cutoff=knowledge_cutoff, ) if conversation.compress_turns: compressor = getattr(client, "compress_context", None) @@ -2821,7 +2862,11 @@ async def ask_agent( conversation.summary, conversation.recent_turns, ) - if not sources: + grounding_status = ask_grounding_status(sources, knowledge_cutoff) + limitations = historical_body_limitations(sources) + cutoff_text = knowledge_cutoff.isoformat() if knowledge_cutoff is not None else None + llm_sources = [source for source in sources if not source.historical_body_unavailable] + if not llm_sources: async with pool.acquire() as conn: await persist_global_ask_turn(conn, conversation.session_id, question, "", ()) await publish_operation_event( @@ -2834,17 +2879,24 @@ async def ask_agent( "session_id": conversation.session_id, "answer_text": "", "cited_post_ids": [], - "cited_posts": [], - "source_post_ids": [], + "cited_posts": cited_post_citations(sources, [source.post_id for source in sources]), + "source_post_ids": [source.post_id for source in sources], "cited_post_evidence": [], - "timeline": [], - "next_action": "No authorized source posts are available for this question.", + "timeline": global_ask_timeline(sources), + "knowledge_cutoff": cutoff_text, + "grounding_status": grounding_status, + "limitations": limitations, + "next_action": ask_next_action( + grounding_status, + has_sources=bool(sources), + has_retained_bodies=bool(llm_sources), + ), } try: answer = await asyncio.to_thread( client.answer, question, - sources, + llm_sources, conversation_context=conversation_context, ) except (HttpClientError, KeyError, OSError, RuntimeError, ValueError) as exc: @@ -2876,10 +2928,22 @@ async def ask_agent( "session_id": conversation.session_id, "answer_text": answer.answer_text, "cited_post_ids": cited_ids, - "cited_posts": cited_post_summaries(sources, cited_ids), - "cited_post_evidence": cited_post_evidence(sources, cited_ids), + "cited_posts": cited_post_citations(llm_sources, cited_ids), + "cited_post_evidence": cited_post_evidence(llm_sources, cited_ids), + # The timeline is the complete authorized retrieval boundary. A + # source without a retained cutoff body is still a real timeline + # event and must remain navigable, even though it is excluded from + # the LLM evidence bundle. "source_post_ids": [source.post_id for source in sources], "timeline": global_ask_timeline(sources), + "knowledge_cutoff": cutoff_text, + "grounding_status": grounding_status, + "limitations": limitations, + "next_action": ask_next_action( + grounding_status, + has_sources=True, + has_retained_bodies=bool(llm_sources), + ), } @@ -2978,7 +3042,8 @@ async def create_post_ticket( a ticket is a write action, same discipline as extract-keymen. """ _require_post_admin(account) - await _load_visible_post(post_id, account, pool) + post = await _load_visible_post(post_id, account, pool) + _require_ticket_post_access(account, post) async with pool.acquire() as conn: try: ticket = await create_ticket( @@ -3039,7 +3104,8 @@ async def patch_ticket( post_id = await fetch_ticket_post_id(conn, issue_ticket_id) if post_id is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "ticket not found") - await _load_visible_post(post_id, account, pool) + post = await _load_visible_post(post_id, account, pool) + _require_ticket_post_access(account, post) async with pool.acquire() as conn: try: ticket = await update_ticket( @@ -3100,6 +3166,7 @@ async def derive_post_commitment( """ _require_post_admin(account) post = await _load_visible_post(post_id, account, pool) + _require_ticket_post_access(account, post) post_metadata = build_post_llm_metadata(post_id, post) with use_llm_metadata(post_metadata): client = _commitment_extraction_client() @@ -3371,6 +3438,76 @@ async def read_calendar( } +@app.get("/api/project-history/projects") +async def read_project_history_projects( + limit: int = Query(PROJECT_INDEX_DEFAULT_LIMIT, ge=1, le=PROJECT_INDEX_MAXIMUM_LIMIT), + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Return exact project identities available to the signed-in buyer.""" + + _require_post_read(account) + knowledge_cutoff = datetime.now(timezone.utc) + async with pool.acquire() as conn: + return await fetch_project_history_index( + conn, + knowledge_cutoff=knowledge_cutoff, + corporate_entity_ids=list(account.corporate_entity_ids), + limit=limit, + ) + + +@app.get("/api/project-history") +async def read_project_history( + project_key: str = Query(..., min_length=1), + focus_post_id: str | None = Query(None), + knowledge_cutoff: str | None = Query(None), + limit: int = Query(PROJECT_HISTORY_DEFAULT_LIMIT, ge=1, le=PROJECT_HISTORY_MAXIMUM_LIMIT), + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Return one exact, authorized project history for the Buyer timeline.""" + + _require_post_read(account) + try: + normalized_project_key = normalize_project_key(project_key) + except ValueError as exc: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + "project_key must contain a non-empty exact identity", + ) from exc + if focus_post_id is not None: + try: + UUID(focus_post_id) + except ValueError as exc: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + "focus_post_id must be a UUID", + ) from exc + if knowledge_cutoff is None: + cutoff = datetime.now(timezone.utc) + else: + try: + cutoff = parse_as_of_clock(knowledge_cutoff) + except ValueError as exc: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + "knowledge_cutoff must be an ISO-8601 timestamp", + ) from exc + async with pool.acquire() as conn: + try: + return await fetch_project_history_projection( + conn, + project_key=project_key, + focus_post_id=focus_post_id, + knowledge_cutoff=cutoff, + corporate_entity_ids=list(account.corporate_entity_ids), + limit=limit, + ) + except ProjectHistoryNotFound as exc: + raise HTTPException(status.HTTP_404_NOT_FOUND, "project history not found") from exc + + @app.get("/api/rankings") async def read_rankings( account: CurrentAccount = Depends(get_current_account), diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index 499691c70..d5d9b2ec4 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -20,6 +20,7 @@ import asyncio import re from dataclasses import dataclass +from datetime import datetime, timezone from typing import Any, Callable, Iterable from uuid import uuid4 @@ -44,6 +45,7 @@ from lineageweave.post_content_normalization import normalize_post_body from .knowledge_graph import hydrate_related_nodes, load_visible_subgraph +from .source_post_revision import fetch_cutoff_revisions from lineageweave.ontology import ontology_annotations @@ -586,6 +588,93 @@ async def gather_chat_sources( return sources +def _row_value(row: Any, key: str, default: Any = None) -> Any: + getter = getattr(row, "get", None) + if callable(getter): + return getter(key, default) + try: + return row[key] + except (KeyError, Exception): + return default + + +def _clock_iso(value: datetime) -> str: + return value.isoformat() + + +def _global_ask_candidate_sql(*, knowledge_cutoff: bool) -> str: + if not knowledge_cutoff: + return """ + select post_id, matched_in + from ( + (select post_id, created_at, 'title' as matched_in + from source_post + where post_title ilike '%' || $1 || '%' + limit 32) + union all + (select post_id, created_at, 'body' as matched_in + from source_post + where lower(left(source_post_search_text(post_body), 16384)) + like '%' || lower($1) || '%' + limit 32) + union all + (select post_id, created_at, 'body' as matched_in + from source_post + where to_tsvector('simple', source_post_search_text(post_body)) + @@ plainto_tsquery('simple', $1) + limit 32) + union all + (select post_id, created_at, 'source_field' as matched_in + from source_post + where concat_ws(' ', source_system_code, source_record_key, + source_author_code, source_author_name, + source_company_code, source_company_name, + source_process_unit_code, source_process_unit_name, + source_sales_pool_code, source_sales_pool_name, + source_customer_code, source_customer_name, + source_project_code, source_project_name) + ilike '%' || $1 || '%' + limit 32) + ) matches + order by created_at desc, post_id desc + limit 32 + """ + covering = ( + "spr.written_at <= $2 " + "and (spr.superseded_at is null or spr.superseded_at > $2) " + "and sp.created_at <= $2" + ) + return f""" + select post_id, matched_in + from ( + (select sp.post_id, sp.created_at, 'title' as matched_in + from source_post_revision spr + join source_post sp on sp.post_id = spr.post_id + where {covering} + and spr.post_title ilike '%' || $1 || '%' + limit 32) + union all + (select sp.post_id, sp.created_at, 'body' as matched_in + from source_post_revision spr + join source_post sp on sp.post_id = spr.post_id + where {covering} + and lower(left(source_post_search_text(spr.post_body), 16384)) + like '%' || lower($1) || '%' + limit 32) + union all + (select sp.post_id, sp.created_at, 'body' as matched_in + from source_post_revision spr + join source_post sp on sp.post_id = spr.post_id + where {covering} + and to_tsvector('simple', source_post_search_text(spr.post_body)) + @@ plainto_tsquery('simple', $1) + limit 32) + ) matches + order by created_at desc, post_id desc + limit 32 + """ + + async def gather_global_chat_sources( conn: asyncpg.Connection, can_see_post: Callable[[asyncpg.Record], bool], @@ -594,12 +683,15 @@ async def gather_global_chat_sources( *, question: str | None = None, limit: int = 4, + knowledge_cutoff: datetime | None = None, ) -> list[ChatSourceDocument]: """Assemble a bounded, ABAC-filtered source set for Global Ask. The source set is intentionally bounded until retrieval/reranking is needed for a much larger corpus; every selected body still uses the same image normalization and persisted graph evidence as post-scoped chat. + A knowledge cutoff uses retained revisions (ADR 0135) and never + substitutes a live body for a missing historical one. """ if limit <= 0: return [] @@ -646,45 +738,12 @@ async def gather_global_chat_sources( # loosely related posts in a live reproduction of this bug. _MATCH_WEIGHT = {"title": 3.0, "body": 1.0, "source_field": 1.0} candidate_scores: dict[str, float] = {} + candidate_sql = _global_ask_candidate_sql(knowledge_cutoff=knowledge_cutoff is not None) for term in search_terms: - candidate_rows = await conn.fetch( - """ - select post_id, matched_in - from ( - (select post_id, created_at, 'title' as matched_in - from source_post - where post_title ilike '%' || $1 || '%' - limit 32) - union all - (select post_id, created_at, 'body' as matched_in - from source_post - where lower(left(source_post_search_text(post_body), 16384)) - like '%' || lower($1) || '%' - limit 32) - union all - (select post_id, created_at, 'body' as matched_in - from source_post - where to_tsvector('simple', source_post_search_text(post_body)) - @@ plainto_tsquery('simple', $1) - limit 32) - union all - (select post_id, created_at, 'source_field' as matched_in - from source_post - where concat_ws(' ', source_system_code, source_record_key, - source_author_code, source_author_name, - source_company_code, source_company_name, - source_process_unit_code, source_process_unit_name, - source_sales_pool_code, source_sales_pool_name, - source_customer_code, source_customer_name, - source_project_code, source_project_name) - ilike '%' || $1 || '%' - limit 32) - ) matches - order by created_at desc, post_id desc - limit 32 - """, - term, + candidate_args: tuple[object, ...] = ( + (term, knowledge_cutoff) if knowledge_cutoff is not None else (term,) ) + candidate_rows = await conn.fetch(candidate_sql, *candidate_args) for row in candidate_rows: post_id = str(row["post_id"]) candidate_scores[post_id] = candidate_scores.get(post_id, 0.0) + _MATCH_WEIGHT[row["matched_in"]] @@ -702,11 +761,23 @@ async def gather_global_chat_sources( lineage_neighbor_ids: list[str] = [] lineage_anchor_id = candidate_ids[0] if candidate_ids else None if lineage_anchor_id: - lineage_rows = await conn.fetch( - "select child_post_id as other_id from post_lineage_edge where parent_post_id = $1 " - "union select parent_post_id as other_id from post_lineage_edge where child_post_id = $1", - lineage_anchor_id, - ) + if knowledge_cutoff is not None: + lineage_rows = await conn.fetch( + "select child_post_id as other_id from post_lineage_edge " + "join source_post on source_post.post_id = child_post_id " + "where parent_post_id = $1 and source_post.created_at <= $2 " + "union select parent_post_id as other_id from post_lineage_edge " + "join source_post on source_post.post_id = parent_post_id " + "where child_post_id = $1 and source_post.created_at <= $2", + lineage_anchor_id, + knowledge_cutoff, + ) + else: + lineage_rows = await conn.fetch( + "select child_post_id as other_id from post_lineage_edge where parent_post_id = $1 " + "union select parent_post_id as other_id from post_lineage_edge where child_post_id = $1", + lineage_anchor_id, + ) lineage_neighbor_ids = sorted( { str(row["other_id"]) @@ -721,54 +792,121 @@ async def gather_global_chat_sources( candidate_ids = candidate_ids[:candidate_budget] lineage_neighbor_id_set = frozenset(lineage_neighbor_ids) - rows = await conn.fetch( - """ - select post_id, post_title, post_body, visibility_code, corporate_entity_id, - created_at, - source_system_code, source_record_key, source_author_code, source_author_name, - source_company_code, source_company_name, source_process_unit_code, - source_process_unit_name, source_sales_pool_code, source_sales_pool_name, - source_customer_code, source_customer_name, - source_project_code, source_project_name - from source_post - where visibility_code = 'public' - or corporate_entity_id::text = any($1::text[]) - order by array_position($2::uuid[], post_id) nulls last, - created_at desc, post_id desc - limit $3 - """, - list(authorized_corporate_entity_ids), - candidate_ids, - limit, - ) + if knowledge_cutoff is not None: + rows = await conn.fetch( + """ + select post_id, post_title, post_body, visibility_code, corporate_entity_id, + created_at, updated_at, + source_system_code, source_record_key, source_author_code, source_author_name, + source_company_code, source_company_name, source_process_unit_code, + source_process_unit_name, source_sales_pool_code, source_sales_pool_name, + source_customer_code, source_customer_name, + source_project_code, source_project_name + from source_post + where (visibility_code = 'public' + or corporate_entity_id::text = any($1::text[])) + and created_at <= $4 + order by array_position($2::uuid[], post_id) nulls last, + created_at desc, post_id desc + limit $3 + """, + list(authorized_corporate_entity_ids), + candidate_ids, + limit, + knowledge_cutoff, + ) + else: + rows = await conn.fetch( + """ + select post_id, post_title, post_body, visibility_code, corporate_entity_id, + created_at, + source_system_code, source_record_key, source_author_code, source_author_name, + source_company_code, source_company_name, source_process_unit_code, + source_process_unit_name, source_sales_pool_code, source_sales_pool_name, + source_customer_code, source_customer_name, + source_project_code, source_project_name + from source_post + where visibility_code = 'public' + or corporate_entity_id::text = any($1::text[]) + order by array_position($2::uuid[], post_id) nulls last, + created_at desc, post_id desc + limit $3 + """, + list(authorized_corporate_entity_ids), + candidate_ids, + limit, + ) visible_rows = [row for row in rows if can_see_post(row)][:limit] visible_ids = [str(row["post_id"]) for row in visible_rows] anchor_is_visible = lineage_anchor_id in visible_ids - semantic_facts = await _semantic_facts_for_posts(conn, visible_ids) - graph_facts = (await _graph_facts_for_posts(conn, visible_ids))[:16] + cutoff_revisions = ( + await fetch_cutoff_revisions(conn, visible_ids, knowledge_cutoff) + if knowledge_cutoff is not None + else {} + ) + semantic_facts = ( + {} + if knowledge_cutoff is not None + else await _semantic_facts_for_posts(conn, visible_ids) + ) + graph_facts = ( + () + if knowledge_cutoff is not None + else (await _graph_facts_for_posts(conn, visible_ids))[:16] + ) + cutoff_text = _clock_iso(knowledge_cutoff) if knowledge_cutoff is not None else None sources: list[ChatSourceDocument] = [] for index, row in enumerate(visible_rows): - normalized_body = await _normalize_post_body_text(row["post_body"], vision_client) - if len(normalized_body) > 4000: - normalized_body = ( - normalized_body[:4000] - + "\n[Source body truncated for Global Ask; open the cited post for the full body.]" - ) post_id = str(row["post_id"]) lineage_fact = ( (f"Event Lineage: reconstructed timeline neighbor of post_id={lineage_anchor_id}",) if post_id in lineage_neighbor_id_set and anchor_is_visible else () ) + revision = cutoff_revisions.get(post_id) if knowledge_cutoff is not None else None + historical_body_unavailable = knowledge_cutoff is not None and revision is None + if historical_body_unavailable: + title = row["post_title"] + normalized_body = "" + evidence_facts: tuple[str, ...] = lineage_fact + source_revision_id = None + evidence_available_at = None + elif revision is not None: + title = revision["post_title"] + normalized_body = await _normalize_post_body_text( + revision["post_body"], vision_client + ) + evidence_facts = lineage_fact + source_revision_id = revision["source_revision_id"] + evidence_available_at = revision["written_at"] + else: + title = row["post_title"] + normalized_body = await _normalize_post_body_text(row["post_body"], vision_client) + evidence_facts = _source_hint_facts(row) + semantic_facts.get(post_id, ()) + lineage_fact + source_revision_id = None + evidence_available_at = None + if len(normalized_body) > 4000: + normalized_body = ( + normalized_body[:4000] + + "\n[Source body truncated for Global Ask; open the cited post for the full body.]" + ) + updated_at = _row_value(row, "updated_at") + live_after_cutoff = False + if knowledge_cutoff is not None and updated_at is not None: + live_clock = updated_at + if getattr(updated_at, "tzinfo", None) is None: + live_clock = updated_at.replace(tzinfo=timezone.utc) + try: + live_after_cutoff = live_clock > knowledge_cutoff + except TypeError: + live_after_cutoff = False sources.append( ChatSourceDocument( post_id, - row["post_title"], + title, normalized_body, graph_facts=graph_facts if index == 0 else (), - evidence_facts=_source_hint_facts(row) - + semantic_facts.get(post_id, ()) - + lineage_fact, + evidence_facts=evidence_facts, occurred_at=_timestamp_text(row), timeline_kind=( "lineage_neighbor" @@ -777,6 +915,11 @@ async def gather_global_chat_sources( if post_id == lineage_anchor_id else "keyword_match" ), + source_revision_id=source_revision_id, + evidence_available_at=evidence_available_at, + knowledge_cutoff=cutoff_text, + live_after_cutoff=bool(live_after_cutoff), + historical_body_unavailable=historical_body_unavailable, ) ) return sources diff --git a/backend/app/project_history.py b/backend/app/project_history.py new file mode 100644 index 000000000..1ac74422d --- /dev/null +++ b/backend/app/project_history.py @@ -0,0 +1,436 @@ +"""ABAC-safe PostgreSQL projections for Buyer project histories.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import Any, Protocol +from uuid import UUID + +from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL +from lineageweave.project_history import ( + PROJECT_HISTORY_CONTRACT_VERSION, + PROJECT_HISTORY_TIME_BASIS, + _as_utc, + build_project_history_projection, + normalize_project_key, +) + +PROJECT_HISTORY_DEFAULT_LIMIT = 64 +PROJECT_HISTORY_MAXIMUM_LIMIT = 128 +PROJECT_INDEX_DEFAULT_LIMIT = 100 +PROJECT_INDEX_MAXIMUM_LIMIT = 200 +PROJECT_INDEX_MINIMUM_SOURCE_POST_LIMIT = 1024 +PROJECT_INDEX_STATEMENT_TIMEOUT_MILLISECONDS = 5000 + + +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.""" + + raise NotImplementedError + + +_ELIGIBILITY = SOURCE_POST_ELIGIBILITY_SQL.format(alias="post") +_SOURCE_CODE = "nullif(btrim(post.source_project_code), '')" +_SOURCE_NAME = "nullif(btrim(post.source_project_name), '')" +_MENTION_KEY = "nullif(btrim(mention.project_key), '')" +_MENTION_NAME = "nullif(btrim(mention.project_name), '')" +_PROJECT_MATCH = f""" +( + ( + {_SOURCE_CODE} is not null + and lower(normalize({_SOURCE_CODE}, NFKC)) = $1 + ) + or ( + {_SOURCE_CODE} is null + and {_SOURCE_NAME} is not null + and lower(normalize({_SOURCE_NAME}, NFKC)) = $1 + ) + or exists ( + select 1 + from post_project_mention mention + where mention.post_id = post.post_id + and ( + ( + {_MENTION_KEY} is not null + and lower(normalize({_MENTION_KEY}, NFKC)) = $1 + ) + or ( + {_MENTION_KEY} is null + and {_MENTION_NAME} is not null + and lower(normalize({_MENTION_NAME}, NFKC)) = $1 + ) + ) + ) +) +""" +_EVENT_SQL = f""" +select post.post_id, + post.post_title, + post.created_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 {_ELIGIBILITY} + and post.created_at <= $3 + and {_PROJECT_MATCH} + order by post.created_at, post.post_id + limit $4 +""" +_FOCUS_SQL = f""" +select post.post_id, + post.post_title, + post.created_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 {_ELIGIBILITY} + and post.created_at <= $3 + and post.post_id = $4::uuid + and {_PROJECT_MATCH} + limit 1 +""" +_MATCH_SQL = f""" +select post.post_id, + 'source_project_code'::text as match_kind_code, + {_SOURCE_CODE} as identity_key, + {_SOURCE_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 {_SOURCE_CODE} is not null + and lower(normalize({_SOURCE_CODE}, NFKC)) = $2 +union all +select post.post_id, + 'source_project_name'::text, + coalesce({_SOURCE_CODE}, {_SOURCE_NAME}) as identity_key, + {_SOURCE_NAME} as matched_value, + null::numeric, + null::text, + 'source_post.source_project_name'::text + from source_post post + where post.post_id = any($1::uuid[]) + and {_SOURCE_NAME} is not null + and ( + ({_SOURCE_CODE} is not null and lower(normalize({_SOURCE_CODE}, NFKC)) = $2) + or ({_SOURCE_CODE} is null and lower(normalize({_SOURCE_NAME}, NFKC)) = $2) + ) +union all +select mention.post_id, + 'semantic_project_key'::text, + {_MENTION_KEY} as identity_key, + {_MENTION_KEY} as matched_value, + mention.confidence, + mention.ontology_iri, + 'post_project_mention.project_key'::text + from post_project_mention mention + where mention.post_id = any($1::uuid[]) + and {_MENTION_KEY} is not null + and lower(normalize({_MENTION_KEY}, NFKC)) = $2 +union all +select mention.post_id, + 'semantic_project_name'::text, + coalesce({_MENTION_KEY}, {_MENTION_NAME}) as identity_key, + {_MENTION_NAME} as matched_value, + mention.confidence, + mention.ontology_iri, + 'post_project_mention.project_name'::text + from post_project_mention mention + where mention.post_id = any($1::uuid[]) + and {_MENTION_NAME} is not null + and ( + ({_MENTION_KEY} is not null and lower(normalize({_MENTION_KEY}, NFKC)) = $2) + or ({_MENTION_KEY} is null and lower(normalize({_MENTION_NAME}, NFKC)) = $2) + ) +order by post_id, match_kind_code, matched_value +""" +_ROLE_SQL = """ +select post.post_id, + coalesce( + nullif(btrim(post.source_author_name), ''), + nullif(btrim(post.source_author_code), '') + ) as actor_name, + 'Source author'::text as responsibility, + 'prov_person'::text as actor_type_code, + nullif(btrim(post.source_company_name), '') as affiliated_organization_name, + null::uuid as cataloged_person_id, + null::uuid as cataloged_team_id, + null::uuid as cataloged_corporate_entity_id, + 'observed'::text as truth_status_code, + 'source_post.source_author'::text as provenance + from source_post post + where post.post_id = any($1::uuid[]) + and coalesce( + nullif(btrim(post.source_author_name), ''), + nullif(btrim(post.source_author_code), '') + ) is not null +union all +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, + 'inferred'::text as truth_status_code, + 'post_summary_role'::text as provenance + from post_summary_role role + where role.post_id = any($1::uuid[]) +order by post_id, truth_status_code, actor_type_code, actor_name, 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 +""" +_INDEX_SQL = f""" +with query_timeout as materialized ( + select set_config( + 'statement_timeout', + '{PROJECT_INDEX_STATEMENT_TIMEOUT_MILLISECONDS}', + true + ) +), recent_visible_post as materialized ( + select post.post_id, + post.created_at, + {_SOURCE_CODE} as source_project_code, + {_SOURCE_NAME} as source_project_name + from source_post post + cross join query_timeout + where (post.visibility_code = 'public' + or post.corporate_entity_id::text = any($1::text[])) + and {_ELIGIBILITY} + and post.created_at <= $2 + order by post.created_at desc, post.post_id desc + limit ($4 + 1) +), visible_post as materialized ( + select post_id, created_at, source_project_code, source_project_name + from recent_visible_post + order by created_at desc, post_id desc + limit $4 +), source_scan as ( + select count(*) > $4 as source_scan_truncated + from recent_visible_post +), project_evidence as ( + select visible_post.post_id, + visible_post.created_at, + coalesce(visible_post.source_project_code, visible_post.source_project_name) as project_key, + coalesce(visible_post.source_project_name, visible_post.source_project_code) as project_name, + 'observed'::text as truth_status_code, + 0::integer as truth_order + from visible_post + where coalesce(visible_post.source_project_code, visible_post.source_project_name) is not null + union all + select visible_post.post_id, + visible_post.created_at, + coalesce({_MENTION_KEY}, {_MENTION_NAME}) as project_key, + coalesce({_MENTION_NAME}, {_MENTION_KEY}) as project_name, + 'inferred'::text as truth_status_code, + 1::integer as truth_order + from visible_post + join post_project_mention mention on mention.post_id = visible_post.post_id + where coalesce({_MENTION_KEY}, {_MENTION_NAME}) is not null +), normalized_evidence as ( + select project_evidence.*, + lower(normalize(project_evidence.project_key, NFKC)) as normalized_project_key + from project_evidence +), ranked_evidence as ( + select normalized_evidence.*, + row_number() over ( + partition by normalized_project_key + order by truth_order, created_at, project_name, project_key, post_id + ) as display_rank + from normalized_evidence +), project_group as ( + select normalized_project_key, + min(project_key) filter (where display_rank = 1) as project_key, + min(project_name) filter (where display_rank = 1) as project_name, + min(truth_status_code) filter (where display_rank = 1) as truth_status_code, + count(distinct post_id) as event_count, + max(created_at) as latest_event_at + from ranked_evidence + group by normalized_project_key +) +select normalized_project_key, + project_key, + project_name, + truth_status_code, + event_count, + latest_event_at, + source_scan.source_scan_truncated + from project_group + cross join source_scan + order by latest_event_at desc, project_name, project_key, normalized_project_key + limit $3 +""" + + +class ProjectHistoryNotFound(LookupError): + """No authorized project history matched the requested identity.""" + + +def _require_aware_cutoff(knowledge_cutoff: datetime) -> None: + """Require an offset-aware cutoff before any database read.""" + + if knowledge_cutoff.tzinfo is None or knowledge_cutoff.utcoffset() is None: + raise ValueError("knowledge_cutoff must be offset-aware") + + +def _canonical_focus_post_id(focus_post_id: str | None) -> str | None: + """Validate and canonicalize an optional UUID before PostgreSQL sees it.""" + + if focus_post_id is None: + return None + try: + return str(UUID(focus_post_id)) + except (TypeError, ValueError, AttributeError) as exc: + raise ValueError("focus_post_id must be a UUID") from exc + + +async def fetch_project_history_index( + conn: ProjectHistoryConnection, + *, + knowledge_cutoff: datetime, + corporate_entity_ids: Sequence[str], + limit: int = PROJECT_INDEX_DEFAULT_LIMIT, +) -> dict[str, Any]: + """Return a bounded exact-identity index from authorized source evidence.""" + + if limit < 1 or limit > PROJECT_INDEX_MAXIMUM_LIMIT: + raise ValueError("project index limit is outside the supported bound") + _require_aware_cutoff(knowledge_cutoff) + source_post_limit = max( + PROJECT_INDEX_MINIMUM_SOURCE_POST_LIMIT, + (limit + 1) * PROJECT_HISTORY_DEFAULT_LIMIT, + ) + rows = list( + await conn.fetch( + _INDEX_SQL, + list(corporate_entity_ids), + knowledge_cutoff, + limit + 1, + source_post_limit, + ) + ) + truncated = len(rows) > limit or any( + bool(row["source_scan_truncated"]) for row in rows + ) + projects = [ + { + "normalized_project_key": str(row["normalized_project_key"]), + "project_key": str(row["project_key"]), + "project_name": str(row["project_name"]), + "truth_status_code": str(row["truth_status_code"]), + "event_count": int(row["event_count"]), + "latest_event_at": _as_utc(row["latest_event_at"]), + } + for row in rows[:limit] + ] + return { + "contract_version": PROJECT_HISTORY_CONTRACT_VERSION, + "time_basis_code": PROJECT_HISTORY_TIME_BASIS, + "knowledge_cutoff": _as_utc(knowledge_cutoff), + "project_count": len(projects), + "truncated": truncated, + "projects": projects, + } + + +async def fetch_project_history_projection( + conn: ProjectHistoryConnection, + *, + project_key: str, + focus_post_id: str | None, + knowledge_cutoff: datetime, + corporate_entity_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, exact identity, and ABAC + before selecting event IDs. Child evidence is constrained to that visible + ID set, so hidden rows cannot affect counts, transitions, or relation paths. + An authorized focus event remains included when the earliest page truncates. + """ + + canonical_focus_id = _canonical_focus_post_id(focus_post_id) + _require_aware_cutoff(knowledge_cutoff) + 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), + knowledge_cutoff, + limit + 1, + ) + ) + truncated = len(rows) > limit + event_rows = rows[:limit] + if not event_rows: + raise ProjectHistoryNotFound(project_key) + visible_ids = [str(row["post_id"]) for row in event_rows] + if canonical_focus_id is not None and canonical_focus_id not in set(visible_ids): + focus_rows = list( + await conn.fetch( + _FOCUS_SQL, + normalized_key, + list(corporate_entity_ids), + knowledge_cutoff, + canonical_focus_id, + ) + ) + if not focus_rows: + raise ProjectHistoryNotFound(project_key) + truncated = True + event_rows = (event_rows[: limit - 1] if limit > 1 else []) + [focus_rows[0]] + event_rows.sort(key=lambda row: (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, + ) + projection = build_project_history_projection( + project_key=project_key, + focus_event_id=canonical_focus_id, + event_rows=event_rows, + match_rows=match_rows, + role_rows=role_rows, + edge_rows=edge_rows, + truncated=truncated, + ) + projection["knowledge_cutoff"] = _as_utc(knowledge_cutoff) + projection["evidence_boundary_code"] = "authorized_visible_source_posts" + return projection + + +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/backend/app/source_post_revision.py b/backend/app/source_post_revision.py index 489f6f486..0fa1e9932 100644 --- a/backend/app/source_post_revision.py +++ b/backend/app/source_post_revision.py @@ -72,7 +72,7 @@ async def fetch_known_at_revision( cutoff label when no revision covers the clock. """ row = await conn.fetchrow( - "select post_title, post_body, written_at " + "select source_post_revision_id, post_title, post_body, written_at " "from source_post_revision " "where post_id = $1 " "and written_at <= $2 " @@ -85,8 +85,45 @@ async def fetch_known_at_revision( if row is None: return None return { + "source_post_revision_id": str(row["source_post_revision_id"]), "post_title": row["post_title"], "post_body": row["post_body"], "written_at": _iso(row["written_at"]), "as_of": _iso(as_of), } + + +async def fetch_cutoff_revisions( + conn: "asyncpg.Connection", + post_ids: list[str], + as_of: datetime, +) -> dict[str, dict[str, str]]: + """Return covering revisions keyed by post id. + + A missing cover is omitted. Callers must not fall back to the live + body under a cutoff label. + """ + if not post_ids: + return {} + rows = await conn.fetch( + "select source_post_revision_id, post_id, post_title, post_body, written_at " + "from source_post_revision " + "where post_id = any($1::uuid[]) " + "and written_at <= $2 " + "and (superseded_at is null or superseded_at > $2) " + "order by written_at desc", + post_ids, + as_of, + ) + revisions: dict[str, dict[str, str]] = {} + for row in rows: + post_id = str(row["post_id"]) + if post_id in revisions: + continue + revisions[post_id] = { + "source_revision_id": str(row["source_post_revision_id"]), + "post_title": row["post_title"], + "post_body": row["post_body"], + "written_at": _iso(row["written_at"]), + } + return revisions diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 71e9a8509..f2936a8bf 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1510,6 +1510,64 @@ def test_post_detail_exposes_explicit_and_semantic_project_evidence( assert listed_post["project_evidence"][0]["project_name"] == "Semantic project" assert listed_post["project_evidence"][0]["provenance"] == "post_project_mention.evidence_text" + index = client.get( + "/api/project-history/projects", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert index.status_code == 200, index.text + semantic_project = next( + project for project in index.json()["projects"] if project["project_key"] == "semantic-project" + ) + assert semantic_project["project_name"] == "Semantic project" + + conn = psycopg2.connect(seeded_db["dsn"]) + try: + with conn.cursor() as cur: + cur.execute( + "update source_post set source_project_code = %s, source_project_name = %s where post_id = %s", + (" ", "Source name fallback", seeded_db["public_post_id"]), + ) + conn.commit() + finally: + conn.close() + fallback_index = client.get( + "/api/project-history/projects", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert fallback_index.status_code == 200, fallback_index.text + assert any( + project["normalized_project_key"] == "source name fallback" + and project["project_name"] == "Source name fallback" + for project in fallback_index.json()["projects"] + ) + + history = client.get( + "/api/project-history", + params={ + "project_key": semantic_project["project_key"], + "focus_post_id": seeded_db["public_post_id"], + "knowledge_cutoff": index.json()["knowledge_cutoff"], + }, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert history.status_code == 200, history.text + assert history.json()["project_key"] == "semantic-project" + assert history.json()["events"][0]["source_post_id"] == seeded_db["public_post_id"] + + invalid_key = client.get( + "/api/project-history", + params={"project_key": " "}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert invalid_key.status_code == 422 + + invalid_focus = client.get( + "/api/project-history", + params={"project_key": "semantic-project", "focus_post_id": "not-a-uuid"}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert invalid_focus.status_code == 422 + def test_post_detail_as_of_returns_the_cutoff_known_body( client, demo_analyst_token, seeded_db @@ -4123,6 +4181,50 @@ def test_patch_ticket_on_other_corp_private_post_is_forbidden(client, demo_analy assert response.status_code == 403 +def test_patch_ticket_on_public_post_owned_by_other_account_is_forbidden( + client, demo_analyst_token, seeded_db +) -> None: + """Public read access must not become cross-account ticket write access.""" + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + """ + insert into source_post + (author_account_id, corporate_entity_id, post_title, post_body, + voc_type_code, visibility_code) + values ( + (select user_account_id from user_account + where external_subject_id like 'other-%%' limit 1), + %s, 'Public post from another account', 'body', 'voc', 'public' + ) + returning post_id + """, + (seeded_db["other_corp_id"],), + ) + post_id = str(cur.fetchone()[0]) + cur.execute( + """ + insert into issue_ticket (post_id, ticket_status_code, ticket_title) + values (%s, 'open', 'Ticket owned by another account') + returning issue_ticket_id + """, + (post_id,), + ) + ticket_id = str(cur.fetchone()[0]) + finally: + admin_conn.close() + + _grant_post_admin(seeded_db["dsn"]) + response = client.patch( + f"/api/tickets/{ticket_id}", + json={"ticket_status_code": "closed"}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 403 + + def test_post_activity_is_empty_before_any_mutation(client, demo_analyst_token, seeded_db) -> None: response = client.get( f"/api/posts/{seeded_db['own_private_post_id']}/activity", @@ -4132,6 +4234,48 @@ def test_post_activity_is_empty_before_any_mutation(client, demo_analyst_token, assert response.json()["events"] == [] +def test_derive_commitment_cannot_write_a_public_post_owned_by_other_account( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """Public read visibility must not authorize derived-ticket writes.""" + _grant_post_admin(seeded_db["dsn"]) + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + """ + insert into source_post + (author_account_id, corporate_entity_id, post_title, post_body, + voc_type_code, visibility_code) + values ( + (select user_account_id from user_account + where external_subject_id like 'other-%%' limit 1), + %s, 'Public commitment from another account', + 'A commitment is present.', 'voc', 'public' + ) + returning post_id + """, + (seeded_db["other_corp_id"],), + ) + post_id = str(cur.fetchone()[0]) + finally: + admin_conn.close() + + class _UnexpectedClient: + available = True + + def extract(self, *_args): + raise AssertionError("authorization must run before commitment extraction") + + monkeypatch.setattr("backend.app.main._commitment_extraction_client", lambda: _UnexpectedClient()) + response = client.post( + f"/api/posts/{post_id}/derive-commitment", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 403 + + def test_ticket_mutations_publish_real_events_to_the_activity_feed( client, demo_analyst_token, seeded_db ) -> None: diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index 35d51546c..f442f628a 100644 --- a/docker/postgres-init/migrate.sh +++ b/docker/postgres-init/migrate.sh @@ -18,7 +18,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do migration_name=${migration##*/} case "$migration_name" in 0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;; - 0051_*|0052_*) ;; + 0051_*|0052_*|0053_*) ;; 0060_*|0100_*|0101_*|0102_*) ;; *) continue ;; esac diff --git a/docs/adr/0122-ticket-mutation-resource-authorization.md b/docs/adr/0122-ticket-mutation-resource-authorization.md new file mode 100644 index 000000000..7319fe5c7 --- /dev/null +++ b/docs/adr/0122-ticket-mutation-resource-authorization.md @@ -0,0 +1,48 @@ +# ADR 0122: Ticket mutation requires owning-post authorization + +- Status: Accepted +- Date: 2026-08-21 + +## Context + +An `issue_ticket` has no independent visibility policy; it inherits the +visibility of its owning `source_post`. That inheritance is sufficient for a +read, but a `post_admin` account must not turn public read access into a write +right over another account's workflow ticket. The old PATCH path checked the +permission and post visibility, then mutated the child row without checking the +authoring boundary. + +## Decision + +Ticket create, update, and LLM-derived commitment-ticket creation require all +of the following: + +1. `post_admin` permission; +2. visibility of the owning post under the normal ABAC predicate; and +3. either authorship of the owning post or affiliation with its corporate + entity. + +The check is performed after resolving the owning post and before both the +commitment extraction call and any ticket mutation. Public visibility remains +a read property and does not grant cross-account ticket mutation. Unknown +ticket identifiers still return 404 before any authorization detail is +disclosed. + +## Consequences + +- A public post can be read without exposing its ticket workflow to unrelated + administrators. +- Private same-corporate ticket management remains unchanged. +- A commitment extractor cannot spend provider cost or create a calendar row + for a public post owned by another account. +- Future child resources must resolve and enforce the owning-post write + boundary instead of copying a visibility check. + +## References — APA 7th + +National Institute of Standards and Technology. (2020). *Security and privacy +controls for information systems and organizations* (NIST Special Publication +800-53 Rev. 5). https://doi.org/10.6028/NIST.SP.800-53r5 + +OWASP Foundation. (2025). *Authorization testing guide*. OWASP Web Security +Testing Guide. https://owasp.org/www-project-web-security-testing-guide/ diff --git a/docs/adr/0135-global-ask-knowledge-cutoff.md b/docs/adr/0135-global-ask-knowledge-cutoff.md new file mode 100644 index 000000000..229ff2112 --- /dev/null +++ b/docs/adr/0135-global-ask-knowledge-cutoff.md @@ -0,0 +1,79 @@ +# ADR 0135: Global Ask optional knowledge cutoff is evidence-honest + +- Status: Accepted +- Date: 2026-08-20 +- Related: [0016](0016-analysis-run-knowledge-cutoff-posts.md), + [0025](0025-source-post-revision.md), + [0039](0039-global-ask-agent-source-boundary.md), + [0096](0096-ask-agent-open-focuses-event-lineage.md) +- Refs: Issue #271 + +## Context + +Analysis-run detail already preserves `knowledge_cutoff` and reads the +cutoff-known body from `source_post_revision` (ADR 0016 / ADR 0025). +Global Ask still assembled only the live `source_post` row. A dated +question could therefore cite a later rewrite without saying so. + +W3C Time Ontology in OWL (World Wide Web Consortium, 2022) and ISO +8601-1:2019 keep event time, valid time, and transaction/available time +distinct. Jensen and Snodgrass (1999) keep valid-time intervals +half-open. A prompt-only date is not a retrieval boundary. + +## Decision + +1. `POST /api/ask` accepts an optional `knowledge_cutoff` ISO-8601 clock. + Omitting it preserves the live-query contract and must never label the + answer as as-of. +2. When the cutoff is present, candidate and visible source rows require + `created_at <= knowledge_cutoff`. Candidate keyword search uses the + covering `source_post_revision` title and body, never the live rewrite + and never live source-field hints. A post created after the cutoff is + excluded even when its live text matches. +3. Each selected post resolves to the latest `source_post_revision` + covering that clock (`written_at <= cutoff < superseded_at`). That + retained title and body enter the orchestrator context. The live body + is never substituted. +4. If the post existed by the cutoff but no covering revision remains, + the assembler records `historical_body_unavailable` and drops that + body from the reason-and-cite set. Absence is not a fabricated earlier + sentence. +5. Project, role, Keyman, graph, and source-hint facts that lack their + own recorded/effective-time contract stay out of a historical answer. + Current facts must not leak into an as-of response. +6. Each citation names source identity, revision identity when retained, + evidence-available time, the requested cutoff, whether the live row + changed after the cutoff, and any unavailable historical channel. +7. `grounding_status` is `live_only`, `fully_cutoff_grounded`, or + `partially_cutoff_grounded`. A live-only answer is never called + as-of. +8. Browser Ask Agent uses this assembler. Authenticated MCP is not on + this Event Lineage slice; later MCP work must call the same function + rather than a second retrieval path. + +No TEPP theta is invented. No SearXNG claim is invented. No LLM score is +invented. Issues #79 and #87 remain outside this slice. + +## Consequences + +- Ask Agent can name a dated question and show which retained bodies + existed by that clock. +- A rewritten Demo public post contributes its January sentence at a + January cutoff and is marked live-after-cutoff when the live row moved. +- A February post stays out of a January cutoff even when its title + matches. +- Buyer next action distinguishes fully grounded, partly grounded, and + live-only answers. + +## References + +International Organization for Standardization. (2019). *ISO 8601-1:2019: +Date and time—Representations for information interchange—Part 1: Basic +rules* (confirmed 2024; Amendment 1:2022). + +Jensen, C. S., & Snodgrass, R. T. (1999). Temporal data management. +*IEEE Transactions on Knowledge and Data Engineering, 11*(1), 36–44. +https://doi.org/10.1109/69.755613 + +World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C +Recommendation). https://www.w3.org/TR/owl-time/ diff --git a/docs/adr/0136-authorized-project-history-buyer-surface.md b/docs/adr/0136-authorized-project-history-buyer-surface.md new file mode 100644 index 000000000..a2634a9ba --- /dev/null +++ b/docs/adr/0136-authorized-project-history-buyer-surface.md @@ -0,0 +1,74 @@ +# ADR 0136: Authorized project-history buyer surface + +- Status: Proposed on PR #285; not protected-main behavior +- Date: 2026-08-20 +- Figma file: `SBpgot7uTvMxEaxUwvoc0S` +- Figma frames: `308:2` (desktop), `309:2` (mobile), `309:50` (evidence boundary), `310:2` (selected event) + +## Context + +Project evidence existed as post-level hints, but a buyer could not select one +exact authorized project and follow its visible chronology. A fuzzy project +search would create false joins, while a post-only view hides repeated +responsibility, event, and related-lineage evidence. The feature must remain +source-grounded: a semantic mention is an inferred candidate, a source field +is an observed hint, and a lineage edge is related history rather than proof of +causation. + +## Decision + +Add a bounded project index and project-history read model behind the existing +`post_read` RBAC and source-eligibility plus public/same-corporate-entity ABAC +checks. Normalize exact project identities with the same Unicode-compatible +key on both reads. Apply the knowledge cutoff before selecting event IDs, then +constrain matches, roles, and lineage paths to that authorized ID set. +The project index first bounds its input to the newest authorized source rows, +marks the response truncated when that bound is reached, and applies a local +five-second PostgreSQL statement timeout. Expression and recency indexes support +the bounded list and exact-detail paths; forward and rollback migrations remain +symmetric. All response clocks use canonical UTC RFC 3339 `Z` serialization. + +Expose the read model through the Buyer `Project history` destination and the +post-detail project-evidence card. Both entry points use the same +`ProjectHistoryTimeline`; source-post drill-through returns to the Board while +preserving Project History as the Event Lineage focus. Counts, display names, +responsibility transitions, and related paths are bounded projections, not an +HR ledger or a causal graph. + +The UI uses the existing design-token and Storybook component boundary. The +Figma file above is the design source for the desktop, mobile, and evidence +boundary states; no second component-specific token system is introduced. + +## Consequences + +- Buyers can move from an exact project identity to authorized chronology and + source evidence in one workflow. +- Hidden or post-cutoff records cannot affect the index, counts, transitions, + or related paths. +- Semantic project mentions remain visibly inferred and do not overwrite a + source project identity. +- The current document-time fallback remains explicit until a durable event + clock is introduced. +- The project chooser is a bounded recent-project view, not an unbounded catalog + export; buyers are told when its source or display limit is reached. +- A future customer-master graph may reuse the projection pattern, but this + ADR deliberately does not invent organization roles or temporal facts that + are absent from persisted evidence. + +## Verification + +- `tests/test_project_history.py` covers exact normalization, lifecycle + classification, responsibility evidence, and bounded index SQL. +- `backend/tests/test_api.py` covers live PostgreSQL/API index and history + reads, cutoff propagation, source/semantic project evidence, and malformed + project/focus inputs. +- Frontend tests, lint, production build, and Storybook cover the shared + destination, post-detail entry point, and keyboard-accessible timeline. + +## References + +World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. +https://www.w3.org/TR/prov-o/ + +World Wide Web Consortium. (2021). *WAI-ARIA Authoring Practices 1.2*. +https://www.w3.org/WAI/ARIA/apg/ diff --git a/docs/doctoring/GLOBAL_ASK_KNOWLEDGE_CUTOFF.md b/docs/doctoring/GLOBAL_ASK_KNOWLEDGE_CUTOFF.md new file mode 100644 index 000000000..3e8e838a9 --- /dev/null +++ b/docs/doctoring/GLOBAL_ASK_KNOWLEDGE_CUTOFF.md @@ -0,0 +1,28 @@ +# Global Ask knowledge cutoff (ADR 0135 / v2.23.0) + +Ask Agent now accepts an optional ISO-8601 `knowledge_cutoff`. + +## Buyer next action + +1. Open Ask Agent. +2. Enter a question. Optionally set **Knowledge cutoff**. +3. If the cutoff is empty, the answer is live-only and is never labeled + as-of. +4. If the cutoff is set, open a cited post to compare the retained body. + A post created after the cutoff does not appear. Keyword search uses + the retained revision, not the live rewrite. A missing historical + body is named; the live rewrite is not used. + +No TEPP theta is invented. No SearXNG claim is invented. + +## References + +International Organization for Standardization. (2019). *ISO 8601-1:2019: +Date and time—Representations for information interchange—Part 1: Basic +rules*. + +Jensen, C. S., & Snodgrass, R. T. (1999). Temporal data management. +*IEEE Transactions on Knowledge and Data Engineering, 11*(1), 36–44. + +World Wide Web Consortium. (2022). *Time ontology in OWL*. +https://www.w3.org/TR/owl-time/ diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index f87f9914c..bb58ea2a2 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -9,13 +9,70 @@ explicit WAI-ARIA ownership; final-head hosted Checks and independent approval r requirements, technical contracts, implementation evidence, and active PRs. An active PR is proposed work, not shipped behavior. -## 2. LLM Extraction & Knowledge Graph Gaps -- **Multiple Project Extraction**: (Resolved) LLM prompt updated to request key_events as objects with project_name, separating events correctly. -- **5W1H Missing**: (Resolved) LLM prompt updated to explicitly request 5W1H evidence items in the JSON output array. -- **R&R and Keyman Missing**: (Resolved) LLM prompt updated to explicitly instruct using actual stated names rather than collective titles. -- **Entity Resolution / Searxng**: Abbreviations like "한전" and "한국전력" are not mapped properly using Searxng and KG corroboration. -- **Meso-level Team Mapping**: (Resolved) Checked extraction logic; `team` mapping logic is present and correct, but LLM needed better explicit instruction which is covered by R&R resolution. -- **Base64 Image Omni-modal**: Current text-only embedding fails on images. Omni-modal LLM processing is required for images to capture layout, font size, colors, and spatial meaning. +## 1. Known Parsing & Frontend Display Gaps +- **Footnote and table parsing**: rich-text exports still require synthetic regression cases for footnote ownership, merged-cell boundaries, table-row grouping, and nested list semantics. +- **Indentation**: mixed source whitespace, CSS, and OOXML indentation must remain distinguishable so visual alignment cannot manufacture hierarchy; browser evidence remains required for continuation-line normalization. +- **Image/table OCR**: partial visual regions, table text, markdown-like source, and image captions require persisted, position-aware evidence or an explicit unavailable state. +- **Math/superscripts**: superscript and formula-like source needs a semantic-unit grammar that preserves the original text and exposes normalized search text. +- **Lineage navigation**: the Event Lineage DAG must complement Project History rather than replace it, with source focus and the next actionable step preserved across Board, Project History, Global Ask, Customer Master, Calendar, and Admin routes. + +Exact private runtime identifiers are intentionally omitted; the authorized +runtime and synthetic fixtures retain the reproducibility detail. + +## Historical exact-head checkpoint (2026-08-20 19:14 Asia/Seoul) + +The following is the current GitHub observation used for this branch. It +supersedes the historical 17:08 snapshot and does not claim protected-main +behavior. GitHub reports 24 open PRs from #190 through #309; none of the +`#258`-and-later stack has an independent `APPROVED` review at this checkpoint. + +| PR group | Exact observed heads | Merge observation | +|---|---|---| +| #258-#266 | `#258 f8d2fa98`, `#260 dfd95d9c`, `#261 bd1b4d2f`, `#262 80445b8a`, `#263 d670acd5`, `#264 d5dbdf71`, `#266 26a6d9c6` | `BLOCKED`, review required | +| #270-#276 | `#270 c58aef89`, `#275 35035783`, `#276 55679fa2` | `BLOCKED`, review required or draft | +| #282-#287 | `#282 6eeaf89d`, `#285 cbb959ce`, `#286 65a461de`, `#287 554efb9b` | `UNSTABLE`/`UNKNOWN`/`BLOCKED`; not merge-ready | +| #298-#303 | `#298 49c9976f`, `#301 59ccdf91`, `#302 40b0a8ea`, `#303 fe0a4f26` | `UNKNOWN`/`CLEAN`/`UNSTABLE`; independent review pending | +| #306-#309 | `#306 e0dbc386`, `#307 313d38a4`, `#308 42e6230c`, `#309 e6fd907e` | `UNSTABLE`; independent review pending | + +PR #285 received concurrent remote commits through `cbb959ce` while its local +Buyer wiring was under review. Those commits were incorporated with a normal +merge; no force push is permitted. The current change adds the missing API/GNB +connection, exact-input validation, source-name whitespace fallback, a shared +timeline entry point, Storybook-compatible truth rendering, and live +PostgreSQL/API regressions. The final exact head and Checks must be recorded +after the ordinary push. + +## Historical exact-head refresh (2026-08-20 19:50 Asia/Seoul) + +This refresh supersedes the 19:14 checkpoint for the PRs it names. It records +GitHub observations, not protected-main behavior. The repository has 25 open +PRs; no approval or queued Check is treated as merge evidence. + +| PR | Exact observed head | Current observation | +|---|---|---| +| #258 | `f8d2fa98` | `BLOCKED`, review required | +| #260-#266 | `dfd95d9c`, `bd1b4d2f`, `80445b8a`, `d670acd5`, `d5dbdf71`, `26a6d9c6` | stacked, review required; #264 is `DIRTY` | +| #282 | `6eeaf89d` | `CLEAN`, no formal approval | +| #285 | `30dae74a` | `UNSTABLE`, exact-head Checks queued, no formal approval | +| #287 | `26fa7346` | `UNKNOWN`, review required, exact-head Checks queued | +| #298-#303 | `49c9976f`, `59ccdf91`, `40b0a8ea`, `b7e6e82d` | mixed `DIRTY`/`CLEAN`/`UNSTABLE`, review pending | +| #306-#311 | `e0dbc386`, `a4d1de59`, `42e6230c`, `e6fd907e`, `d8b7f561` | `CLEAN`/`UNSTABLE`, review pending | + +The #285 exact head includes the independent review repairs for case-preserving +project identity, route-specific bounds, and sibling-project match isolation; +the local tree recorded `741 passed, 16 skipped`. The #287 exact head removes +the Semgrep dynamic-SQL findings and aligns public claim adjudication with the +contextual-orchestrator `mode=auto` strict structured contract; its local tree +recorded `791 passed, 16 skipped`. Both remain open until current-head Checks +and protected approval are observed. + +The organization-owned `.github` repository already provides the hourly +commercial-readiness coordinator at cron `7 * * * *` and the review/merge +scheduler's hourly fallback. This repository does not add a competing local +timer; the central OpenCode/scheduler credential boundary remains authoritative +and `COPILOT_GITHUB_TOKEN` is not used. + +## PRD ## 3. General Architecture Gaps - **DB Architecture**: Ensure PostgreSQL is strictly used (no file DBs), 3rd normal form is maintained, and Hot Partitions are handled. DB locks must be managed (or use read/write replicas). diff --git a/frontend/package.json b/frontend/package.json index 4a61cd78c..2468e4eee 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.19.0", + "version": "2.23.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 8f3254d58..740162a77 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -8,6 +8,7 @@ import { isoWeekFromCreatedAt } from "./isoWeek"; const signinRedirect = vi.fn(); const signoutRedirect = vi.fn(); let mockAuth: Record; +let projectHistoryRequestUrl: string | null = null; vi.mock("react-oidc-context", () => ({ useAuth: () => mockAuth, @@ -17,6 +18,7 @@ beforeEach(() => { setLocale("en"); signinRedirect.mockReset(); signoutRedirect.mockReset(); + projectHistoryRequestUrl = null; mockAuth = { isLoading: false, isAuthenticated: false, @@ -87,6 +89,8 @@ describe("App, authenticated", () => { deferMe?: boolean; deferPostOneSummary?: boolean; deferSecondAsk?: boolean; + partialCutoff?: boolean; + deferProjectHistory?: boolean; invalidAskSessionOnce?: boolean; meFailed?: boolean; postBody?: string; @@ -129,6 +133,7 @@ describe("App, authenticated", () => { releaseGroupRelated: () => void; releaseDemoRelated: () => void; releasePostOneSummary: () => void; + releaseProjectHistory: () => void; } { const statusLabel: Record = { open: "Open", @@ -186,6 +191,12 @@ describe("App, authenticated", () => { releasePostOneSummary = resolve; }) : 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); @@ -1161,7 +1172,7 @@ describe("App, authenticated", () => { visibility_label: "Public", project_evidence: [ { - project_key: "source-project", + project_key: "semantic-project", project_name: "Semantic project", evidence: "project was described in the body", confidence: 0.9, @@ -1680,8 +1691,12 @@ describe("App, authenticated", () => { } if (url.endsWith("/api/ask") && method === "POST") { askRequestCount += 1; - const requestBody = JSON.parse(String(init?.body ?? "{}")) as { session_id?: string }; - if (options?.invalidAskSessionOnce && askRequestCount === 1 && requestBody.session_id) { + const askBody = JSON.parse(String(init?.body ?? "{}")) as { + question?: string; + knowledge_cutoff?: string; + session_id?: string; + }; + if (options?.invalidAskSessionOnce && askRequestCount === 1 && askBody.session_id) { return Promise.resolve( new Response(JSON.stringify({ detail: "Global Ask session not found" }), { status: 404, @@ -1693,23 +1708,56 @@ describe("App, authenticated", () => { options?.deferSecondAsk && askRequestCount === 2 ? secondAskReady : Promise.resolve(); + const cutoffGrounded = Boolean(askBody.knowledge_cutoff); + const partialCutoff = cutoffGrounded && Boolean(options?.partialCutoff); return ready.then(() => Promise.resolve( jsonResponse({ session_id: "session-1", - answer_text: "The cited project is supported by the stored semantic evidence.", + answer_text: cutoffGrounded + ? "By the cutoff Phoenix was still the January kickoff." + : "The cited project is supported by the stored semantic evidence.", cited_post_ids: ["post-2"], - cited_posts: [{ post_id: "post-2", post_title: "Linked post" }], - cited_post_evidence: [ + cited_posts: [ { post_id: "post-2", - facts: [ - { kind: "semantic_project", text: "project: Semantic project | evidence: Body evidence" }, - { kind: "semantic_keyman", text: "Keyman mention: Ada West | context: account lead" }, - ], + post_title: "Linked post", + ...(cutoffGrounded + ? { + source_revision_id: "rev-january", + knowledge_cutoff: askBody.knowledge_cutoff, + live_after_cutoff: true, + historical_body_unavailable: false, + } + : {}), }, ], + cited_post_evidence: cutoffGrounded + ? [] + : [ + { + post_id: "post-2", + facts: [ + { kind: "semantic_project", text: "project: Semantic project | evidence: Body evidence" }, + { kind: "semantic_keyman", text: "Keyman mention: Ada West | context: account lead" }, + ], + }, + ], source_post_ids: ["post-1", "post-2"], + grounding_status: partialCutoff + ? "partially_cutoff_grounded" + : cutoffGrounded + ? "fully_cutoff_grounded" + : "live_only", + knowledge_cutoff: askBody.knowledge_cutoff ?? null, + next_action: partialCutoff + ? "This answer is only partly grounded at the requested cutoff. Open a cited post to see which historical bodies were retained." + : cutoffGrounded + ? "This answer is fully grounded at the requested cutoff. Open a cited post to compare the retained body." + : undefined, + limitations: partialCutoff + ? [{ post_id: "post-1", limitation_code: "historical_body_unavailable" }] + : [], timeline: [ { post_id: "post-1", @@ -1818,6 +1866,63 @@ describe("App, authenticated", () => { }), ); } + if (url.endsWith("/api/project-history/projects") && method === "GET") { + return Promise.resolve( + jsonResponse({ + contract_version: 1, + time_basis_code: "source_post_created_at_fallback", + knowledge_cutoff: "2026-01-12T12:00:00Z", + project_count: 1, + truncated: false, + projects: [{ + normalized_project_key: "semantic-project", + project_key: "semantic-project", + project_name: "Semantic project", + truth_status_code: "inferred", + event_count: 1, + latest_event_at: "2026-01-01T00:00:00Z", + }], + }), + ); + } + if (url.includes("/api/project-history?") && method === "GET") { + projectHistoryRequestUrl = url; + return projectHistoryReady.then(() => + jsonResponse({ + contract_version: 1, + project_key: "Semantic project", + normalized_project_key: "semantic-project", + project_name: "Semantic project", + focus_event_id: "post-1", + time_basis_code: "source_post_created_at_fallback", + event_count: 1, + distinct_actor_count: 0, + distinct_observed_actor_count: 0, + evidence_boundary_code: "authorized_visible_source_posts", + truncated: false, + events: [ + { + event_id: "post-1", + source_post_id: "post-1", + event_title: "Public post", + event_type_code: "source_recorded", + event_type_basis_code: "display_classification", + occurred_at: "2026-01-01T00:00:00Z", + time_basis_code: "source_post_created_at_fallback", + voc_type_code: "voc", + source_stage_code: null, + source_detail_state_code: null, + project_matches: [], + responsibility_evidence: [], + observed_responsibilities: [], + responsibility_transition_truth_status_code: null, + responsibility_transition_code: null, + related_prior_paths: [], + }, + ], + }), + ); + } return Promise.reject(new Error(`unexpected fetch: ${method} ${url}`)); }); vi.stubGlobal("fetch", fetchMock); @@ -1827,6 +1932,7 @@ describe("App, authenticated", () => { releaseGroupRelated, releaseDemoRelated, releasePostOneSummary, + releaseProjectHistory, }); } @@ -1863,6 +1969,48 @@ describe("App, authenticated", () => { expect(screen.queryByText(/ontology_iri|contextual_orchestrator/i)).not.toBeInTheDocument(); }); + it("names a cutoff-grounded Ask answer and does not call it live-only", async () => { + stubBackend(); + render(); + await userEvent.click(await screen.findByRole("button", { name: "Ask Agent" })); + const ask = await screen.findByRole("region", { name: "Ask Agent" }); + await userEvent.type(within(ask).getByRole("textbox", { name: "Ask a question" }), "What did we know about Phoenix?"); + fireEvent.change(within(ask).getByLabelText("Knowledge cutoff (optional)"), { + target: { value: "2026-01-15T12:00" }, + }); + await userEvent.click(within(ask).getByRole("button", { name: "Ask" })); + + expect( + await within(ask).findByText("By the cutoff Phoenix was still the January kickoff."), + ).toBeInTheDocument(); + expect( + within(ask).getByText( + "This answer is fully grounded at the requested cutoff. Open a cited post to compare the retained body.", + ), + ).toBeInTheDocument(); + expect(within(ask).getByText("This live source changed after the cutoff.")).toBeInTheDocument(); + expect( + within(ask).queryByText("Authorized cited posts are current. Open a cited post to read Event Lineage."), + ).not.toBeInTheDocument(); + }); + + it("shows unavailable historical bodies for a partially grounded Ask answer", async () => { + stubBackend({ partialCutoff: true }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "Ask Agent" })); + const ask = await screen.findByRole("region", { name: "Ask Agent" }); + await userEvent.type(within(ask).getByRole("textbox", { name: "Ask a question" }), "What changed?"); + fireEvent.change(within(ask).getByLabelText("Knowledge cutoff (optional)"), { + target: { value: "2026-01-15T12:00" }, + }); + await userEvent.click(within(ask).getByRole("button", { name: "Ask" })); + + expect(await within(ask).findByRole("heading", { name: "Historical evidence limitations" })).toBeInTheDocument(); + expect(within(ask).getByRole("region", { name: "Historical evidence limitations" })).toHaveTextContent( + "Public post: Historical body unavailable", + ); + }); + it("hides previous Ask evidence while a new answer is pending", async () => { const fetchMock = stubBackend({ deferSecondAsk: true }); render(); @@ -2041,6 +2189,33 @@ describe("App, authenticated", () => { expect(screen.queryByRole("button", { name: "Close" })).not.toBeInTheDocument(); }); + it("opens the shared project history from a semantic project evidence card", async () => { + stubBackend(); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click( + await screen.findByRole("button", { name: "Open project history for: Semantic project" }), + ); + + expect(await screen.findByRole("heading", { name: "Project event timeline" })).toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: "Select project" })).toHaveValue("semantic-project"); + expect(screen.getByRole("button", { name: "Open source record: Public post" })).toBeInTheDocument(); + expect(projectHistoryRequestUrl).toContain("focus_post_id=post-1"); + }); + + it("shows a next-action loading state while project history is requested", async () => { + const fetchMock = stubBackend({ deferProjectHistory: true }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click( + await screen.findByRole("button", { name: "Open project history for: Semantic project" }), + ); + + expect(await screen.findByRole("status")).toHaveTextContent("Loading project history..."); + fetchMock.releaseProjectHistory(); + expect(await screen.findByRole("heading", { name: "Project event timeline" })).toBeInTheDocument(); + }); + it("clicking Weekly VOC keeps the 2026-W01 Voice of Customer post and names Event Lineage as the next action", async () => { stubBackend({ boardPosts: [ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 90dcc2aca..dfb136f2f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -36,6 +36,8 @@ import { fetchPeriodComparison, fetchPeriodReportIndex, fetchPeriodReports, + fetchProjectHistory, + fetchProjectHistoryIndex, fetchPosts, fetchRankings, fetchRelatedEntity, @@ -90,6 +92,12 @@ import { LineageDag } from "./LineageDag"; import { PostBody } from "./PostBody"; import { decodeHtmlEntities } from "./postBodyDisplay"; import { FiveW1H } from "./components/FiveW1H"; +import { ProjectHistoryTimeline } from "./components/ProjectHistoryTimeline"; +import { + projectHistoryText, + type ProjectHistoryIndex, + type ProjectHistoryProjection, +} from "./projectHistory"; import { CustomerMasterTree, CustomerRelatedPostCard } from "./components/CustomerMasterTree"; import { subgraphForPost } from "./lineageLayout"; import { @@ -1681,6 +1689,7 @@ function PostDetailPopup({ liveBodyWarning, knowledgeCutoff, focusEventLineage, + onOpenProjectHistory, focusKeyman, fromReportMember, onClose, @@ -1694,6 +1703,7 @@ function PostDetailPopup({ liveBodyWarning?: string | null; knowledgeCutoff?: string | null; focusEventLineage?: boolean; + onOpenProjectHistory?: (projectKey: string, postId: string) => void; focusKeyman?: boolean; fromReportMember?: boolean; onClose: () => void; @@ -2181,6 +2191,17 @@ function PostDetailPopup({ {project.project_name} {t("Search related posts")} {" "} + {onOpenProjectHistory ? ( + + ) : null}{" "} {project.confidence === null ? `(${t("Hint only")})` : `(${Math.round(project.confidence * 100)}%)`} @@ -2634,6 +2655,7 @@ type SelectPostOptions = { fromCalendar?: boolean; fromCustomerMaster?: boolean; fromAskAgent?: boolean; + fromProjectHistory?: boolean; }; /** @@ -3689,7 +3711,9 @@ function PostList({ postOpenFromCalendar = false, postOpenFromCustomerMaster = false, postOpenFromAskAgent = false, + postOpenFromProjectHistory = false, onPostOpened, + onOpenProjectHistory, }: { accessToken: string; showLabPanels?: boolean; @@ -3697,7 +3721,9 @@ function PostList({ postOpenFromCalendar?: boolean; postOpenFromCustomerMaster?: boolean; postOpenFromAskAgent?: boolean; + postOpenFromProjectHistory?: boolean; onPostOpened?: () => void; + onOpenProjectHistory?: (projectKey: string, postId: string) => void; }) { const [posts, setPosts] = useState(null); const [graph, setGraph] = useState(null); @@ -3721,6 +3747,7 @@ function PostList({ const [openedFromCalendar, setOpenedFromCalendar] = useState(false); const [openedFromCustomerMaster, setOpenedFromCustomerMaster] = useState(false); const [openedFromAskAgent, setOpenedFromAskAgent] = useState(false); + const [openedFromProjectHistory, setOpenedFromProjectHistory] = useState(false); const [corporateEntities, setCorporateEntities] = useState(null); const [entitiesLoadError, setEntitiesLoadError] = useState(null); const [totalPosts, setTotalPosts] = useState(0); @@ -3780,6 +3807,7 @@ function PostList({ setOpenedFromCalendar(Boolean(options?.fromCalendar)); setOpenedFromCustomerMaster(Boolean(options?.fromCustomerMaster)); setOpenedFromAskAgent(Boolean(options?.fromAskAgent)); + setOpenedFromProjectHistory(Boolean(options?.fromProjectHistory)); } useEffect(() => { @@ -3788,6 +3816,7 @@ function PostList({ fromCalendar: postOpenFromCalendar, fromCustomerMaster: postOpenFromCustomerMaster, fromAskAgent: postOpenFromAskAgent, + fromProjectHistory: postOpenFromProjectHistory, }); onPostOpened?.(); }, [ @@ -3796,6 +3825,7 @@ function PostList({ postOpenFromCalendar, postOpenFromCustomerMaster, postOpenFromAskAgent, + postOpenFromProjectHistory, ]); function closeSelectedPost() { @@ -3808,6 +3838,7 @@ function PostList({ setOpenedFromCalendar(false); setOpenedFromCustomerMaster(false); setOpenedFromAskAgent(false); + setOpenedFromProjectHistory(false); const url = new URL(window.location.href); if (url.searchParams.has("post")) { url.searchParams.delete("post"); @@ -4296,7 +4327,8 @@ function PostList({ openedFromWeeklyVoc || openedFromCalendar || openedFromCustomerMaster || - openedFromAskAgent + openedFromAskAgent || + openedFromProjectHistory } focusKeyman={ openedFromWeeklyVoc || @@ -4318,9 +4350,11 @@ function PostList({ fromCalendar: openedFromCalendar, fromCustomerMaster: openedFromCustomerMaster, fromAskAgent: openedFromAskAgent, + fromProjectHistory: openedFromProjectHistory, }); }} onSearch={searchBoard} + onOpenProjectHistory={onOpenProjectHistory} /> )} @@ -4557,6 +4591,30 @@ function CustomerMasterPanel({ ); } +function askCitedNextAction(answer: AskAgentResponse): string { + if (answer.grounding_status === "fully_cutoff_grounded") { + return ( + answer.next_action || + "This answer is fully grounded at the requested cutoff. Open a cited post to compare the retained body." + ); + } + if (answer.grounding_status === "partially_cutoff_grounded") { + return ( + answer.next_action || + "This answer is only partly grounded at the requested cutoff. Open a cited post to see which historical bodies were retained." + ); + } + return "Authorized cited posts are current. Open a cited post to read Event Lineage."; +} + +function toKnowledgeCutoffIso(value: string): string | undefined { + const trimmed = value.trim(); + if (!trimmed) return undefined; + const parsed = new Date(trimmed); + if (Number.isNaN(parsed.getTime())) return undefined; + return parsed.toISOString(); +} + function AskAgentPanel({ accessToken, onOpenPost, @@ -4565,6 +4623,7 @@ function AskAgentPanel({ onOpenPost: (postId: string) => void; }) { const [question, setQuestion] = useState(""); + const [knowledgeCutoff, setKnowledgeCutoff] = useState(""); const [answer, setAnswer] = useState(null); const [error, setError] = useState(null); const [asking, setAsking] = useState(false); @@ -4581,14 +4640,24 @@ function AskAgentPanel({ try { let nextAnswer: AskAgentResponse; try { - nextAnswer = await askAgent(accessToken, normalized, sessionId); + nextAnswer = await askAgent( + accessToken, + normalized, + sessionId, + toKnowledgeCutoffIso(knowledgeCutoff), + ); } catch (err) { if (!(err instanceof BackendError) || err.status !== 404 || !sessionId) { throw err; } setSessionId(undefined); window.sessionStorage.removeItem(GLOBAL_ASK_SESSION_STORAGE_KEY); - nextAnswer = await askAgent(accessToken, normalized); + nextAnswer = await askAgent( + accessToken, + normalized, + undefined, + toKnowledgeCutoffIso(knowledgeCutoff), + ); } setAnswer(nextAnswer); setSessionId(nextAnswer.session_id); @@ -4616,6 +4685,15 @@ function AskAgentPanel({ rows={4} /> + @@ -4623,7 +4701,9 @@ function AskAgentPanel({

{t("Answer")}

{answer.answer_text ?

{answer.answer_text}

: null} - {answer.next_action ?

{t(answer.next_action)}

: null} + {answer.next_action && !(answer.cited_posts && answer.cited_posts.length > 0) ? ( +

{t(answer.next_action)}

+ ) : null} {answer.timeline && answer.timeline.length > 0 ? ( <>

{t("Event Lineage timeline")}

@@ -4644,10 +4724,27 @@ function AskAgentPanel({ ) : null} + {answer.limitations && answer.limitations.length > 0 ? ( +
+

{t("Historical evidence limitations")}

+
    + {answer.limitations.map((limitation) => { + const timelinePost = answer.timeline?.find( + (event) => event.post_id === limitation.post_id, + ); + return timelinePost ? ( +
  • + {timelinePost.post_title}: {t("Historical body unavailable for this cited post. The live body was not used.")} +
  • + ) : null; + })} +
+
+ ) : null} {answer.cited_posts && answer.cited_posts.length > 0 && ( <>

- {t("Authorized cited posts are current. Open a cited post to read Event Lineage.")} + {t(askCitedNextAction(answer))}

{t("Cited posts")}

    @@ -4660,6 +4757,14 @@ function AskAgentPanel({ > {post.post_title} + {post.historical_body_unavailable ? ( +

    + {t("Historical body unavailable for this cited post. The live body was not used.")} +

    + ) : null} + {post.live_after_cutoff ? ( +

    {t("This live source changed after the cutoff.")}

    + ) : null} {answer.cited_post_evidence?.find((item) => item.post_id === post.post_id)?.facts.length ? (
      {answer.cited_post_evidence @@ -4683,11 +4788,125 @@ function AskAgentPanel({ ); } +function ProjectHistoryPanel({ + accessToken, + initialProjectKey, + initialFocusPostId, + onOpenPost, +}: { + accessToken: string; + initialProjectKey?: string | null; + initialFocusPostId?: string | null; + onOpenPost: (postId: string) => void; +}) { + const [index, setIndex] = useState(null); + const [selectedProjectKey, setSelectedProjectKey] = useState(""); + const [projection, setProjection] = useState(null); + const [loadingHistory, setLoadingHistory] = useState(false); + const [error, setError] = useState(false); + const locale = useLocale(); + const historyRequest = useRef(0); + + useEffect(() => { + let active = true; + fetchProjectHistoryIndex(accessToken) + .then((result) => { + if (!active) return; + setIndex(result); + setSelectedProjectKey((current) => + initialProjectKey && result.projects.some((project) => project.project_key === initialProjectKey) + ? initialProjectKey + : current || result.projects[0]?.project_key || "", + ); + setError(false); + }) + .catch(() => { + if (!active) return; + setIndex({ + contract_version: 1, + time_basis_code: "source_post_created_at_fallback", + knowledge_cutoff: "", + project_count: 0, + truncated: false, + projects: [], + }); + setError(true); + }); + return () => { + active = false; + }; + }, [accessToken, initialProjectKey]); + + useEffect(() => { + if (!selectedProjectKey || !index?.knowledge_cutoff) { + setProjection(null); + setLoadingHistory(false); + return; + } + const request = ++historyRequest.current; + setProjection(null); + setLoadingHistory(true); + setError(false); + const focusPostId = + initialProjectKey === selectedProjectKey ? initialFocusPostId ?? undefined : undefined; + fetchProjectHistory(accessToken, selectedProjectKey, index.knowledge_cutoff, focusPostId) + .then((result) => { + if (request !== historyRequest.current) return; + setProjection(result); + setLoadingHistory(false); + }) + .catch(() => { + if (request !== historyRequest.current) return; + setError(true); + setLoadingHistory(false); + }); + }, [accessToken, index?.knowledge_cutoff, initialFocusPostId, initialProjectKey, selectedProjectKey]); + + return ( +
      +

      {projectHistoryText(locale, "destinationHeading")}

      +

      {projectHistoryText(locale, "destinationIntro")}

      + {error ?

      {projectHistoryText(locale, "historyUnavailable")}

      : null} + {index === null ?

      {projectHistoryText(locale, "loadingProjects")}

      : null} + {index?.projects.length === 0 && !error ? ( +

      {projectHistoryText(locale, "noProjects")}

      + ) : null} + {index?.truncated ? ( +

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

      + ) : null} + {index && index.projects.length > 0 ? ( + + ) : null} + {loadingHistory && !error ? ( +

      {projectHistoryText(locale, "loadingHistory")}

      + ) : null} + {projection ? : null} +
      + ); +} + export default function App({ showLabPanels = false }: { showLabPanels?: boolean } = {}) { useLocale(); const [brandName, setBrandName] = useState("LineageWeave"); const auth = useAuth(); const [destination, setDestination] = useState("board"); + const [projectHistoryKey, setProjectHistoryKey] = useState(null); + const [projectHistoryFocusPostId, setProjectHistoryFocusPostId] = useState(null); const [postToOpen, setPostToOpen] = useState(() => { if (typeof window === "undefined") return null; return new URLSearchParams(window.location.search).get("post"); @@ -4695,6 +4914,7 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean const [postOpenFromCalendar, setPostOpenFromCalendar] = useState(false); const [postOpenFromCustomerMaster, setPostOpenFromCustomerMaster] = useState(false); const [postOpenFromAskAgent, setPostOpenFromAskAgent] = useState(false); + const [postOpenFromProjectHistory, setPostOpenFromProjectHistory] = useState(false); // Test-only compatibility for legacy analysis-panel coverage; this prop // never forces the panels open outside Vitest. In a real build the // advanced-review section (ADR 0037) is gated on PostList's own @@ -4796,7 +5016,13 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean { + if (nextDestination === "project-history") { + setProjectHistoryKey(null); + setProjectHistoryFocusPostId(null); + } + setDestination(nextDestination); + }} tools={} />
      @@ -4808,11 +5034,18 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean postOpenFromCalendar={postOpenFromCalendar} postOpenFromCustomerMaster={postOpenFromCustomerMaster} postOpenFromAskAgent={postOpenFromAskAgent} + postOpenFromProjectHistory={postOpenFromProjectHistory} onPostOpened={() => { setPostToOpen(null); setPostOpenFromCalendar(false); setPostOpenFromCustomerMaster(false); setPostOpenFromAskAgent(false); + setPostOpenFromProjectHistory(false); + }} + onOpenProjectHistory={(projectKey, postId) => { + setProjectHistoryKey(projectKey); + setProjectHistoryFocusPostId(postId); + setDestination("project-history"); }} /> ) : null} @@ -4824,6 +5057,7 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean setPostOpenFromCalendar(false); setPostOpenFromCustomerMaster(true); setPostOpenFromAskAgent(false); + setPostOpenFromProjectHistory(false); setDestination("board"); }} /> @@ -4838,6 +5072,7 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean setPostOpenFromCalendar(true); setPostOpenFromCustomerMaster(false); setPostOpenFromAskAgent(false); + setPostOpenFromProjectHistory(false); setDestination("board"); }} /> @@ -4850,6 +5085,22 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean setPostOpenFromCalendar(false); setPostOpenFromCustomerMaster(false); setPostOpenFromAskAgent(true); + setPostOpenFromProjectHistory(false); + setDestination("board"); + }} + /> + ) : null} + {destination === "project-history" ? ( + { + setPostToOpen(postId); + setPostOpenFromCalendar(false); + setPostOpenFromCustomerMaster(false); + setPostOpenFromAskAgent(false); + setPostOpenFromProjectHistory(true); setDestination("board"); }} /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 6956419b9..249b1b355 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -270,6 +270,11 @@ export interface PostLineage { export interface CitedPostRef { post_id: string; post_title: string; + source_revision_id?: string | null; + evidence_available_at?: string | null; + knowledge_cutoff?: string | null; + live_after_cutoff?: boolean; + historical_body_unavailable?: boolean; } export interface CitedPostEvidenceFact { @@ -311,6 +316,14 @@ export interface AskAgentResponse { source_post_ids: string[]; timeline?: AskTimelineEntry[]; next_action?: string; + knowledge_cutoff?: string | null; + grounding_status?: "live_only" | "fully_cutoff_grounded" | "partially_cutoff_grounded"; + limitations?: AskLimitation[]; +} + +export interface AskLimitation { + post_id: string; + limitation_code: string; } export interface AskTimelineEntry { @@ -429,6 +442,24 @@ export function fetchLineageGraph(accessToken: string, postId?: string): Promise return backendFetch(`/api/lineage${query}`, accessToken); } +export function fetchProjectHistoryIndex( + accessToken: string, +): Promise { + return backendFetch("/api/project-history/projects", accessToken); +} + +export function fetchProjectHistory( + accessToken: string, + projectKey: string, + knowledgeCutoff?: string, + focusPostId?: string, +): Promise { + const query = new URLSearchParams({ project_key: projectKey }); + if (knowledgeCutoff) query.set("knowledge_cutoff", knowledgeCutoff); + if (focusPostId) query.set("focus_post_id", focusPostId); + return backendFetch(`/api/project-history?${query.toString()}`, accessToken); +} + export interface CorporateEntityRef { corporate_entity_id: string; entity_name: string; @@ -889,10 +920,15 @@ export function askAgent( accessToken: string, question: string, sessionId?: string, + knowledgeCutoff?: string, ): Promise { return backendFetch("/api/ask", accessToken, { method: "POST", - body: JSON.stringify({ question, ...(sessionId ? { session_id: sessionId } : {}) }), + body: JSON.stringify({ + question, + ...(sessionId ? { session_id: sessionId } : {}), + ...(knowledgeCutoff ? { knowledge_cutoff: knowledgeCutoff } : {}), + }), }); } diff --git a/frontend/src/components/BuyerNav.test.tsx b/frontend/src/components/BuyerNav.test.tsx index 21c7995fb..b6efc4aa5 100644 --- a/frontend/src/components/BuyerNav.test.tsx +++ b/frontend/src/components/BuyerNav.test.tsx @@ -3,12 +3,13 @@ import { describe, expect, it, vi } from "vitest"; import { BuyerNav } from "./BuyerNav"; describe("BuyerNav", () => { - it("renders the four buyer destinations and marks the current page", () => { + it("renders the five buyer destinations and marks the current page", () => { render(); expect(screen.getByRole("navigation")).toHaveAccessibleName("Buyer navigation"); expect(screen.getByRole("button", { name: "Board" })).toHaveAttribute("aria-current", "page"); expect(screen.getByRole("button", { name: "Customer master" })).not.toHaveAttribute("aria-current"); + expect(screen.getByRole("button", { name: "Project history" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Calendar" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Ask Agent" })).toBeInTheDocument(); }); diff --git a/frontend/src/components/BuyerNav.tsx b/frontend/src/components/BuyerNav.tsx index b691bfd9c..02d456de6 100644 --- a/frontend/src/components/BuyerNav.tsx +++ b/frontend/src/components/BuyerNav.tsx @@ -1,7 +1,7 @@ import { t } from "../i18n"; import type { ReactNode } from "react"; -export type BuyerDestination = "board" | "customers" | "calendar" | "ask" | "admin"; +export type BuyerDestination = "board" | "customers" | "calendar" | "ask" | "project-history" | "admin"; export type BuyerNavProps = { destination: BuyerDestination; @@ -9,11 +9,12 @@ export type BuyerNavProps = { tools?: ReactNode; }; -const ITEMS: BuyerDestination[] = ["board", "customers", "calendar", "ask", "admin"]; +const ITEMS: BuyerDestination[] = ["board", "customers", "project-history", "calendar", "ask", "admin"]; const LABELS: Record = { board: "Board", customers: "Customer master", + "project-history": "Project history", calendar: "Calendar", ask: "Ask Agent", admin: "Admin", diff --git a/frontend/src/components/ProjectHistoryTimeline.css b/frontend/src/components/ProjectHistoryTimeline.css new file mode 100644 index 000000000..6d9424bb7 --- /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(--border-color, #9aa4b2); +} + +.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(--surface-color, #fff); + 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(--border-color, #c8d0da); + 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(--border-color, #d7dde5); + 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(--border-color, #c8d0da); + 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(--border-color, #9aa4b2); + 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..49d4e8675 --- /dev/null +++ b/frontend/src/components/ProjectHistoryTimeline.stories.tsx @@ -0,0 +1,150 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import type { ProjectHistoryProjection, ProjectHistoryTruthStatus } from "../projectHistory"; +import { ProjectHistoryTimeline } from "./ProjectHistoryTimeline"; + +const event = ( + eventId: string, + title: string, + type: string, + occurredAt: string, + transition: "continuous" | "handoff" | "assignment_gap" | null, + actorName?: string, + truthStatus: ProjectHistoryTruthStatus = "observed", +) => { + const responsibilityEvidence = actorName + ? [ + { + actor_key: `actor:${actorName}`, + actor_name: actorName, + actor_type_code: "prov_person", + affiliated_organization_name: "Demo Corp", + responsibility: + truthStatus === "observed" ? "Source author" : `Coordinate ${title.toLowerCase()}`, + truth_status_code: truthStatus, + provenance: + truthStatus === "observed" ? "source_post.source_author" : "post_summary_role", + }, + ] + : []; + return { + event_id: eventId, + source_post_id: `post-${eventId}`, + event_title: title, + event_type_code: type, + event_type_basis_code: "display_classification" as const, + occurred_at: occurredAt, + time_basis_code: "document_time" as const, + voc_type_code: eventId === "voc" ? "voc" : "vom", + source_stage_code: null, + source_detail_state_code: null, + project_matches: [], + responsibility_evidence: responsibilityEvidence, + observed_responsibilities: responsibilityEvidence.filter( + (row) => row.truth_status_code === "observed", + ), + responsibility_transition_code: transition, + responsibility_transition_truth_status_code: + transition === null ? null : truthStatus, + 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", + knowledge_cutoff: "2026-08-20T00:00:00Z", + evidence_boundary_code: "authorized_visible_source_posts", + event_count: 5, + distinct_actor_count: 3, + distinct_observed_actor_count: 2, + truncated: false, + events: [ + event("award", "Contract awarded", "contract_awarded", "2022-03-11T09:00:00Z", null, "Ada West"), + event( + "spec", + "Specification revision requested", + "specification_changed", + "2023-06-15T09:00:00Z", + "continuous", + "Ada West", + ), + event( + "delivery", + "Delivery confirmed", + "delivered", + "2024-02-20T09:00:00Z", + "handoff", + "Priya Nair", + "inferred", + ), + event("voc", "VOC received", "voc_received", "2026-07-30T09:00:00Z", "assignment_gap"), + event( + "rebid", + "Rebid started", + "rebid_started", + "2026-08-10T09:00:00Z", + "assignment_gap", + "Bid team", + "inferred", + ), + ], +}; + +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: "Buyer/Project History Timeline", + component: ProjectHistoryTimeline, + args: { + projection, + onOpenPost: () => undefined, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const AwardToRebid: Story = {}; + +export const TruncatedAtSelectedVoc: Story = { + args: { + projection: { + ...projection, + truncated: true, + }, + }, +}; + +export const ResponsibilityEvidenceGap: Story = { + args: { + projection: { + ...projection, + focus_event_id: "voc", + events: projection.events.map((row) => + row.event_id === "voc" + ? { ...row, responsibility_evidence: [], observed_responsibilities: [] } + : row, + ), + }, + }, +}; diff --git a/frontend/src/components/ProjectHistoryTimeline.test.tsx b/frontend/src/components/ProjectHistoryTimeline.test.tsx new file mode 100644 index 000000000..8b53f654b --- /dev/null +++ b/frontend/src/components/ProjectHistoryTimeline.test.tsx @@ -0,0 +1,182 @@ +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", + knowledge_cutoff: "2026-08-20T00:00:00+00:00", + evidence_boundary_code: "authorized_visible_source_posts", + event_count: 3, + distinct_actor_count: 2, + distinct_observed_actor_count: 1, + truncated: false, + events: [ + { + event_id: "award", + source_post_id: "post-award", + event_title: "Contract awarded", + event_type_code: "contract_awarded", + event_type_basis_code: "display_classification", + 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: [], + responsibility_evidence: [ + { + actor_key: "text:prov_person\u001fkim oo\u001fdemo corp", + actor_name: "Kim OO", + actor_type_code: "prov_person", + affiliated_organization_name: "Demo Corp", + responsibility: "Source author", + truth_status_code: "observed", + provenance: "source_post.source_author", + }, + ], + observed_responsibilities: [ + { + actor_key: "text:prov_person\u001fkim oo\u001fdemo corp", + actor_name: "Kim OO", + actor_type_code: "prov_person", + affiliated_organization_name: "Demo Corp", + responsibility: "Source author", + truth_status_code: "observed", + provenance: "source_post.source_author", + }, + ], + responsibility_transition_code: null, + responsibility_transition_truth_status_code: null, + related_prior_paths: [], + }, + { + event_id: "spec", + source_post_id: "post-spec", + event_title: "Specification changed", + event_type_code: "specification_changed", + event_type_basis_code: "display_classification", + 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: [], + responsibility_evidence: [ + { + actor_key: "person:pm", + actor_name: "Park OO", + actor_type_code: "prov_person", + affiliated_organization_name: "Demo Corp", + responsibility: "Coordinate the specification revision", + truth_status_code: "inferred", + provenance: "post_summary_role", + }, + ], + observed_responsibilities: [], + responsibility_transition_code: "handoff", + responsibility_transition_truth_status_code: "inferred", + related_prior_paths: [], + }, + { + event_id: "voc", + source_post_id: "post-voc", + event_title: "VOC received", + event_type_code: "voc_received", + event_type_basis_code: "display_classification", + occurred_at: "2026-02-02T09:00:00Z", + time_basis_code: "source_post_created_at_fallback", + voc_type_code: "voc", + source_stage_code: null, + source_detail_state_code: null, + project_matches: [], + responsibility_evidence: [], + observed_responsibilities: [], + responsibility_transition_code: "assignment_gap", + responsibility_transition_truth_status_code: "inferred", + 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("describes a recorded document clock without calling it a source-post fallback", () => { + render( + , + ); + + expect(screen.getByText(/dates use the document time recorded by the source/i)).toBeInTheDocument(); + expect(screen.queryByText(/separate event clock is not recorded/i)).not.toBeInTheDocument(); + }); + + it("shows the focus event, evidence gap, authorization boundary, 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.getByText(/evidence gap/i, { selector: "dd" })).toBeInTheDocument(); + expect(screen.getByText(/related history, not causality/i)).toBeInTheDocument(); + expect(screen.getByText(/permission, visibility, publication, and cutoff gates/i)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /open source record: VOC received/i })); + expect(onOpenPost).toHaveBeenCalledWith("post-voc"); + }); + + it("uses roving keyboard selection and exposes inferred responsibility truth", () => { + 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); + expect(screen.getAllByText("Inferred").length).toBeGreaterThan(0); + expect(screen.getByText("post_summary_role")).toBeInTheDocument(); + expect(screen.queryByText("Observed award owner")).not.toBeInTheDocument(); + }); + + it("labels the projection's actual time basis", () => { + const { rerender } = render( + , + ); + expect( + screen.getByText("Dates use source-post creation time because a separate event clock is not recorded."), + ).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByText("Dates use the document time recorded by the source.")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ProjectHistoryTimeline.tsx b/frontend/src/components/ProjectHistoryTimeline.tsx new file mode 100644 index 000000000..a7832716a --- /dev/null +++ b/frontend/src/components/ProjectHistoryTimeline.tsx @@ -0,0 +1,338 @@ +import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react"; + +import { useLocale } from "../i18n"; +import { + type ProjectHistoryEvent, + type ProjectHistoryProjection, + projectHistoryEventTypeLabel, + 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 selectedResponsibilities = + selectedEvent?.responsibility_evidence ?? selectedEvent?.observed_responsibilities ?? []; + const actorCount = projection.distinct_actor_count ?? projection.distinct_observed_actor_count; + 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: actorCount, + })} +

      +
      + +

      + {projectHistoryText( + locale, + projection.time_basis_code === "document_time" ? "documentTime" : "sourcePostTime", + )} +

      + {projection.evidence_boundary_code ? ( +

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

      + ) : null} + {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)}
      +
      + {selectedEvent.responsibility_transition_code ? ( +
      +
      {projectHistoryText(locale, "columnTransition")}
      +
      + {projectHistoryTransitionLabel( + locale, + selectedEvent.responsibility_transition_code, + )} + {selectedEvent.responsibility_transition_truth_status_code ? ( + + {projectHistoryText( + locale, + selectedEvent.responsibility_transition_truth_status_code, + )} + + ) : null} +
      +
      + ) : null} +
      + +
      +
      + {projectHistoryText(locale, "responsibilityEvidence")} +
      + {selectedResponsibilities.length > 0 ? ( +
        + {selectedResponsibilities.map((responsibility) => ( +
      • + {responsibility.actor_name} + {responsibility.affiliated_organization_name + ? ` · ${responsibility.affiliated_organization_name}` + : ""} + {responsibility.responsibility} + + {projectHistoryText(locale, responsibility.truth_status_code)} + + {responsibility.provenance} +
      • + ))} +
      + ) : ( +

      {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} · {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.responsibility_evidence ?? event.observed_responsibilities).length > 0 + ? (event.responsibility_evidence ?? 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 aed9240c1..85f8bb2a4 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -42,6 +42,12 @@ describe("i18n", () => { "Authorized commitments are current. Open a commitment to read Event Lineage.", "Authorized customer entities are current. Open a related post to read Event Lineage.", "Authorized cited posts are current. Open a cited post to read Event Lineage.", + "Knowledge cutoff (optional)", + "This answer is fully grounded at the requested cutoff. Open a cited post to compare the retained body.", + "This answer is only partly grounded at the requested cutoff. Open a cited post to see which historical bodies were retained.", + "Historical body unavailable for this cited post. The live body was not used.", + "Historical evidence limitations", + "This live source changed after the cutoff.", "Event Lineage timeline", "Open timeline post:", ] as const; diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 2fb01c150..a07e605b3 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -147,6 +147,13 @@ const TRANSLATIONS: Partial>> = { "Board posts": "게시판 글", "No posts match the current filters.": "현재 필터에 맞는 글이 없습니다.", "Customer master": "고객 마스터", + "Project history": "프로젝트 이력", + "Authorized project evidence": "권한이 있는 프로젝트 근거", + "Select an exact project identity to read its visible history.": "정확한 프로젝트 식별자를 선택하여 공개 가능한 이력을 읽으세요.", + "Loading project history...": "프로젝트 이력을 불러오는 중...", + "No authorized project evidence is available.": "권한이 있는 프로젝트 근거가 없습니다.", + "Open project history": "프로젝트 이력 열기", + "Open project history for: {name}": "{name} 프로젝트 이력 열기", "Ask Agent": "Ask Agent", "Buyer navigation": "구매자 메뉴", "Authorized customer scope": "권한이 있는 고객 범위", @@ -172,8 +179,19 @@ const TRANSLATIONS: Partial>> = { "Evidence-grounded questions": "근거 기반 질문", "Questions use authorized posts and their evidence.": "질문은 권한이 있는 글과 그 근거를 사용합니다.", "Ask a question": "질문 입력", - Ask: "질의", - "Asking...": "질의 중...", + "Knowledge cutoff (optional)": "지식 기준 시각(선택)", + "This answer is fully grounded at the requested cutoff. Open a cited post to compare the retained body.": + "이 답변은 요청한 기준 시각의 근거로 완전히 뒷받침됩니다. 인용된 글을 열어 당시 본문을 비교하세요.", + "This answer is only partly grounded at the requested cutoff. Open a cited post to see which historical bodies were retained.": + "이 답변은 요청한 기준 시각의 근거로 일부만 뒷받침됩니다. 인용된 글을 열어 남은 당시 본문을 확인하세요.", + "Historical body unavailable for this cited post. The live body was not used.": + "이 인용 글의 당시 본문을 찾을 수 없습니다. 현재 본문은 사용하지 않았습니다.", + "Historical evidence limitations": "과거 근거 제한", + "This live source changed after the cutoff.": "현재 원문은 기준 시각 이후에 바뀌었습니다.", + "No authorized source posts are available at this cutoff.": + "이 기준 시각에 권한이 있는 원문 글이 없습니다.", + Ask: "질문하기", + "Asking...": "질문하는 중...", Answer: "답변", "Cited posts": "인용된 글", "Search related posts": "관련 글 검색", @@ -500,6 +518,13 @@ const TRANSLATIONS: Partial>> = { "Board posts": "看板文章", "No posts match the current filters.": "没有文章符合当前筛选条件。", "Customer master": "客户主数据", + "Project history": "项目历史", + "Authorized project evidence": "已授权的项目依据", + "Select an exact project identity to read its visible history.": "选择准确的项目身份以查看可见历史。", + "Loading project history...": "正在加载项目历史…", + "No authorized project evidence is available.": "没有可用的已授权项目依据。", + "Open project history": "打开项目历史", + "Open project history for: {name}": "打开{name}的项目历史", "Ask Agent": "Ask Agent", "Buyer navigation": "买家导航", "Authorized customer scope": "已授权的客户范围", @@ -525,6 +550,17 @@ const TRANSLATIONS: Partial>> = { "Evidence-grounded questions": "基于证据的问题", "Questions use authorized posts and their evidence.": "问题使用已授权文章及其证据。", "Ask a question": "输入问题", + "Knowledge cutoff (optional)": "知识截止时间(可选)", + "This answer is fully grounded at the requested cutoff. Open a cited post to compare the retained body.": + "此答案完全基于所请求截止时间的证据。打开一篇引用文章以比较当时保留的正文。", + "This answer is only partly grounded at the requested cutoff. Open a cited post to see which historical bodies were retained.": + "此答案仅部分基于所请求截止时间的证据。打开一篇引用文章以查看保留了哪些当时正文。", + "Historical body unavailable for this cited post. The live body was not used.": + "找不到这篇引用文章当时的正文。未使用当前正文。", + "Historical evidence limitations": "历史证据限制", + "This live source changed after the cutoff.": "当前原文在截止时间之后发生了更改。", + "No authorized source posts are available at this cutoff.": + "该截止时间没有可授权的原文文章。", Ask: "提问", "Asking...": "正在提问...", Answer: "回答", @@ -876,6 +912,13 @@ const TRANSLATIONS: Partial>> = { "Board posts": "掲示板の投稿", "No posts match the current filters.": "現在の絞り込みに一致する投稿はありません。", "Customer master": "顧客マスター", + "Project history": "プロジェクト履歴", + "Authorized project evidence": "認証済みプロジェクト証拠", + "Select an exact project identity to read its visible history.": "正確なプロジェクト識別子を選び、表示可能な履歴を読みます。", + "Loading project history...": "プロジェクト履歴を読み込み中…", + "No authorized project evidence is available.": "利用可能な認証済みプロジェクト証拠はありません。", + "Open project history": "プロジェクト履歴を開く", + "Open project history for: {name}": "{name}のプロジェクト履歴を開く", "Ask Agent": "Ask Agent", "Buyer navigation": "購入者ナビゲーション", "Authorized customer scope": "許可された顧客範囲", @@ -901,6 +944,17 @@ const TRANSLATIONS: Partial>> = { "Evidence-grounded questions": "証拠に基づく質問", "Questions use authorized posts and their evidence.": "質問には許可された投稿とその証拠を使用します。", "Ask a question": "質問を入力", + "Knowledge cutoff (optional)": "知識カットオフ(任意)", + "This answer is fully grounded at the requested cutoff. Open a cited post to compare the retained body.": + "この回答は指定した基準時刻の根拠で完全に裏付けられています。引用投稿を開いて当時の本文を比較してください。", + "This answer is only partly grounded at the requested cutoff. Open a cited post to see which historical bodies were retained.": + "この回答は指定した基準時刻の根拠で一部のみ裏付けられています。引用投稿を開いて残っている当時の本文を確認してください。", + "Historical body unavailable for this cited post. The live body was not used.": + "この引用投稿の当時の本文は見つかりません。現在の本文は使っていません。", + "Historical evidence limitations": "過去の証拠の制限", + "This live source changed after the cutoff.": "現在の原文は基準時刻の後に変更されています。", + "No authorized source posts are available at this cutoff.": + "この基準時刻に権限のある原文投稿はありません。", Ask: "質問する", "Asking...": "質問中...", Answer: "回答", @@ -1228,6 +1282,13 @@ const TRANSLATIONS: Partial>> = { "Board posts": "Bài viết trên bảng tin", "No posts match the current filters.": "Không có bài viết nào khớp với bộ lọc hiện tại.", "Customer master": "Danh mục khách hàng", + "Project history": "Lịch sử dự án", + "Authorized project evidence": "Bằng chứng dự án được cấp quyền", + "Select an exact project identity to read its visible history.": "Chọn đúng danh tính dự án để đọc lịch sử có thể xem.", + "Loading project history...": "Đang tải lịch sử dự án…", + "No authorized project evidence is available.": "Không có bằng chứng dự án được cấp quyền.", + "Open project history": "Mở lịch sử dự án", + "Open project history for: {name}": "Mở lịch sử dự án của {name}", "Ask Agent": "Ask Agent", "Buyer navigation": "Điều hướng người mua", "Authorized customer scope": "Phạm vi khách hàng được cấp quyền", @@ -1253,6 +1314,17 @@ const TRANSLATIONS: Partial>> = { "Evidence-grounded questions": "Câu hỏi dựa trên bằng chứng", "Questions use authorized posts and their evidence.": "Câu hỏi sử dụng các bài viết được cấp quyền và bằng chứng của chúng.", "Ask a question": "Nhập câu hỏi", + "Knowledge cutoff (optional)": "Mốc kiến thức (tùy chọn)", + "This answer is fully grounded at the requested cutoff. Open a cited post to compare the retained body.": + "Câu trả lời này được căn cứ đầy đủ theo mốc thời gian đã chọn. Hãy mở bài trích dẫn để so sánh nội dung được lưu lúc đó.", + "This answer is only partly grounded at the requested cutoff. Open a cited post to see which historical bodies were retained.": + "Câu trả lời này chỉ được căn cứ một phần theo mốc thời gian đã chọn. Hãy mở bài trích dẫn để xem nội dung lịch sử nào còn được lưu.", + "Historical body unavailable for this cited post. The live body was not used.": + "Không có nội dung tại mốc thời gian cho bài trích dẫn này. Nội dung hiện tại không được dùng.", + "Historical evidence limitations": "Giới hạn bằng chứng lịch sử", + "This live source changed after the cutoff.": "Nguồn hiện tại đã thay đổi sau mốc thời gian.", + "No authorized source posts are available at this cutoff.": + "Không có bài nguồn được cấp quyền tại mốc thời gian này.", Ask: "Hỏi", "Asking...": "Đang hỏi...", Answer: "Câu trả lời", diff --git a/frontend/src/projectHistory.ts b/frontend/src/projectHistory.ts new file mode 100644 index 000000000..208202746 --- /dev/null +++ b/frontend/src/projectHistory.ts @@ -0,0 +1,483 @@ +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: ProjectHistoryTruthStatus; + 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: "display_classification"; + 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[]; + responsibility_evidence?: ProjectHistoryResponsibility[]; + observed_responsibilities: ProjectHistoryResponsibility[]; + responsibility_transition_code: ResponsibilityTransitionCode | null; + responsibility_transition_truth_status_code?: ProjectHistoryTruthStatus | 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; + knowledge_cutoff?: string; + evidence_boundary_code?: "authorized_visible_source_posts" | string; + event_count: number; + distinct_actor_count?: number; + distinct_observed_actor_count: number; + truncated: boolean; + events: ProjectHistoryEvent[]; +} + +export interface ProjectHistoryIndexEntry { + normalized_project_key: string; + project_key: string; + project_name: string; + truth_status_code: ProjectHistoryTruthStatus; + event_count: number; + latest_event_at: string; +} + +export interface ProjectHistoryIndex { + contract_version: 1; + time_basis_code: ProjectHistoryTimeBasis; + knowledge_cutoff: string; + project_count: number; + truncated: boolean; + projects: ProjectHistoryIndexEntry[]; +} + +export interface ProjectEvidenceGroup { + normalizedProjectKey: string; + projectKey: string; + projectName: string; + evidence: ProjectEvidence[]; +} + +function normalizeProjectIdentity(value: string): string { + return value.normalize("NFKC").trim().toLocaleLowerCase("en-US"); +} + +function evidenceOrder(evidence: ProjectEvidence): number { + if (evidence.extraction_method === "source_field_hint") return 0; + if (evidence.resolution_status === "hint_only") return 1; + return 2; +} + +function compareEvidence(left: ProjectEvidence, right: ProjectEvidence): number { + return ( + evidenceOrder(left) - evidenceOrder(right) || + left.project_name.localeCompare(right.project_name) || + left.project_key.localeCompare(right.project_key) || + left.provenance.localeCompare(right.provenance) + ); +} + +export function groupProjectEvidence(evidence: ProjectEvidence[]): ProjectEvidenceGroup[] { + const groups = new Map(); + for (const item of evidence) { + const normalizedProjectKey = normalizeProjectIdentity(item.project_key || item.project_name); + if (!normalizedProjectKey) continue; + const rows = groups.get(normalizedProjectKey) ?? []; + rows.push(item); + groups.set(normalizedProjectKey, rows); + } + return Array.from(groups.entries()) + .map(([normalizedProjectKey, rows]) => { + const ordered = [...rows].sort(compareEvidence); + const representative = ordered[0]; + return { + normalizedProjectKey, + projectKey: representative.project_key || representative.project_name, + projectName: representative.project_name || representative.project_key, + evidence: ordered, + }; + }) + .sort( + (left, right) => + left.projectName.localeCompare(right.projectName) || + left.normalizedProjectKey.localeCompare(right.normalizedProjectKey), + ); +} + +const MESSAGE_KEYS = [ + "destinationHeading", + "destinationIntro", + "selectProject", + "loadingProjects", + "noProjects", + "loadingHistory", + "historyUnavailable", + "projectListTruncated", + "openProject", + "evidenceBoundary", + "heading", + "summaryCounts", + "sourcePostTime", + "documentTime", + "truncated", + "eventDetail", + "eventType", + "eventDate", + "responsibilityEvidence", + "noResponsibilityEvidence", + "continuous", + "handoff", + "assignmentGap", + "priorHistory", + "noPriorHistory", + "inferredBoundary", + "projectEvidence", + "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 = { + destinationHeading: "Project lifecycle history", + destinationIntro: "Read the authorized order, change, delivery, VOC, and rebid evidence on one timeline.", + selectProject: "Select project", + loadingProjects: "Loading authorized projects...", + noProjects: "No project evidence is available in your authorized scope.", + loadingHistory: "Loading project history...", + historyUnavailable: "Project history could not be loaded. Open a source record and check its project evidence.", + projectListTruncated: "Only the most recent projects within the display bound are shown.", + openProject: "Open project: {name}", + evidenceBoundary: "Only source posts that pass the current permission, visibility, publication, and cutoff gates are included.", + heading: "Project event timeline", + summaryCounts: "{events} events · {actors} actors in evidence", + sourcePostTime: "Dates use source-post creation time because a separate event clock is not recorded.", + documentTime: "Dates use the document time recorded by the source.", + truncated: "This bounded timeline is truncated. The selected event remains included.", + eventDetail: "Event detail", + eventType: "Display event type", + eventDate: "Source-post date", + responsibilityEvidence: "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", + 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: "Actors in evidence", + 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: { + destinationHeading: "프로젝트 전 주기 이력", + destinationIntro: "권한 범위의 수주·변경·납품·VOC·재입찰 근거를 하나의 시간축에서 확인합니다.", + selectProject: "프로젝트 선택", + loadingProjects: "열람 가능한 프로젝트를 불러오는 중...", + noProjects: "현재 권한 범위에는 프로젝트 근거가 없습니다.", + loadingHistory: "프로젝트 이력을 불러오는 중...", + historyUnavailable: "프로젝트 이력을 불러오지 못했습니다. 원천 기록을 열어 프로젝트 근거를 확인하세요.", + projectListTruncated: "표시 한도 안의 최근 프로젝트만 보여 줍니다.", + openProject: "프로젝트 열기: {name}", + evidenceBoundary: "현재 권한·공개 범위·게시 상태·기준 시각을 통과한 원천 게시물만 포함합니다.", + heading: "프로젝트 이벤트 타임라인", + summaryCounts: "이벤트 {events}건 · 근거에 등장한 담당자 {actors}명", + sourcePostTime: "별도 사건 시각이 없어 날짜는 원천 게시물 생성 시각을 사용합니다.", + documentTime: "날짜는 기록된 문서 시각을 사용합니다.", + truncated: "이 제한된 타임라인은 일부만 표시합니다. 선택한 이벤트는 계속 포함됩니다.", + eventDetail: "이벤트 상세", + eventType: "표시용 이벤트 유형", + eventDate: "원천 게시물 날짜", + responsibilityEvidence: "담당 근거", + noResponsibilityEvidence: "이 이벤트에는 기록된 담당 근거가 없습니다.", + continuous: "담당 근거 유지", + handoff: "담당 근거 변경", + assignmentGap: "담당 근거 공백", + priorHistory: "관련 과거 이력", + noPriorHistory: "이 이벤트로 이어지는 공개 가능한 이전 계보가 없습니다.", + inferredBoundary: "이는 추론된 관련 이력이며 인과관계나 권위 있는 인사 배정 기록이 아닙니다.", + projectEvidence: "프로젝트 식별 근거", + observed: "관찰됨", + inferred: "추론됨", + openSourceRecord: "원천 기록 열기: {title}", + exactValues: "정확한 값", + exactTableLabel: "프로젝트 이력 정확한 값", + columnDate: "날짜", + columnEvent: "이벤트", + columnType: "유형", + columnTransition: "담당 근거 변화", + columnActors: "근거에 등장한 담당자", + columnPathScore: "최소 계보 점수", + notApplicable: "해당 없음", + contractAwarded: "수주 확정", + specificationChanged: "사양 변경", + delivered: "납품", + handoffRecorded: "인수인계 기록", + vocReceived: "VOC 접수", + rebidStarted: "재입찰 시작", + sourceRecorded: "원천 기록", + }, + zh: { + destinationHeading: "项目全周期历史", + destinationIntro: "在一条时间线上查看权限范围内的订单、变更、交付、客户之声和重新投标依据。", + selectProject: "选择项目", + loadingProjects: "正在加载可访问项目...", + noProjects: "当前授权范围内没有项目依据。", + loadingHistory: "正在加载项目历史...", + historyUnavailable: "无法加载项目历史。请打开源记录并检查项目依据。", + projectListTruncated: "仅显示边界内最近的项目。", + openProject: "打开项目:{name}", + evidenceBoundary: "仅包含通过当前权限、可见性、发布状态和截止时间检查的源帖子。", + heading: "项目事件时间线", + summaryCounts: "{events} 个事件 · 依据中出现 {actors} 名责任人", + sourcePostTime: "未记录独立事件时钟,因此日期采用源帖子创建时间。", + documentTime: "日期采用记录的文档时间。", + truncated: "此有界时间线已截断,但所选事件仍保留。", + eventDetail: "事件详情", + eventType: "显示事件类型", + eventDate: "源帖子日期", + responsibilityEvidence: "责任依据", + noResponsibilityEvidence: "此事件没有记录责任依据。", + continuous: "责任依据持续", + handoff: "责任依据变化", + assignmentGap: "责任依据缺口", + priorHistory: "相关既往历史", + noPriorHistory: "此事件没有可见的既往谱系路径。", + inferredBoundary: "这是推断的相关历史,并非因果关系或权威任命记录。", + projectEvidence: "项目身份依据", + observed: "已观察", + inferred: "已推断", + openSourceRecord: "打开源记录:{title}", + exactValues: "精确值", + exactTableLabel: "项目历史精确值", + columnDate: "日期", + columnEvent: "事件", + columnType: "类型", + columnTransition: "责任依据变化", + columnActors: "依据中的责任人", + columnPathScore: "最低谱系分数", + notApplicable: "不适用", + contractAwarded: "合同授予", + specificationChanged: "规格变更", + delivered: "已交付", + handoffRecorded: "已记录交接", + vocReceived: "收到客户之声", + rebidStarted: "重新投标开始", + sourceRecorded: "源记录", + }, + ja: { + destinationHeading: "プロジェクト全期間履歴", + destinationIntro: "権限範囲内の受注・変更・納品・VOC・再入札の根拠を一つの時間軸で確認します。", + selectProject: "プロジェクトを選択", + loadingProjects: "閲覧可能なプロジェクトを読み込み中...", + noProjects: "現在の権限範囲にはプロジェクト根拠がありません。", + loadingHistory: "プロジェクト履歴を読み込み中...", + historyUnavailable: "プロジェクト履歴を読み込めませんでした。原資料を開いてプロジェクト根拠を確認してください。", + projectListTruncated: "表示上限内の最近のプロジェクトのみを表示します。", + openProject: "プロジェクトを開く: {name}", + evidenceBoundary: "現在の権限・可視性・公開状態・基準時刻を通過した原資料だけを含みます。", + heading: "プロジェクトイベントのタイムライン", + summaryCounts: "イベント {events}件 · 根拠内の担当者 {actors}名", + sourcePostTime: "独立したイベント時刻がないため、原資料の作成時刻を使用します。", + documentTime: "日付は記録された文書時刻を使用します。", + truncated: "この上限付きタイムラインは省略されていますが、選択イベントは保持されます。", + eventDetail: "イベント詳細", + eventType: "表示用イベント種別", + eventDate: "原資料の日付", + responsibilityEvidence: "担当根拠", + noResponsibilityEvidence: "このイベントには担当根拠が記録されていません。", + continuous: "担当根拠が継続", + handoff: "担当根拠が変更", + assignmentGap: "担当根拠の空白", + priorHistory: "関連する過去履歴", + noPriorHistory: "このイベントに至る可視の過去系譜はありません。", + inferredBoundary: "これは推論された関連履歴であり、因果関係や権威ある配属記録ではありません。", + projectEvidence: "プロジェクト識別根拠", + observed: "観察済み", + inferred: "推論済み", + openSourceRecord: "原資料を開く: {title}", + exactValues: "正確な値", + exactTableLabel: "プロジェクト履歴の正確な値", + columnDate: "日付", + columnEvent: "イベント", + columnType: "種別", + columnTransition: "担当根拠の変化", + columnActors: "根拠内の担当者", + columnPathScore: "最小系譜スコア", + notApplicable: "該当なし", + contractAwarded: "受注確定", + specificationChanged: "仕様変更", + delivered: "納品", + handoffRecorded: "引継ぎ記録", + vocReceived: "VOC受付", + rebidStarted: "再入札開始", + sourceRecorded: "原資料", + }, + vi: { + destinationHeading: "Lịch sử toàn vòng đời dự án", + destinationIntro: "Xem bằng chứng đơn hàng, thay đổi, giao hàng, VOC và đấu thầu lại được phép trên một dòng thời gian.", + selectProject: "Chọn dự án", + loadingProjects: "Đang tải các dự án được phép...", + noProjects: "Không có bằng chứng dự án trong phạm vi được phép hiện tại.", + loadingHistory: "Đang tải lịch sử dự án...", + historyUnavailable: "Không thể tải lịch sử dự án. Hãy mở bản ghi nguồn và kiểm tra bằng chứng dự án.", + projectListTruncated: "Chỉ hiển thị các dự án gần đây trong giới hạn trình bày.", + openProject: "Mở dự án: {name}", + evidenceBoundary: "Chỉ bao gồm bài nguồn vượt qua quyền, khả năng hiển thị, trạng thái xuất bản và mốc thời gian hiện tại.", + heading: "Dòng thời gian sự kiện dự án", + summaryCounts: "{events} sự kiện · {actors} người xuất hiện trong bằng chứng", + sourcePostTime: "Không có đồng hồ sự kiện riêng, nên dùng thời gian tạo bài nguồn.", + documentTime: "Ngày sử dụng thời gian tài liệu được ghi nhậ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 bài nguồn", + responsibilityEvidence: "Bằng chứng trách nhiệm", + 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", + 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 trong bằng chứng", + 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 key ? projectHistoryText(locale, key) : code; +} + +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/__init__.py b/lineageweave/__init__.py index 95330cb50..99a6edf49 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "2.12.6" +__version__ = "2.23.0" diff --git a/lineageweave/post_chat.py b/lineageweave/post_chat.py index ca7a562c5..2c6591f14 100644 --- a/lineageweave/post_chat.py +++ b/lineageweave/post_chat.py @@ -68,6 +68,11 @@ class ChatSourceDocument: evidence_facts: tuple[str, ...] = field(default_factory=tuple) occurred_at: str | None = None timeline_kind: str | None = None + source_revision_id: str | None = None + evidence_available_at: str | None = None + knowledge_cutoff: str | None = None + live_after_cutoff: bool = False + historical_body_unavailable: bool = False lineage_relation: str = "source" @@ -98,6 +103,100 @@ def cited_post_summaries( ] +LIVE_ONLY = "live_only" +FULLY_CUTOFF_GROUNDED = "fully_cutoff_grounded" +PARTIALLY_CUTOFF_GROUNDED = "partially_cutoff_grounded" + + +def ask_grounding_status( + sources: list[ChatSourceDocument] | tuple[ChatSourceDocument, ...], + knowledge_cutoff: object | None, +) -> str: + """Name whether this answer is live-only or cutoff-grounded. + + A live query is never labeled as-of. A cutoff query that kept every + retained body is fully grounded. A cutoff query that dropped any + historical body is only partly grounded. + """ + if knowledge_cutoff is None: + return LIVE_ONLY + if any(source.historical_body_unavailable for source in sources): + return PARTIALLY_CUTOFF_GROUNDED + return FULLY_CUTOFF_GROUNDED + + +def cited_post_citations( + sources: list[ChatSourceDocument] | tuple[ChatSourceDocument, ...], + cited_post_ids: tuple[str, ...] | list[str], +) -> list[dict[str, object]]: + """Titles plus cutoff provenance for cited ids, in citation order.""" + by_id = {source.post_id: source for source in sources} + citations: list[dict[str, object]] = [] + for post_id in cited_post_ids: + source = by_id.get(post_id) + if source is None: + continue + citation: dict[str, object] = { + "post_id": post_id, + "post_title": source.post_title, + } + if source.knowledge_cutoff: + citation["source_revision_id"] = source.source_revision_id + citation["evidence_available_at"] = source.evidence_available_at + citation["knowledge_cutoff"] = source.knowledge_cutoff + citation["live_after_cutoff"] = source.live_after_cutoff + citation["historical_body_unavailable"] = source.historical_body_unavailable + citations.append(citation) + return citations + + +def historical_body_limitations( + sources: list[ChatSourceDocument] | tuple[ChatSourceDocument, ...], +) -> list[dict[str, str]]: + """Return explicit missing-revision limitations, never a live substitute.""" + return [ + { + "post_id": source.post_id, + "limitation_code": "historical_body_unavailable", + } + for source in sources + if source.historical_body_unavailable + ] + + +def ask_next_action( + grounding_status: str, + *, + has_sources: bool, + has_retained_bodies: bool = True, +) -> str: + """Buyer next action for live versus cutoff-grounded Ask answers. + + ``has_retained_bodies`` distinguishes a partially grounded answer from a + cutoff result where every selected post lost its historical revision. + """ + if grounding_status == FULLY_CUTOFF_GROUNDED: + if not has_sources: + return "No authorized source posts are available at this cutoff." + return ( + "This answer is fully grounded at the requested cutoff. " + "Open a cited post to compare the retained body." + ) + if grounding_status == PARTIALLY_CUTOFF_GROUNDED: + if not has_retained_bodies: + return ( + "No historical source bodies were retained at the requested cutoff. " + "Open a timeline post to review each unavailable source." + ) + return ( + "This answer is only partly grounded at the requested cutoff. " + "Open a cited post to see which historical bodies were retained." + ) + if not has_sources: + return "No authorized source posts are available for this question." + return "Authorized cited posts are current. Open a cited post to read Event Lineage." + + def _buyer_evidence_kind(fact: str) -> str: if fact.startswith("project:"): return "semantic_project" diff --git a/lineageweave/project_history.py b/lineageweave/project_history.py new file mode 100644 index 000000000..29ce504be --- /dev/null +++ b/lineageweave/project_history.py @@ -0,0 +1,490 @@ +"""Build evidence-bound project histories from already-authorized rows. + +Callers apply RBAC, ABAC, source eligibility, exact project identity, and +knowledge-cutoff filtering before invoking this module. The pure projection +layer orders visible source records, preserves observed and inferred evidence, +compares responsibility evidence without promoting it to an HR ledger, and +exposes persisted lineage as related history rather than causality. +""" + +from __future__ import annotations + +from collections import deque +from collections.abc import Mapping, Sequence +from datetime import datetime, timezone +from decimal import Decimal +from typing import Any +from unicodedata import normalize + +PROJECT_HISTORY_CONTRACT_VERSION = 1 +PROJECT_HISTORY_TIME_BASIS = "source_post_created_at_fallback" +PROJECT_HISTORY_MAX_DEPTH = 8 +PROJECT_HISTORY_MAX_PATHS_PER_EVENT = 32 + +_EVENT_PATTERNS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("rebid_started", ("rebid", "re-bid", "retender", "re-tender", "재입찰")), + ( + "handoff_recorded", + ("handoff", "hand-off", "transferred ownership", "operational transfer", "인수인계"), + ), + ( + "specification_changed", + ( + "specification change", + "specification revision", + "revised specification", + "spec revision", + "사양 변경", + "사양변경", + ), + ), + ( + "delivered", + ( + "delivery confirmed", + "delivery completed", + "delivered", + "shipment completed", + "납품 완료", + "납품완료", + ), + ), + ( + "contract_awarded", + ( + "contract awarded", + "award confirmed", + "order confirmation", + "purchase order received", + "수주 확정", + "수주확정", + ), + ), +) +_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. + + Explicit title/stage/state markers take precedence. Every already-visible + VOC-family row is labelled as VOC, not only the currently selected row. + ``is_focus`` is retained for contract compatibility but never changes the + truth status or creates an event. + """ + + del is_focus + text = " ".join( + part.strip().lower() + for part in (title, source_stage_code or "", source_detail_state_code or "") + if part.strip() + ) + for event_code, patterns in _EVENT_PATTERNS: + if any(pattern in text for pattern in patterns): + return event_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 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 responsibility-evidence 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 result != result or result in (float("inf"), float("-inf")): + raise ValueError("lineage score must be finite") + return result + + +def _normalized_matches(value: object, normalized_key: str) -> bool: + """Return whether a non-empty identity value exactly matches the key.""" + + if value is None: + return False + try: + return normalize_project_key(str(value)) == normalized_key + except ValueError: + return False + + +def _match_belongs_to_project( + row: Mapping[str, Any], + *, + normalized_key: str, +) -> bool: + """Validate one evidence row against its authoritative identity key. + + New callers provide ``identity_key`` so a human display name may differ + from the code/key that selected the project. Rows without that authoritative + identity can only match on their own value; a sibling row must never make a + second project appear in this history. + """ + + identity_key = row.get("identity_key") + if identity_key is not None and str(identity_key).strip(): + return _normalized_matches(identity_key, normalized_key) + matched_value = row.get("matched_value") + return _normalized_matches(matched_value, normalized_key) + + +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 deterministic shortest visible predecessor paths per 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, + maximum_depth: int = PROJECT_HISTORY_MAX_DEPTH, + maximum_paths_per_event: int = PROJECT_HISTORY_MAX_PATHS_PER_EVENT, +) -> dict[str, Any]: + """Build the versioned Buyer 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. + Every evidence item retains its own observed/inferred truth status. + """ + + 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) + if current is None or (row["created_at"], event_id) < (current["created_at"], event_id): + deduplicated[event_id] = row + ordered_rows = sorted( + deduplicated.values(), + key=lambda row: (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") + + 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 + if not _match_belongs_to_project( + row, + normalized_key=normalized_key, + ): + continue + matched_value = str(row["matched_value"]) + 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("NFKC", matched_value).strip().lower(), + 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} + truth_by_event: dict[str, set[str]] = {event_id: set() for event_id in ordered_ids} + distinct_actor_keys: set[str] = set() + distinct_observed_actor_keys: set[str] = set() + seen_roles: set[tuple[str, 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"]) + truth = str(row.get("truth_status_code") or "inferred") + if truth not in _TRUTH_ORDER: + raise ValueError(f"unsupported responsibility truth status: {truth}") + provenance = str(row.get("provenance") or "post_summary_role") + role_key = (event_id, actor_key, responsibility, provenance) + if role_key in seen_roles: + continue + seen_roles.add(role_key) + distinct_actor_keys.add(actor_key) + if truth == "observed": + distinct_observed_actor_keys.add(actor_key) + actor_keys_by_event[event_id].append(actor_key) + truth_by_event[event_id].add(truth) + 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": truth, + "provenance": provenance, + } + ) + for event_id, roles in roles_by_event.items(): + roles.sort( + key=lambda role: ( + _TRUTH_ORDER[role["truth_status_code"]], + 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 + previous_truth: set[str] | None = None + for row in ordered_rows: + event_id = str(row["post_id"]) + current_actor_keys = actor_keys_by_event[event_id] + current_truth = truth_by_event[event_id] + transition = ( + None + if previous_actor_keys is None + else responsibility_transition_code(previous_actor_keys, current_actor_keys) + ) + transition_truth = None + if transition is not None: + combined_truth = (previous_truth or set()) | current_truth + if combined_truth: + transition_truth = "inferred" if "inferred" in combined_truth else "observed" + evidence = roles_by_event[event_id] + 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": "display_classification", + "occurred_at": _as_utc(row["created_at"]), + "time_basis_code": PROJECT_HISTORY_TIME_BASIS, + "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], + "responsibility_evidence": evidence, + "observed_responsibilities": [ + item for item in evidence if item["truth_status_code"] == "observed" + ], + "responsibility_transition_code": transition, + "responsibility_transition_truth_status_code": transition_truth, + "related_prior_paths": paths_by_event[event_id], + } + ) + previous_actor_keys = current_actor_keys + previous_truth = current_truth + + 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_TIME_BASIS, + "event_count": len(events), + "distinct_actor_count": len(distinct_actor_keys), + "distinct_observed_actor_count": len(distinct_observed_actor_keys), + "truncated": bool(truncated), + "events": events, + } diff --git a/migrations/0053_project_history_lookup.sql b/migrations/0053_project_history_lookup.sql new file mode 100644 index 000000000..75e78d786 --- /dev/null +++ b/migrations/0053_project_history_lookup.sql @@ -0,0 +1,40 @@ +begin; + +-- Bound the project index by the newest authorized source rows before its +-- normalization/window/group stages, and support exact project lookups. +create index if not exists source_post_project_history_recent_idx + on source_post (created_at desc, post_id desc); + +create index if not exists source_post_project_code_history_idx + on source_post ( + lower(normalize(btrim(source_project_code), NFKC)), + created_at, + post_id + ) + where source_project_code is not null and btrim(source_project_code) <> ''; + +create index if not exists source_post_project_name_history_idx + on source_post ( + lower(normalize(btrim(source_project_name), NFKC)), + created_at, + post_id + ) + where source_project_name is not null and btrim(source_project_name) <> ''; + +create index if not exists post_project_mention_key_history_idx + on post_project_mention ( + lower(normalize(btrim(project_key), NFKC)), + post_id + ); + +create index if not exists post_project_mention_name_history_idx + on post_project_mention ( + lower(normalize(btrim(project_name), NFKC)), + post_id + ); + +create index if not exists post_lineage_edge_child_history_idx + on post_lineage_edge (child_post_id, parent_post_id) + include (fused_score); + +commit; diff --git a/migrations/rollback/0053_project_history_lookup.sql b/migrations/rollback/0053_project_history_lookup.sql new file mode 100644 index 000000000..03a9d6751 --- /dev/null +++ b/migrations/rollback/0053_project_history_lookup.sql @@ -0,0 +1,10 @@ +begin; + +drop index if exists post_lineage_edge_child_history_idx; +drop index if exists post_project_mention_name_history_idx; +drop index if exists post_project_mention_key_history_idx; +drop index if exists source_post_project_name_history_idx; +drop index if exists source_post_project_code_history_idx; +drop index if exists source_post_project_history_recent_idx; + +commit; diff --git a/pyproject.toml b/pyproject.toml index 07035ef31..f4c910ca6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.19.0" +version = "2.23.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py index 010bb5e5d..85c05b7d2 100644 --- a/tests/test_documentation_hygiene.py +++ b/tests/test_documentation_hygiene.py @@ -2,7 +2,9 @@ from __future__ import annotations +import json import re +import tomllib from collections import Counter from pathlib import Path @@ -20,6 +22,35 @@ ) +def test_release_versions_are_consistent() -> None: + """Python, frontend, runtime, and changelog expose one release version.""" + project_version = tomllib.loads((_ROOT / "pyproject.toml").read_text(encoding="utf-8"))[ + "project" + ]["version"] + frontend_version = json.loads( + (_ROOT / "frontend" / "package.json").read_text(encoding="utf-8") + )["version"] + locked_project = next( + package + for package in tomllib.loads((_ROOT / "uv.lock").read_text(encoding="utf-8"))["package"] + if package["name"] == "lineageweave" + ) + runtime_source = (_ROOT / "lineageweave" / "__init__.py").read_text(encoding="utf-8") + runtime_match = re.search(r'^__version__ = "([^"]+)"$', runtime_source, re.MULTILINE) + changelog_source = (_ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + changelog_match = re.search(r"^## \[([^]]+)]", changelog_source, re.MULTILINE) + + assert runtime_match is not None + assert changelog_match is not None + assert { + project_version, + frontend_version, + locked_project["version"], + runtime_match.group(1), + changelog_match.group(1), + } == {project_version} + + def test_adr_numbers_are_unique_and_documents_are_not_placeholders() -> None: """Every committed ADR number identifies one substantive UTF-8 document.""" paths = sorted(_ADR_DIRECTORY.glob("*.md")) diff --git a/tests/test_global_ask_cutoff.py b/tests/test_global_ask_cutoff.py new file mode 100644 index 000000000..ed6d242c6 --- /dev/null +++ b/tests/test_global_ask_cutoff.py @@ -0,0 +1,530 @@ +"""Global Ask optional knowledge cutoff keeps retrieval evidence-honest (ADR 0135).""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone + +from lineageweave.post_chat import ( + FULLY_CUTOFF_GROUNDED, + LIVE_ONLY, + PARTIALLY_CUTOFF_GROUNDED, + ChatSourceDocument, + ask_grounding_status, + ask_next_action, + cited_post_citations, + historical_body_limitations, +) +from backend.app.post_chat_ingestion import gather_global_chat_sources +from backend.app.main import global_ask_timeline +from backend.app.source_post_revision import parse_as_of_clock + +_CUTOFF = datetime(2026, 1, 15, 12, 0, tzinfo=timezone.utc) +_JANUARY = datetime(2026, 1, 10, 9, 0, tzinfo=timezone.utc) +_FEBRUARY = datetime(2026, 2, 10, 9, 0, tzinfo=timezone.utc) + + +def _row( + post_id: str, + *, + title: str, + body: str, + created_at: datetime, + updated_at: datetime | None = None, + matched_in: str = "title", +) -> dict[str, object]: + return { + "post_id": post_id, + "post_title": title, + "post_body": body, + "visibility_code": "public", + "corporate_entity_id": None, + "matched_in": matched_in, + "created_at": created_at, + "updated_at": updated_at or created_at, + "source_project_code": "PHOENIX-LIVE", + } + + +class _CutoffConnection: + def __init__( + self, + rows: list[dict[str, object]], + revisions: list[dict[str, object]], + semantic_rows: list[dict[str, object]] | None = None, + lineage_edges: list[tuple[str, str]] | None = None, + ) -> None: + self.rows = rows + self.revisions = revisions + self.semantic_rows = semantic_rows or [] + self.lineage_edges = lineage_edges or [] + self.calls: list[tuple[str, tuple[object, ...]]] = [] + + async def fetch(self, query: str, *args): + self.calls.append((query, args)) + if "matched_in" in query: + term = str(args[0]).casefold() + cutoff = args[1] if len(args) > 1 else None + matches = [] + if cutoff is not None: + covering: dict[str, dict[str, object]] = {} + for revision in self.revisions: + if revision["written_at"] <= cutoff and ( + revision.get("superseded_at") is None + or revision["superseded_at"] > cutoff + ): + covering[str(revision["post_id"])] = revision + by_id = {str(row["post_id"]): row for row in self.rows} + for post_id, revision in covering.items(): + row = by_id.get(post_id) + if row is None or row["created_at"] > cutoff: + continue + haystack = f"{revision['post_title']} {revision['post_body']}".casefold() + if term in haystack: + matches.append( + { + "post_id": post_id, + "matched_in": ( + "title" + if term in str(revision["post_title"]).casefold() + else "body" + ), + } + ) + return matches + for row in self.rows: + haystack = f"{row['post_title']} {row['post_body']}".casefold() + if term in haystack: + matches.append(row) + return matches + if "from source_post_revision" in query: + cutoff = args[1] + wanted = {str(post_id) for post_id in args[0]} + return [ + revision + for revision in self.revisions + if str(revision["post_id"]) in wanted + and revision["written_at"] <= cutoff + and ( + revision.get("superseded_at") is None + or revision["superseded_at"] > cutoff + ) + ] + if "from post_project_mention" in query or "from post_summary_role" in query: + return self.semantic_rows + if "post_lineage_edge" in query: + anchor = str(args[0]) + cutoff = args[1] if len(args) > 1 else None + others = [] + for parent_id, child_id in self.lineage_edges: + other = ( + child_id + if parent_id == anchor + else parent_id + if child_id == anchor + else None + ) + if other is None: + continue + row = next((item for item in self.rows if str(item["post_id"]) == other), None) + if row is None: + continue + if cutoff is not None and row["created_at"] > cutoff: + continue + others.append({"other_id": other}) + return others + if "array_position($2::uuid[], post_id)" in query: + cutoff = args[3] if len(args) > 3 else None + by_id = {str(row["post_id"]): row for row in self.rows} + selected = [] + for post_id in args[1]: + row = by_id.get(str(post_id)) + if row is None: + continue + if cutoff is not None and row["created_at"] > cutoff: + continue + selected.append(row) + return selected[: args[2]] + return [] + + +def test_cutoff_uses_retained_revision_not_live_body() -> None: + rows = [ + _row( + "phoenix-post", + title="Phoenix live rewrite", + body="Live delivery window slipped to March.", + created_at=_JANUARY, + updated_at=_FEBRUARY, + ) + ] + revisions = [ + { + "source_post_revision_id": "rev-january", + "post_id": "phoenix-post", + "post_title": "Phoenix January note", + "post_body": "Phoenix kickoff completed in January.", + "written_at": _JANUARY, + "superseded_at": _FEBRUARY, + } + ] + sources = asyncio.run( + gather_global_chat_sources( + _CutoffConnection(rows, revisions), + lambda _row: True, + question="Phoenix", + knowledge_cutoff=_CUTOFF, + ) + ) + assert [source.post_id for source in sources] == ["phoenix-post"] + assert sources[0].post_title == "Phoenix January note" + assert "January" in sources[0].post_body + assert "March" not in sources[0].post_body + assert sources[0].source_revision_id == "rev-january" + assert sources[0].live_after_cutoff is True + assert sources[0].historical_body_unavailable is False + assert sources[0].knowledge_cutoff == _CUTOFF.isoformat() + assert sources[0].evidence_facts == () + + +def test_cutoff_excludes_posts_created_after_the_clock() -> None: + rows = [ + _row( + "late-post", + title="Phoenix February note", + body="Phoenix later status.", + created_at=_FEBRUARY, + ) + ] + connection = _CutoffConnection(rows, []) + sources = asyncio.run( + gather_global_chat_sources( + connection, + lambda _row: True, + question="Phoenix", + knowledge_cutoff=_CUTOFF, + ) + ) + assert sources == [] + candidate_query, candidate_args = connection.calls[0] + assert "source_post_revision" in candidate_query + assert "created_at <= $2" in candidate_query + assert candidate_args[1] == _CUTOFF + source_query = next(query for query, _args in connection.calls if "array_position" in query) + assert "created_at <= $4" in source_query + + +def test_cutoff_does_not_leak_current_semantic_facts() -> None: + rows = [ + _row( + "semantic-post", + title="Operational note", + body="No project name in this body.", + created_at=_JANUARY, + ) + ] + revisions = [ + { + "source_post_revision_id": "rev-ops", + "post_id": "semantic-post", + "post_title": "Operational note", + "post_body": "No project name in this body.", + "written_at": _JANUARY, + "superseded_at": None, + } + ] + connection = _CutoffConnection( + rows, + revisions, + semantic_rows=[ + { + "post_id": "semantic-post", + "fact": "project: later invented project | ontology_iri: urn:test", + } + ], + ) + sources = asyncio.run( + gather_global_chat_sources( + connection, + lambda _row: True, + question="Operational", + knowledge_cutoff=_CUTOFF, + ) + ) + assert sources[0].evidence_facts == () + assert all("post_project_mention" not in query for query, _args in connection.calls) + assert all("PHOENIX-LIVE" not in fact for fact in sources[0].evidence_facts) + + +def test_missing_historical_body_is_explicit_and_never_live() -> None: + rows = [ + _row( + "anchor-post", + title="Phoenix January note", + body="Phoenix kickoff completed in January.", + created_at=_JANUARY, + ), + _row( + "body-lost", + title="Phoenix live only", + body="This live rewrite must not become the cutoff body.", + created_at=_JANUARY, + updated_at=_FEBRUARY, + ), + ] + revisions = [ + { + "source_post_revision_id": "rev-anchor", + "post_id": "anchor-post", + "post_title": "Phoenix January note", + "post_body": "Phoenix kickoff completed in January.", + "written_at": _JANUARY, + "superseded_at": None, + } + ] + sources = asyncio.run( + gather_global_chat_sources( + _CutoffConnection( + rows, + revisions, + lineage_edges=[("anchor-post", "body-lost")], + ), + lambda _row: True, + question="Phoenix", + knowledge_cutoff=_CUTOFF, + limit=4, + ) + ) + by_id = {source.post_id: source for source in sources} + assert "body-lost" in by_id + assert by_id["body-lost"].historical_body_unavailable is True + assert by_id["body-lost"].post_body == "" + assert "live rewrite" not in by_id["body-lost"].post_body + assert historical_body_limitations(sources) == [ + {"post_id": "body-lost", "limitation_code": "historical_body_unavailable"} + ] + assert ask_grounding_status(sources, _CUTOFF) == PARTIALLY_CUTOFF_GROUNDED + assert [event["post_id"] for event in global_ask_timeline(sources)] == [ + "anchor-post", + "body-lost", + ] + + +def test_naive_updated_at_is_compared_as_utc_at_cutoff() -> None: + rows = [ + _row( + "naive-clock-post", + title="Phoenix live rewrite", + body="Live rewrite", + created_at=_JANUARY, + updated_at=_FEBRUARY.replace(tzinfo=None), + ) + ] + revisions = [ + { + "source_post_revision_id": "rev-naive-clock", + "post_id": "naive-clock-post", + "post_title": "Phoenix January note", + "post_body": "January body", + "written_at": _JANUARY, + "superseded_at": _FEBRUARY, + } + ] + sources = asyncio.run( + gather_global_chat_sources( + _CutoffConnection(rows, revisions), + lambda _row: True, + question="Phoenix", + knowledge_cutoff=_CUTOFF, + ) + ) + assert sources[0].live_after_cutoff is True + + +def test_two_cutoffs_select_revision_specific_citations() -> None: + rows = [ + _row( + "phoenix-post", + title="Phoenix live rewrite", + body="March window", + created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + updated_at=_FEBRUARY, + ) + ] + revisions = [ + { + "source_post_revision_id": "rev-early", + "post_id": "phoenix-post", + "post_title": "Phoenix kickoff", + "post_body": "Kickoff body", + "written_at": datetime(2026, 1, 1, tzinfo=timezone.utc), + "superseded_at": _JANUARY, + }, + { + "source_post_revision_id": "rev-mid", + "post_id": "phoenix-post", + "post_title": "Phoenix follow-up", + "post_body": "Follow-up body", + "written_at": _JANUARY, + "superseded_at": _FEBRUARY, + }, + ] + first = asyncio.run( + gather_global_chat_sources( + _CutoffConnection(rows, revisions), + lambda _row: True, + question="Phoenix", + knowledge_cutoff=datetime(2026, 1, 5, tzinfo=timezone.utc), + ) + ) + second = asyncio.run( + gather_global_chat_sources( + _CutoffConnection(rows, revisions), + lambda _row: True, + question="Phoenix", + knowledge_cutoff=_CUTOFF, + ) + ) + assert first[0].source_revision_id == "rev-early" + assert first[0].post_title == "Phoenix kickoff" + assert second[0].source_revision_id == "rev-mid" + assert second[0].post_title == "Phoenix follow-up" + citations = cited_post_citations(second, ["phoenix-post"]) + assert citations[0]["source_revision_id"] == "rev-mid" + assert citations[0]["knowledge_cutoff"] == _CUTOFF.isoformat() + + +def test_live_query_without_cutoff_stays_backward_compatible() -> None: + rows = [ + _row( + "phoenix-post", + title="Phoenix live rewrite", + body="Live delivery window slipped to March.", + created_at=_JANUARY, + updated_at=_FEBRUARY, + ) + ] + connection = _CutoffConnection( + rows, + [], + semantic_rows=[ + {"post_id": "phoenix-post", "fact": "project: live project | ontology_iri: urn:test"} + ], + ) + sources = asyncio.run( + gather_global_chat_sources( + connection, + lambda _row: True, + question="Phoenix", + ) + ) + assert sources[0].post_body.startswith("Live delivery") + assert sources[0].knowledge_cutoff is None + assert ask_grounding_status(sources, None) == LIVE_ONLY + assert "created_at <= $2" not in connection.calls[0][0] + assert "source_post_revision" not in connection.calls[0][0] + + +def test_live_rewrite_text_does_not_select_historical_post() -> None: + rows = [ + _row( + "phoenix-post", + title="Phoenix classified patent", + body="Classified patent window slipped to March.", + created_at=_JANUARY, + updated_at=_FEBRUARY, + ) + ] + revisions = [ + { + "source_post_revision_id": "rev-january", + "post_id": "phoenix-post", + "post_title": "Phoenix kickoff", + "post_body": "Phoenix kickoff completed in January.", + "written_at": _JANUARY, + "superseded_at": _FEBRUARY, + } + ] + sources = asyncio.run( + gather_global_chat_sources( + _CutoffConnection(rows, revisions), + lambda _row: True, + question="classified patent", + knowledge_cutoff=_CUTOFF, + ) + ) + assert sources == [] + + +def test_unauthorized_historical_revisions_remain_unauthorized() -> None: + rows = [ + _row( + "hidden-post", + title="Phoenix January note", + body="Phoenix kickoff completed in January.", + created_at=_JANUARY, + ) + ] + revisions = [ + { + "source_post_revision_id": "rev-hidden", + "post_id": "hidden-post", + "post_title": "Phoenix January note", + "post_body": "Phoenix kickoff completed in January.", + "written_at": _JANUARY, + "superseded_at": None, + } + ] + connection = _CutoffConnection(rows, revisions) + sources = asyncio.run( + gather_global_chat_sources( + connection, + lambda _row: False, + question="Phoenix", + knowledge_cutoff=_CUTOFF, + ) + ) + assert sources == [] + covering_fetches = [ + args + for query, args in connection.calls + if "from source_post_revision" in query and "matched_in" not in query + ] + assert covering_fetches == [] + +def test_parse_cutoff_rejects_unparseable_clocks() -> None: + try: + parse_as_of_clock("not-a-clock") + except ValueError: + return + raise AssertionError("unparseable knowledge_cutoff must fail closed") + + +def test_ask_next_action_never_calls_live_only_an_as_of_answer() -> None: + live = ChatSourceDocument("post-1", "Live", "body") + assert ask_grounding_status([live], None) == LIVE_ONLY + assert "as-of" not in ask_next_action(LIVE_ONLY, has_sources=True).lower() + assert "cutoff" not in ask_next_action(LIVE_ONLY, has_sources=True).lower() + unavailable = ChatSourceDocument( + "post-1", + "Lost", + "", + historical_body_unavailable=True, + knowledge_cutoff=_CUTOFF.isoformat(), + ) + assert ask_grounding_status([unavailable], _CUTOFF) == PARTIALLY_CUTOFF_GROUNDED + retained = ChatSourceDocument( + "post-1", + "Kept", + "January body", + knowledge_cutoff=_CUTOFF.isoformat(), + ) + assert ask_grounding_status([retained], _CUTOFF) == FULLY_CUTOFF_GROUNDED + + +def test_ask_next_action_names_when_no_historical_body_was_retained() -> None: + assert "no historical source bodies" in ask_next_action( + PARTIALLY_CUTOFF_GROUNDED, + has_sources=True, + has_retained_bodies=False, + ).lower() diff --git a/tests/test_migration_replay.py b/tests/test_migration_replay.py index 29098f0a5..6bccf9d41 100644 --- a/tests/test_migration_replay.py +++ b/tests/test_migration_replay.py @@ -56,3 +56,25 @@ def test_migrate_sh_replays_global_ask_context_migration() -> None: ).read_text(encoding="utf-8") assert "0052_*" in script + + +def test_migrate_sh_replays_project_history_lookup_indexes() -> None: + """Existing volumes receive the bounded project-history lookup indexes.""" + root = Path(__file__).resolve().parents[1] + script = (root / "docker/postgres-init/migrate.sh").read_text(encoding="utf-8") + forward = (root / "migrations/0053_project_history_lookup.sql").read_text(encoding="utf-8") + rollback = (root / "migrations/rollback/0053_project_history_lookup.sql").read_text( + encoding="utf-8" + ) + + assert "0053_*" in script + for index_name in ( + "source_post_project_history_recent_idx", + "source_post_project_code_history_idx", + "source_post_project_name_history_idx", + "post_project_mention_key_history_idx", + "post_project_mention_name_history_idx", + "post_lineage_edge_child_history_idx", + ): + assert f"create index if not exists {index_name}" in forward + assert f"drop index if exists {index_name}" in rollback diff --git a/tests/test_project_history.py b/tests/test_project_history.py new file mode 100644 index 000000000..752ce1b4b --- /dev/null +++ b/tests/test_project_history.py @@ -0,0 +1,431 @@ +"""Contracts for the Buyer project-history timeline.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone + +import pytest + +from backend.app.project_history import ( + ProjectHistoryNotFound, + ProjectHistoryConnection, + fetch_project_history_index, + fetch_project_history_projection, +) +from lineageweave.project_history import ( + build_project_history_projection, + classify_project_event, + normalize_project_key, + responsibility_transition_code, +) + + +def _event_row(post_id: str = "00000000-0000-0000-0000-000000000001") -> dict[str, object]: + """Return one synthetic authorized source row for pure projection tests.""" + + return { + "post_id": post_id, + "post_title": "Contract awarded", + "created_at": datetime(2022, 3, 11, 9, tzinfo=timezone.utc), + "voc_type_code": None, + "source_stage_code": None, + "source_detail_state_code": None, + } + + +class _IndexConnection: + """Return one bounded index row for repository-level contract tests.""" + + def __init__(self, rows=None) -> None: + self.rows = rows or [ + { + "normalized_project_key": "p-100", + "project_key": "P-100", + "project_name": "Synthetic project", + "truth_status_code": "observed", + "event_count": 2, + "latest_event_at": datetime(2026, 1, 2, tzinfo=timezone.utc), + "source_scan_truncated": False, + } + ] + self.calls: list[tuple[str, tuple[object, ...]]] = [] + + async def fetch(self, query: str, *args: object): + """Capture bounded index arguments and return configured rows.""" + self.calls.append((query, args)) + return self.rows + + +class _ProjectionConnection: + """Return event, optional focus, and child rows in query order.""" + + def __init__(self, events, focus=()) -> None: + self.events = list(events) + self.focus = list(focus) + self.calls: list[tuple[str, tuple[object, ...]]] = [] + + async def fetch(self, query: str, *args: object): + """Serve the projection query sequence without bypassing its bounds.""" + self.calls.append((query, args)) + if len(self.calls) == 1: + return self.events + if "post_id = $4::uuid" in query: + return self.focus + return [] + + +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_does_not_create_authority() -> None: + """The lifecycle label is presentation metadata over an existing post.""" + + assert ( + classify_project_event( + title="Contract awarded", + source_stage_code=None, + source_detail_state_code=None, + voc_type_code=None, + is_focus=False, + ) + == "contract_awarded" + ) + 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_assignment_gap_without_role_evidence_has_no_truth_status() -> None: + """Two empty role sets must not manufacture an observed assignment fact.""" + second = _event_row("00000000-0000-0000-0000-000000000002") + second["created_at"] = datetime(2022, 3, 12, 9, tzinfo=timezone.utc) + + projection = build_project_history_projection( + project_key="P-100", + focus_event_id=None, + event_rows=[_event_row(), second], + match_rows=[], + role_rows=[], + edge_rows=[], + ) + + transition = projection["events"][1] + assert transition["responsibility_transition_code"] == "assignment_gap" + assert transition["responsibility_transition_truth_status_code"] is None + + +def test_matching_observed_project_code_keeps_its_distinct_display_name() -> None: + """A matching code may carry a human display name that is not itself the key.""" + + event_id = "00000000-0000-0000-0000-000000000001" + projection = build_project_history_projection( + project_key="P-100", + focus_event_id=event_id, + event_rows=[_event_row(event_id)], + match_rows=[ + { + "post_id": event_id, + "match_kind_code": "source_project_code", + "matched_value": "P-100", + "confidence": None, + "ontology_iri": None, + "provenance": "source_post.source_project_code", + }, + { + "post_id": event_id, + "match_kind_code": "source_project_name", + "identity_key": "P-100", + "matched_value": "Transformer renewal", + "confidence": None, + "ontology_iri": None, + "provenance": "source_post.source_project_name", + }, + ], + role_rows=[], + edge_rows=[], + ) + + assert projection["project_name"] == "Transformer renewal" + assert [row["matched_value"] for row in projection["events"][0]["project_matches"]] == [ + "P-100", + "Transformer renewal", + ] + + +def test_project_name_cannot_inherit_a_sibling_project_identity() -> None: + """A display-name row without its own key cannot leak another project.""" + + event_id = "00000000-0000-0000-0000-000000000001" + projection = build_project_history_projection( + project_key="P-100", + focus_event_id=event_id, + event_rows=[_event_row(event_id)], + match_rows=[ + { + "post_id": event_id, + "match_kind_code": "source_project_code", + "matched_value": "P-100", + "confidence": None, + "ontology_iri": None, + "provenance": "source_post.source_project_code", + }, + { + "post_id": event_id, + "match_kind_code": "source_project_name", + "matched_value": "Unrelated project", + "confidence": None, + "ontology_iri": None, + "provenance": "source_post.source_project_name", + }, + ], + role_rows=[], + edge_rows=[], + ) + + assert [row["matched_value"] for row in projection["events"][0]["project_matches"]] == ["P-100"] + + +def test_summary_responsibilities_remain_inferred_evidence() -> None: + """LLM-derived summary roles must not become observed or an HR assignment ledger.""" + + event_id = "00000000-0000-0000-0000-000000000001" + projection = build_project_history_projection( + project_key="P-100", + focus_event_id=event_id, + event_rows=[_event_row(event_id)], + match_rows=[ + { + "post_id": event_id, + "match_kind_code": "source_project_code", + "matched_value": "P-100", + "confidence": None, + "ontology_iri": None, + "provenance": "source_post.source_project_code", + } + ], + role_rows=[ + { + "post_id": event_id, + "actor_name": "Synthetic Project Manager", + "responsibility": "Coordinate the specification revision", + "actor_type_code": "prov_person", + "affiliated_organization_name": "Demo Corp", + "cataloged_person_id": None, + "cataloged_team_id": None, + "cataloged_corporate_entity_id": None, + "truth_status_code": "inferred", + "provenance": "post_summary_role", + } + ], + edge_rows=[], + ) + + role = projection["events"][0]["responsibility_evidence"][0] + assert role["truth_status_code"] == "inferred" + assert role["provenance"] == "post_summary_role" + + +def test_assignment_gap_without_role_evidence_has_no_truth_status() -> None: + """An empty adjacent evidence pair remains unknown, never observed.""" + + first = _event_row("00000000-0000-0000-0000-000000000001") + second = _event_row("00000000-0000-0000-0000-000000000002") + second["created_at"] = datetime(2022, 3, 12, 9, tzinfo=timezone.utc) + projection = build_project_history_projection( + project_key="P-100", + focus_event_id=second["post_id"], + event_rows=[first, second], + match_rows=[], + role_rows=[], + edge_rows=[], + ) + + transition = projection["events"][1] + assert transition["responsibility_transition_code"] == "assignment_gap" + assert transition["responsibility_transition_truth_status_code"] is None + + +def test_project_history_connection_protocol_fails_explicitly() -> None: + """The protocol default is not an executable ellipsis/no-op.""" + + async def invoke() -> None: + await ProjectHistoryConnection.fetch(object(), "select 1") + + with pytest.raises(NotImplementedError): + asyncio.run(invoke()) + + +def test_invalid_focus_identifier_fails_before_a_database_cast() -> None: + """A malformed focus identifier must fail closed before reaching PostgreSQL.""" + + class FocusConnection: + def __init__(self) -> None: + self.calls = 0 + + async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: + self.calls += 1 + if self.calls == 1: + return [_event_row()] + raise AssertionError("malformed focus identifier reached a database query") + + connection = FocusConnection() + with pytest.raises(ValueError, match="focus_post_id"): + asyncio.run( + fetch_project_history_projection( + connection, + project_key="P-100", + focus_post_id="not-a-uuid", + knowledge_cutoff=datetime(2026, 8, 20, tzinfo=timezone.utc), + corporate_entity_ids=[], + ) + ) + assert connection.calls == 0 + + +def test_project_history_index_is_authorized_bounded_and_versioned() -> None: + """The index exposes only the versioned, bounded projection contract.""" + connection = _IndexConnection() + result = asyncio.run( + fetch_project_history_index( + connection, + knowledge_cutoff=datetime(2026, 8, 20, tzinfo=timezone.utc), + corporate_entity_ids=["corp-1"], + limit=1, + ) + ) + + assert result["contract_version"] == 1 + assert result["project_count"] == 1 + assert result["projects"][0]["truth_status_code"] == "observed" + assert result["projects"][0]["latest_event_at"] == "2026-01-02T00:00:00Z" + assert result["knowledge_cutoff"] == "2026-08-20T00:00:00Z" + assert connection.calls[0][1][0] == ["corp-1"] + assert connection.calls[0][1][-2] == 2 + assert "set_config(" in connection.calls[0][0] + assert "'statement_timeout'" in connection.calls[0][0] + assert "limit ($4 + 1)" in connection.calls[0][0] + + connection.rows[0]["source_scan_truncated"] = True + truncated = asyncio.run( + fetch_project_history_index( + connection, + knowledge_cutoff=datetime(2026, 8, 20, tzinfo=timezone.utc), + corporate_entity_ids=["corp-1"], + limit=1, + ) + ) + assert truncated["truncated"] is True + + with pytest.raises(ValueError): + asyncio.run( + fetch_project_history_index( + connection, + knowledge_cutoff=datetime(2026, 8, 20, tzinfo=timezone.utc), + corporate_entity_ids=[], + limit=201, + ) + ) + with pytest.raises(ValueError, match="offset-aware"): + asyncio.run( + fetch_project_history_index( + connection, + knowledge_cutoff=datetime(2026, 8, 20), + corporate_entity_ids=[], + ) + ) + + +def test_project_history_projection_keeps_focus_and_authorization_bounds() -> None: + """A focused event outside the first page is appended only when returned by the focus query.""" + first = _event_row("00000000-0000-0000-0000-000000000001") + second = _event_row("00000000-0000-0000-0000-000000000002") + second["created_at"] = datetime(2026, 1, 2, tzinfo=timezone.utc) + focus = _event_row("00000000-0000-0000-0000-000000000099") + focus["created_at"] = datetime(2026, 1, 3, tzinfo=timezone.utc) + connection = _ProjectionConnection([first, second, focus], [focus]) + + result = asyncio.run( + fetch_project_history_projection( + connection, + project_key="P-100", + focus_post_id="00000000-0000-0000-0000-000000000099", + knowledge_cutoff=datetime(2026, 8, 20, tzinfo=timezone.utc), + corporate_entity_ids=[], + limit=2, + ) + ) + + assert result["truncated"] is True + assert result["focus_event_id"] == "00000000-0000-0000-0000-000000000099" + assert result["knowledge_cutoff"] == "2026-08-20T00:00:00Z" + assert len(connection.calls) == 5 + assert connection.calls[0][1][-1] == 3 + + +def test_project_history_projection_empty_and_invalid_focus_fail_closed() -> None: + """Missing authorized evidence and malformed boundaries never become a result.""" + with pytest.raises(ProjectHistoryNotFound): + asyncio.run( + fetch_project_history_projection( + _ProjectionConnection([]), + project_key="P-100", + focus_post_id=None, + knowledge_cutoff=datetime(2026, 8, 20, tzinfo=timezone.utc), + corporate_entity_ids=[], + ) + ) + first = _event_row() + with pytest.raises(ProjectHistoryNotFound): + asyncio.run( + fetch_project_history_projection( + _ProjectionConnection([first], []), + project_key="P-100", + focus_post_id="00000000-0000-0000-0000-000000000099", + knowledge_cutoff=datetime(2026, 8, 20, tzinfo=timezone.utc), + corporate_entity_ids=[], + ) + ) + with pytest.raises(ValueError): + asyncio.run( + fetch_project_history_projection( + _ProjectionConnection([first]), + project_key="P-100", + focus_post_id=None, + knowledge_cutoff=datetime(2026, 8, 20, tzinfo=timezone.utc), + corporate_entity_ids=[], + limit=0, + ) + ) + with pytest.raises(ValueError, match="offset-aware"): + asyncio.run( + fetch_project_history_projection( + _ProjectionConnection([first]), + project_key="P-100", + focus_post_id=None, + knowledge_cutoff=datetime(2026, 8, 20), + corporate_entity_ids=[], + ) + ) diff --git a/tests/test_project_history_api_contract.py b/tests/test_project_history_api_contract.py new file mode 100644 index 000000000..252c8e439 --- /dev/null +++ b/tests/test_project_history_api_contract.py @@ -0,0 +1,59 @@ +"""Unit contracts for the Buyer project-history HTTP boundary.""" + +from __future__ import annotations + +import asyncio + +from backend.app import main +from backend.app.auth import CurrentAccount + + +class _Acquire: + """Minimal async context manager for a route-level database seam.""" + + async def __aenter__(self) -> object: + return object() + + async def __aexit__(self, exc_type, exc_value, traceback) -> None: + return None + + +class _Pool: + """Pool-shaped test double that does not create a database connection.""" + + def acquire(self) -> _Acquire: + return _Acquire() + + +def test_history_route_preserves_display_identity_case(monkeypatch) -> None: + """Validation normalizes for matching but the buyer response keeps its key.""" + + captured: dict[str, object] = {} + + async def fake_projection(connection, **kwargs): + captured.update(kwargs) + return {"project_key": kwargs["project_key"]} + + monkeypatch.setattr(main, "fetch_project_history_projection", fake_projection) + account = CurrentAccount( + user_account_id="account-1", + external_subject_id="subject-1", + display_name="Synthetic analyst", + preferred_locale="en", + corporate_entity_ids=frozenset(), + permission_codes=frozenset({"post_read"}), + ) + + result = asyncio.run( + main.read_project_history( + project_key="P-100", + focus_post_id=None, + knowledge_cutoff=None, + limit=64, + account=account, + pool=_Pool(), + ) + ) + + assert captured["project_key"] == "P-100" + assert result["project_key"] == "P-100" diff --git a/uv.lock b/uv.lock index 92bcd4086..4fcb9ae4b 100644 --- a/uv.lock +++ b/uv.lock @@ -533,7 +533,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.19.0" +version = "2.23.0" source = { editable = "." } dependencies = [ { name = "certifi" },