diff --git a/AGENTS.md b/AGENTS.md index a71928ba8..49ce7c418 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -179,6 +179,8 @@ Opening a Customer master related post uses the same focus path (ADR 0095). Do not invent a week, a theta, a cutoff body, a CalDAV event, or a customer. Opening an Ask Agent cited post uses the same focus path (ADR 0096). Do not invent a cited post. +A linked Event Lineage node opened from that focused popup keeps the +originating flags (ADR 0097). Do not invent a cited post. ## Tests diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 95f840e9b..a5453c508 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -285,7 +285,8 @@ 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), and the full detail popup includes Korean +(ADR 0096). A linked Event Lineage node opened from a focused popup keeps +those flags (ADR 0097), and 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 + diff --git a/CHANGELOG.d/2.17.0-event-lineage-node-keeps-gnb-focus.md b/CHANGELOG.d/2.17.0-event-lineage-node-keeps-gnb-focus.md new file mode 100644 index 000000000..7edaedcfb --- /dev/null +++ b/CHANGELOG.d/2.17.0-event-lineage-node-keeps-gnb-focus.md @@ -0,0 +1,9 @@ +# 2.17.0 A linked Event Lineage node keeps GNB focus + +Opening a linked Event Lineage DAG node from a GNB-focused popup keeps +Event Lineage focused and names Keyman and evaluation next. A home-list +DAG walk does not. No TEPP theta is invented. + +Ask Agent now replaces an expired saved session once, then stores the new +session. The Event Lineage timeline heading, list, and post-button +accessibility labels are translated in the supported locales. diff --git a/CHANGELOG.d/2.17.0-image-and-summary-boundaries.md b/CHANGELOG.d/2.17.0-image-and-summary-boundaries.md new file mode 100644 index 000000000..a88842f8f --- /dev/null +++ b/CHANGELOG.d/2.17.0-image-and-summary-boundaries.md @@ -0,0 +1,8 @@ +# 2.17.0 — Preserve table image boundaries and avoid duplicate summaries + +## Fixed + +- Inline or invalid images no longer split a table row or surrounding + paragraph into disconnected semantic units. +- Opening a post requests its summary once; the explicit retry action remains + the only path that requests another summary. diff --git a/CHANGELOG.md b/CHANGELOG.md index fbcfc636c..ae23f4d76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ 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.17.0] - 2026-08-19 + +### Added + +- Opening a linked Event Lineage DAG node from a GNB-focused popup now + keeps Event Lineage focused and names Keyman and evaluation as the next + read. A home-list DAG walk does not add that focus or copy. No TEPP + theta is invented. No cited post, customer, week, or cutoff body is + invented (ADR 0097 / ADR 0096 / ADR 0016). + ## [2.16.0] - 2026-08-19 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 8675cec71..b989aa8b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,3 +99,9 @@ Open Ask Agent. After an authorized answer, cited posts are current. Open a cited post: Event Lineage takes focus and names Keyman and evaluation next (ADR 0096). A home-list open does not. Do not invent a theta or a cited post. + +## Event Lineage DAG walk (v2.17.0) + +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. diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index a47bf9343..066829ca8 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -29,6 +29,10 @@ def _stream_key(post_id: str) -> str: return f"activity:{post_id}" +def _operation_stream_key(account_id: str) -> str: + return f"operation:{account_id}" + + def ticket_created_summary(ticket_title: str) -> str: """The ``summary`` field ``ticket_created`` producers must share.""" return f"Ticket created: {ticket_title}" @@ -74,6 +78,21 @@ async def publish_activity_event( ) +async def publish_operation_event( + client: redis.Redis, + actor_account_id: str, + event_type: str, + summary: str, +) -> str: + """Register an account operation that has no single owning post.""" + return await client.xadd( + _operation_stream_key(actor_account_id), + _activity_fields(event_type, actor_account_id, summary), + maxlen=1000, + approximate=True, + ) + + def publish_activity_event_sync( client: Any, post_id: str, diff --git a/backend/app/entity_relationship_ingestion.py b/backend/app/entity_relationship_ingestion.py index da22a9136..6b8f074f1 100644 --- a/backend/app/entity_relationship_ingestion.py +++ b/backend/app/entity_relationship_ingestion.py @@ -22,6 +22,9 @@ OrganizationRelationship, ) +# Keep the relationship-network response bounded independently of frontend caps. +_RELATIONSHIP_NETWORK_LIMIT = 100 + async def ingest_post_entity_relationships( conn: asyncpg.Connection, @@ -90,7 +93,67 @@ def attach_resolved_entity_ids( ] -async def fetch_post_counterparties(conn: asyncpg.Connection, post_id: str) -> list[dict[str, Any]]: +def merge_relationship_network_rows( + rows: Sequence[Mapping[str, Any]], + candidates: Sequence[CorporateEntityCandidate], +) -> list[dict[str, Any]]: + """Merge raw-name variants only when they share one unique catalog id.""" + candidate_names = {candidate.corporate_entity_id: candidate.entity_name for candidate in candidates} + merged: dict[tuple[str, str], dict[str, Any]] = {} + for row in rows: + raw_name = str(row["counterparty_entity_name"]) + corporate_entity_id = resolve_corporate_entity(raw_name, candidates) + key = ("entity", corporate_entity_id) if corporate_entity_id else ("name", raw_name) + entry = merged.setdefault( + key, + { + "counterparty_entity_name": candidate_names.get(corporate_entity_id, raw_name), + "corporate_entity_id": corporate_entity_id, + "total_post_count": 0, + "relationship_counts": {}, + }, + ) + entry["total_post_count"] += int(row["total_post_count"]) + relationships = row["relationships"] + if isinstance(relationships, str): + relationships = json.loads(relationships) + for relationship in relationships: + relationship_key = ( + relationship["relationship_type_code"], + relationship["relationship_label"], + ) + counts = entry["relationship_counts"] + counts[relationship_key] = counts.get(relationship_key, 0) + int(relationship["post_count"]) + + result: list[dict[str, Any]] = [] + for entry in merged.values(): + relationships = [ + { + "relationship_type_code": code, + "relationship_label": label, + "post_count": post_count, + } + for (code, label), post_count in entry["relationship_counts"].items() + ] + relationships.sort(key=lambda relationship: (-relationship["post_count"], relationship["relationship_type_code"])) + result.append( + { + "counterparty_entity_name": entry["counterparty_entity_name"], + "corporate_entity_id": entry["corporate_entity_id"], + "total_post_count": entry["total_post_count"], + "relationships": relationships, + "multi_role": len(relationships) > 1, + } + ) + result.sort(key=lambda entry: (-entry["total_post_count"], entry["counterparty_entity_name"])) + return result[:_RELATIONSHIP_NETWORK_LIMIT] + + +async def fetch_post_counterparties( + conn: asyncpg.Connection, + post_id: str, + authorized_corporate_entity_ids: Sequence[str] = (), +) -> list[dict[str, Any]]: """Classified counterparties with a cataloged org id when the name resolves. Unresolved names keep ``corporate_entity_id`` null -- a missing @@ -108,7 +171,15 @@ async def fetch_post_counterparties(conn: asyncpg.Connection, post_id: str) -> l """, post_id, ) - candidate_rows = await conn.fetch("select corporate_entity_id, entity_name from corporate_entity") + candidate_rows = ( + await conn.fetch( + "select corporate_entity_id, entity_name from corporate_entity " + "where corporate_entity_id = any($1::uuid[])", + list(authorized_corporate_entity_ids), + ) + if authorized_corporate_entity_ids + else [] + ) candidates = [ CorporateEntityCandidate(str(row["corporate_entity_id"]), row["entity_name"]) for row in candidate_rows @@ -232,27 +303,13 @@ async def fetch_relationship_network( """, list(corporate_entity_ids), ) - candidate_rows = await conn.fetch("select corporate_entity_id, entity_name from corporate_entity") + candidate_rows = await conn.fetch( + "select corporate_entity_id, entity_name from corporate_entity " + "where corporate_entity_id = any($1::uuid[])", + list(corporate_entity_ids), + ) candidates = [ CorporateEntityCandidate(str(row["corporate_entity_id"]), row["entity_name"]) for row in candidate_rows ] - network: list[dict[str, Any]] = [] - for row in rows: - relationships = ( - json.loads(row["relationships"]) - if isinstance(row["relationships"], str) - else row["relationships"] - ) - network.append( - { - "counterparty_entity_name": row["counterparty_entity_name"], - "corporate_entity_id": resolve_corporate_entity( - row["counterparty_entity_name"], candidates - ), - "total_post_count": row["total_post_count"], - "relationships": relationships, - "multi_role": len(relationships) > 1, - } - ) - return network + return merge_relationship_network_rows(rows, candidates) diff --git a/backend/app/five_w1h_ingestion.py b/backend/app/five_w1h_ingestion.py index 736a8ecd6..c1b21a64f 100644 --- a/backend/app/five_w1h_ingestion.py +++ b/backend/app/five_w1h_ingestion.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Sequence from typing import Any import asyncpg @@ -18,6 +18,7 @@ async def load_five_w1h_slots( conn: asyncpg.Connection, post_id: str, can_see_post: Callable[[asyncpg.Record], bool], + authorized_corporate_entity_ids: Sequence[str] = (), ) -> dict[str, Any]: """Build 5W1H from stored projections and visible lineage only.""" summary = await fetch_persisted_summary(conn, post_id) or {} @@ -41,7 +42,9 @@ async def load_five_w1h_slots( ) linked_titles = [row["post_title"] for row in rows if can_see_post(row)] - counterparties = await fetch_post_counterparties(conn, post_id) + counterparties = await fetch_post_counterparties( + conn, post_id, authorized_corporate_entity_ids + ) slots = assemble_five_w1h_slots( roles=summary.get("roles_and_responsibilities", []), key_events=summary.get("key_events", []), diff --git a/backend/app/main.py b/backend/app/main.py index cd50f0aad..3ae9d90d6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -70,10 +70,12 @@ NullOrganizationNameResolutionClient, ) from lineageweave.post_chat import ( + ChatSourceDocument, ContextualOrchestratorPostChatClient, NullPostChatClient, cited_post_evidence, cited_post_summaries, + render_global_ask_context, ) from lineageweave.post_content_normalization import normalize_post_body from lineageweave.post_evaluation import ( @@ -114,6 +116,7 @@ create_valkey_client, get_valkey, publish_activity_event, + publish_operation_event, read_activity_events, ticket_created_summary, ticket_status_changed_summary, @@ -167,11 +170,15 @@ ) from backend.app.lineage_ingestion import rebuild_lineage, visible_lineage_graph from backend.app.post_chat_ingestion import ( + ensure_global_ask_session, fetch_persisted_chat, fetch_persisted_chats, find_linked_post_ids, gather_chat_sources, gather_global_chat_sources, + load_global_ask_context, + persist_global_ask_summary, + persist_global_ask_turn, persist_post_chat, ) from backend.app.post_summary_ingestion import ( @@ -683,6 +690,7 @@ async def update_me_preferences( preference: LocalePreferenceRequest, account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), ) -> dict[str, str]: """Persist member preferences without putting them in browser-only state.""" async with pool.acquire() as conn: @@ -691,6 +699,12 @@ async def update_me_preferences( preference.preferred_locale, account.user_account_id, ) + await publish_operation_event( + valkey, + account.user_account_id, + "preferences_updated", + "Locale preference updated", + ) return {"preferred_locale": preference.preferred_locale} @@ -1058,6 +1072,7 @@ async def resolve_customer_master_hint( request: CustomerHintResolveRequest, account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), ) -> dict[str, Any]: """Resolve one observed customer-hint code to a real corporate_entity. @@ -1097,6 +1112,12 @@ async def resolve_customer_master_hint( status.HTTP_422_UNPROCESSABLE_ENTITY, "this hint could not be resolved to a corroborated organization name", ) + await publish_operation_event( + valkey, + account.user_account_id, + "customer_hint_resolved", + "Customer hint resolved", + ) return resolution @@ -1122,6 +1143,7 @@ async def read_lineage_graph( async def rebuild_lineage_graph( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), ) -> dict[str, Any]: """Run reconstruct over every source_post and persist post_lineage_edge. @@ -1131,6 +1153,12 @@ async def rebuild_lineage_graph( async with pool.acquire() as conn: async with conn.transaction(): edges = await rebuild_lineage(conn) + await publish_operation_event( + valkey, + account.user_account_id, + "lineage_rebuilt", + "Lineage rebuilt", + ) return {"edge_count": len(edges)} @@ -1966,7 +1994,9 @@ async def read_post_counterparties( """ post = await _load_visible_post(post_id, account, pool) async with pool.acquire() as conn: - counterparties = await fetch_post_counterparties(conn, post_id) + counterparties = await fetch_post_counterparties( + conn, post_id, account.corporate_entity_ids + ) return { "post_id": str(post["post_id"]), "counterparties": counterparties, @@ -2444,6 +2474,7 @@ async def rebuild_period_report_endpoint( period_code: str, account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), ) -> dict[str, Any]: """Refit or FIPC-score every group in the period. post_admin only.""" _require_post_admin(account) @@ -2456,6 +2487,12 @@ async def rebuild_period_report_endpoint( async with pool.acquire() as conn: async with conn.transaction(): reports = await rebuild_period_reports(conn, grouping_kind, period_code) + await publish_operation_event( + valkey, + account.user_account_id, + "period_report_rebuilt", + "Period report rebuilt", + ) return { "grouping_kind": grouping_kind, "period_code": period_code, @@ -2585,6 +2622,7 @@ async def read_post_five_w1h( conn, post_id, lambda row: _can_see_post(account, row), + account.corporate_entity_ids, ) @@ -2598,6 +2636,28 @@ class GlobalAskRequest(BaseModel): """JSON body for the buyer's source-grounded Global Ask Agent.""" question: str + session_id: str | None = None + + +def global_ask_timeline(sources: list[ChatSourceDocument]) -> list[dict[str, str | None]]: + """Return every authorized Ask source in event order, not citation order.""" + ordered = sorted( + sources, + key=lambda source: ( + source.occurred_at is None, + source.occurred_at or "", + source.post_id, + ), + ) + return [ + { + "post_id": source.post_id, + "post_title": source.post_title, + "occurred_at": source.occurred_at, + "timeline_kind": source.timeline_kind, + } + for source in ordered + ] @app.get("/api/posts/{post_id}/chat") @@ -2700,12 +2760,18 @@ async def ask_agent( request: GlobalAskRequest, account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), ) -> dict[str, Any]: """Answer a buyer question from authorized post and graph evidence.""" question = request.question.strip() if not question: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "question is required") _require_post_read(account) + if request.session_id is not None: + try: + UUID(request.session_id) + except ValueError: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Global Ask session not found") from None client = _post_chat_client() if not client.available: raise HTTPException( @@ -2713,23 +2779,74 @@ async def ask_agent( "Ask Agent is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY", ) async with pool.acquire() as conn: + session_id = await ensure_global_ask_session( + conn, account.user_account_id, request.session_id + ) + if session_id is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Global Ask session not found") + conversation = await load_global_ask_context(conn, session_id) sources = await gather_global_chat_sources( conn, lambda row: _can_see_post(account, row), account.corporate_entity_ids, question=question, ) + if conversation.compress_turns: + compressor = getattr(client, "compress_context", None) + if not callable(compressor): + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Ask Agent conversation context compression is unavailable", + ) + try: + compressed = await asyncio.to_thread( + compressor, + conversation.summary, + list(conversation.compress_turns), + ) + async with pool.acquire() as conn: + await persist_global_ask_summary( + conn, + conversation.session_id, + compressed, + conversation.compress_turns[-1][0], + ) + conversation = await load_global_ask_context(conn, conversation.session_id) + except (HttpClientError, KeyError, OSError, ValueError) as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Ask Agent conversation context compression is unavailable", + ) from exc + conversation_context = render_global_ask_context( + conversation.summary, + conversation.recent_turns, + ) if not sources: + async with pool.acquire() as conn: + await persist_global_ask_turn(conn, conversation.session_id, question, "", ()) + await publish_operation_event( + valkey, + account.user_account_id, + "global_ask_completed", + "Global Ask completed with no authorized source posts", + ) return { + "session_id": conversation.session_id, "answer_text": "", "cited_post_ids": [], "cited_posts": [], "source_post_ids": [], "cited_post_evidence": [], + "timeline": [], "next_action": "No authorized source posts are available for this question.", } try: - answer = await asyncio.to_thread(client.answer, question, sources) + answer = await asyncio.to_thread( + client.answer, + question, + sources, + conversation_context=conversation_context, + ) except (HttpClientError, KeyError, OSError, RuntimeError, ValueError) as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, @@ -2741,12 +2858,28 @@ async def ask_agent( "Ask Agent is unavailable: contextual-orchestrator returned no complete evidence object", ) from exc cited_ids = list(answer.cited_post_ids) + async with pool.acquire() as conn: + await persist_global_ask_turn( + conn, + conversation.session_id, + question, + answer.answer_text, + cited_ids, + ) + await publish_operation_event( + valkey, + account.user_account_id, + "global_ask_completed", + f"Global Ask completed with {len(cited_ids)} cited source post(s)", + ) return { + "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), "source_post_ids": [source.post_id for source in sources], + "timeline": global_ask_timeline(sources), } @@ -2776,6 +2909,7 @@ async def write_post_bookmark( request: PostBookmarkRequest, account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), ) -> dict[str, Any]: await _load_visible_post(post_id, account, pool) async with pool.acquire() as conn: @@ -2795,6 +2929,13 @@ async def write_post_bookmark( account.user_account_id, post_id, ) + await publish_activity_event( + valkey, + post_id, + "bookmark_changed", + account.user_account_id, + "Post bookmark added" if request.bookmarked else "Post bookmark removed", + ) return {"post_id": post_id, "bookmarked": request.bookmarked} @@ -3054,6 +3195,7 @@ async def create_analysis_run( request: CreateAnalysisRunRequest, account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), ) -> dict[str, Any]: """Record a Pending lineage run on an authorized cutoff capture. @@ -3078,6 +3220,12 @@ async def create_analysis_run( ) except AnalysisRunCreateError as exc: raise HTTPException(exc.status_code, exc.detail) from exc + await publish_operation_event( + valkey, + account.user_account_id, + "analysis_run_created", + "Analysis run created", + ) return created @@ -3123,6 +3271,12 @@ async def start_analysis_run( work_kind_code=str(queued.get("run_kind_code") or ""), request_sha256=request_digest, ) + await publish_operation_event( + valkey, + account.user_account_id, + "analysis_run_start_requested", + "Analysis run start requested", + ) async with pool.acquire() as conn: async with conn.transaction(): try: diff --git a/backend/app/organization_name_resolution_ingestion.py b/backend/app/organization_name_resolution_ingestion.py index 9300586c4..fe6520389 100644 --- a/backend/app/organization_name_resolution_ingestion.py +++ b/backend/app/organization_name_resolution_ingestion.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio +import hashlib import asyncpg @@ -17,6 +18,11 @@ ) +def _context_sha256(context_text: str) -> str: + """Return the cache key for context without persisting the source body.""" + return hashlib.sha256(context_text.encode("utf-8")).hexdigest() + + async def resolve_organization_name( conn: asyncpg.Connection, resolution_client: OrganizationNameResolutionClient, @@ -26,13 +32,19 @@ async def resolve_organization_name( ) -> str: """Return the corroborated canonical name, otherwise ``raw_name``. - Synchronous network adapters run in a worker thread so this async - ingestion path does not block unrelated requests. + The cache is scoped by the exact post context. A raw abbreviation is not + globally unambiguous, and only the digest is stored so the source body is + not duplicated in the resolution cache. Synchronous network adapters run + in a worker thread so this async ingestion path does not block unrelated + requests. """ + context_sha256 = _context_sha256(context_text) cached = await conn.fetchrow( "select resolved_organization_name, verification_status_code " - "from organization_name_resolution where raw_organization_name = $1", + "from organization_name_resolution " + "where raw_organization_name = $1 and context_sha256 = $2", raw_name, + context_sha256, ) if cached is not None: if cached["verification_status_code"] == STATUS_CORROBORATED: @@ -54,16 +66,17 @@ async def resolve_organization_name( await conn.execute( """ insert into organization_name_resolution - (raw_organization_name, resolved_organization_name, + (raw_organization_name, context_sha256, resolved_organization_name, verification_status_code, verification_evidence_url) - values ($1, $2, $3, $4) - on conflict (raw_organization_name) do update set + values ($1, $2, $3, $4, $5) + on conflict (raw_organization_name, context_sha256) do update set resolved_organization_name = excluded.resolved_organization_name, verification_status_code = excluded.verification_status_code, verification_evidence_url = excluded.verification_evidence_url, resolved_at = now() """, resolution.raw_organization_name, + context_sha256, resolution.resolved_organization_name, resolution.verification_status_code, resolution.verification_evidence_url, diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index 71c0f2053..e60b00aa5 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -21,6 +21,7 @@ import re from dataclasses import dataclass from typing import Any, Callable, Iterable +from uuid import uuid4 import asyncpg @@ -57,6 +58,181 @@ class LinkedPostIds: indirect: frozenset[str] +@dataclass(frozen=True) +class GlobalAskContext: + """Account-scoped continuity context; source evidence is re-retrieved.""" + + session_id: str + summary: str | None + summary_through_ordinal: int + recent_turns: tuple[tuple[int, str, str], ...] + compress_turns: tuple[tuple[int, str, str], ...] + + +async def ensure_global_ask_session( + conn: asyncpg.Connection, + account_id: str, + session_id: str | None, +) -> str | None: + """Create or account-check one Global Ask session; hidden ids stay hidden.""" + if session_id is not None: + row = await conn.fetchrow( + """ + select global_ask_session_id + from global_ask_session + where global_ask_session_id = $1 + and user_account_id = $2 + """, + session_id, + account_id, + ) + return str(row["global_ask_session_id"]) if row is not None else None + created = str(uuid4()) + await conn.execute( + "insert into global_ask_session (global_ask_session_id, user_account_id) values ($1, $2)", + created, + account_id, + ) + return created + + +async def load_global_ask_context( + conn: asyncpg.Connection, + session_id: str, + *, + recent_limit: int = 6, + compression_batch: int = 4, +) -> GlobalAskContext: + """Load bounded continuity rows and the oldest batch eligible for compression.""" + session = await conn.fetchrow( + """ + select context_summary, context_summary_through_ordinal + from global_ask_session + where global_ask_session_id = $1 + """, + session_id, + ) + if session is None: + raise ValueError("global ask session not found") + through = int(session["context_summary_through_ordinal"]) + pending_count = int( + await conn.fetchval( + "select count(*) from global_ask_turn where global_ask_session_id = $1 and turn_ordinal > $2", + session_id, + through, + ) + ) + compress_count = min(compression_batch, max(0, pending_count - recent_limit)) + compress_rows = ( + await conn.fetch( + """ + select turn_ordinal, question_text, answer_text + from global_ask_turn + where global_ask_session_id = $1 + and turn_ordinal > $2 + order by turn_ordinal + limit $3 + """, + session_id, + through, + compress_count, + ) + if compress_count + else [] + ) + recent_rows = await conn.fetch( + """ + select turn_ordinal, question_text, answer_text + from global_ask_turn + where global_ask_session_id = $1 + and turn_ordinal > $2 + order by turn_ordinal desc + limit $3 + """, + session_id, + through, + recent_limit, + ) + return GlobalAskContext( + session_id=session_id, + summary=session["context_summary"], + summary_through_ordinal=through, + recent_turns=tuple( + (int(row["turn_ordinal"]), row["question_text"], row["answer_text"]) + for row in reversed(recent_rows) + ), + compress_turns=tuple( + (int(row["turn_ordinal"]), row["question_text"], row["answer_text"]) + for row in compress_rows + ), + ) + + +async def persist_global_ask_summary( + conn: asyncpg.Connection, + session_id: str, + summary: str, + through_ordinal: int, +) -> None: + """Replace the bounded continuity summary after orchestrator compression.""" + if not summary.strip() or through_ordinal <= 0: + raise ValueError("global ask summary requires covered turns") + await conn.execute( + """ + update global_ask_session + set context_summary = $2, + context_summary_through_ordinal = $3, + updated_at = now() + where global_ask_session_id = $1 + """, + session_id, + summary.strip(), + through_ordinal, + ) + + +async def persist_global_ask_turn( + conn: asyncpg.Connection, + session_id: str, + question: str, + answer: str, + cited_post_ids: Iterable[str], +) -> int: + """Append one serialized turn and its normalized citation references.""" + citations = list(dict.fromkeys(str(post_id) for post_id in cited_post_ids)) + async with conn.transaction(): + await conn.fetchrow( + "select global_ask_session_id from global_ask_session where global_ask_session_id = $1 for update", + session_id, + ) + ordinal = int( + await conn.fetchval( + "select coalesce(max(turn_ordinal), 0) + 1 from global_ask_turn where global_ask_session_id = $1", + session_id, + ) + ) + await conn.execute( + "insert into global_ask_turn (global_ask_session_id, turn_ordinal, question_text, answer_text) values ($1, $2, $3, $4)", + session_id, + ordinal, + question, + answer, + ) + for citation_ordinal, post_id in enumerate(citations): + await conn.execute( + "insert into global_ask_turn_citation (global_ask_session_id, turn_ordinal, citation_ordinal, cited_post_id) values ($1, $2, $3, $4)", + session_id, + ordinal, + citation_ordinal, + post_id, + ) + await conn.execute( + "update global_ask_session set updated_at = now() where global_ask_session_id = $1", + session_id, + ) + return ordinal + + async def _normalize_post_body_text( body: str, vision_client: ImageContentClient, @@ -178,6 +354,13 @@ def _source_hint_facts(row: Any) -> tuple[str, ...]: return tuple(facts) +def _timestamp_text(row: Any) -> str | None: + value = row.get("created_at") + if value is None: + return None + return value.isoformat() if hasattr(value, "isoformat") else str(value) + + async def _semantic_facts_for_posts( conn: asyncpg.Connection, post_ids: list[str] ) -> dict[str, tuple[str, ...]]: @@ -469,6 +652,7 @@ async def gather_global_chat_sources( 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"]] + candidate_budget = min(_POST_CHAT_CANDIDATE_LIMIT, max(limit, limit * 4)) candidate_ids = sorted(candidate_scores, key=lambda post_id: candidate_scores[post_id], reverse=True) # A keyword match only proves one post's text is relevant -- the @@ -496,14 +680,15 @@ async def gather_global_chat_sources( ) candidate_ids = list( dict.fromkeys([lineage_anchor_id, *lineage_neighbor_ids, *candidate_ids[1:]]) - )[:limit] + )[:candidate_budget] else: - candidate_ids = [] + 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, @@ -548,6 +733,14 @@ async def gather_global_chat_sources( evidence_facts=_source_hint_facts(row) + semantic_facts.get(post_id, ()) + lineage_fact, + occurred_at=_timestamp_text(row), + timeline_kind=( + "lineage_neighbor" + if post_id in lineage_neighbor_id_set and anchor_is_visible + else "lineage_anchor" + if post_id == lineage_anchor_id + else "keyword_match" + ), ) ) return sources diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 391120ff2..00430364d 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -100,6 +100,16 @@ _POST_CONTENT_QUEUE_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" / "0050_post_content_ingestion_queue.sql" ) +_ORGANIZATION_CONTEXT_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0051_context_scoped_organization_name_resolution.sql" +) +_GLOBAL_ASK_CONTEXT_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0052_global_ask_context.sql" +) _MAJOR_EVENT_ACTION_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" / "0100_major_event_action.sql" ) @@ -223,6 +233,8 @@ def seeded_db(demo_analyst_token): cur.execute(_IMAGE_REGION_EMBEDDING_MIGRATION.read_text()) cur.execute(_SUMMARY_FIVE_W1H_MIGRATION.read_text()) cur.execute(_POST_CONTENT_QUEUE_MIGRATION.read_text()) + cur.execute(_ORGANIZATION_CONTEXT_MIGRATION.read_text()) + cur.execute(_GLOBAL_ASK_CONTEXT_MIGRATION.read_text()) cur.execute(_MAJOR_EVENT_ACTION_MIGRATION.read_text()) cur.execute(_PROJECT_BOUND_ACTION_MIGRATION.read_text()) cur.execute(_PROJECT_BOUND_EVENT_MIGRATION.read_text()) @@ -3624,6 +3636,35 @@ def test_counterparties_resolve_cataloged_org_ids(client, demo_analyst_token, se assert by_name["Northridge Grid"]["corporate_entity_id"] is None +def test_counterparties_do_not_expose_unauthorized_catalog_entity( + client, demo_analyst_token, seeded_db +) -> None: + """A public post must not resolve a name to a private catalog row.""" + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "insert into post_counterparty_entity " + "(post_id, counterparty_entity_name, relationship_type_code) " + "values (%s, 'Other Corp', 'rel_voc')", + (seeded_db["public_post_id"],), + ) + finally: + admin_conn.close() + + response = client.get( + f"/api/posts/{seeded_db['public_post_id']}/counterparties", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 200, response.text + row = next( + item for item in response.json()["counterparties"] + if item["counterparty_entity_name"] == "Other Corp" + ) + assert row["corporate_entity_id"] is None + + def test_counterparties_endpoint_is_empty_before_extraction(client, demo_analyst_token, seeded_db) -> None: response = client.get( f"/api/posts/{seeded_db['own_private_post_id']}/counterparties", diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index f329117d6..35d51546c 100644 --- a/docker/postgres-init/migrate.sh +++ b/docker/postgres-init/migrate.sh @@ -18,6 +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_*) ;; 0060_*|0100_*|0101_*|0102_*) ;; *) continue ;; esac diff --git a/docs/adr/0008-organization-abbreviation-resolution.md b/docs/adr/0008-organization-abbreviation-resolution.md index 72b121253..59e4e8717 100644 --- a/docs/adr/0008-organization-abbreviation-resolution.md +++ b/docs/adr/0008-organization-abbreviation-resolution.md @@ -23,6 +23,9 @@ abbreviated name creates its own unmatched, un-linkable free-text string in `person_affiliation`/R&R -- the same organization looks like N different unknown entities across N posts, each failing to link into the corporate hierarchy a human reader would recognize instantly. +The reverse failure is equally unsafe: the same short name can refer to +different organizations in different post contexts, so a context-free cache +can link later mentions to the first model answer by accident. ## Decision @@ -62,11 +65,14 @@ itself already established: a wrong resolution corrupts every downstream Knowledge Graph link through it, so "did not resolve" must stay a real, distinguishable outcome from "resolved to X." -Persistence: a new `organization_name_resolution` cache table -(`migrations/0015_organization_name_resolution.sql`), keyed by -`raw_organization_name` -- the same abbreviation is resolved once, not -re-queried on every one of its (potentially many) mentions across -posts. `verification_status_code` reuses the existing +Persistence: the `organization_name_resolution` cache table +(`migrations/0015_organization_name_resolution.sql`, extended by a later +migration that adds `context_sha256`) is keyed by +`raw_organization_name` plus a SHA-256 digest of the bounded post context. +The context body is not persisted in the cache. Exact-context reprocessing +can reuse a result, while a homonymous abbreviation in a different context +gets a separate resolution instead of inheriting the first answer. +`verification_status_code` reuses the existing `relation_verification_status` lookup category rather than a near-duplicate one: a resolved name is corroborated/uncorroborated the exact same way a classified relationship already is. @@ -102,6 +108,9 @@ canonical form too rather than reintroducing the raw abbreviation. - Every channel here follows the existing pluggable-client discipline: `NullOrganizationNameResolutionClient`/an unavailable verification client degrade to "use the raw name," never a fabricated resolution. +- Context-sensitive caching follows entity-linking evidence that ambiguous + mentions must be disambiguated with document-level semantic context, not a + name-only lookup (Rama-Maneiro, Vidal, & Lama, 2020). ## Related @@ -117,4 +126,6 @@ Miles, A., & Bechhofer, S. (Eds.). (2009). *SKOS simple knowledge organization s Bhattacharya, I., & Getoor, L. (2007). Collective entity resolution in relational data. *ACM Transactions on Knowledge Discovery from Data*, 1(1), Article 5. https://doi.org/10.1145/1217299.1217304 +Rama-Maneiro, E., Vidal, J. C., & Lama, M. (2020). Collective disambiguation in entity linking based on topic coherence in semantic graphs. *Knowledge-Based Systems, 199*, Article 105967. https://doi.org/10.1016/j.knosys.2020.105967 + Thorne, J., Vlachos, A., Christodoulopoulos, C., & Mittal, A. (2018). FEVER: A large-scale dataset for fact extraction and VERification. In *Proceedings of the 2018 Conference of the North American Chapter of the Association for Computational Linguistics: Human Language Technologies* (pp. 809–819). Association for Computational Linguistics. https://doi.org/10.18653/v1/N18-1074 diff --git a/docs/adr/0090-global-ask-lineage-timeline-expansion.md b/docs/adr/0090-global-ask-lineage-timeline-expansion.md index da98d9388..ca2cc7c8a 100644 --- a/docs/adr/0090-global-ask-lineage-timeline-expansion.md +++ b/docs/adr/0090-global-ask-lineage-timeline-expansion.md @@ -68,12 +68,24 @@ sequence around it. - The source budget is no longer a fixed constant per request; callers reading `limit` as an upper bound on retrieved posts must account for the lineage-expansion addition. -- Global Ask still has no persisted multi-turn conversation state, so - there is no long-context-compression problem yet. Recursive dialogue - summarization (Wang et al., 2023) is recorded in - `docs/lineage-bi-research-notes.md` as the citation a future persisted - Global Ask conversation thread would build on, not as a claim that - conversation-level compression exists today. +- Global Ask conversation continuity is an explicit follow-on contract. An + account-owned `global_ask_session` and normalized `global_ask_turn` rows + retain the question, answer, and cited post ids; a browser may send the + session id back on the next turn. The server creates the id when omitted, + and every session lookup is scoped to the requesting account. +- The current authorized source set is rebuilt and ABAC-filtered on every + turn. Prior answers and a compressed conversation summary are continuity + context only, never evidence or citations; a changed authorization cannot + make an old answer reintroduce a hidden post. +- Once the retained turns exceed the bounded context budget, the older turns + are compressed through contextual-orchestrator using the Wang et al. (2023) + recursive-dialogue-summarization grounding. The compressed result records + the covered turn ordinal and is passed as explicitly non-evidentiary + context. A failed compression is unavailable, not silently replaced with a + guessed summary or an unbounded transcript. +- Every successful Global Ask turn returns the authorized Event Lineage + timeline for that request, including an empty timeline when no authorized + sources were found. ## Evidence and literature diff --git a/docs/adr/0097-event-lineage-node-keeps-gnb-focus.md b/docs/adr/0097-event-lineage-node-keeps-gnb-focus.md new file mode 100644 index 000000000..a31084961 --- /dev/null +++ b/docs/adr/0097-event-lineage-node-keeps-gnb-focus.md @@ -0,0 +1,35 @@ +# ADR 0097: A linked Event Lineage node keeps GNB focus + +- Status: Accepted +- Date: 2026-08-19 + +## Context + +Opening a Board Weekly VOC post, Calendar commitment, Customer master +related post, Ask Agent cited post, or report member already focuses +Event Lineage (ADR 0093 / ADR 0094 / ADR 0095 / ADR 0096). Clicking a +linked Event Lineage DAG node then called `selectPost` without those +flags. The popup switched records and dropped the GNB focus contract: +Keyman and evaluation were no longer named next. + +## Decision + +A popup-internal Event Lineage DAG open reuses the originating GNB +flags (`fromReportMember`, `fromWeeklyVoc`, `fromCalendar`, +`fromCustomerMaster`, `fromAskAgent`): + +- The popup Event Lineage heading stays focused. +- The popup names the newly opened post as current in Event Lineage + and tells the buyer to read Keyman and evaluation next. + +A Board home-list DAG walk does not focus Event Lineage and does not +add that copy. Closing the popup still clears the originating flags. + +No TEPP theta is invented. No cutoff body is invented (ADR 0016). No +cited post, customer, week, or CalDAV event is invented. + +## Consequences + +- GNB destinations share one Event Lineage focus contract across the + first open and a linked DAG walk from that popup. +- A home-list DAG walk stays a home-list open. diff --git a/docs/adr/0097-internal-relation-evidence.md b/docs/adr/0100-internal-relation-evidence.md similarity index 96% rename from docs/adr/0097-internal-relation-evidence.md rename to docs/adr/0100-internal-relation-evidence.md index 53ce197a2..6c9a5f43c 100644 --- a/docs/adr/0097-internal-relation-evidence.md +++ b/docs/adr/0100-internal-relation-evidence.md @@ -1,4 +1,4 @@ -# ADR 0097: Preserve authorized internal evidence for relation verification +# ADR 0100: Preserve authorized internal evidence for relation verification - Status: Accepted - Date: 2026-08-18 diff --git a/docs/adr/0101-enrichment-timeout-does-not-block-summary.md b/docs/adr/0101-enrichment-timeout-does-not-block-summary.md index 11c2242cd..cab8985ee 100644 --- a/docs/adr/0101-enrichment-timeout-does-not-block-summary.md +++ b/docs/adr/0101-enrichment-timeout-does-not-block-summary.md @@ -47,4 +47,4 @@ post. - [ADR 0010](0010-corporate-hierarchy-auto-creation.md) - [ADR 0026](0026-tied-organization-similarity.md) -- [ADR 0100](0100-major-event-requester-processor.md) +- [ADR 0102](0102-major-event-requester-processor.md) diff --git a/docs/adr/0100-major-event-requester-processor.md b/docs/adr/0102-major-event-requester-processor.md similarity index 97% rename from docs/adr/0100-major-event-requester-processor.md rename to docs/adr/0102-major-event-requester-processor.md index c6e9d49b9..600c48893 100644 --- a/docs/adr/0100-major-event-requester-processor.md +++ b/docs/adr/0102-major-event-requester-processor.md @@ -1,4 +1,4 @@ -# ADR 0100 — Major event actions retain requester and processor evidence +# ADR 0102 — Major event actions retain requester and processor evidence **Decision status:** Accepted **Date:** 2026-08-20 diff --git a/docs/adr/0102-semantic-source-unit-boundaries.md b/docs/adr/0108-semantic-source-unit-boundaries.md similarity index 97% rename from docs/adr/0102-semantic-source-unit-boundaries.md rename to docs/adr/0108-semantic-source-unit-boundaries.md index 92bfe9457..e875fedee 100644 --- a/docs/adr/0102-semantic-source-unit-boundaries.md +++ b/docs/adr/0108-semantic-source-unit-boundaries.md @@ -1,4 +1,4 @@ -# ADR 0102: Preserve authored semantic source-unit boundaries +# ADR 0108: Preserve authored semantic source-unit boundaries - Status: Accepted - Date: 2026-08-20 diff --git a/docs/adr/0126-valkey-account-operation-events.md b/docs/adr/0126-valkey-account-operation-events.md new file mode 100644 index 000000000..722214942 --- /dev/null +++ b/docs/adr/0126-valkey-account-operation-events.md @@ -0,0 +1,35 @@ +# ADR 0126: Register account operation events in Valkey + +- Status: Accepted +- Date: 2026-08-20 +- Related: [0023](0023-analysis-run-outbox.md), [0098](0098-valkey-backed-post-content-ingestion.md) + +## Context + +Valkey already carries post activity and durable worker wake-ups, but several +successful account-scoped mutations had no operation event. That makes the +operation stream incomplete even though PostgreSQL remains the source of +truth. + +## Decision + +1. Successful account-scoped mutations without one owning post publish a + bounded event to `operation:{account_id}` through + `publish_operation_event`. +2. Post-scoped bookmark changes publish through the existing post activity + stream. Existing ticket, chat, extraction, evaluation, and verification + events keep their current event types. +3. Events contain only an operation type, actor account id, and short generic + summary. They do not carry source bodies, model output, credentials, or + unbounded identifiers. +4. PostgreSQL remains the durable source of truth; a Valkey write is a + notification and does not replace the database mutation or its recovery + path. + +## Consequences + +Operation consumers can account for preference, catalog, lineage, report, +bookmark, and analysis-run actions consistently. Valkey failure has the same +runtime behavior as the existing activity stream: the durable database write +must be recovered or retried by the caller rather than silently presented as +an observed event. diff --git a/docs/lineage-bi-research-notes.md b/docs/lineage-bi-research-notes.md index 94f99d73d..8de7a2fbd 100644 --- a/docs/lineage-bi-research-notes.md +++ b/docs/lineage-bi-research-notes.md @@ -370,12 +370,11 @@ source limit -- expanding every keyword hit instead of only the top one was rejected because a loosely related term would otherwise drag in an unrelated lineage chain into the model's context. -Global Ask's chat turns are not yet persisted as a running conversation -- -each question is answered independently, so there is no multi-turn -context to compress. Recursive dialogue summarization (Wang et al., 2023) -is the grounding this repository would use if/when Global Ask grows a -persisted conversation thread that can exceed a bounded context window: -summarize-and-replace older turns instead of an unbounded transcript or a -hard truncation that silently drops earlier decisions. This is recorded -here as the citation this feature would build on, not as a claim that -conversation-level compression is implemented today. +Global Ask retains an account-owned normalized conversation thread. Current +authorized source retrieval still runs on every turn; prior answers are +continuity context, not evidence. When the bounded context budget is +exceeded, older turns are summarized and replaced through the +contextual-orchestrator rather than silently truncating an unbounded +transcript. Recursive dialogue summarization (Wang et al., 2023) grounds that +compression boundary; the stored summary records which turn ordinal it +covers and is explicitly excluded from citation evidence. diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl index 8242ea2f4..6385314d0 100644 --- a/docs/ontology/lineageweave-kg.ttl +++ b/docs/ontology/lineageweave-kg.ttl @@ -34,8 +34,9 @@ a owl:Ontology ; owl:versionIRI ; owl:versionInfo "1.0.0" ; - owl:imports , - , + owl:imports + , + , ; rdfs:label "LineageWeave Knowledge Graph Ontology" ; rdfs:comment "Formal OWL 2 / RDFS / SKOS vocabulary for LineageWeave's knowledge_graph_edge node and edge types, entity_relationship_type, person_side, corporate_entity_level, and post_summary_role.actor_type_code controlled vocabularies." . @@ -85,6 +86,24 @@ rdfs:comment "A cataloged_team row: a named company sub-unit (ADR 0009) with a stable team_id, distinct from :RoleActorTeam (ADR 0007's per-row actor_type_code classification) the same way :Person is distinct from :RoleActorPerson." ; :lookupCode "node_team" . +################################################################# +# W3C ORG -- real organization containment and unit membership +################################################################# + +:subOrganizationOf a owl:ObjectProperty ; + rdfs:subPropertyOf org:subOrganizationOf ; + rdfs:domain :CorporateEntity ; + rdfs:range :CorporateEntity ; + rdfs:label "sub-organization of" ; + rdfs:comment "The semantic projection of corporate_entity.parent_entity_id. This is real organizational containment, not SKOS concept hierarchy." . + +:hasSubOrganization a owl:ObjectProperty ; + rdfs:subPropertyOf org:hasSubOrganization ; + owl:inverseOf :subOrganizationOf ; + rdfs:domain :CorporateEntity ; + rdfs:range :CorporateEntity ; + rdfs:label "has sub-organization" . + ################################################################# # Object properties -- edge_type (knowledge_graph_edge.edge_type_code) ################################################################# @@ -211,6 +230,17 @@ # SKOS -- corporate_entity_level (Group -> Company -> Plant) ################################################################# +:CorporateEntityLevel a owl:Class ; + rdfs:subClassOf skos:Concept ; + rdfs:label "Corporate entity level" ; + rdfs:comment "A classification concept such as Group, Company, or Plant. It is not the real organization instance." . + +:hasEntityLevel a owl:ObjectProperty ; + rdfs:domain :CorporateEntity ; + rdfs:range :CorporateEntityLevel ; + rdfs:label "has corporate entity level" ; + rdfs:comment "Projects corporate_entity.entity_level_code to the corresponding controlled SKOS concept." . + :corporateEntityLevelScheme a skos:ConceptScheme ; rdfs:label "Corporate entity level scheme" ; rdfs:comment "The Acme Group -> Acme Electronics Korea -> Acme Electronics Gwangju Plant kind of level, ordered broadest first." . diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index d69c840f9..3180fd588 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -23,11 +23,263 @@ - **Security & Compliance**: PII masking cannot break the system. Need SOC 2 and CSAP compliance alternatives to blind PII masking. - **LLM Orchestration**: Ensure ALL LLM calls route through `contextual-orchestrator` utilizing API keys (BYTEZ, NVIDIA, OPENROUTER, OPENAI) with auto model discovery and optimal reasoning effort allocation (Fugu/Conductor/TRINITY research). -## 4. Current Stacked PR Product-Surface Gaps -- **Customer Master relationship composition — PR #262**: (Resolved on the current feature branch) The hierarchy, selected customer, and linked evidence were previously stacked vertically, so the selected customer scrolled away while the user inspected relationships and source posts. ADR 0125 and Figma frames `313:2` / `314:2` define a customer-centered three-pane workspace that preserves the WAI-ARIA tree, keeps the selected customer stable, and places source-backed evidence in a separate pane. -- **Responsive Customer Master flow — PR #262**: (Resolved on the current feature branch) PC uses three horizontal panes, tablet uses two columns plus full-width evidence, and phone preserves the semantic order hierarchy → selected customer → evidence at the shared 1024 px / 768 px breakpoints. -- **Effective-dated relationship authority**: (Open) The current Customer Master projection still owns only one `parent_entity_id`. Legal ownership, operating structure, sales roll-up, billing hierarchy, historical roles, and multiple simultaneous relationship types require a normalized, effective-dated relation model before they can be shown as authoritative facts. -- **Unresolved hierarchy repair workflow**: (Open) Cycle, self-parent, and missing-visible-parent members remain safely visible and marked unresolved, but operators still need a source-data quality queue, evidence review, and approved correction workflow. -- **Customer relationship exact-value export**: (Open) The three-pane workspace is accessible and source-backed, but an auditable CSV/JSON export of the selected customer, visible relations, truth status, effective interval, and evidence references remains a later product slice. +Buyers need to turn scattered, timestamped records into reviewable branching +histories without confusing a plausible relation with a proven fact. The +product succeeds when an authorized buyer can move from an aggregate signal or +answer to its source post, lineage neighborhood, channel evidence, actor and +project context, while every derived claim retains provenance and an explicit +availability boundary. + +### Users and jobs + +| User | Job | Success evidence | +|---|---|---| +| Buyer | Find a relevant customer, project, event, commitment, or Keyman and inspect its history | Browser navigation reaches an authorized source-backed post and focused lineage | +| Analyst | Reconstruct a cutoff-bounded lineage and inspect why edges were selected | Persisted run, digest, edge scores, channel breakdown, and status history | +| Operator | Import, rebuild, retry, and diagnose without inventing unavailable results | Durable ledger/outbox state and explicit failed/unavailable status | +| Retention admin | Purge run-bearing evidence only under a deliberate grant | Database role plus unrevoked retention grant; no public purge route | + +### Scope + +In scope: authorized source import, semantic units and visual regions, +multi-channel lineage reconstruction, source-grounded ontology/provenance, +period reports, Board/Global Ask/Calendar/Customer/Keyman navigation, TEPP and +contextual-orchestrator integration, and buyer-visible evidence. + +Out of scope: TEPP model reimplementation, raw provider calls, locally chosen +models, forced links for missing channels, public real-data fixtures, and +claims that an unmerged PR or historical runtime observation is live behavior. + +### Product measures + +- Every displayed derived claim can navigate to authorized evidence or is + labeled unavailable. +- No relation crosses the analysis cutoff or caller ABAC boundary. +- Missing model, embedding, TEPP, Vision, or verification channels are dropped + and weights renormalized; no placeholder score or actor is invented. +- A real-stack acceptance run covers login, PostgreSQL-backed API behavior, + buyer navigation, and aggregate non-identifying evidence. + +## Functional specification + +| ID | Requirement and acceptance criterion | Normative source | Current evidence | +|---|---|---|---| +| FR-01 | Import authorized records while preserving immutable source identity, raw state, publication state, and revisions. No real record enters git. | ADR 0001, 0040, 0046, 0056-0059, 0068, 0089 | Import/reconciliation modules and migrations; private runtime evidence only | +| FR-02 | Derive paragraph/list/table/image-region semantic units without replacing the source representation. | ADR 0061, 0062, 0066, 0067, 0077, 0087, 0091 | Chunking, image-content, visual-region and embedding paths | +| FR-03 | Reconstruct backward-only candidate edges from available channels, fuse through RankWeave, apply a minimum floor, persist scores, and assemble trees through ThreadWeave. | ADR 0024, 0064, 0084 | `lineageweave/reconstruct.py`, channel clients, reconstruction tables/tests | +| FR-04 | Create, start, observe, and retain analysis runs with cutoff snapshots, append-only status, outbox delivery, authorization, and explicit failure. | ADR 0013-0023, 0025 | Backend analysis-run modules, migrations, API tests | +| FR-05 | Keep TEPP as a versioned external measurement boundary; failed or unused responses never become invented theta. | ADR 0003, 0022 | `tepp_client.py`, report and start contracts | +| FR-06 | Resolve actors, organizations, projects, roles, and relationships without collapsing ties or same-name mentions; preserve catalog identifiers on role rows. | ADR 0004-0012, 0018-0019, 0026-0027, 0036 | Summary, entity-resolution, KG and report paths/tests | +| FR-07 | Global Ask and buyer surfaces retrieve only authorized evidence and let cited results open the relevant post/lineage context. | ADR 0032, 0037, 0039, 0041-0044, 0047, 0053-0055, 0075, 0078, 0090 | Main has the earlier surfaces; PR stack #258-#301 proposes the integrated navigation/evidence flow | +| FR-08 | LLM, structured output, embedding, and Vision work crosses contextual-orchestrator with one post session and bounded provenance; provider/model/protocol selection stays upstream. | ADR 0030, 0045, 0052, 0070-0077, 0079, 0081-0088 | Orchestrator clients, Compose boundary, historical gateway observations | +| FR-09 | Period reports use real fast-mlsirm results; missing cells remain missing and leftover pairs are residual-derived and navigable. | ADR 0003, 0034-0035, 0048-0050 | Historical authenticated report rebuilds; report tests and schema | +| FR-10 | Standard provenance uses normalized PROV-O relations; qualified influence implies its unqualified relation and KG edges remain a navigation projection. | ADR 0011, 0065 | PROV-O implementation matrices, ontology, CI contract | +| FR-11 | Post summaries expose evidence-bearing events and R&R. Requester/processor actions are nullable and may only name actors already bound to the same post summary. | ADR 0052, ADR 0102 | Commit `15e1a378` is on PR #258 and the schema exists locally; the current database has zero populated action rows, so buyer-data acceptance remains unproven | +| FR-12 | A hierarchy-enrichment timeout leaves the source-grounded summary readable and the actor unbound; it never creates a guessed catalog identity. | ADR 0101, ADR 0010, ADR 0026 | Commit `1c260f20` contains the boundary, ADR, and focused test; independent review, protected-main merge, and fresh runtime evidence remain pending | +| FR-13 | Customer Master projects authorized corporate entities as a Group → Company → Plant tree. Real organization containment uses W3C ORG while Group/Company/Plant remain separate SKOS level concepts. Missing-parent, self-parent, and cyclic edges remain visible as unresolved roots; the UI owns nested `group` elements from their parent `treeitem`, supports Arrow/Home/End and Enter/Space operation, and opens source-backed evidence outside the tree. | ADR 0124, ADR 0004, ADR 0010 | Ontology/SHACL interoperability tests, `customerMasterTree.ts`, `CustomerMasterTree.tsx`, component tests, Storybook, and code commit `21074cf80cbfab3001bf18b6e1a618f75f4bed24` | + +## TRD + +### Runtime components and trust boundaries + +```mermaid +flowchart LR + B[Authenticated buyer] -->|OIDC token| F[React buyer UI] + F -->|bounded JSON| A[FastAPI] + A -->|ABAC-scoped SQL| P[(PostgreSQL)] + A -->|durable ledger| P + A -->|wake-up only| V[(Valkey)] + A -->|provider-neutral contract| O[contextual-orchestrator] + A -->|published wire contract| T[TEPP] + O --> X[LLM / Vision / embedding providers] + P -->|authorized source boundary| S[(Private source)] +``` + +- PostgreSQL is authoritative for normalized product state, run snapshots, + provenance, status, and durable work ledgers. Valkey is not the source of + truth. +- FastAPI applies authentication and ABAC before projecting records or edge + endpoints. The browser receives bounded projections, not raw source bags. +- contextual-orchestrator owns provider capability discovery, reasoning + effort, structured synthesis/repair, sessions, and cost lineage. +- ThreadWeave, RankWeave, TEPP, and fast-mlsirm are reused at their published + boundaries; LineageWeave does not clone their algorithms. + +### Analysis-run lifecycle UML + +```mermaid +stateDiagram-v2 + [*] --> Pending: authorized lineage request + frozen cutoff + Pending --> Running: start + durable outbox claim + Running --> Succeeded: result persisted + digest recorded + Running --> Failed: explicit failure code + Failed --> [*] + Succeeded --> [*] + note right of Pending + TEPP creation is not a fake Pending lineage run. + Retention purge has a separate DB-only grant boundary. + end note +``` + +### Evidence sequence UML + +```mermaid +sequenceDiagram + actor Buyer + participant UI + participant API + participant DB as PostgreSQL + participant Orch as contextual-orchestrator + Buyer->>UI: open source-backed feature + UI->>API: authenticated bounded request + API->>DB: load ABAC-visible cutoff evidence + opt semantic adjudication is available + API->>Orch: bounded units + provenance + session id + Orch-->>API: validated result + usage/verification metadata + end + API->>DB: persist result or explicit unavailable/failure state + API-->>UI: evidence-bearing projection + UI-->>Buyer: claim, provenance, and source navigation +``` + +### Non-functional requirements + +| ID | Contract | Verification | +|---|---|---| +| NFR-01 | OIDC authentication, endpoint ABAC, no public retention purge, no repository secrets | authorization-specific API tests and Compose identity-boundary check | +| NFR-02 | Bounded row, batch, browser, image, and MCP payloads | boundary unit tests plus real-stack response-size observation | +| NFR-03 | Third-normal-form identities and provenance; database constraints enforce integrity | migration/schema tests against PostgreSQL | +| NFR-04 | Python 3.12+ project-local environment; pinned Node/pnpm and Rust toolchain; checked lockfiles | clean-environment backend/frontend builds | +| NFR-05 | Synthetic fixtures only; runtime validation returns aggregate, non-identifying evidence | repository scan and evidence-document review | +| NFR-06 | ADR-first architectural change and paper-grounded model policy | ADR link check and review; unsupported policies remain unavailable | +| NFR-07 | Buyer hierarchy controls meet WCAG 2.2 keyboard operation and the WAI-ARIA tree ownership contract without inventing ontology facts | Ontology tests, focused hierarchy tests, full frontend test/lint/build, Storybook build, and final-head hosted verification | + +## Current aggregate data and runtime evidence + +Observed from the running local Compose stack without selecting a post title, +body, source code, person, organization, or identifier: + +| Evidence | Observed result | +|---|---| +| Stack availability | PostgreSQL, Valkey, and contextual-orchestrator healthy; backend and frontend running; backend `/healthz` and frontend `/` returned HTTP 200 | +| Source boundary | 43,839 source posts: 43,814 have both source-system and source-record identity; 25 lack that import identity | +| Source state/body | 43,814 rows carry source-state evidence; 43,438 rows have a non-empty body; 87,297 source revisions persist | +| Derived content | 562,394 semantic units, 1,308 live lineage edges, 48 KG navigation edges, and 95 persisted summaries | +| Run registry | Three runs: one lineage, one report, one TEPP; latest states are two Succeeded and one Failed | +| Run evidence | One snapshot with 42,577 members; one persisted reconstruction with 1,281 edges; zero persisted TEPP results | +| Requester/processor | `post_summary_action` exists with composite actor foreign keys; one authorized target refresh stored three action rows | +| Summary refresh | One authorized target request returned HTTP 200 with contract v5, four key events, one role, three actions, and one project | +| Authentication | Real synthetic-user OIDC login, live JWKS fetch, and RS256 verification passed | +| Authorization | Unauthenticated `/api/analysis-runs` and `/api/posts` returned 401; four focused live-Keycloak/PostgreSQL API tests covering authenticated account, list ABAC, direct deny, and missing token passed | +| Focused contracts | Post-summary and transaction-contract tests: 31 passed, 1 skipped; the skip is not runtime proof for the skipped capability | + +These observations prove data presence and the listed boundaries only. They do +not prove a browser-clicked buyer journey, current TEPP transport success, +post-summary-action population across the corpus, or equivalence between every +running container image and the PR head. The target refresh is bounded runtime +evidence for one authorized post, not a corpus-wide acceptance claim. + +## Active-PR gap closure evidence + +| Closed gap | Root cause | Closure evidence | Remaining boundary | +|---|---|---|---| +| Customer entities could disappear from the buyer surface when `parent_entity_id` formed a self-parent or cycle; the first tree refactor also placed child `group` content beside rather than inside its parent `treeitem`. | The old projection assembled only root-reachable nodes, overloaded evidence state with hierarchy semantics, and did not satisfy the APG ownership rule. | Code commit `21074cf80cbfab3001bf18b6e1a618f75f4bed24` promotes malformed edges to visible unresolved roots, keeps ORG containment separate from SKOS classification, makes every parent `treeitem` own its child `group`, separates evidence into an external region, and adds navigation, failure, stale-response, ontology, and Storybook regressions. | The API still exposes one parent context; authoritative acyclicity, level-transition rules, legal/operating/sales/billing contexts, and effective-dated history remain future normalized-model work. | + +## Active PR audit + +A focused 2026-08-21 refresh found PR #258 open and mergeable at customer-hierarchy +code commit `21074cf80cbfab3001bf18b6e1a618f75f4bed24`. The organization queue has changed since the 18-row inventory below, so the +table is retained only as historical stack topology. Current acceptance must be read from the final +PR head, valid unresolved threads, qualifying independent review, and terminal hosted Checks. + +| PR | Proposed increment | Base → head | Snapshot state | +|---|---|---|---| +| #301 | Global Ask knowledge cutoff | `#264 stack` → `v2.23.0` | Ready / UNSTABLE | +| #298 | bounded async lineage LLM rebuild | `#276` → `v2.22.0` | Ready / UNSTABLE | +| #287 | exact Event Lineage channel evidence | `#276` → feature | Ready / UNSTABLE | +| #286 | exact byte-bounded MCP browser admission | `#270` → fix | Ready / UNSTABLE | +| #285 | project lifecycle timeline | `#264 stack` → `v2.18.4` | Ready / UNSTABLE | +| #282 | TEPP project history in read/Ask | `#264 stack` → `v2.18.0` | Ready / UNSTABLE | +| #276 | public verification of Global Ask claims | `#266` → `v2.20.0` | Ready / UNSTABLE | +| #275 | evidence-bound Event Intelligence | `#270` → `v2.18.3` | Ready / UNSTABLE | +| #270 | authenticated MCP Global Ask | `main` → feature | Ready / BLOCKED / review required | +| #266 | Event Lineage to Keyman focus | `#264` → `v2.19.0` | Ready / BLOCKED / review required | +| #264 | keep Event Lineage DAG focus | `#263` → `v2.17.0` | Ready / BLOCKED / review required | +| #263 | Ask citation to Event Lineage | `#262` → `v2.16.0` | Ready / BLOCKED / review required | +| #262 | Customer post to Event Lineage | `#261` → `v2.15.0` | Ready / BLOCKED / review required | +| #261 | Calendar commitment to Event Lineage | `#260` → `v2.14.0` | Ready / BLOCKED / review required | +| #260 | Weekly VOC to Event Lineage | `#258` → `v2.13.0` | Ready / DIRTY / review required | +| #258 | buyer evidence board, standards-composed ontology, and cycle-safe Customer Master tree | `main` → `21074cf80cbfab3001bf18b6e1a618f75f4bed24` | Ready / mergeable / final-head Checks and independent approval pending | +| #258 | buyer evidence board, standards-composed ontology, and cycle-safe Customer Master tree | `main` → `21074cf80cbfab3001bf18b6e1a618f75f4bed24` | Ready / mergeable / final-head Checks and independent approval pending | +| #192 | plural affiliation next action | `main` → `v0.77.0` | Ready / DIRTY / review required | +| #190 | duplicate-numbered entity-resolution ADR | `main` → docs | Ready / BLOCKED | + +The dominant delivery topology is a long dependent stack rooted at #258 and +then #260-#266. Parallel descendants (#275, #282, #285, #276-#301) are based +on intermediate heads rather than one integration head. Green checks on a +child do not prove that the stack is mergeable or that the behavior exists on +main. + +Manual triage of #258's four unresolved scanner threads found literal SQL in +`entity_relationship_ingestion.py` and `demo_scope.py`; request-derived entity +ids are passed as `$1` arguments rather than interpolated. This is evidence for +a likely narrow false-positive suppression, not authority to dismiss the +findings: the required security workflow and independent reviewer must accept +the exact-head disposition. + +## Gap register + +| Priority | Gap | Evidence | Closure criterion | +|---|---|---|---| +| P0 | No protected-main integrated buyer journey for the active feature stack | Main is 2.12.5; 18 open PRs span dependent and parallel bases | Establish one reviewed integration order, update each exact head, pass required checks, merge without bypass, then run login-to-source browser acceptance on main | +| P0 | Current runtime proof is incomplete | The current aggregate/OIDC/ABAC checks cover data presence and selected boundaries; 2026-08-18/19 notes cover other slices, but no evidence set proves the entire PR head or main journey | Complete the real-stack matrix on an exact revision: browser login/navigation, Ask, reports, Vision, TEPP availability, action population, and cleanup | +| P0 | PR #190's duplicate ADR identity was corrected but is not protected-main truth | Active PR head `ac1b4e17` now uses ADR 0038 and aligns the entity-resolution claims with implementation; independent review and Checks remain pending | Re-audit exact head, obtain independent approval, pass required Checks, and merge normally; never merge a duplicate ADR identity | +| P0 | PR #258 still requires final-head review and hosted CI | Customer hierarchy code is at `21074cf80cbfab3001bf18b6e1a618f75f4bed24`; branch-local verification does not transfer to the following documentation-only head | Re-read review threads, obtain qualifying independent approval, require all final-head hosted Checks to reach terminal success, and merge only through normal protection | +| P1 | Requirements were implicit across ADRs and architecture phases | No prior PRD/TRD/requirement traceability baseline existed | Keep FR/NFR IDs in this document linked from ADR index; require new product PRs to name affected IDs and runtime evidence | +| P1 | Active PR topology obscures release truth | 8 blocked, 8 unstable, and 2 dirty; many bases are other open branches | Publish a dependency order, retire obsolete/duplicate branches, and avoid version claims until their base chain reaches main | +| P1 | ADR 0102 schema exists but current data does not exercise it | Commit `15e1a378` is on PR #258 and the table exists, but 95 summaries yield zero requester/processor action rows | Regenerate an authorized bounded sample, report aggregate accepted/dropped/absent counts, verify source evidence and actor FKs, then exercise the buyer popup without exposing record content | +| P1 | ADR 0101 is active-PR behavior but not protected-main behavior | Commit `1c260f20` contains the corrected ADR link, boundary, and focused tests; independent review and protected-main merge remain pending | Re-audit the exact head, obtain independent approval, pass required checks, merge normally, and collect fresh runtime evidence | +| P1 | ADR status vocabulary is inconsistent and sometimes stale | Several ADRs say “Accepted on this active PR; not protected-main truth” even after branch evolution | Add a mechanical ADR status/link audit that distinguishes Proposed, Accepted-on-PR, Accepted-on-main, and Superseded | +| P2 | ADR numbering skips 0031 and 0093-0097 while file 0092 titles itself ADR 0031 | File identity and displayed identity differ | Correct the 0092 title or document an intentional alias; reserve or explain skipped numbers in the index | +| P2 | Product measures lack explicit targets | Research supports evidence boundaries but not universal model-quality thresholds | Define targets only from an approved evaluation protocol and authorized labeled aggregate dataset; do not invent accuracy goals | +| P2 | UML covers core trust/lifecycle flow but not every buyer navigation branch | Architecture and PR stack evolve faster than diagrams | Add diagrams only when a stable main integration makes a flow materially distinct; keep this baseline small | + +## Verification matrix + +| Scope | Evidence available now | Claim allowed now | Missing proof | +|---|---|---|---| +| Protected main | `origin/main` manifests show 2.12.5 | Existing main contracts only | Fresh main runtime matrix | +| Historical local runtime | Authenticated PostgreSQL report rebuilds and orchestrator/Vision observations dated 2026-08-18/19 | Those exact bounded observations | Current head/main equivalence and full browser journey | +| Active PRs | GitHub head/base, review, merge, check, and review-thread states at snapshot | Proposed increments and gate state | Normal merge and post-merge runtime behavior | +| Local PR checkout | PR #258 was observed at `bf599aca`; full suite passed and one authorized target summary refresh returned v5/HTTP 200 with persisted actions | Only the exact observations in the current-data table; no claim for protected-main behavior | Full suite/CI, browser journey, external channel results, review, merge, and corpus-level action evidence | + +## Maintenance rule + +ADRs remain normative. This document is the product/technical traceability +projection: update the affected FR/NFR row and Gap closure evidence when an ADR +or PR changes product behavior. Never turn a PR title, green unit test, or old +runtime note into a shipped/live claim. + +## Current stacked PR product-surface gaps + +- **Customer Master relationship composition — PR #262**: Resolved on the + current feature branch. ADR 0125 and Figma frames `313:2` / `314:2` define a + customer-centered three-pane workspace that keeps the selected customer + stable while the user inspects relationships and source posts. +- **Responsive Customer Master flow — PR #262**: Resolved on the current + feature branch. PC uses three horizontal panes, tablet uses two columns plus + full-width evidence, and phone preserves the semantic order hierarchy → + selected customer → evidence at the shared 1024 px / 768 px breakpoints. +- **Effective-dated relationship authority**: Open. The current projection + still owns one `parent_entity_id`; legal ownership, operating structure, + sales roll-up, billing hierarchy, historical roles, and simultaneous + relationship types require a normalized effective-dated relation model. +- **Unresolved hierarchy repair workflow**: Open. Cycle, self-parent, and + missing-visible-parent members remain visible and unresolved, but operators + still need a source-data quality queue, evidence review, and approved + correction workflow. +- **Customer relationship exact-value export**: Open. An auditable CSV/JSON + export of the selected customer, visible relations, truth status, effective + interval, and evidence references remains a later product slice. *This document is continuously updated by the hourly automated agent loop.* diff --git a/frontend/package.json b/frontend/package.json index 96c2b72ba..7a697d0c9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.16.0", + "version": "2.17.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 830187589..39909fdd9 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -85,7 +85,9 @@ describe("App, authenticated", () => { pendingTeppRun?: boolean; pluralAffiliations?: boolean; deferMe?: boolean; + deferPostOneSummary?: boolean; deferSecondAsk?: boolean; + invalidAskSessionOnce?: boolean; meFailed?: boolean; postBody?: string; manyCustomerHints?: number; @@ -126,6 +128,7 @@ describe("App, authenticated", () => { releaseSecondAsk: () => void; releaseGroupRelated: () => void; releaseDemoRelated: () => void; + releasePostOneSummary: () => void; } { const statusLabel: Record = { open: "Open", @@ -177,6 +180,12 @@ describe("App, authenticated", () => { releaseDemoRelated = resolve; }) : Promise.resolve(); + let releasePostOneSummary = () => {}; + const postOneSummaryReady = options?.deferPostOneSummary + ? new Promise((resolve) => { + releasePostOneSummary = resolve; + }) + : Promise.resolve(); const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -1239,7 +1248,7 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/posts/post-1/summary")) { - return Promise.resolve( + return postOneSummaryReady.then(() => jsonResponse({ post_id: "post-1", korean_summary: "이것은 요약입니다.", @@ -1671,6 +1680,15 @@ 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) { + return Promise.resolve( + new Response(JSON.stringify({ detail: "Global Ask session not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }), + ); + } const ready = options?.deferSecondAsk && askRequestCount === 2 ? secondAskReady @@ -1678,6 +1696,7 @@ describe("App, authenticated", () => { return ready.then(() => Promise.resolve( jsonResponse({ + session_id: "session-1", answer_text: "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" }], @@ -1691,6 +1710,20 @@ describe("App, authenticated", () => { }, ], source_post_ids: ["post-1", "post-2"], + timeline: [ + { + post_id: "post-1", + post_title: "Public post", + occurred_at: "2026-01-01T00:00:00Z", + timeline_kind: "lineage_anchor", + }, + { + post_id: "post-2", + post_title: "Linked post", + occurred_at: "2026-01-02T00:00:00Z", + timeline_kind: "lineage_neighbor", + }, + ], }), ), ); @@ -1793,6 +1826,7 @@ describe("App, authenticated", () => { releaseSecondAsk, releaseGroupRelated, releaseDemoRelated, + releasePostOneSummary, }); } @@ -1807,6 +1841,8 @@ describe("App, authenticated", () => { expect(await screen.findByRole("list", { name: "Evidence facts" })).toBeInTheDocument(); expect(screen.getByText("Semantic project", { exact: true })).toBeInTheDocument(); expect(screen.getByText(/project: Semantic project \| evidence: Body evidence/)).toBeInTheDocument(); + expect(screen.getByRole("list", { name: "Event Lineage timeline" })).toBeInTheDocument(); + expect(screen.getByText("2026-01-01T00:00:00Z")).toBeInTheDocument(); expect(screen.queryByText(/ontology_iri|contextual_orchestrator/i)).not.toBeInTheDocument(); }); @@ -1846,6 +1882,26 @@ describe("App, authenticated", () => { ).toBeInTheDocument(); }); + it("replaces an invalid saved Ask session without requiring storage cleanup", async () => { + window.sessionStorage.setItem("lineageweave.globalAskSessionId", "stale-session"); + const fetchMock = stubBackend({ invalidAskSessionOnce: 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" }), "Which project?"); + await userEvent.click(within(ask).getByRole("button", { name: "Ask" })); + + expect( + await within(ask).findByText("The cited project is supported by the stored semantic evidence."), + ).toBeInTheDocument(); + const askBodies = fetchMock.mock.calls + .filter(([url]) => String(url).endsWith("/api/ask")) + .map(([, init]) => JSON.parse(String((init as RequestInit).body)) as { session_id?: string }); + expect(askBodies.map((body) => body.session_id)).toEqual(["stale-session", undefined]); + expect(window.sessionStorage.getItem("lineageweave.globalAskSessionId")).toBe("session-1"); + }); + it("labels the Customer Master entity level and Keymen side, never the raw lookup code", async () => { // Live UI finding (2026-08-19): read_customer_master() skipped the // common_lookup_value join both endpoints elsewhere already use, @@ -2243,6 +2299,67 @@ describe("App, authenticated", () => { expect(screen.queryByRole("status", { name: "Event Lineage next action" })).not.toBeInTheDocument(); }); + it("ignores a stale summary after Event Lineage navigation changes the selected post", async () => { + const fetchMock = stubBackend({ deferPostOneSummary: true }); + render(); + + const board = await screen.findByRole("region", { name: "Board" }); + await userEvent.click( + within(board).getByRole("button", { name: "View post: Public post" }), + ); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + + const linkedPosts = screen.getAllByLabelText("Open post: Linked post"); + await userEvent.click(linkedPosts[linkedPosts.length - 1]); + await waitFor(() => + expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), + ); + expect(await screen.findByText("연결된 글입니다.")).toBeInTheDocument(); + + fetchMock.releasePostOneSummary(); + + await waitFor(() => { + expect(screen.getByText("연결된 글입니다.")).toBeInTheDocument(); + expect(screen.queryByText("이것은 요약입니다.")).not.toBeInTheDocument(); + }); + }); + + it("opening a linked Event Lineage node from Ask Agent keeps GNB focus; a home-list DAG walk does not", 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" }), "Which project?"); + await userEvent.click(within(ask).getByRole("button", { name: "Ask" })); + await userEvent.click(within(ask).getByRole("button", { name: "Open cited post: Linked post" })); + + await waitFor(() => + expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), + ); + expect(screen.getByRole("status", { name: "Event Lineage next action" })).toHaveTextContent( + "Linked post is current in Event Lineage. Read Keyman and evaluation next.", + ); + + await userEvent.click(screen.getByLabelText("Open post: Public post")); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + expect(document.getElementById("post-event-lineage")).toHaveFocus(); + expect(screen.getByRole("status", { name: "Event Lineage next action" })).toHaveTextContent( + "Public post is current in Event Lineage. Read Keyman and evaluation next.", + ); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + const boardAfterAsk = screen.getByRole("region", { name: "Board" }); + await userEvent.click(within(boardAfterAsk).getByRole("button", { name: "View post: Public post" })); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + await userEvent.click(screen.getByLabelText("Open post: Linked post")); + await waitFor(() => + expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), + ); + expect(document.getElementById("post-event-lineage")).not.toHaveFocus(); + expect(screen.queryByRole("status", { name: "Event Lineage next action" })).not.toBeInTheDocument(); + }); + it("renders the A-100 fork as a git-style DAG, not a flat edge list", async () => { stubBackend(); render(); @@ -2479,6 +2596,17 @@ describe("App, authenticated", () => { expect(screen.getByRole("button", { name: "Retry summary refresh" })).toBeInTheDocument(); }); + it("requests one summary per post open and keeps retry as the only second request", async () => { + const fetchMock = stubBackend(); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await waitFor(() => expect(screen.getByText("이것은 요약입니다.")).toBeInTheDocument()); + expect( + fetchMock.mock.calls.filter(([input]) => String(input).endsWith("/api/posts/post-1/summary")), + ).toHaveLength(1); + }); + it("refreshes newly processed source content after summary generation", async () => { stubBackend({ contentAfterSummary: true }); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 85eb6cf89..52075e2cc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -102,8 +102,14 @@ import { useLocale, } from "./i18n"; import { isoWeekFromCreatedAt, latestIsoWeek } from "./isoWeek"; +import { + analysisRunTargetClock, + type AnalysisRunNavigationContext, +} from "./analysisRunNavigation"; import "./App.css"; +const GLOBAL_ASK_SESSION_STORAGE_KEY = "lineageweave.globalAskSessionId"; + function orchestratorUnavailableMessage(err: unknown, action: string): string { if (err instanceof BackendError && err.status === 503) { return `${action} ${t("is temporarily unavailable.")} ${t("Saved evidence is still available.")}`; @@ -1713,31 +1719,58 @@ function PostDetailPopup({ const [focusTeam, setFocusTeam] = useState<{ teamId: string; teamName: string } | null>(null); const contentReloadRef = useRef<() => void>(() => undefined); + const detailRequestGeneration = useRef(0); + function reloadKeymen() { + const generation = detailRequestGeneration.current; fetchPostKeymen(accessToken, postId) .then((r) => { + if (detailRequestGeneration.current !== generation) return; setKeymen(r.keymen); setSourceAuthorContext(r.source_author_context ?? null); }) .catch(() => { + if (detailRequestGeneration.current !== generation) return; setKeymen([]); setSourceAuthorContext(null); }); fetchPostAffiliateTree(accessToken, postId) - .then((r) => setAffiliateTrees(r.trees)) - .catch(() => setAffiliateTrees([])); - fetchPostVocEvidence(accessToken, postId).then(setVocEvidence).catch(() => setVocEvidence(null)); + .then((r) => { + if (detailRequestGeneration.current === generation) setAffiliateTrees(r.trees); + }) + .catch(() => { + if (detailRequestGeneration.current === generation) setAffiliateTrees([]); + }); + fetchPostVocEvidence(accessToken, postId) + .then((value) => { + if (detailRequestGeneration.current === generation) setVocEvidence(value); + }) + .catch(() => { + if (detailRequestGeneration.current === generation) setVocEvidence(null); + }); reloadCounterparties(); } function reloadCounterparties() { + const generation = detailRequestGeneration.current; fetchPostCounterparties(accessToken, postId) - .then((r) => setCounterparties(r.counterparties)) - .catch(() => setCounterparties([])); + .then((r) => { + if (detailRequestGeneration.current === generation) { + setCounterparties(r.counterparties); + } + }) + .catch(() => { + if (detailRequestGeneration.current === generation) setCounterparties([]); + }); } useEffect(() => { + const generation = detailRequestGeneration.current + 1; + detailRequestGeneration.current = generation; + const isCurrent = () => detailRequestGeneration.current === generation; + setPost(null); + setImageContent([]); setStructureUnits([]); setBookmarked(null); setBookmarkSaving(false); @@ -1759,11 +1792,17 @@ function PostDetailPopup({ let disposed = false; let contentPollTimer: number | undefined; const asOf = liveBodyWarning && knowledgeCutoff ? knowledgeCutoff : undefined; - fetchPost(accessToken, postId, asOf).then(setPost).catch((err) => setError(String(err))); + fetchPost(accessToken, postId, asOf) + .then((value) => { + if (isCurrent()) setPost(value); + }) + .catch((err) => { + if (isCurrent()) setError(String(err)); + }); const reloadContent = () => fetchPostContent(accessToken, postId) .then((content) => { - if (disposed) return; + if (disposed || !isCurrent()) return; setImageContent(content.images); setStructureUnits(content.units); if (content.status === "processing" && contentPollTimer === undefined) { @@ -1774,43 +1813,77 @@ function PostDetailPopup({ } }) .catch(() => { - if (disposed) return; + if (disposed || !isCurrent()) return; setImageContent([]); setStructureUnits([]); }); contentReloadRef.current = reloadContent; - reloadContent(); + void reloadContent(); fetchPostBookmark(accessToken, postId) - .then((r) => setBookmarked(r.bookmarked)) + .then((r) => { + if (isCurrent()) setBookmarked(r.bookmarked); + }) .catch(() => { - setBookmarked(null); + if (isCurrent()) setBookmarked(null); }); fetchPostEvaluation(accessToken, postId) - .then((r) => setEvaluation(r.responses)) - .catch(() => setEvaluation([])); + .then((r) => { + if (isCurrent()) setEvaluation(r.responses); + }) + .catch(() => { + if (isCurrent()) setEvaluation([]); + }); fetchPostFiveW1H(accessToken, postId) - .then(setFiveW1H) - .catch(() => setFiveW1H(null)); + .then((value) => { + if (isCurrent()) setFiveW1H(value); + }) + .catch(() => { + if (isCurrent()) setFiveW1H(null); + }); fetchPostKeymen(accessToken, postId) .then((r) => { + if (!isCurrent()) return; setKeymen(r.keymen); setSourceAuthorContext(r.source_author_context ?? null); }) .catch(() => { + if (!isCurrent()) return; setKeymen([]); setSourceAuthorContext(null); }); fetchPostCounterparties(accessToken, postId) - .then((r) => setCounterparties(r.counterparties)) - .catch(() => setCounterparties([])); - fetchPostLineage(accessToken, postId).then(setLineage).catch(() => setLineage(null)); + .then((r) => { + if (isCurrent()) setCounterparties(r.counterparties); + }) + .catch(() => { + if (isCurrent()) setCounterparties([]); + }); + fetchPostLineage(accessToken, postId) + .then((value) => { + if (isCurrent()) setLineage(value); + }) + .catch(() => { + if (isCurrent()) setLineage(null); + }); fetchPostAffiliateTree(accessToken, postId) - .then((r) => setAffiliateTrees(r.trees)) - .catch(() => setAffiliateTrees([])); - fetchPostVocEvidence(accessToken, postId).then(setVocEvidence).catch(() => setVocEvidence(null)); + .then((r) => { + if (isCurrent()) setAffiliateTrees(r.trees); + }) + .catch(() => { + if (isCurrent()) setAffiliateTrees([]); + }); + fetchPostVocEvidence(accessToken, postId) + .then((value) => { + if (isCurrent()) setVocEvidence(value); + }) + .catch(() => { + if (isCurrent()) setVocEvidence(null); + }); + return () => { disposed = true; if (contentPollTimer !== undefined) window.clearTimeout(contentPollTimer); + if (isCurrent()) detailRequestGeneration.current = generation + 1; if (contentReloadRef.current === reloadContent) { contentReloadRef.current = () => undefined; } @@ -2542,6 +2615,7 @@ function analysisRunDigestPrefix(digest: string): string { type SelectPostOptions = { liveAfterCutoff?: boolean; knowledgeCutoff?: string; + analysisRunContext?: AnalysisRunNavigationContext; fromReportMember?: boolean; fromWeeklyVoc?: boolean; fromCalendar?: boolean; @@ -2707,10 +2781,13 @@ function analysisRunReportPeriod(run: AnalysisRun): string | null { * title is marked rewritten after this run. */ function analysisRunPostOpenOptions(run: AnalysisRun, postId: string): SelectPostOptions { - const post = run.visible_posts?.find((item) => item.post_id === postId); - return { - liveAfterCutoff: Boolean(post?.live_after_cutoff), + const analysisRunContext: AnalysisRunNavigationContext = { knowledgeCutoff: run.knowledge_cutoff, + visiblePosts: run.visible_posts ?? [], + }; + return { + ...analysisRunTargetClock(analysisRunContext, postId), + analysisRunContext, }; } @@ -3616,6 +3693,8 @@ function PostList({ const [selectedPostId, setSelectedPostId] = useState(null); const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false); const [openedCutoffIso, setOpenedCutoffIso] = useState(null); + const [openedAnalysisRunContext, setOpenedAnalysisRunContext] = + useState(null); const [canRebuild, setCanRebuild] = useState(false); const [rebuilding, setRebuilding] = useState(false); const [rebuildError, setRebuildError] = useState(null); @@ -3682,6 +3761,7 @@ function PostList({ setFocusedGraph(null); setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff)); setOpenedCutoffIso(options?.knowledgeCutoff ?? null); + setOpenedAnalysisRunContext(options?.analysisRunContext ?? null); setOpenedFromReportMember(Boolean(options?.fromReportMember)); setOpenedFromWeeklyVoc(Boolean(options?.fromWeeklyVoc)); setOpenedFromCalendar(Boolean(options?.fromCalendar)); @@ -3709,6 +3789,7 @@ function PostList({ setSelectedPostId(null); setOpenedAfterCutoff(false); setOpenedCutoffIso(null); + setOpenedAnalysisRunContext(null); setOpenedFromReportMember(false); setOpenedFromWeeklyVoc(false); setOpenedFromCalendar(false); @@ -4206,7 +4287,20 @@ function PostList({ } focusAskOnLand={openedFromReportMember} onClose={closeSelectedPost} - onSelectPost={selectPost} + onSelectPost={(postId) => { + const cutoffOptions = openedAnalysisRunContext + ? analysisRunTargetClock(openedAnalysisRunContext, postId) + : {}; + selectPost(postId, { + ...cutoffOptions, + analysisRunContext: openedAnalysisRunContext ?? undefined, + fromReportMember: openedFromReportMember, + fromWeeklyVoc: openedFromWeeklyVoc, + fromCalendar: openedFromCalendar, + fromCustomerMaster: openedFromCustomerMaster, + fromAskAgent: openedFromAskAgent, + }); + }} onSearch={searchBoard} /> )} @@ -4455,6 +4549,9 @@ function AskAgentPanel({ const [answer, setAnswer] = useState(null); const [error, setError] = useState(null); const [asking, setAsking] = useState(false); + const [sessionId, setSessionId] = useState(() => + window.sessionStorage.getItem(GLOBAL_ASK_SESSION_STORAGE_KEY) ?? undefined, + ); async function handleAsk() { const normalized = question.trim(); @@ -4463,7 +4560,20 @@ function AskAgentPanel({ setError(null); setAnswer(null); try { - setAnswer(await askAgent(accessToken, normalized)); + let nextAnswer: AskAgentResponse; + try { + nextAnswer = await askAgent(accessToken, normalized, sessionId); + } 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); + } + setAnswer(nextAnswer); + setSessionId(nextAnswer.session_id); + window.sessionStorage.setItem(GLOBAL_ASK_SESSION_STORAGE_KEY, nextAnswer.session_id); } catch (err) { setAnswer(null); setError(orchestratorUnavailableMessage(err, t("Ask Agent"))); @@ -4495,6 +4605,26 @@ function AskAgentPanel({

