From d991bfbaaa3fa3dac99c739c6c68f6c2aed6a3ff Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 00:54:54 +0900 Subject: [PATCH 1/4] feat(ask): ground answers at knowledge cutoff --- backend/app/global_ask_queue.py | 114 ++++++++++---- backend/app/main.py | 30 +++- backend/app/post_chat_ingestion.py | 132 ++++++++++++++-- backend/app/source_post_revision.py | 39 ++++- backend/tests/test_api.py | 28 ++++ docs/adr/0216-global-ask-knowledge-cutoff.md | 50 ++++++ docs/adr/README.md | 1 + .../GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md | 18 +++ docs/product-requirements.md | 14 ++ docs/product-technical-gap-baseline.md | 11 +- docs/storybook-inventory.md | 1 + frontend/src/App.css | 36 +++++ frontend/src/App.tsx | 83 +++++++--- frontend/src/AskAgentCutoff.stories.tsx | 62 ++++++++ frontend/src/AskAgentPanel.test.tsx | 24 ++- frontend/src/api.ts | 22 ++- frontend/src/i18n.ts | 32 ++++ lineageweave/post_chat.py | 62 +++++++- .../0212_global_ask_knowledge_cutoff.sql | 21 +++ .../0212_global_ask_knowledge_cutoff.sql | 1 + tests/test_global_ask_cutoff.py | 145 ++++++++++++++++++ tests/test_global_ask_queue.py | 1 + tests/test_global_ask_sources.py | 2 +- tests/test_migration_replay.py | 14 ++ 24 files changed, 858 insertions(+), 85 deletions(-) create mode 100644 docs/adr/0216-global-ask-knowledge-cutoff.md create mode 100644 docs/doctoring/GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md create mode 100644 frontend/src/AskAgentCutoff.stories.tsx create mode 100644 migrations/0212_global_ask_knowledge_cutoff.sql create mode 100644 migrations/rollback/0212_global_ask_knowledge_cutoff.sql create mode 100644 tests/test_global_ask_cutoff.py diff --git a/backend/app/global_ask_queue.py b/backend/app/global_ask_queue.py index 5c08eeb75..1c7433f6c 100644 --- a/backend/app/global_ask_queue.py +++ b/backend/app/global_ask_queue.py @@ -21,7 +21,7 @@ import logging import time from collections.abc import Callable -from datetime import date +from datetime import date, datetime from typing import Any import asyncpg @@ -46,8 +46,10 @@ from lineageweave.post_chat import ( ChatSourceDocument, PostChatClient, + ask_grounding_status, cited_post_evidence, cited_post_summaries, + historical_body_limitations, ) from lineageweave.temporal_expressions import resolve_korean_relative_time @@ -106,6 +108,7 @@ async def enqueue_global_ask_job( requesting_account_id: str, question_text: str, verify_external_requested: bool, + knowledge_cutoff: datetime | None, corporate_entity_ids: frozenset[str], process_unit_ids: frozenset[str], ) -> str: @@ -119,12 +122,14 @@ async def enqueue_global_ask_job( job_id = await conn.fetchval( """ insert into global_ask_job - (requesting_account_id, question_text, verify_external_requested) - values ($1, $2, $3) returning global_ask_job_id + (requesting_account_id, question_text, verify_external_requested, + knowledge_cutoff) + values ($1, $2, $3, $4) returning global_ask_job_id """, requesting_account_id, question_text, verify_external_requested, + knowledge_cutoff, ) await conn.executemany( """ @@ -290,6 +295,7 @@ async def compute_global_ask_answer( embedding_client: EmbeddingClient | None = None, verify_external: bool = False, claim_verification_client: ClaimVerificationClient | None = None, + knowledge_cutoff: datetime | None = None, ) -> dict[str, Any]: """Assemble one complete Ask answer payload from authorized evidence. @@ -325,6 +331,7 @@ def can_see(row: asyncpg.Record) -> bool: question_embedding=question_embedding, today=today, embedding_client=NullEmbeddingClient(), + knowledge_cutoff=knowledge_cutoff, ) except Exception as exc: log_internal_fault("global_ask", exc) @@ -333,11 +340,19 @@ def can_see(row: asyncpg.Record) -> bool: status.HTTP_503_SERVICE_UNAVAILABLE, "Ask Agent is unavailable: authorized evidence could not be assembled", ) from exc + cutoff_text = knowledge_cutoff.isoformat() if knowledge_cutoff else None + grounding_status = ask_grounding_status(sources, cutoff_text) + limitations = historical_body_limitations(sources) if knowledge_cutoff else [] + usable_sources = ( + [source for source in sources if not source.historical_body_unavailable] + if knowledge_cutoff + else sources + ) verification_client = claim_verification_client or NullClaimVerificationClient() - if not sources: + if not usable_sources: verification_status, external_claims = await _verify_public_claims( question_text, - sources, + usable_sources, [], verify_external=verify_external, client=verification_client, @@ -347,18 +362,29 @@ def can_see(row: asyncpg.Record) -> bool: "answer_text": "", "cited_post_ids": [], "cited_posts": [], - "source_post_ids": [], + "source_post_ids": [source.post_id for source in sources], "cited_post_evidence": [], "lineage_graph": {"nodes": [], "edges": [], "truncated": False}, "cited_post_images": [], "external_verification_status": verification_status, "external_claims": [claim.to_payload() for claim in external_claims], - "next_action": "No authorized source posts are available for this question.", + "next_action": ( + "Review unavailable historical channels before relying on this cutoff answer." + if limitations + else "No authorized source posts are available for this question." + ), "delivery": delivery, + "knowledge_cutoff": cutoff_text, + "grounding_status": grounding_status, + "limitations": limitations, } try: answer = await asyncio.to_thread( - chat_client.answer, _temporally_grounded_question(question_text, today=today), sources + chat_client.answer, + _temporally_grounded_question( + question_text, today=today, knowledge_cutoff=knowledge_cutoff + ), + usable_sources, ) except (HttpClientError, OSError) as exc: # Known transport/provider failure. Same generic 503 text on every @@ -391,16 +417,27 @@ def can_see(row: asyncpg.Record) -> bool: cited_ids = list(answer.cited_post_ids) verification_status, external_claims = await _verify_public_claims( question_text, - sources, + usable_sources, cited_ids, verify_external=verify_external, client=verification_client, ) - async with pool.acquire() as conn: - lineage_graph = await lineage_graphs_for_posts(conn, can_see, cited_ids) - images = await cited_post_images(conn, cited_ids) - cited_posts = cited_post_summaries(sources, cited_ids) - cited_evidence = cited_post_evidence(sources, cited_ids) + if knowledge_cutoff is None: + async with pool.acquire() as conn: + lineage_graph = await lineage_graphs_for_posts(conn, can_see, cited_ids) + images = await cited_post_images(conn, cited_ids) + else: + lineage_graph = {"nodes": [], "edges": [], "truncated": False} + images = [] + cited_posts = cited_post_summaries(usable_sources, cited_ids) + cited_evidence = cited_post_evidence(usable_sources, cited_ids) + next_action = _verification_next_action(verification_status) + if knowledge_cutoff is not None: + next_action = ( + "Review unavailable historical channels before relying on this cutoff answer." + if limitations + else "Compare these cutoff-grounded citations with live evidence next." + ) return { "answer_text": answer.answer_text, "cited_post_ids": cited_ids, @@ -412,11 +449,19 @@ def can_see(row: asyncpg.Record) -> bool: "delivery": build_ask_delivery(answer.answer_text, cited_posts, cited_evidence), "external_verification_status": verification_status, "external_claims": [claim.to_payload() for claim in external_claims], - "next_action": _verification_next_action(verification_status), + "next_action": next_action, + "knowledge_cutoff": cutoff_text, + "grounding_status": grounding_status, + "limitations": limitations, } -def _temporally_grounded_question(question_text: str, *, today: date | None = None) -> str: +def _temporally_grounded_question( + question_text: str, + *, + today: date | None = None, + knowledge_cutoff: datetime | None = None, +) -> str: """Restate a resolved relative-time window inside the question. Retrieval already scopes sources to the resolved window, but the @@ -428,19 +473,26 @@ def _temporally_grounded_question(question_text: str, *, today: date | None = No """ today = today or _seoul_today() window = resolve_korean_relative_time(question_text, today=today) - if window is None: - return question_text - start_date, end_date = window - # Phrasing matters: an earlier clause that only named the window was - # read by the model as the reference point ("now"), which re-subtracted - # the offset and looked for events seven further months back. Anchor - # today's date and equate the expression to the window outright. - return ( - f"{question_text}\n(오늘은 {today.isoformat()}입니다. 질문의 상대 시점 표현은 " - f"{start_date.isoformat()}부터 {end_date.isoformat()}까지의 기간을 가리킵니다. " - "제공된 소스 게시물은 모두 이 기간에 작성된 것이므로, 이 기간의 일을 " - "이 소스들로 답하십시오.)" - ) + grounded = question_text + if window is not None: + start_date, end_date = window + # Phrasing matters: an earlier clause that only named the window was + # read by the model as the reference point ("now"), which re-subtracted + # the offset and looked for events seven further months back. Anchor + # today's date and equate the expression to the window outright. + grounded = ( + f"{question_text}\n(오늘은 {today.isoformat()}입니다. 질문의 상대 시점 표현은 " + f"{start_date.isoformat()}부터 {end_date.isoformat()}까지의 기간을 가리킵니다. " + "제공된 소스 게시물은 모두 이 기간에 작성된 것이므로, 이 기간의 일을 " + "이 소스들로 답하십시오.)" + ) + if knowledge_cutoff is not None: + grounded += ( + f"\n(Knowledge cutoff: {knowledge_cutoff.isoformat()}. Every numbered " + "source body is the retained revision available by this cutoff. Do not " + "claim that later evidence was known at the cutoff.)" + ) + return grounded async def process_global_ask_job( @@ -465,7 +517,8 @@ async def process_global_ask_job( """ update global_ask_job set job_status_code = $2, updated_at = now() where global_ask_job_id = $1 and job_status_code = $3 - returning requesting_account_id, question_text, verify_external_requested + returning requesting_account_id, question_text, verify_external_requested, + knowledge_cutoff """, job_id, RUNNING, @@ -501,6 +554,7 @@ async def process_global_ask_job( embedding_client=embedding_factory(), verify_external=bool(row["verify_external_requested"]), claim_verification_client=claim_verification_factory(), + knowledge_cutoff=row["knowledge_cutoff"], ), timeout=JOB_DEADLINE_SECONDS, ) diff --git a/backend/app/main.py b/backend/app/main.py index 6feeacff4..ebbe269d4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2983,6 +2983,7 @@ class GlobalAskRequest(BaseModel): question: str verify_external: bool = False + knowledge_cutoff: str | None = None @app.get("/api/posts/{post_id}/chat") @@ -3128,19 +3129,36 @@ async def ask_agent( if not question: raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "question is required") _require_post_read(account) - if not _post_chat_client().available: - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "Ask Agent is unavailable. Ask an administrator to configure the analysis service, " - "then retry.", - ) + knowledge_cutoff = None + if request.knowledge_cutoff is not None: + try: + knowledge_cutoff = parse_as_of_clock(request.knowledge_cutoff) + except ValueError as exc: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, + "knowledge_cutoff must be an ISO-8601 timestamp", + ) from exc async with pool.acquire() as conn: + if knowledge_cutoff is not None and knowledge_cutoff > await conn.fetchval( + "select now()" + ): + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, + "knowledge_cutoff must be at or before the database clock", + ) + if not _post_chat_client().available: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Ask Agent is unavailable. Ask an administrator to configure the analysis service, " + "then retry.", + ) job_id = await enqueue_global_ask_job( conn, valkey, requesting_account_id=account.user_account_id, question_text=question, verify_external_requested=request.verify_external, + knowledge_cutoff=knowledge_cutoff, corporate_entity_ids=account.corporate_entity_ids, process_unit_ids=account.process_unit_ids, ) diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index c74fdfeea..249a2dd80 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -53,6 +53,7 @@ from .config import load_settings from .knowledge_graph import hydrate_related_nodes, load_visible_subgraph from .post_eligibility import SOURCE_POST_ELIGIBILITY_SQL +from .source_post_revision import fetch_known_at_revisions @dataclass(frozen=True) @@ -82,6 +83,7 @@ async def _normalize_post_body_text( async def _graph_facts_for_posts( conn: asyncpg.Connection, visible_post_ids: list[str], + knowledge_cutoff: datetime | None = None, ) -> dict[str, tuple[str, ...]]: """Render graph facts under each visible post that evidences them. @@ -91,7 +93,7 @@ async def _graph_facts_for_posts( also prevents a fact evidenced by one source from being rendered beneath a different source and then cited as though that source supported it. """ - if not visible_post_ids: + if not visible_post_ids or knowledge_cutoff is not None: return {} edge_rows = await conn.fetch( """ @@ -228,7 +230,9 @@ def _source_hint_facts(row: Any) -> tuple[str, ...]: async def _semantic_facts_for_posts( - conn: asyncpg.Connection, post_ids: list[str] + conn: asyncpg.Connection, + post_ids: list[str], + knowledge_cutoff: datetime | None = None, ) -> dict[str, tuple[str, ...]]: """Load persisted project/role/Keyman facts for already-visible posts.""" if not post_ids: @@ -244,6 +248,7 @@ async def _semantic_facts_for_posts( || ' [provenance=post_project_mention]' as fact from post_project_mention where post_id = any($1::uuid[]) + and ($2::timestamptz is null or created_at <= $2) union all select post_id::text as post_id, 'actor: ' || left(actor_name, 200) @@ -252,6 +257,7 @@ async def _semantic_facts_for_posts( || ' [provenance=post_summary_role]' as fact from post_summary_role where post_id = any($1::uuid[]) + and $2::timestamptz is null union all select mention.post_id::text as post_id, 'Keyman mention: ' || left(person.person_name, 200) @@ -260,9 +266,11 @@ async def _semantic_facts_for_posts( from post_person_mention mention join cataloged_person person on person.person_id = mention.person_id where mention.post_id = any($1::uuid[]) + and $2::timestamptz is null order by post_id, fact """, post_ids, + knowledge_cutoff, ) facts: dict[str, list[str]] = {} for row in rows: @@ -498,6 +506,7 @@ async def gather_global_chat_sources( question_embedding: tuple[list[float], str, float] | None = None, limit: int = 4, today: date | None = None, + knowledge_cutoff: datetime | None = None, ) -> list[ChatSourceDocument]: """Assemble a bounded, ABAC-filtered source set for Global Ask. @@ -517,6 +526,10 @@ async def gather_global_chat_sources( Embedding candidates use maximum cosine similarity with exact model and dimension agreement. Persisted semantic/KG evidence remains available when that channel is unavailable; title/body lexical fallback does not. + A cutoff instead retrieves retained revisions plus timestamped project and + ontology-edge evidence. Current-only embeddings, roles, Keymen, graph + labels, lineage, images, and source hints are excluded rather than + back-projected into history. """ if limit <= 0: return [] @@ -530,7 +543,10 @@ async def gather_global_chat_sources( if not (question and question.strip()): return [] supplied_question_embedding = question_embedding is not None - if question_embedding is None: + if knowledge_cutoff is not None: + question_embedding = None + supplied_question_embedding = False + if question_embedding is None and knowledge_cutoff is None: question_embedding = await prepare_global_question_embedding( question, embedding_client ) @@ -549,7 +565,68 @@ async def gather_global_chat_sources( ) # Safe SQL: the only interpolation is the repository-owned eligibility # expression; all request and model values remain asyncpg parameters. - candidate_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + if knowledge_cutoff is not None: + candidate_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f""" + with evidence_query as ( + select websearch_to_tsquery('simple', $1) as terms + ), evidence_post_candidates as ( + select revision.post_id + from source_post_revision revision, evidence_query query + where revision.written_at <= $2 + and (revision.superseded_at is null or revision.superseded_at > $2) + and to_tsvector( + 'simple', + coalesce(revision.post_title, '') || ' ' || + coalesce(revision.post_body, '') + ) @@ query.terms + union + select project.post_id + from post_project_mention project, evidence_query query + where project.created_at <= $2 + and to_tsvector( + 'simple', + coalesce(project.project_name, '') || ' ' || + coalesce(project.evidence_text, '') || ' ' || + coalesce(project.ontology_iri, '') + ) @@ query.terms + union + select evidence.evidence_post_id + from knowledge_graph_edge edge + join knowledge_graph_edge_evidence evidence + on evidence.knowledge_graph_edge_id = edge.knowledge_graph_edge_id + where edge.created_at <= $2 + and edge.edge_type_code = any($3::text[]) + ) + select 'evidence'::text as candidate_channel, candidate.post_id, + row_number() over ( + order by coalesce(post.event_occurred_at, post.created_at) desc, + candidate.post_id desc + ) as channel_rank + from evidence_post_candidates candidate + join source_post post on post.post_id = candidate.post_id + where post.created_at <= $2 + and (post.visibility_code = 'public' + or (post.corporate_entity_id::text = any($4::text[]) + and (cardinality($5::text[]) = 0 + or post.process_unit_id::text = any($5::text[])))) + and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} + and ($6::date is null or (coalesce(post.event_occurred_at, post.created_at) at time zone 'Asia/Seoul')::date >= $6) + and ($7::date is null or (coalesce(post.event_occurred_at, post.created_at) at time zone 'Asia/Seoul')::date <= $7) + order by channel_rank + limit $8 + """, + question, + knowledge_cutoff, + _ontology_lookup_codes_in_question(question), + list(authorized_corporate_entity_ids), + list(authorized_process_unit_ids), + resolved_time_range[0] if resolved_time_range else None, + resolved_time_range[1] if resolved_time_range else None, + limit, + ) + else: + candidate_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli f""" with question_vector as ( select ordinality - 1 as dimension_index, dimension_value @@ -755,7 +832,7 @@ async def gather_global_chat_sources( # cannot each pull a separate lineage chain into the bounded context. lineage_neighbor_ids: list[str] = [] lineage_anchor_id = candidate_ids[0] if candidate_ids else None - if lineage_anchor_id: + if lineage_anchor_id and knowledge_cutoff is None: lineage_rows = await conn.fetch( "select child_post_id as other_id from post_lineage_edge where parent_post_id = $1 " "union select parent_post_id as other_id from post_lineage_edge where child_post_id = $1", @@ -771,7 +848,7 @@ async def gather_global_chat_sources( candidate_ids = list( dict.fromkeys([lineage_anchor_id, *lineage_neighbor_ids, *candidate_ids[1:]]) )[:limit] - else: + elif not lineage_anchor_id: candidate_ids = [] lineage_neighbor_id_set = frozenset(lineage_neighbor_ids) @@ -785,7 +862,7 @@ async def gather_global_chat_sources( source_process_unit_name, source_sales_pool_code, source_sales_pool_name, source_customer_code, source_customer_name, source_project_code, source_project_name, - created_at, event_occurred_at + created_at, updated_at, event_occurred_at from source_post where (visibility_code = 'public' or (corporate_entity_id::text = any($1::text[]) @@ -793,6 +870,7 @@ async def gather_global_chat_sources( or process_unit_id::text = any($2::text[])))) and source_post.post_id = any($3::uuid[]) and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')} + and ($7::timestamptz is null or source_post.created_at <= $7) and ($5::date is null or (coalesce(event_occurred_at, created_at) at time zone 'Asia/Seoul')::date >= $5) and ($6::date is null or (coalesce(event_occurred_at, created_at) at time zone 'Asia/Seoul')::date <= $6) order by array_position($3::uuid[], post_id) nulls last, @@ -805,6 +883,7 @@ async def gather_global_chat_sources( limit, resolved_time_range[0] if resolved_time_range else None, resolved_time_range[1] if resolved_time_range else None, + knowledge_cutoff, ) visible_rows = [ row @@ -813,19 +892,30 @@ async def gather_global_chat_sources( ][:limit] visible_ids = [str(row["post_id"]) for row in visible_rows] anchor_is_visible = lineage_anchor_id in visible_ids - semantic_facts = await _semantic_facts_for_posts(conn, visible_ids) - graph_facts = await _graph_facts_for_posts(conn, visible_ids) + revisions = await fetch_known_at_revisions(conn, visible_ids, knowledge_cutoff) if knowledge_cutoff else {} + semantic_facts = await _semantic_facts_for_posts(conn, visible_ids, knowledge_cutoff) + graph_facts = await _graph_facts_for_posts(conn, visible_ids, knowledge_cutoff) remaining_graph_facts = 16 time_filter_active = resolved_time_range is not None sources: list[ChatSourceDocument] = [] for index, row in enumerate(visible_rows): - normalized_body = await _normalize_post_body_text(row["post_body"], vision_client) + post_id = str(row["post_id"]) + revision = revisions.get(post_id) if knowledge_cutoff else None + historical_body_unavailable = knowledge_cutoff is not None and revision is None + source_title = ( + revision["post_title"] + if revision is not None + else ("Historical body unavailable" if historical_body_unavailable else row["post_title"]) + ) + source_body = revision["post_body"] if revision is not None else ( + "" if historical_body_unavailable else row["post_body"] + ) + normalized_body = await _normalize_post_body_text(source_body, vision_client) if len(normalized_body) > 4000: normalized_body = ( normalized_body[:4000] + "\n[Source body truncated for Global Ask; open the cited post for the full body.]" ) - post_id = str(row["post_id"]) lineage_fact = ( (f"Event Lineage: reconstructed timeline neighbor of post_id={lineage_anchor_id}",) if post_id in lineage_neighbor_id_set and anchor_is_visible @@ -846,13 +936,29 @@ async def gather_global_chat_sources( sources.append( source_type( post_id, - row["post_title"], + source_title, normalized_body, graph_facts=post_graph_facts, - evidence_facts=_source_hint_facts(row) + evidence_facts=( + () if knowledge_cutoff is not None else _source_hint_facts(row) + ) + semantic_facts.get(post_id, ()) + lineage_fact + time_axis_evidence_fact(row, time_filter_active=time_filter_active), + source_post_revision_id=( + revision["source_post_revision_id"] if revision is not None else None + ), + evidence_available_at=(revision["written_at"] if revision is not None else None), + knowledge_cutoff=(knowledge_cutoff.isoformat() if knowledge_cutoff else None), + live_changed_after_cutoff=( + knowledge_cutoff is not None and row["updated_at"] > knowledge_cutoff + ), + historical_body_unavailable=historical_body_unavailable, + unavailable_channels=( + ("historical_body", "semantic_role", "semantic_keyman", "knowledge_graph", "lineage", "image") + if historical_body_unavailable + else (("semantic_role", "semantic_keyman", "knowledge_graph", "lineage", "image") if knowledge_cutoff else ()) + ), **source_arguments, ) ) diff --git a/backend/app/source_post_revision.py b/backend/app/source_post_revision.py index 489f6f486..3b51e09d3 100644 --- a/backend/app/source_post_revision.py +++ b/backend/app/source_post_revision.py @@ -72,7 +72,7 @@ async def fetch_known_at_revision( cutoff label when no revision covers the clock. """ row = await conn.fetchrow( - "select post_title, post_body, written_at " + "select source_post_revision_id, post_title, post_body, written_at " "from source_post_revision " "where post_id = $1 " "and written_at <= $2 " @@ -85,8 +85,45 @@ async def fetch_known_at_revision( if row is None: return None return { + "source_post_revision_id": str(row["source_post_revision_id"]), "post_title": row["post_title"], "post_body": row["post_body"], "written_at": _iso(row["written_at"]), "as_of": _iso(as_of), } + + +async def fetch_known_at_revisions( + conn: "asyncpg.Connection", + post_ids: list[str], + as_of: datetime, +) -> dict[str, dict[str, str]]: + """Batch-load the retained revision covering ``as_of`` for each post. + + Missing posts stay absent so callers can report an honest historical-body + limitation without substituting the live title or body. + """ + + if not post_ids: + return {} + rows = await conn.fetch( + "select distinct on (post_id) post_id, source_post_revision_id, " + "post_title, post_body, written_at " + "from source_post_revision " + "where post_id = any($1::uuid[]) " + "and written_at <= $2 " + "and (superseded_at is null or superseded_at > $2) " + "order by post_id, written_at desc", + post_ids, + as_of, + ) + return { + str(row["post_id"]): { + "source_post_revision_id": str(row["source_post_revision_id"]), + "post_title": row["post_title"], + "post_body": row["post_body"], + "written_at": _iso(row["written_at"]), + "as_of": _iso(as_of), + } + for row in rows + } diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 928e9959f..58d6413d0 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -196,6 +196,11 @@ / "migrations" / "0211_global_ask_public_verification.sql" ) +_GLOBAL_ASK_KNOWLEDGE_CUTOFF_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0212_global_ask_knowledge_cutoff.sql" +) _LEFTOVER_MAP_AXIS_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" @@ -371,6 +376,8 @@ def seeded_db(demo_analyst_token): cur.execute(_GLOBAL_ASK_JOB_MIGRATION.read_text()) cur.execute(_GLOBAL_ASK_SCOPE_MIGRATION.read_text()) cur.execute(_GLOBAL_ASK_PUBLIC_VERIFICATION_MIGRATION.read_text()) + cur.execute(_GLOBAL_ASK_KNOWLEDGE_CUTOFF_MIGRATION.read_text()) + cur.execute(_GLOBAL_ASK_KNOWLEDGE_CUTOFF_MIGRATION.read_text()) cur.execute(_EVENT_OCCURRED_AT_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_AXIS_MIGRATION.read_text()) cur.execute(_CHANNEL_EVIDENCE_MIGRATION.read_text()) @@ -5061,6 +5068,27 @@ def test_ask_rejects_an_empty_question(client, demo_analyst_token, seeded_db) -> assert response.status_code == 422 +def test_ask_rejects_invalid_or_future_knowledge_cutoffs( + client, demo_analyst_token, seeded_db +) -> None: + """The HTTP trust boundary accepts only a valid clock no later than DB now.""" + + headers = {"Authorization": f"Bearer {demo_analyst_token}"} + invalid = client.post( + "/api/ask", + json={"question": "What was known?", "knowledge_cutoff": "not-a-clock"}, + headers=headers, + ) + future = client.post( + "/api/ask", + json={"question": "What was known?", "knowledge_cutoff": "2999-01-01T00:00:00Z"}, + headers=headers, + ) + + assert invalid.status_code == 422 + assert future.status_code == 422 + + def test_ask_is_unavailable_without_orchestrator_credentials( client, demo_analyst_token, seeded_db, monkeypatch ) -> None: diff --git a/docs/adr/0216-global-ask-knowledge-cutoff.md b/docs/adr/0216-global-ask-knowledge-cutoff.md new file mode 100644 index 000000000..484fc4403 --- /dev/null +++ b/docs/adr/0216-global-ask-knowledge-cutoff.md @@ -0,0 +1,50 @@ +# ADR 0216: Global Ask uses retained revisions at a knowledge cutoff + +## Status + +Accepted + +## Context + +Global Ask previously answered only from live source bodies. Filtering posts by +their creation clock does not establish what body or derived semantic evidence +was available at an earlier instant. PROV-O distinguishes an entity from its +specializations and derivations, while OWL-Time defines instants and intervals; +therefore an as-of answer needs a recorded revision interval, not a rewritten +live body presented as historical evidence. + +## Decision + +`POST /api/ask` accepts an optional `knowledge_cutoff` instant no later than the +database clock. The async job persists that instant. Retrieval applies ABAC, +source eligibility, creation/event time, and the cutoff before its candidate +limit, then substitutes the `source_post_revision` whose half-open availability +interval contains the cutoff. + +When no retained revision covers the instant, the response records +`historical_body_unavailable` and does not send the live body to +contextual-orchestrator. Current-only role, Keyman, graph-label, embedding, +image, and Event Lineage projections are excluded until their stores expose a +compatible system-time contract. Timestamped project and ontology-edge +evidence may nominate a post only when their recorded creation time is not +later than the cutoff; the answer still cites the retained source revision. + +Responses expose the cutoff, full/partial grounding status, retained revision +identity and availability time, later-live-change status, and limitations. +Omitting the cutoff preserves the existing live request and response behavior. + +## Consequences + +- A historical answer cannot silently quote a later rewrite. +- Missing historical bodies and semantic channels remain explicit limitations. +- Historical graph/image projections stay unavailable instead of being + reconstructed from current state. +- MCP parity remains a separate delivery requirement on the shared Ask contract. + +## References + +Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV +ontology*. World Wide Web Consortium. https://www.w3.org/TR/prov-o/ + +Cox, S., & Little, C. (Eds.). (2020). *Time ontology in OWL*. World Wide Web +Consortium. https://www.w3.org/TR/owl-time/ diff --git a/docs/adr/README.md b/docs/adr/README.md index c6bcf63d9..4b452944f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -19,6 +19,7 @@ decision from them. | [`storybook-inventory.md`](../storybook-inventory.md) | [0118](0118-uiux-standard-guide-v3-design-overhaul.md), [0184](0184-ontology-provenance-explorer.md) | | [`POSTGRESQL_CONCURRENCY_REFERENCES.md`](../doctoring/POSTGRESQL_CONCURRENCY_REFERENCES.md) | [0204](0204-analysis-run-short-transaction-delivery.md), [0213](0213-global-ask-embedding-pool-release.md) | | [`GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md`](../doctoring/GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md) | [0215](0215-global-ask-public-claim-verification.md) | +| [`GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md`](../doctoring/GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md) | [0216](0216-global-ask-knowledge-cutoff.md) | | [`operability/http-concurrency-evidence.md`](../operability/http-concurrency-evidence.md) | [0204](0204-analysis-run-short-transaction-delivery.md), [0212](0212-single-query-authorized-post-filter-options.md), [0213](0213-global-ask-embedding-pool-release.md) | | Evidence operations Dashboard (`/`) | [0206](0206-evidence-operations-dashboard.md) | | [`temporal-topic-context-influence-research.md`](../temporal-topic-context-influence-research.md) | [0210](0210-temporal-topic-context-influence-dashboard.md) | diff --git a/docs/doctoring/GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md b/docs/doctoring/GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md new file mode 100644 index 000000000..1929cae21 --- /dev/null +++ b/docs/doctoring/GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md @@ -0,0 +1,18 @@ +# Global Ask knowledge-cutoff references + +Supporting research register for ADR 0216. The ADR is normative. + +## Adopted standards + +Cox, S., & Little, C. (Eds.). (2020). *Time ontology in OWL*. World Wide Web +Consortium. https://www.w3.org/TR/owl-time/ + +Adopted use: model the caller's cutoff as an instant and revision availability +as a half-open interval. LineageWeave does not infer an instant when none was +recorded. + +Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV +ontology*. World Wide Web Consortium. https://www.w3.org/TR/prov-o/ + +Adopted use: keep the retained revision and later live entity distinguishable, +and retain the revision identifier alongside each historical citation. diff --git a/docs/product-requirements.md b/docs/product-requirements.md index 8dc3f28db..bc9822f33 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -106,6 +106,20 @@ Acceptance: leaving the control off causes no public request; hidden or uncited facts cause no public request; unavailable services fail closed; and each displayed public judgment retains its originating internal evidence IDs. +### PRD-FR-5B — Knowledge-cutoff Global Ask + +- Persist the optional cutoff with the asynchronous request and reject a future + instant against the database clock. +- Apply authorization, eligibility, and cutoff filters before candidate limits, + then cite the retained source revision available at that instant. +- Never replace a missing historical body or semantic channel with current + state; expose the limitation and later-live-change status. +- Preserve the live contract when no cutoff is supplied. + +Acceptance: a later rewrite never appears in a cutoff answer; an uncovered +revision is explicitly unavailable; and API and rendered citations identify +the retained revision and full/partial grounding state. + ### PRD-FR-6 — Measurement boundary - Consume TEPP accepted/completed wire contracts and fast-mlsirm outputs; do diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index c1ebf037c..90a071aa8 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -87,13 +87,13 @@ context only. | PR | Exact observed head | Merge/check state at this snapshot | | ---: | --- | --- | -| #641 | `2eac0a26` | stacked public semantic/KG claim verification candidate; checks and independent review remain required | -| #640 | `41527fa9` | dashboard case metrics and project journeys; checks and independent review remain required | +| #643 | `041ec13b` | shared token-backed status notice; checks and independent review remain required | +| #640 | `97c85794` | dashboard case metrics and project journeys; checks and independent review remain required | | #639 | `aee02dca` | Running action and Compose contract repair; checks and independent review remain required | -| #636 | `eeeb23c6` | calibrated external lineage contract; checks and independent review remain required | -| #632 | `bfeaecd9` | Global Ask fact provenance and semantic candidate nomination; checks and independent review remain required | +| #636 | `20d25fe6` | calibrated external lineage contract; checks and independent review remain required | +| #632 | `e1ebe50a` | Global Ask fact provenance, semantic nomination, and public verification stack; checks and independent review remain required | | #631 | `c0022c97` | current-main ADR stack decomposition; checks and independent review remain required | -| #629 | `0f4665b5` | provider pool release and bounded landing reads; checks and independent review remain required | +| #629 | `74823e99` | provider pool release and bounded landing reads; checks and independent review remain required | | #579 | `689a21b6` | leftover interaction-map persistence; checks and independent review remain required | No row above is merge evidence. Immediately before any lifecycle action, @@ -393,6 +393,7 @@ this file per §3.5 of the prior snapshot). | Knowledge Graph prompt provenance | PR #632 maps every ontology-annotated graph fact to the visible post recorded in `knowledge_graph_edge_evidence` and drops post endpoints outside the same authorized source window before label hydration; the current public-verification candidate is stacked on that provenance boundary | Exact-head tests must prove post chat and Global Ask attach each fact only to its evidencing source, never hydrate a hidden/out-of-window post endpoint, retain ABAC and prompt bounds, and merge through protected `main` | | Semantic/KG candidate nomination | PR #637 is merged into exact-head #632 (`6b99489e`): normalized project, R&R, Keyman, Knowledge Graph endpoint/edge, and canonical ontology-IRI evidence now nominate candidates through replay-safe indexes, with parameter-free RankWeave RRF and evidence-only operation when embeddings are unavailable. Live PostgreSQL tests cover project-only, endpoint-only, and ontology-IRI-only retrieval; issue #272 remains open for its separate external-verification slice | Exact-head checks must prove ABAC/eligibility/event-time filters run before each channel limit and again at hydration, duplicate hits deduplicate, hidden endpoint labels do not leak, missing RankWeave drops only the added channel, and protected `main` contains #632's merge SHA before the candidate-nomination gap is marked delivered | | Public semantic/KG claim verification | This candidate persists an explicit opt-in, restricts external nomination to cited public semantic/KG facts, uses bounded SearXNG retrieval plus contextual-orchestrator `verify` adjudication, and renders FEVER-style supported/refuted/not-enough-information states separately from internal citations. Synthetic Storybook desktop/mobile inspection and backend/API tests cover private, uncited, unavailable, and three-way states | Land the candidate and its #632 provenance base through protected `main`; then perform aggregate authenticated acceptance showing that opt-out/private/uncited inputs emit zero external queries and that external URLs never replace internal evidence | +| Global Ask knowledge cutoff | The current stacked candidate persists one optional cutoff on the async job, resolves retained `source_post_revision` intervals, excludes later/current-only semantic channels, and renders revision identity, later-live-change status, and full/partial grounding. Focused backend tests and synthetic desktop/mobile Storybook scenes cover retained and missing history; browser/API live behavior remains unchanged when omitted | Land the candidate and #632 provenance base through protected `main`; add the same shared contract to authenticated MCP delivery (#269), then perform aggregate authorized-runtime acceptance proving that later rewrites and after-cutoff facts never enter answers | | Natural-language semantic nomination | Current database-native `websearch_to_tsquery('simple', full_question)` requires every retained question token. A single semantic-only term is accepted, but a longer natural-language question can suppress an otherwise matching fact when generic words are absent from persisted evidence | Adopt a paper-grounded contextual-orchestrator query-interpretation contract or another standards-backed method; prove multilingual natural-language recall and ABAC preservation without local stop-word or weighting heuristics | | Knowledge Graph readability | The black evidence-node root cause is an undefined-token fallback; the design-token repair and long-label/evidence-table coverage remain only on closed, unmerged #490, not protected `main` | Recreate the token repair on a current base and deliver it through protected `main`, then verify light/dark contrast, keyboard graph navigation, full labels, and evidence tables in the authenticated rendered surface | | Source-code lookup UX | Source state/detail codes remain evidence-bearing machine values and current detail presentation is dense | Catalog-backed display labels with raw-code provenance, compact 5W1H/source-detail hierarchy, keyboard access, and no unsupported customer/project binding | diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 866e0025c..50fcf75c8 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -16,6 +16,7 @@ operator-facing control you can click before changing product CSS. | `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` | | `Workspace/WorkspaceCalendar` | Read observed Naruon events, or open a commitment to land on that post. Fail-closed copy stays `이 범위의 일정을 아직 받을 수 없습니다`. | `--color-chip-border`, `WorkspaceCalendar`, `EvidenceStatusMark` | | `Ask Agent/Public claim verification` | Compare supported, refuted, and not-enough-information states; open only the external evidence link, then review the separate internal citation before changing governed graph state. | `--space-panel-block`, `--space-control-gap`, `--color-border`, `--size-control-min`, `PublicClaimVerification` | +| `Ask Agent/Knowledge cutoff` | Exercise partial historical grounding, retained-revision provenance, later-live-change disclosure, and the narrow viewport before relying on a historical answer. | Native `datetime-local`, `--space-panel-block`, `--space-control-gap`, `--color-border`, `--size-control-min` | Repeated web objects must use `frontend/src/styles/tokens.css` and a module under `frontend/src/components/`. Do not add a second Node package manager; diff --git a/frontend/src/App.css b/frontend/src/App.css index eeaa61299..f4db12fe6 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -843,6 +843,42 @@ color: var(--badge-status-prediction-text); } +.ask-agent-form { + display: grid; + gap: var(--space-control-gap); + max-width: 42rem; +} + +.ask-agent-field { + display: grid; + gap: 0.35rem; +} + +.ask-agent-field textarea, +.ask-agent-field input { + box-sizing: border-box; + width: 100%; + min-height: var(--size-control-min); + border: 1px solid var(--color-border); + border-radius: var(--radius-control); + background: var(--color-background); + color: var(--color-text); + font: inherit; + padding: 0.65rem; +} + +.ask-agent-checkbox { + display: inline-flex; + align-items: center; + gap: 0.5rem; + min-height: var(--size-control-min); +} + +.ask-agent-form > .btn-primary { + justify-self: start; + min-height: var(--size-control-min); +} + .keyman-select { background: none; border: none; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 79c94ceee..87ec37688 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4826,6 +4826,7 @@ export function AskAgentPanel({ const [error, setError] = useState(null); const [asking, setAsking] = useState(false); const [verifyExternal, setVerifyExternal] = useState(false); + const [knowledgeCutoff, setKnowledgeCutoff] = useState(""); const [evidenceLayerPostId, setEvidenceLayerPostId] = useState(null); async function handleAsk() { @@ -4834,7 +4835,14 @@ export function AskAgentPanel({ setAsking(true); setError(null); try { - setAnswer(await askAgent(accessToken, normalized, verifyExternal)); + setAnswer( + await askAgent( + accessToken, + normalized, + verifyExternal, + knowledgeCutoff ? new Date(knowledgeCutoff).toISOString() : undefined, + ), + ); } catch (err) { setAnswer(null); setError(orchestratorUnavailableMessage(err, t("Ask Agent"))); @@ -4849,30 +4857,57 @@ export function AskAgentPanel({

{t("Ask Agent")}

{t("Questions use authorized posts and their evidence.")}

{error ?

{error}

: null} -