{t("Answer")}

{answer.answer_text ?

{answer.answer_text}

: null} {answer.next_action ?

{t(answer.next_action)}

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

{t("Event Lineage timeline")}

+
    + {answer.timeline.map((event) => ( +
  1. + +
  2. + ))} +
+ + ) : null} {answer.cited_posts && answer.cited_posts.length > 0 && ( <>

@@ -4634,7 +4764,15 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean

{auth.user?.profile.preferred_username} - +
{ + const context = { + knowledgeCutoff: "2026-01-15T12:00:00Z", + visiblePosts: [ + { post_id: "unchanged", live_after_cutoff: false }, + { post_id: "rewritten", live_after_cutoff: true }, + ], + }; + + it("uses the selected DAG target's own live_after_cutoff value", () => { + expect(analysisRunTargetClock(context, "rewritten")).toEqual({ + liveAfterCutoff: true, + knowledgeCutoff: context.knowledgeCutoff, + }); + expect(analysisRunTargetClock(context, "unchanged")).toEqual({ + liveAfterCutoff: false, + knowledgeCutoff: context.knowledgeCutoff, + }); + }); + + it("keeps the run cutoff and fails closed for a target absent from visible_posts", () => { + expect(analysisRunTargetClock(context, "missing")).toEqual({ + liveAfterCutoff: false, + knowledgeCutoff: context.knowledgeCutoff, + }); + }); +}); diff --git a/frontend/src/analysisRunNavigation.ts b/frontend/src/analysisRunNavigation.ts new file mode 100644 index 000000000..541f8eb43 --- /dev/null +++ b/frontend/src/analysisRunNavigation.ts @@ -0,0 +1,17 @@ +/** Immutable analysis-run clock context carried across post navigation. */ +export type AnalysisRunNavigationContext = { + knowledgeCutoff: string; + visiblePosts: Array<{ post_id: string; live_after_cutoff?: boolean }>; +}; + +/** Resolve the selected target's own write-clock flag under the originating run cutoff. */ +export function analysisRunTargetClock( + context: AnalysisRunNavigationContext, + postId: string, +): { liveAfterCutoff: boolean; knowledgeCutoff: string } { + const target = context.visiblePosts.find((post) => post.post_id === postId); + return { + liveAfterCutoff: Boolean(target?.live_after_cutoff), + knowledgeCutoff: context.knowledgeCutoff, + }; +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 703be4de6..6956419b9 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -303,14 +303,23 @@ export interface ChatHistory { } export interface AskAgentResponse { + session_id: string; answer_text: string; cited_post_ids: string[]; cited_posts?: CitedPostRef[]; cited_post_evidence?: CitedPostEvidence[]; source_post_ids: string[]; + timeline?: AskTimelineEntry[]; next_action?: string; } +export interface AskTimelineEntry { + post_id: string; + post_title: string; + occurred_at: string | null; + timeline_kind: string | null; +} + export interface IssueTicket { issue_ticket_id: string; post_id: string; @@ -876,10 +885,14 @@ export function askPostChat(accessToken: string, postId: string, question: strin }); } -export function askAgent(accessToken: string, question: string): Promise { +export function askAgent( + accessToken: string, + question: string, + sessionId?: string, +): Promise { return backendFetch("/api/ask", accessToken, { method: "POST", - body: JSON.stringify({ question }), + body: JSON.stringify({ question, ...(sessionId ? { session_id: sessionId } : {}) }), }); } diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index 81f4612f7..aed9240c1 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -42,6 +42,8 @@ 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.", + "Event Lineage timeline", + "Open timeline post:", ] as const; it("supports the five product locales", () => { diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 79d2cfc69..2fb01c150 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -322,6 +322,8 @@ const TRANSLATIONS: Partial>> = { "Ask citation next action": "Ask 인용 다음 작업", "Evidence next action": "근거 다음 작업", "Event Lineage next action": "이벤트 계보 다음 작업", + "Event Lineage timeline": "이벤트 계보 타임라인", + "Open timeline post:": "타임라인 글 열기:", "Related next action": "관련 대상 다음 작업", "Ask next action": "Ask 다음 작업", "Keyman next action": "Keyman 다음 작업", @@ -672,6 +674,8 @@ const TRANSLATIONS: Partial>> = { "Ask citation next action": "Ask 引用操作", "Evidence next action": "证据操作", "Event Lineage next action": "事件谱系操作", + "Event Lineage timeline": "事件谱系时间线", + "Open timeline post:": "打开时间线文章:", "Related next action": "相关节点操作", "Ask next action": "Ask 操作", "Keyman next action": "关键联系人操作", @@ -1022,6 +1026,8 @@ const TRANSLATIONS: Partial>> = { "Ask citation next action": "Ask引用の操作", "Evidence next action": "証拠の操作", "Event Lineage next action": "イベント系譜の操作", + "Event Lineage timeline": "イベント系譜タイムライン", + "Open timeline post:": "タイムラインの投稿を開く:", "Related next action": "関連ノードの操作", "Ask next action": "Askの操作", "Keyman next action": "キーパーソンの操作", @@ -1372,6 +1378,8 @@ const TRANSLATIONS: Partial>> = { "Ask citation next action": "Thao tác trích dẫn Ask", "Evidence next action": "Thao tác bằng chứng", "Event Lineage next action": "Thao tác Dòng sự kiện", + "Event Lineage timeline": "Dòng sự kiện theo thời gian", + "Open timeline post:": "Mở bài viết trên dòng thời gian:", "Related next action": "Thao tác nút liên quan", "Ask next action": "Thao tác Ask", "Keyman next action": "Thao tác người liên hệ chính", diff --git a/frontend/src/postBodyDisplay.test.ts b/frontend/src/postBodyDisplay.test.ts index 07ca9514d..94a2da6c3 100644 --- a/frontend/src/postBodyDisplay.test.ts +++ b/frontend/src/postBodyDisplay.test.ts @@ -47,20 +47,34 @@ describe("splitPostBody", () => { ]); }); - it("reads CSS box shorthand indentation and markerless footnotes", () => { + it("reads CSS box shorthand indentation and numbered footnotes", () => { expect( splitPostBody( '
  • Outer
' + '
  • Nested
' + - "

*Tier 2: note

", + "

[1] Source note

*Tier 2: note

", ), ).toEqual([ { kind: "text", text: "Outer", indentLevel: 7 }, { kind: "text", text: "Nested", indentLevel: 10 }, + { kind: "text", text: "[1] Source note", role: "footnote" }, { kind: "text", text: "*Tier 2: note", role: "footnote" }, ]); }); + it("keeps nested list items in source order with their list depth", () => { + expect( + splitPostBody( + "
  1. Outer
    • Inner
    After inner
  2. Sibling
", + ), + ).toEqual([ + { kind: "text", text: "Outer", indentLevel: 1 }, + { kind: "text", text: "Inner", indentLevel: 2 }, + { kind: "text", text: "After inner", indentLevel: 1 }, + { kind: "text", text: "Sibling", indentLevel: 1 }, + ]); + }); + it("leaves a plain-text post unchanged so existing popups keep their wording", () => { expect(splitPostBody("The full body text.")).toEqual([ { kind: "text", text: "The full body text." }, diff --git a/frontend/src/postBodyDisplay.ts b/frontend/src/postBodyDisplay.ts index 919e8c0ca..7e942bb48 100644 --- a/frontend/src/postBodyDisplay.ts +++ b/frontend/src/postBodyDisplay.ts @@ -20,7 +20,7 @@ const BLOCK_TAG = /<\/?(?:article|blockquote|div|h[1-6]|li|ol|p|section|table|tbody|td|tfoot|th|thead|tr|ul|w:p|w:tbl|w:tr|w:tc)\b[^>]*>/gi; const WORD_INDENT_TAG = /]*\/?\s*>/gi; const LIST_ITEM_START = /^\s*(?:[-*•·]\s+|[*†‡](?=\S)|(?:\d{1,3}|[A-Za-z가-힣])[.)]\s+|[①-⑳]\s+)/; -const FOOTNOTE_START = /^\s*[*†‡](?=\S)/; +const FOOTNOTE_START = /^\s*(?:[*†‡](?=\S)|\[\d{1,3}\]\s+\S)/; const INDENT_MARKER = "\u0001lw-indent:"; const INDENT_MARKER_END = "\u0002"; const INDENT_MARKER_PATTERN = /lw-indent:(\d+)/g; @@ -91,11 +91,27 @@ function indentMarker(width: number): string { function stripHtmlTags(text: string): string { text = text.replace(/]*>(.*?)<\/sup>/gi, "^$1"); + const listIndentWidths: number[] = []; + let listIndent = 0; const withBoundaries = text .replace(BREAK_TAG, "\n") .replace(BLOCK_TAG, (tag) => { - if (/^<\//.test(tag)) return "\n\n"; - return `\n\n${indentMarker(declaredIndentWidth(tag))}`; + const closing = /^<\//.test(tag); + const tagName = tag.match(/^<\/?\s*([a-z0-9:]+)/i)?.[1]?.toLowerCase() ?? ""; + if (closing) { + if (tagName === "ul" || tagName === "ol") { + listIndent = Math.max(0, listIndent - (listIndentWidths.pop() ?? 4)); + return `\n\n${indentMarker(listIndent)}`; + } + return "\n\n"; + } + const width = declaredIndentWidth(tag); + if (tagName === "ul" || tagName === "ol") { + listIndentWidths.push(width); + listIndent += width; + } + const effectiveWidth = tagName === "li" ? Math.max(listIndent, width) : listIndent + width; + return `\n\n${indentMarker(effectiveWidth)}`; }) .replace(WORD_INDENT_TAG, (tag) => indentMarker(declaredIndentWidth(tag))); const withoutTags = withBoundaries.replace(HTML_TAG, (tag) => diff --git a/lineageweave/chunking.py b/lineageweave/chunking.py index 6d1b7ccbc..bec3ff9e0 100644 --- a/lineageweave/chunking.py +++ b/lineageweave/chunking.py @@ -65,8 +65,8 @@ "footer", "div", "p", - "ol", "ul", + "ol", "li", "footnote", "endnote", @@ -101,7 +101,7 @@ _LIST_ITEM_START = re.compile( r"^(?:[-*•·]\s+|[*†‡](?=\S)|(?:\d{1,3}|[A-Za-z가-힣])[.)]\s+|[①-⑳]\s+)" ) -_FOOTNOTE_START = re.compile(r"^[*†‡](?=\S)") +_FOOTNOTE_START = re.compile(r"^(?:[*†‡](?=\S)|\[\d{1,3}\]\s+\S)") def _is_footnote_block(tag: str, attrs: list[tuple[str, str | None]]) -> bool: @@ -353,10 +353,12 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None """Collect relevant text state when an HTML start tag is encountered.""" if tag == "img": src = next((value for name, value in attrs if name == "src" and value), None) - if src: - decoded = _decode_data_uri_image(src) - if decoded is not None: - self._finished.append(("image", decoded, "", None, 0, 0)) + decoded = _decode_data_uri_image(src) if src else None + if decoded is None: + return + if self._stack and not any(entry[0] in _TABLE_ROW_TAGS for entry in self._stack): + self._flush_current_buffer() + self._finished.append(("image", decoded, "", None, 0, 0)) return if tag in {"br", "w:br"} and self._stack: self._stack[-1][1].append("\n") @@ -385,11 +387,8 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None if any(entry[0] in _TABLE_ROW_TAGS for entry in self._stack): return if tag in _DOM_BLOCK_TAGS: - if self._stack and self._stack[-1][1]: - tag_name, buffer, style, _, is_footnote = self._stack[-1] - declared_width = sum(entry[3] for entry in self._stack) - self._finish_block(tag_name, buffer, style, declared_width, is_footnote) - buffer.clear() + if self._stack: + self._flush_current_buffer() style = next((value for name, value in attrs if name == "style" and value), None) is_footnote = _is_footnote_block(tag, attrs) or any( entry[4] for entry in self._stack @@ -411,6 +410,20 @@ def handle_endtag(self, tag: str) -> None: tag_name, buffer, style, _, is_footnote = self._stack.pop() self._finish_block(tag_name, buffer, style, declared_width, is_footnote) + def _flush_current_buffer(self) -> None: + """Emit direct parent text before a nested block or embedded image.""" + tag_name, buffer, style, indent_width, is_footnote = self._stack[-1] + if not buffer: + return + self._stack[-1] = (tag_name, [], style, indent_width, is_footnote) + self._finish_block( + tag_name, + buffer, + style, + sum(entry[3] for entry in self._stack), + is_footnote, + ) + def _finish_block( self, tag_name: str, diff --git a/lineageweave/post_chat.py b/lineageweave/post_chat.py index 724c2eb2a..0ad85f59c 100644 --- a/lineageweave/post_chat.py +++ b/lineageweave/post_chat.py @@ -64,6 +64,8 @@ class ChatSourceDocument: post_body: str graph_facts: tuple[str, ...] = field(default_factory=tuple) evidence_facts: tuple[str, ...] = field(default_factory=tuple) + occurred_at: str | None = None + timeline_kind: str | None = None @dataclass(frozen=True) @@ -142,7 +144,13 @@ class PostChatClient(Protocol): available: bool - def answer(self, question: str, sources: list[ChatSourceDocument]) -> ChatAnswer: + def answer( + self, + question: str, + sources: list[ChatSourceDocument], + *, + conversation_context: str = "", + ) -> ChatAnswer: """Answer ``question`` using only ``sources``, with citations. Implementations must raise if they cannot answer. Protocol stubs @@ -157,7 +165,13 @@ class NullPostChatClient: available = False - def answer(self, question: str, sources: list[ChatSourceDocument]) -> ChatAnswer: + def answer( + self, + question: str, + sources: list[ChatSourceDocument], + *, + conversation_context: str = "", + ) -> ChatAnswer: """Answer the question using the supplied source documents.""" raise RuntimeError("NullPostChatClient cannot answer; check .available first") @@ -181,6 +195,9 @@ def answer(self, question: str, sources: list[ChatSourceDocument]) -> ChatAnswer {sources_block} Question: {question} + +Conversation continuity (not source evidence; verify it against the numbered sources): +{conversation_context} """ _CODE_FENCE_PATTERN = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.DOTALL) @@ -196,6 +213,9 @@ def answer(self, question: str, sources: list[ChatSourceDocument]) -> ChatAnswer {sources_block} Question: {question} + +Conversation continuity (not source evidence; verify it against the numbered sources): +{conversation_context} """ @@ -226,9 +246,27 @@ def _render_sources_block(sources: list[ChatSourceDocument]) -> str: "raw source hints as resolved ontology assertions):\n" + "\n".join(f"- {fact}" for fact in source.evidence_facts) ) + occurred_block = f"Occurred at: {source.occurred_at}\n" if source.occurred_at else "" blocks.append( f"[Source {i}] (post_id={source.post_id})\n" - f"Title: {source.post_title}\n{body}{graph_block}{evidence_block}" + f"Title: {source.post_title}\n" + f"{occurred_block}{body}{graph_block}{evidence_block}" + ) + return "\n\n".join(blocks) + + +def render_global_ask_context( + summary: str | None, + turns: list[tuple[int, str, str]] | tuple[tuple[int, str, str], ...], +) -> str: + """Render account-owned continuity as explicitly non-evidentiary context.""" + blocks: list[str] = [] + if summary and summary.strip(): + blocks.append(f"Compressed prior context:\n{summary.strip()}") + for ordinal, question, answer in turns: + blocks.append( + f"Turn {ordinal} question: {question.strip()}\n" + f"Turn {ordinal} answer: {answer.strip()}" ) return "\n\n".join(blocks) @@ -305,10 +343,18 @@ def __init__( self._reasoning_effort = reasoning_effort self._timeout = timeout - def answer(self, question: str, sources: list[ChatSourceDocument]) -> ChatAnswer: + def answer( + self, + question: str, + sources: list[ChatSourceDocument], + *, + conversation_context: str = "", + ) -> ChatAnswer: """Answer the question using the supplied source documents.""" prompt = _CHAT_REQUEST_PROMPT_TEMPLATE.format( - sources_block=_render_sources_block(sources), question=question + sources_block=_render_sources_block(sources), + question=question, + conversation_context=conversation_context, ) body = post_json( f"{self._base_url}/v1/chat/completions", @@ -325,3 +371,36 @@ def answer(self, question: str, sources: list[ChatSourceDocument]) -> ChatAnswer if answer is None: raise ValueError("chat response did not match the required format") return answer + + def compress_context( + self, + previous_summary: str | None, + turns: list[tuple[int, str, str]], + ) -> str: + """Compress older Global Ask turns through the orchestrator boundary.""" + turn_block = "\n\n".join( + f"Turn {ordinal}\nQuestion: {question}\nAnswer: {answer}" + for ordinal, question, answer in turns + ) + prompt = ( + "Compress the prior Global Ask conversation into a short factual continuity summary. " + "Keep unresolved questions, decisions, dates, and requested follow-ups. " + "Do not add facts, names, or conclusions not present in the supplied context. " + "This is continuity context, not source evidence; return only the summary text.\n\n" + f"Existing compressed context:\n{previous_summary or '(none)'}\n\n" + f"Older turns to incorporate:\n{turn_block}" + ) + body = post_json( + f"{self._base_url}/v1/chat/completions", + { + "messages": [{"role": "user", "content": prompt}], + "mode": "auto", + "reasoning_effort": self._reasoning_effort, + }, + headers={"authorization": f"Bearer {self._api_key}"}, + timeout=self._timeout, + ) + content = body["choices"][0]["message"]["content"] + if not isinstance(content, str) or not content.strip(): + raise ValueError("Global Ask context compression returned no summary") + return content.strip() diff --git a/lineageweave/post_summary.py b/lineageweave/post_summary.py index 9e8090392..7301a1c73 100644 --- a/lineageweave/post_summary.py +++ b/lineageweave/post_summary.py @@ -716,12 +716,16 @@ def parse_summary_response(content: str) -> PostSummary | None: name = entry.get("actor_name") responsibility = entry.get("responsibility") actor_type_raw = entry.get("actor_type") - if actor_type_raw == "organization": + if actor_type_raw is None: + actor_type_code = ACTOR_TYPE_PERSON + elif actor_type_raw == "person": + actor_type_code = ACTOR_TYPE_PERSON + elif actor_type_raw == "organization": actor_type_code = ACTOR_TYPE_ORGANIZATION elif actor_type_raw == "team": actor_type_code = ACTOR_TYPE_TEAM else: - actor_type_code = ACTOR_TYPE_PERSON + continue affiliation_raw = entry.get("affiliated_organization_name") affiliated_organization_name = ( affiliation_raw.strip() diff --git a/lineageweave/relation_verification.py b/lineageweave/relation_verification.py index 509c7552a..fa058405d 100644 --- a/lineageweave/relation_verification.py +++ b/lineageweave/relation_verification.py @@ -59,6 +59,12 @@ "foundation", "the", "and", + "fictitious", + "nonexistent", + "synthetic", + "sample", + "example", + "unknown", } ) diff --git a/migrations/0051_context_scoped_organization_name_resolution.sql b/migrations/0051_context_scoped_organization_name_resolution.sql new file mode 100644 index 000000000..b200cb333 --- /dev/null +++ b/migrations/0051_context_scoped_organization_name_resolution.sql @@ -0,0 +1,46 @@ +-- ADR 0008: the same short organization name may resolve differently in +-- different post contexts. Keep only a digest of the context, never the body. +alter table organization_name_resolution + add column if not exists context_sha256 text; + +update organization_name_resolution + set context_sha256 = '' + where context_sha256 is null; + +alter table organization_name_resolution + alter column context_sha256 set default '', + alter column context_sha256 set not null; + +alter table organization_name_resolution + drop constraint if exists organization_name_resolution_pkey; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'organization_name_resolution_context_pkey' + ) then + alter table organization_name_resolution + add constraint organization_name_resolution_context_pkey + primary key (raw_organization_name, context_sha256); + end if; +end +$$; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'organization_name_resolution_context_sha256_check' + ) then + alter table organization_name_resolution + add constraint organization_name_resolution_context_sha256_check + check ( + context_sha256 = '' + or context_sha256 ~ '^[0-9a-f]{64}$' + ); + end if; +end +$$; diff --git a/migrations/0052_global_ask_context.sql b/migrations/0052_global_ask_context.sql new file mode 100644 index 000000000..7a7d6a094 --- /dev/null +++ b/migrations/0052_global_ask_context.sql @@ -0,0 +1,36 @@ +-- Account-owned Global Ask continuity. Evidence is always retrieved again; +-- these rows only retain conversation context and citation references. + +create table if not exists global_ask_session ( + global_ask_session_id uuid primary key, + user_account_id uuid not null references user_account (user_account_id) on delete cascade, + context_summary text, + context_summary_through_ordinal integer not null default 0 + check (context_summary_through_ordinal >= 0), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists global_ask_session_account_idx + on global_ask_session (user_account_id, updated_at desc); + +create table if not exists global_ask_turn ( + global_ask_session_id uuid not null + references global_ask_session (global_ask_session_id) on delete cascade, + turn_ordinal integer not null check (turn_ordinal > 0), + question_text text not null, + answer_text text not null, + created_at timestamptz not null default now(), + primary key (global_ask_session_id, turn_ordinal) +); + +create table if not exists global_ask_turn_citation ( + global_ask_session_id uuid not null, + turn_ordinal integer not null, + citation_ordinal integer not null check (citation_ordinal >= 0), + cited_post_id uuid not null references source_post (post_id) on delete cascade, + primary key (global_ask_session_id, turn_ordinal, citation_ordinal), + foreign key (global_ask_session_id, turn_ordinal) + references global_ask_turn (global_ask_session_id, turn_ordinal) + on delete cascade +); diff --git a/pyproject.toml b/pyproject.toml index 3257416b3..c3e956e1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.16.0" +version = "2.17.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_activity_stream.py b/tests/test_activity_stream.py index 16bcedb52..2efcea47f 100644 --- a/tests/test_activity_stream.py +++ b/tests/test_activity_stream.py @@ -7,28 +7,38 @@ from __future__ import annotations +import asyncio + from backend.app.activity_stream import ( publish_activity_event_sync, ticket_created_summary, ticket_status_changed_summary, + publish_operation_event, ) class _FakeStream: def __init__(self) -> None: self.entries: list[tuple[str, dict[str, str]]] = [] + self.keys: list[str] = [] def xrevrange(self, key: str, count: int = 50): del key return list(reversed(self.entries[-count:])) def xadd(self, key: str, fields: dict[str, str], maxlen=None, approximate=None): - del key, maxlen, approximate + self.keys.append(key) + del maxlen, approximate entry_id = f"1-{len(self.entries)}" self.entries.append((entry_id, dict(fields))) return entry_id +class _AsyncFakeStream(_FakeStream): + async def xadd(self, key: str, fields: dict[str, str], maxlen=None, approximate=None): + return super().xadd(key, fields, maxlen=maxlen, approximate=approximate) + + def test_ticket_created_summary_matches_the_live_api_wording() -> None: assert ticket_created_summary("Send Northridge Grid the revised quote") == ( "Ticket created: Send Northridge Grid the revised quote" @@ -63,3 +73,20 @@ def test_publish_activity_event_sync_skips_a_matching_summary() -> None: assert len(client.entries) == 1 assert client.entries[0][1]["event_type"] == "ticket_created" assert "Send Northridge Grid the revised quote" in client.entries[0][1]["summary"] + + +def test_global_ask_registers_an_account_operation_stream_event() -> None: + client = _AsyncFakeStream() + + entry_id = asyncio.run( + publish_operation_event( + client, + "acct-1", + "global_ask_completed", + "Global Ask completed with 2 cited source post(s)", + ) + ) + + assert entry_id == "1-0" + assert client.keys == ["operation:acct-1"] + assert client.entries[0][1]["actor_account_id"] == "acct-1" diff --git a/tests/test_chunking.py b/tests/test_chunking.py index d37a300cc..6fc9dfc2b 100644 --- a/tests/test_chunking.py +++ b/tests/test_chunking.py @@ -104,10 +104,25 @@ def test_chunk_by_dom_keeps_nested_table_cell_blocks_in_their_row() -> None: assert [(chunk.label, chunk.text) for chunk in chunks] == [("tr", "No. | Company")] +def test_chunk_by_dom_preserves_nested_list_order_and_depth() -> None: + chunks = chunk_by_dom( + "
  1. Outer
    • Inner
    After inner
  2. " + "
  3. Sibling
" + ) + + assert [chunk.text for chunk in chunks] == ["Outer", "Inner", "After inner", "Sibling"] + assert [chunk.indent_width for chunk in chunks] == [4, 8, 4, 4] + + + def test_chunk_by_dom_labels_markerless_footnotes() -> None: - chunks = chunk_by_dom("

Body text

*Tier 2: follow-up note

") + chunks = chunk_by_dom( + "

Body text[1]

" + "

[1] Source note

*Tier 2: follow-up note

" + ) assert [(chunk.label, chunk.text) for chunk in chunks] == [ - ("p", "Body text"), + ("p", "Body text[1]"), + ("footnote", "[1] Source note"), ("footnote", "*Tier 2: follow-up note"), ] @@ -320,6 +335,41 @@ def test_chunk_by_dom_interleaves_images_with_text_in_document_order() -> None: assert chunks[2].text == "After the picture." +def test_chunk_by_dom_interleaves_image_inside_a_block_with_text() -> None: + tiny_png_b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" + html = f'

Before the picture.After the picture.

' + + chunks = chunk_by_dom(html) + + assert [chunk.unit_type for chunk in chunks] == ["dom", "image", "dom"] + assert [chunk.text for chunk in chunks if chunk.unit_type == "dom"] == [ + "Before the picture.", + "After the picture.", + ] + + +def test_chunk_by_dom_keeps_an_inline_table_image_from_splitting_the_row() -> None: + tiny_png_b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" + html = ( + "
Before " + f'' + "AfterSecond cell
" + ) + + chunks = chunk_by_dom(html) + + assert [chunk.text for chunk in chunks if chunk.unit_type == "dom"] == [ + "Before After | Second cell", + ] + assert [chunk.unit_type for chunk in chunks].count("image") == 1 + + +def test_chunk_by_dom_does_not_split_text_for_an_undecodable_inline_image() -> None: + chunks = chunk_by_dom('

BeforeAfter

') + + assert [(chunk.unit_type, chunk.text) for chunk in chunks] == [("dom", "BeforeAfter")] + + def test_chunk_by_dom_labels_text_chunks_with_their_tag_name() -> None: html = "

A paragraph.

" chunks = chunk_by_dom(html) diff --git a/tests/test_entity_relationship_ingestion.py b/tests/test_entity_relationship_ingestion.py index 81d6fff64..e98566b24 100644 --- a/tests/test_entity_relationship_ingestion.py +++ b/tests/test_entity_relationship_ingestion.py @@ -3,7 +3,11 @@ import asyncio from contextlib import asynccontextmanager -from backend.app.entity_relationship_ingestion import ingest_post_entity_relationships +from backend.app.entity_relationship_ingestion import ( + ingest_post_entity_relationships, + merge_relationship_network_rows, +) +from lineageweave.corporate_hierarchy_resolution import CorporateEntityCandidate from lineageweave.entity_relationship_classification import OrganizationRelationship @@ -55,3 +59,41 @@ def test_relationship_ingestion_clears_rows_when_no_counterparty_remains() -> No assert conn.executed == [ ("delete from post_counterparty_entity where post_id = $1", ("post-2",)) ] + + +def test_relationship_network_merges_unique_catalog_aliases() -> None: + rows = [ + { + "counterparty_entity_name": "Synthetic Group", + "total_post_count": 1, + "relationships": [{ + "relationship_type_code": "rel_voc", + "relationship_label": "Customer", + "post_count": 1, + }], + }, + { + "counterparty_entity_name": "Synthetic Group.", + "total_post_count": 2, + "relationships": [{ + "relationship_type_code": "rel_voco", + "relationship_label": "Competitor", + "post_count": 2, + }], + }, + ] + result = merge_relationship_network_rows( + rows, + [CorporateEntityCandidate("entity-1", "Synthetic Group")], + ) + + assert result == [{ + "counterparty_entity_name": "Synthetic Group", + "corporate_entity_id": "entity-1", + "total_post_count": 3, + "relationships": [ + {"relationship_type_code": "rel_voco", "relationship_label": "Competitor", "post_count": 2}, + {"relationship_type_code": "rel_voc", "relationship_label": "Customer", "post_count": 1}, + ], + "multi_role": True, + }] diff --git a/tests/test_global_ask_sources.py b/tests/test_global_ask_sources.py index 9a41b507c..ba31767f9 100644 --- a/tests/test_global_ask_sources.py +++ b/tests/test_global_ask_sources.py @@ -1,8 +1,10 @@ from __future__ import annotations import asyncio +from datetime import datetime, timezone from backend.app.post_chat_ingestion import gather_global_chat_sources +from backend.app.main import global_ask_timeline def test_global_sources_apply_visibility_before_normalization() -> None: @@ -199,6 +201,7 @@ def test_global_sources_keep_lineage_expansion_within_requested_limit() -> None: "visibility_code": "public", "corporate_entity_id": None, "matched_in": "title", + "created_at": datetime(2026, 1, 2, tzinfo=timezone.utc), } neighbor_ids = [f"neighbor-{index:02d}" for index in range(20)] source_call: tuple[str, tuple[object, ...]] | None = None @@ -240,13 +243,14 @@ async def fetch(self, query: str, *args): assert source_call is not None _query, source_args = source_call assert source_args[2] == 4 - assert list(source_args[1]) == [ + assert list(source_args[1])[:4] == [ "anchor-post", "neighbor-00", "neighbor-01", "neighbor-02", ] - assert [source.post_id for source in sources] == list(source_args[1]) + assert len(source_args[1]) == 16 + assert [source.post_id for source in sources] == list(source_args[1])[:4] assert len(sources) == 4 @@ -281,6 +285,7 @@ def test_global_sources_expand_top_match_through_event_lineage() -> None: "visibility_code": "public", "corporate_entity_id": None, "matched_in": "title", + "created_at": datetime(2026, 1, 2, tzinfo=timezone.utc), } lineage_row = { "post_id": "event-1", @@ -288,6 +293,7 @@ def test_global_sources_expand_top_match_through_event_lineage() -> None: "post_body": "kickoff body", "visibility_code": "public", "corporate_entity_id": None, + "created_at": datetime(2026, 1, 1, tzinfo=timezone.utc), } class FakeConnection: @@ -315,6 +321,12 @@ async def fetch(self, query: str, *args): "Event Lineage: reconstructed timeline neighbor of post_id=event-2" in fact for fact in sources[1].evidence_facts ) + assert sources[0].occurred_at == "2026-01-02T00:00:00+00:00" + assert sources[0].timeline_kind == "lineage_anchor" + assert sources[1].occurred_at == "2026-01-01T00:00:00+00:00" + assert sources[1].timeline_kind == "lineage_neighbor" + timeline = global_ask_timeline(sources) + assert [event["post_id"] for event in timeline] == ["event-1", "event-2"] def test_global_sources_do_not_leak_lineage_anchor_id_when_anchor_is_invisible() -> None: @@ -358,3 +370,51 @@ async def fetch(self, query: str, *args): assert [source.post_id for source in sources] == ["visible-neighbor"] assert sources[0].evidence_facts == () + + +def test_global_sources_overfetch_before_abac_so_visible_hits_are_not_dropped() -> None: + hidden_rows = [ + { + "post_id": f"hidden-{index}", + "post_title": "Restricted match", + "post_body": "restricted body", + "visibility_code": "private", + "corporate_entity_id": "corp-other", + "matched_in": "title", + } + for index in range(3) + ] + visible_row = { + "post_id": "visible-match", + "post_title": "Authorized match", + "post_body": "authorized body", + "visibility_code": "public", + "corporate_entity_id": None, + "matched_in": "title", + } + rows_by_id = {row["post_id"]: row for row in [*hidden_rows, visible_row]} + + class FakeConnection: + async def fetch(self, query: str, *args): + if "matched_in" in query: + return [*hidden_rows, visible_row] + if "post_lineage_edge" in query: + return [] + if "array_position($2::uuid[], post_id)" in query: + return [ + rows_by_id[post_id] + for post_id in args[1] + if rows_by_id[post_id]["visibility_code"] == "public" + ][: args[2]] + return [] + + sources = asyncio.run( + gather_global_chat_sources( + FakeConnection(), + lambda row: row["visibility_code"] == "public", + question="match", + limit=1, + ) + ) + + assert [source.post_id for source in sources] == ["visible-match"] diff --git a/tests/test_migration_replay.py b/tests/test_migration_replay.py index 29fe1c176..29098f0a5 100644 --- a/tests/test_migration_replay.py +++ b/tests/test_migration_replay.py @@ -33,3 +33,26 @@ def test_migrate_sh_replays_leftover_pair_migration_on_existing_volumes() -> Non ).read_text(encoding="utf-8") assert "0012_*" in script + + +def test_migrate_sh_replays_context_scoped_name_cache_migration() -> None: + """Existing volumes must receive the context-scoped resolution key.""" + script = ( + Path(__file__).resolve().parents[1] + / "docker" + / "postgres-init" + / "migrate.sh" + ).read_text(encoding="utf-8") + + assert "0051_*" in script + + +def test_migrate_sh_replays_global_ask_context_migration() -> None: + script = ( + Path(__file__).resolve().parents[1] + / "docker" + / "postgres-init" + / "migrate.sh" + ).read_text(encoding="utf-8") + + assert "0052_*" in script diff --git a/tests/test_organization_name_resolution_ingestion.py b/tests/test_organization_name_resolution_ingestion.py index afdfa8f32..5a41a5db1 100644 --- a/tests/test_organization_name_resolution_ingestion.py +++ b/tests/test_organization_name_resolution_ingestion.py @@ -14,7 +14,7 @@ def __init__(self, cached: dict[str, str] | None = None) -> None: self.cached = cached self.executed: list[tuple[str, tuple[object, ...]]] = [] - async def fetchrow(self, _query: str, _raw_name: str): + async def fetchrow(self, _query: str, _raw_name: str, _context_sha256: str): return self.cached async def execute(self, query: str, *args: object) -> str: @@ -46,6 +46,18 @@ def test_cached_verified_name_is_returned_without_resolution() -> None: assert conn.executed == [] +def test_cache_key_includes_context_digest(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ingestion, "resolve_and_verify_organization_name", lambda *_args: _resolution(STATUS_CORROBORATED)) + conn = _Connection() + result = asyncio.run(ingestion.resolve_organization_name(conn, _Client(), _Client(), "AGP", "different context")) + + assert result == "Aurora Grid Power" + query, args = conn.executed[0] + assert "context_sha256" in query + assert args[0] == "AGP" + assert len(args[1]) == 64 + + def test_cached_unverified_name_stays_raw() -> None: conn = _Connection({"resolved_organization_name": "Aurora Grid Power", "verification_status_code": STATUS_UNCORROBORATED}) result = asyncio.run(ingestion.resolve_organization_name(conn, _UnavailableClient(), _Client(), "AGP", "context")) diff --git a/tests/test_post_chat.py b/tests/test_post_chat.py index f401e50d7..7c7cd46be 100644 --- a/tests/test_post_chat.py +++ b/tests/test_post_chat.py @@ -37,6 +37,7 @@ cited_post_summaries, normalize_chat_question, parse_chat_response, + render_global_ask_context, ) @@ -200,6 +201,7 @@ def test_chat_render_includes_persisted_graph_facts_with_source_evidence() -> No 'node_corporate_entity "Demo Corp" [evidence_post_id=post-graph]', ), evidence_facts=("source project code=PROJECT-HINT [hint_only]",), + occurred_at="2026-01-01T00:00:00+00:00", ) rendered = _render_sources_block([source]) @@ -209,6 +211,7 @@ def test_chat_render_includes_persisted_graph_facts_with_source_evidence() -> No assert "evidence_post_id=post-graph" in rendered assert "Persisted source/semantic evidence" in rendered assert "PROJECT-HINT" in rendered + assert "Occurred at: 2026-01-01T00:00:00+00:00" in rendered def test_graph_facts_are_hydrated_from_visible_evidence_posts(monkeypatch) -> None: @@ -354,3 +357,36 @@ def fake_post_json(url, payload, *, headers, timeout): assert observed["payload"]["reasoning_effort"] == "auto" assert observed["payload"]["mode"] == "auto" assert "CITED SOURCES" in observed["payload"]["messages"][0]["content"] + + +def test_global_ask_context_is_explicitly_non_evidentiary() -> None: + rendered = render_global_ask_context( + "Earlier synthetic decision", + ((3, "Synthetic question", "Synthetic answer"),), + ) + + assert "Compressed prior context" in rendered + assert "Turn 3 question: Synthetic question" in rendered + assert "Turn 3 answer: Synthetic answer" in rendered + + +def test_contextual_orchestrator_compresses_global_ask_turns(monkeypatch) -> None: + observed = {} + + def fake_post_json(url, payload, *, headers, timeout): + observed["url"] = url + observed["payload"] = payload + return {"choices": [{"message": {"content": "Synthetic compressed context"}}]} + + monkeypatch.setattr("lineageweave.post_chat.post_json", fake_post_json) + summary = ContextualOrchestratorPostChatClient( + "https://orchestrator.test", "token" + ).compress_context( + "Earlier synthetic context", + [(1, "Synthetic question", "Synthetic answer")], + ) + + assert summary == "Synthetic compressed context" + assert observed["url"].endswith("/v1/chat/completions") + assert observed["payload"]["mode"] == "auto" + assert "Synthetic question" in observed["payload"]["messages"][0]["content"] diff --git a/tests/test_post_summary.py b/tests/test_post_summary.py index 34abac38a..fe5f569df 100644 --- a/tests/test_post_summary.py +++ b/tests/test_post_summary.py @@ -241,6 +241,17 @@ def test_missing_actor_type_defaults_to_person() -> None: assert summary.roles_and_responsibilities[0].affiliated_organization_name is None +def test_explicit_unknown_actor_type_is_dropped() -> None: + summary = parse_summary_response( + '{"korean_summary": "요약", "roles_and_responsibilities": [{' + '"actor_name": "Synthetic Team", "responsibility": "검토", ' + '"actor_type": "department"}]}' + ) + + assert summary is not None + assert summary.roles_and_responsibilities == () + + def test_missing_korean_summary_returns_none() -> None: content = '{"key_events": [], "roles_and_responsibilities": []}' assert parse_summary_response(content) is None diff --git a/tests/test_relation_verification.py b/tests/test_relation_verification.py index f515d5d25..5068bfcba 100644 --- a/tests/test_relation_verification.py +++ b/tests/test_relation_verification.py @@ -133,6 +133,21 @@ def test_legal_suffix_alone_is_not_corroboration() -> None: ) +def test_generic_nonexistence_words_are_not_corroboration() -> None: + """Search hits for generic fixture wording do not verify an org name.""" + assert ( + corroborating_evidence_url( + "Zzqxvthorp Fictitious Nonexistent Org", + { + "url": "https://www.example.com/about-fictitious-organizations", + "title": "Fictitious organizations", + "content": "A generic example about nonexistent organizations.", + }, + ) + is None + ) + + def test_hangul_org_name_token_is_corroboration() -> None: assert ( corroborating_evidence_url( diff --git a/uv.lock b/uv.lock index 5b2f39e62..7062700b7 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.16.0" +version = "2.17.0" source = { editable = "." } dependencies = [ { name = "certifi" },