From 5387acb4e7816b06cd3fe87ad8ed6704395db242 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 08:41:32 +0900 Subject: [PATCH 01/24] feat(ask): nominate persisted semantic evidence --- CHANGELOG.md | 4 + backend/app/global_ask_semantic_candidates.py | 129 ++++++++++++++++++ backend/app/post_chat_ingestion.py | 85 +++++++----- ...lobal-ask-semantic-candidate-nomination.md | 56 ++++++++ docs/adr/README.md | 1 + ...LOBAL_ASK_SEMANTIC_CANDIDATE_REFERENCES.md | 21 +++ docs/product-technical-gap-baseline.md | 4 +- ...2_global_ask_semantic_candidate_search.sql | 57 ++++++++ tests/test_global_ask_semantic_candidates.py | 98 +++++++++++++ tests/test_global_ask_sources.py | 28 +++- 10 files changed, 439 insertions(+), 44 deletions(-) create mode 100644 backend/app/global_ask_semantic_candidates.py create mode 100644 docs/adr/0225-global-ask-semantic-candidate-nomination.md create mode 100644 docs/doctoring/GLOBAL_ASK_SEMANTIC_CANDIDATE_REFERENCES.md create mode 100644 migrations/0222_global_ask_semantic_candidate_search.sql create mode 100644 tests/test_global_ask_semantic_candidates.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 641306055..d1072fcd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project are documented here. Format follows ### Added +- Global Ask now nominates bounded post IDs from indexed persisted project, + role, person, organization, team, and Knowledge Graph evidence before its + existing embedding channel, then repeats eligibility and authorization + before reading any source (ADR 0225; issue #272 internal-search slice). - Persist explicit paragraph, list, table, MathML formula, and caller-parsed conversation-turn semantic-unit kinds without inferring absent boundaries. - Event Lineage now persists each reconstructed connection's independent diff --git a/backend/app/global_ask_semantic_candidates.py b/backend/app/global_ask_semantic_candidates.py new file mode 100644 index 000000000..fc152d538 --- /dev/null +++ b/backend/app/global_ask_semantic_candidates.py @@ -0,0 +1,129 @@ +"""Nominate Global Ask posts from persisted semantic and KG evidence.""" + +from __future__ import annotations + +from datetime import date + +import asyncpg + +from .post_eligibility import SOURCE_POST_ELIGIBILITY_SQL + + +async def semantic_candidate_post_ids( + conn: asyncpg.Connection, + question: str, + *, + maximum_candidates: int, + authorized_corporate_entity_ids: list[str], + authorized_process_unit_ids: list[str], + date_from: date | None, + date_to: date | None, +) -> list[str]: + """Return bounded post IDs whose persisted semantic evidence matches. + + Nomination grants no access and returns no evidence text. The caller must + apply the ordinary source-post RBAC/ABAC, eligibility, and time boundary + before reading any nominated row. + """ + + if maximum_candidates <= 0 or not question.strip(): + return [] + rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f""" + with search_query as ( + select websearch_to_tsquery('simple', $1) as value + ), candidate_post as ( + select mention.post_id, post.created_at + from post_project_mention mention + join source_post post on post.post_id = mention.post_id + cross join search_query + where to_tsvector( + 'simple', + coalesce(mention.project_key, '') || ' ' || + coalesce(mention.project_name, '') || ' ' || + coalesce(mention.evidence_text, '') || ' ' || + coalesce(mention.ontology_iri, '') + ) @@ search_query.value + union all + select role.post_id, post.created_at + from post_summary_role role + join source_post post on post.post_id = role.post_id + cross join search_query + where to_tsvector( + 'simple', + coalesce(role.actor_name, '') || ' ' || + coalesce(role.responsibility, '') || ' ' || + coalesce(role.affiliated_organization_name, '') + ) @@ search_query.value + union all + select mention.post_id, post.created_at + from post_person_mention mention + join cataloged_person person on person.person_id = mention.person_id + join source_post post on post.post_id = mention.post_id + cross join search_query + where to_tsvector( + 'simple', coalesce(person.person_name, '') || ' ' || + coalesce(person.last_known_job_title, '') + ) @@ search_query.value + union all + select mention.post_id, post.created_at + from post_person_mention mention + join source_post post on post.post_id = mention.post_id + cross join search_query + where to_tsvector('simple', coalesce(mention.mention_context, '')) + @@ search_query.value + union all + select mention.post_id, post.created_at + from post_organization_mention mention + join corporate_entity entity + on entity.corporate_entity_id = mention.corporate_entity_id + join source_post post on post.post_id = mention.post_id + cross join search_query + where to_tsvector('simple', entity.entity_name) @@ search_query.value + union all + select mention.post_id, post.created_at + from post_team_mention mention + join cataloged_team team on team.team_id = mention.team_id + join source_post post on post.post_id = mention.post_id + cross join search_query + where to_tsvector( + 'simple', + coalesce(team.team_name, '') || ' ' || + coalesce(team.affiliated_organization_name, '') + ) @@ search_query.value + union all + select evidence.evidence_post_id, post.created_at + from knowledge_graph_edge edge + join knowledge_graph_edge_evidence evidence + on evidence.knowledge_graph_edge_id = edge.knowledge_graph_edge_id + join source_post post on post.post_id = evidence.evidence_post_id + cross join search_query + where to_tsvector( + 'simple', + replace(coalesce(edge.edge_type_code, '') || ' ' || + coalesce(edge.source_node_type_code, '') || ' ' || + coalesce(edge.target_node_type_code, ''), '_', ' ') + ) @@ search_query.value + ) + select candidate.post_id::text as post_id + from candidate_post candidate + join source_post post on post.post_id = candidate.post_id + where (post.visibility_code = 'public' + or (post.corporate_entity_id::text = any($3::text[]) + and (cardinality($4::text[]) = 0 + or post.process_unit_id::text = any($4::text[])))) + and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} + and ($5::date is null or (coalesce(post.event_occurred_at, post.created_at) at time zone 'Asia/Seoul')::date >= $5) + and ($6::date is null or (coalesce(post.event_occurred_at, post.created_at) at time zone 'Asia/Seoul')::date <= $6) + group by candidate.post_id + order by max(candidate.created_at) desc, candidate.post_id desc + limit $2 + """, + question, + maximum_candidates, + authorized_corporate_entity_ids, + authorized_process_unit_ids, + date_from, + date_to, + ) + return [str(row["post_id"]) for row in rows] diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index 4ca9e2f2d..192f205e2 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -36,6 +36,7 @@ random_walk_with_restart, select_related_nodes, ) +from lineageweave.ontology import ontology_annotations from lineageweave.post_chat import ( CANONICAL_CHAT_QUESTION, CANONICAL_COMMITMENT_QUESTION, @@ -46,9 +47,9 @@ from lineageweave.post_content_normalization import normalize_post_body from lineageweave.temporal_expressions import resolve_korean_relative_time +from .global_ask_semantic_candidates import semantic_candidate_post_ids from .knowledge_graph import hydrate_related_nodes, load_visible_subgraph from .post_eligibility import SOURCE_POST_ELIGIBILITY_SQL -from lineageweave.ontology import ontology_annotations @dataclass(frozen=True) @@ -412,11 +413,12 @@ async def gather_global_chat_sources( or no expression at all applies no date filter. Cited sources name which clock matched (ADR 0202). - Candidates are ranked by the maximum cosine similarity between the - question embedding and each post's persisted semantic-unit embeddings. - The embedding model and dimension must match exactly. An unavailable - channel or incomplete persisted vectors returns no source instead of - falling back to lexical matching. + Exact PostgreSQL full-text matches over persisted project, responsibility, + person, organization, team, and KG-code evidence nominate post IDs before + semantic-unit cosine candidates. Nomination grants no access: the final + source query repeats ABAC, eligibility, and time filtering. The embedding + model and dimension must match exactly; an unavailable embedding channel + drops only that channel and never fabricates a score. """ if limit <= 0: return [] @@ -424,27 +426,36 @@ async def gather_global_chat_sources( vision_client = NullImageContentClient() if embedding_client is None: embedding_client = NullEmbeddingClient() + authorized_corporate_entity_id_list = list(authorized_corporate_entity_ids) + authorized_process_unit_id_list = list(authorized_process_unit_ids) resolved_time_range = resolve_korean_relative_time( question or "", today=today or _seoul_today() ) - if not (question and question.strip() and embedding_client.available): - return [] - try: - question_vector = await asyncio.to_thread(embedding_client.embed, question) - except (OSError, RuntimeError, ValueError): - return [] - if not question_vector: + if not (question and question.strip()): return [] + semantic_candidate_ids = await semantic_candidate_post_ids( + conn, + question, + maximum_candidates=limit, + authorized_corporate_entity_ids=authorized_corporate_entity_id_list, + authorized_process_unit_ids=authorized_process_unit_id_list, + date_from=resolved_time_range[0] if resolved_time_range else None, + date_to=resolved_time_range[1] if resolved_time_range else None, + ) + question_vector: list[float] = [] + if embedding_client.available: + try: + question_vector = await asyncio.to_thread(embedding_client.embed, question) + except (OSError, RuntimeError, ValueError): + question_vector = [] embedding_model_code = embedding_client.resolved_model - if not embedding_model_code: - return [] question_norm = sum(value * value for value in question_vector) ** 0.5 - if question_norm == 0.0: - return [] - # 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 - f""" + candidate_rows = [] + if question_vector and embedding_model_code and question_norm: + # 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 + f""" with question_vector as ( select ordinality - 1 as dimension_index, dimension_value from unnest($1::double precision[]) with ordinality @@ -483,17 +494,21 @@ async def gather_global_chat_sources( group by similarity.post_id order by semantic_score desc, event_clock desc, similarity.post_id desc limit $8 - """, - question_vector, - question_norm, - embedding_model_code, - 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, - ) - candidate_ids = [str(row["post_id"]) for row in candidate_rows] + """, + question_vector, + question_norm, + embedding_model_code, + authorized_corporate_entity_id_list, + authorized_process_unit_id_list, + resolved_time_range[0] if resolved_time_range else None, + resolved_time_range[1] if resolved_time_range else None, + limit, + ) + candidate_ids = list( + dict.fromkeys( + [*semantic_candidate_ids, *(str(row["post_id"]) for row in candidate_rows)] + ) + )[:limit] candidate_id_set = frozenset(candidate_ids) # One semantic match is still only one event snapshot. Expand the @@ -521,7 +536,7 @@ async def gather_global_chat_sources( dict.fromkeys([lineage_anchor_id, *lineage_neighbor_ids, *candidate_ids[1:]]) )[:limit] else: - candidate_ids = [] + return [] lineage_neighbor_id_set = frozenset(lineage_neighbor_ids) # Safe SQL: the only interpolation is the repository-owned eligibility @@ -548,8 +563,8 @@ async def gather_global_chat_sources( coalesce(event_occurred_at, created_at) desc, post_id desc limit $4 """, - list(authorized_corporate_entity_ids), - list(authorized_process_unit_ids), + authorized_corporate_entity_id_list, + authorized_process_unit_id_list, candidate_ids, limit, resolved_time_range[0] if resolved_time_range else None, diff --git a/docs/adr/0225-global-ask-semantic-candidate-nomination.md b/docs/adr/0225-global-ask-semantic-candidate-nomination.md new file mode 100644 index 000000000..1b74e6811 --- /dev/null +++ b/docs/adr/0225-global-ask-semantic-candidate-nomination.md @@ -0,0 +1,56 @@ +# ADR 0225: Global Ask nominates persisted semantic candidates + +- Status: Accepted +- Date: 2026-08-26 +- Related: PRD-FR-4, ADR 0062, ADR 0202, issue #272 + +## Context + +Global Ask ranked persisted semantic-unit embeddings, then loaded project, +role, person, organization, team, and Knowledge Graph evidence only for the +selected posts. A term present only in that persisted evidence could therefore +miss its source. Local token lists, hand-picked term weights, or provider +fallbacks would create an unaudited second semantic policy. + +## Decision + +Before embedding retrieval, PostgreSQL nominates a bounded set of post IDs by +applying `websearch_to_tsquery('simple', question)` to persisted project, +responsibility, affiliation, person, organization, team, and Knowledge Graph +type evidence. Matching expression GIN indexes make this a database-native +search boundary. No local stopword list, score, threshold, or channel weight is +introduced. + +Nomination returns IDs only and grants no access. The existing final source +query repeats corporate-entity/process-unit scope, publication eligibility, +event-time bounds, and caller authorization before reading any body or semantic +fact. Exact persisted-evidence candidates precede embedding candidates; both +use the caller's existing result limit, preserve deterministic ordering, and +deduplicate by post ID. An unavailable embedding channel drops only that +channel. No candidate from either channel is evidence until final authorization +succeeds. + +This decision implements only issue #272's internal semantic nomination slice. +External public verification, three-way truth status, and SearXNG citations +remain separate work because they cross a different trust boundary. + +## Consequences + +- A semantic-only persisted term can nominate its authorized source without a + raw-LLM call or an invented weight. +- Private candidate IDs may be examined inside PostgreSQL, but no private text + leaves the final authorization boundary. +- PostgreSQL full-text query semantics, rather than application token + heuristics, define exact nomination behavior. + +## References + +PostgreSQL Global Development Group. (2026). *Controlling text search*. +PostgreSQL 18 documentation. +https://www.postgresql.org/docs/current/textsearch-controls.html + +PostgreSQL Global Development Group. (2026). *GIN indexes*. +PostgreSQL 18 documentation. https://www.postgresql.org/docs/current/gin.html + +World Wide Web Consortium. (2013). *PROV-O: The PROV ontology* (W3C +Recommendation). https://www.w3.org/TR/prov-o/ diff --git a/docs/adr/README.md b/docs/adr/README.md index eadef2874..51a30902a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -22,6 +22,7 @@ decision from them. | 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) | | [`python-mathematical-compute-boundary-audit.md`](../doctoring/python-mathematical-compute-boundary-audit.md) | [0208](0208-externalize-local-mathematical-compute.md) | +| [`GLOBAL_ASK_SEMANTIC_CANDIDATE_REFERENCES.md`](../doctoring/GLOBAL_ASK_SEMANTIC_CANDIDATE_REFERENCES.md) | [0225](0225-global-ask-semantic-candidate-nomination.md) | [0011](0011-prov-o-standard-relations.md) and [0065](0065-prov-o-provenance-boundary.md) cite the dated W3C PROV-O and PROV-DM Recommendations (https://www.w3.org/TR/2013/REC-prov-o-20130430/ and https://www.w3.org/TR/2013/REC-prov-dm-20130430/). diff --git a/docs/doctoring/GLOBAL_ASK_SEMANTIC_CANDIDATE_REFERENCES.md b/docs/doctoring/GLOBAL_ASK_SEMANTIC_CANDIDATE_REFERENCES.md new file mode 100644 index 000000000..678afd63c --- /dev/null +++ b/docs/doctoring/GLOBAL_ASK_SEMANTIC_CANDIDATE_REFERENCES.md @@ -0,0 +1,21 @@ +# Global Ask semantic candidate research register + +ADR 0225 is normative. This register records the adopted platform evidence. + +| Decision need | Adopted evidence | Product consequence | +|---|---|---| +| Safe user query parsing | PostgreSQL `websearch_to_tsquery` accepts web-search-style input without syntax errors | The application does not invent a tokenizer or repair grammar | +| Indexed multi-value lookup | PostgreSQL GIN is the native inverted-index access method for full-text search | Matching expression indexes cover each persisted semantic evidence family | +| Provenance and authorization | W3C PROV-O distinguishes evidence influence from an access grant | Candidate IDs remain non-authoritative until the final source boundary authorizes them | + +## References + +PostgreSQL Global Development Group. (2026). *Controlling text search*. +PostgreSQL 18 documentation. +https://www.postgresql.org/docs/current/textsearch-controls.html + +PostgreSQL Global Development Group. (2026). *GIN indexes*. +PostgreSQL 18 documentation. https://www.postgresql.org/docs/current/gin.html + +World Wide Web Consortium. (2013). *PROV-O: The PROV ontology* (W3C +Recommendation). https://www.w3.org/TR/prov-o/ diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 974bef3ac..cc5a3aaa5 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -353,7 +353,7 @@ this file per §3.5 of the prior snapshot). | #87 | Milestone 2.1 normalized runtime-analysis schema bridge | related analysis-run work | | #269 | Authenticated Global Ask MCP browser-safe and admission-bounded | Ask stack | | #271 | Evidence-honest knowledge-cutoff scope on Global Ask | Ask stack | -| #272 | Verify Global Ask KG/ontology/semantic claims with public SearXNG evidence | Ask stack | +| #272 | Verify Global Ask KG/ontology/semantic claims with public SearXNG evidence; ADR 0225 candidate implements the internal indexed semantic/KG nomination slice only | Ask stack; external public verification remains open | | #274 | Persist and explain Event Lineage channel evidence | #387 | | #277 | TEPP: persist accepted receipts, poll completed results, keep measurement authority distinct | #468, #417 | | #280 | Full project-lifecycle history and handover intervals | Tracked with issue #284; no active delivery PR confirmed | @@ -378,7 +378,7 @@ this file per §3.5 of the prior snapshot). | Authorized-corpus runtime | Repository tests use synthetic fixtures; private records remain outside git | Authenticated runtime validation returning only aggregate, non-identifying evidence | | Concurrent web responsiveness | ADR 0204 releases pooled transactions during provider work, and the synthetic Compose boundary has an authenticated k6 E2E harness for Ask enqueue, concurrent reads, and job polling. An older-image local observation found repeated post-filter queries while `/api/posts` exceeded 30 seconds; ADR 0212 combines two authorized filter-option queries into one round trip without narrowing ABAC-visible options. The observation is not exact-head evidence or a product guarantee, and no physical scan reduction is claimed without an exact-head plan | Rebuild an exact-head application image, run `make load-http` with declared environment concurrency/window, and retain raw distributions and resource configuration. Compare the post-list database plan and latency with ADR 0212 while preserving the complete authorized filter set; set no SLO until representative capacity evidence is approved | | Image understanding | Region, OCR, and description work exists across active heads (#405, #419), but current runtime acceptance has not yet proved table-image structure, complete region coverage, or summary/image readiness together | Orchestrator-backed rendered workflow, original/derived asset provenance, region-before-OCR processing, and honest unsupported states; reconcile ADR 0052's image-bearing summary readiness with ADR 0098 before changing sequencing | -| Semantic source rendering | ADR 0223 and migration 0221 give new paragraph, list, table, MathML formula, and caller-parsed conversation-turn units explicit persisted kinds without rewriting historical rows; image regions remain ordered normalized children under ADR 0091. This branch is candidate evidence, not protected-main delivery | Land the exact-head candidate, then prove an authorized semantic-only query retrieves each persisted unit kind and gather authenticated browser evidence that nesting, continuation alignment, formula units, and image regions retain source order | +| Semantic source rendering and retrieval | ADR 0223 and migration 0221 give new paragraph, list, table, MathML formula, and caller-parsed conversation-turn units explicit persisted kinds without rewriting historical rows; image regions remain ordered normalized children under ADR 0091. ADR 0225's candidate adds indexed project/role/person/organization/team/KG nomination before embedding and repeats the final authorization boundary. Neither branch is protected-main delivery | Land both exact-head candidates, prove an authorized semantic-only query retrieves each persisted evidence family and unit kind, then gather authenticated browser evidence that nesting, continuation alignment, formula units, and image regions retain source order | | Event and project semantics | Multi-project mentions, project-bound actions, 5W1H, requester/processor, and semantic relations exist in ADR 0036/0052/0100/0111/0129 and active stacks | Aggregate authenticated evidence must show distinct projects and events, explicit requester/processor and real R&R, normalized relative time, and product/entity relations without promoting attendance or co-occurrence | | 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/migrations/0222_global_ask_semantic_candidate_search.sql b/migrations/0222_global_ask_semantic_candidate_search.sql new file mode 100644 index 000000000..28d21a1e0 --- /dev/null +++ b/migrations/0222_global_ask_semantic_candidate_search.sql @@ -0,0 +1,57 @@ +-- Native PostgreSQL full-text indexes for Global Ask semantic nomination. +-- The expressions match backend/app/global_ask_semantic_candidates.py. +create index concurrently if not exists post_project_mention_search_fts_idx + on post_project_mention using gin ( + to_tsvector( + 'simple', + coalesce(project_key, '') || ' ' || coalesce(project_name, '') || ' ' || + coalesce(evidence_text, '') || ' ' || coalesce(ontology_iri, '') + ) + ); + +create index concurrently if not exists post_summary_role_search_fts_idx + on post_summary_role using gin ( + to_tsvector( + 'simple', + coalesce(actor_name, '') || ' ' || coalesce(responsibility, '') || ' ' || + coalesce(affiliated_organization_name, '') + ) + ); + +create index concurrently if not exists cataloged_person_search_fts_idx + on cataloged_person using gin ( + to_tsvector( + 'simple', coalesce(person_name, '') || ' ' || + coalesce(last_known_job_title, '') + ) + ); + +create index concurrently if not exists post_person_mention_context_fts_idx + on post_person_mention using gin ( + to_tsvector('simple', coalesce(mention_context, '')) + ); + +create index concurrently if not exists corporate_entity_name_fts_idx + on corporate_entity using gin (to_tsvector('simple', entity_name)); + +create index concurrently if not exists cataloged_team_search_fts_idx + on cataloged_team using gin ( + to_tsvector( + 'simple', coalesce(team_name, '') || ' ' || + coalesce(affiliated_organization_name, '') + ) + ); + +create index concurrently if not exists knowledge_graph_edge_code_fts_idx + on knowledge_graph_edge using gin ( + to_tsvector( + 'simple', + replace( + coalesce(edge_type_code, '') || ' ' || + coalesce(source_node_type_code, '') || ' ' || + coalesce(target_node_type_code, ''), + '_', + ' ' + ) + ) + ); diff --git a/tests/test_global_ask_semantic_candidates.py b/tests/test_global_ask_semantic_candidates.py new file mode 100644 index 000000000..53ef8c114 --- /dev/null +++ b/tests/test_global_ask_semantic_candidates.py @@ -0,0 +1,98 @@ +"""Focused checks for persisted semantic candidate nomination.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from backend.app.global_ask_semantic_candidates import semantic_candidate_post_ids + + +_MIGRATION = Path(__file__).parents[1] / "migrations" / "0222_global_ask_semantic_candidate_search.sql" + + +def test_semantic_candidates_use_bounded_native_full_text_search() -> None: + """Persisted semantic and graph evidence nominate IDs without local weights.""" + calls: list[tuple[str, tuple[object, ...]]] = [] + + class FakeConnection: + async def fetch(self, query: str, *args: object): + calls.append((query, args)) + return [{"post_id": "semantic-post"}] + + assert asyncio.run( + semantic_candidate_post_ids( + FakeConnection(), + "risk owner", + maximum_candidates=4, + authorized_corporate_entity_ids=["entity-one"], + authorized_process_unit_ids=["unit-one"], + date_from=None, + date_to=None, + ) + ) == ["semantic-post"] + + query, args = calls[0] + assert "websearch_to_tsquery('simple', $1)" in query + assert "from post_project_mention" in query + assert "from post_summary_role" in query + assert "from post_person_mention" in query + assert "from post_organization_mention" in query + assert "from post_team_mention" in query + assert "from knowledge_graph_edge" in query + assert "post.corporate_entity_id::text = any($3::text[])" in query + assert "post.process_unit_id::text = any($4::text[])" in query + assert "post.source_draft_code" in query + assert "post.source_deleted_flag" in query + assert "limit $2" in query + assert "ts_rank" not in query + assert args == ("risk owner", 4, ["entity-one"], ["unit-one"], None, None) + + +def test_semantic_candidates_skip_empty_or_zero_budget_queries() -> None: + """Invalid candidate requests do not touch PostgreSQL.""" + + class FakeConnection: + async def fetch(self, _query: str, *_args: object): + raise AssertionError("no query expected") + + assert asyncio.run( + semantic_candidate_post_ids( + FakeConnection(), + " ", + maximum_candidates=4, + authorized_corporate_entity_ids=[], + authorized_process_unit_ids=[], + date_from=None, + date_to=None, + ) + ) == [] + assert asyncio.run( + semantic_candidate_post_ids( + FakeConnection(), + "risk", + maximum_candidates=0, + authorized_corporate_entity_ids=[], + authorized_process_unit_ids=[], + date_from=None, + date_to=None, + ) + ) == [] + + +def test_semantic_candidate_indexes_match_query_evidence_families() -> None: + """The replay-safe migration indexes every persisted nomination family.""" + sql = _MIGRATION.read_text(encoding="utf-8") + + assert sql.count("create index concurrently if not exists") == 7 + for table_name in ( + "post_project_mention", + "post_summary_role", + "cataloged_person", + "post_person_mention", + "corporate_entity", + "cataloged_team", + "knowledge_graph_edge", + ): + assert f"on {table_name} using gin" in sql + assert "to_tsvector('simple'" in sql diff --git a/tests/test_global_ask_sources.py b/tests/test_global_ask_sources.py index f7fc31ae7..a7a65728e 100644 --- a/tests/test_global_ask_sources.py +++ b/tests/test_global_ask_sources.py @@ -71,14 +71,16 @@ def test_global_sources_apply_process_scope_before_sql_limit() -> None: class FakeConnection: async def fetch(self, query: str, *args): calls.append((query, args)) + if "candidate_post" in query: + return [{"post_id": "semantic-post"}] return [] asyncio.run( gather_global_chat_sources( FakeConnection(), lambda _row: True, - {"corp-demo"}, - {"process-demo"}, + (value for value in ["corp-demo"]), + (value for value in ["process-demo"]), question="synthetic process evidence", ) ) @@ -112,6 +114,8 @@ def test_global_sources_use_semantic_rank_order_and_bound_long_bodies() -> None: class FakeConnection: async def fetch(self, query: str, *args): calls.append((query, args)) + if "candidate_post" in query: + return [] return rows if "from source_post" in query else [] sources = asyncio.run( @@ -123,7 +127,9 @@ async def fetch(self, query: str, *args): ) ) - candidate_query, candidate_args = calls[0] + candidate_query, candidate_args = next( + (query, args) for query, args in calls if "unit_similarity" in query + ) source_query, source_args = next( (query, args) for query, args in calls if "array_position($3::uuid[], post_id)" in query ) @@ -187,6 +193,8 @@ def test_global_sources_embed_identifier_question_without_tokenizing() -> None: class FakeConnection: async def fetch(self, query: str, *args): calls.append((query, args)) + if "candidate_post" in query: + return [{"post_id": "semantic-post"}] return [] asyncio.run( @@ -209,6 +217,8 @@ def test_global_sources_embed_localized_question_once() -> None: class FakeConnection: async def fetch(self, query: str, *args): calls.append((query, args)) + if "candidate_post" in query: + return [{"post_id": "semantic-post"}] return [] asyncio.run( @@ -310,8 +320,9 @@ def embed(self, _text: str) -> list[float]: raise AssertionError("unavailable embedding must not be called") class FakeConnection: - async def fetch(self, _query: str, *_args): - raise AssertionError("lexical fallback must not query the corpus") + async def fetch(self, query: str, *_args): + assert "candidate_post" in query + return [] sources = asyncio.run( _gather_global_chat_sources( @@ -336,8 +347,9 @@ def embed(self, _text: str) -> list[float]: return [1.0, 0.0] class FakeConnection: - async def fetch(self, _query: str, *_args): - raise AssertionError("an unbound vector must not query persisted embeddings") + async def fetch(self, query: str, *_args): + assert "candidate_post" in query + return [] sources = asyncio.run( _gather_global_chat_sources( @@ -459,6 +471,8 @@ def test_global_sources_resolve_relative_time_against_seoul_calendar_day( class FakeConnection: async def fetch(self, query: str, *args): calls.append((query, args)) + if "candidate_post" in query: + return [{"post_id": "semantic-post"}] return [] asyncio.run( From 6d4b0e3996cd07e3c1af8683b3ac4d74028c81a8 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 08:43:28 +0900 Subject: [PATCH 02/24] docs(gaps): record current protected queue --- docs/product-technical-gap-baseline.md | 31 +++++++++++++------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index cc5a3aaa5..39ab482de 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -67,7 +67,7 @@ are not merge readiness. Re-fetch exact heads, unresolved threads, checks, approvals, rulesets, and merge SHA before any lifecycle claim. -> Audit snapshot: 2026-08-26 06:20 KST (refreshed by the autonomous merge +> Audit snapshot: 2026-08-26 08:42 KST (refreshed by the autonomous merge > loop). This repository records synthetic fixtures and aggregate, > non-identifying runtime evidence only. Open PRs and local checks are not > protected-default-branch release evidence. Identifying post identifiers, @@ -76,26 +76,27 @@ lifecycle claim. ## 1. Exact-head and governance evidence -The protected default branch was `04e6b610655d0db91d5f7ba9486bdda1440e0b19` -when this baseline was refreshed. The live queue contained 11 open PRs and 10 +The protected default branch was `494b54e2245040bcf02b45376f221c37cd437e76` +when this baseline was refreshed. The live queue contained 13 open PRs and 10 open issues. The exact-head inventory below supersedes older per-PR snapshots elsewhere in this document; those older rows remain useful historical delivery context only. | PR | Exact observed head | Merge/check state at this snapshot | | ---: | --- | --- | -| #663 | `ab330a0f` | project ontology traversal plus cutoff/snapshot-frozen project focus and labels; exact-head checks/review required | -| #660 | `24fda085` | backend runtime repair plus semantic-unit stack; exact-head checks/review required | -| #659 | `0739b9d7` | ontology node readability and tokenized UI fills; branch is DIRTY against current main and requires conflict repair | -| #658 | `fe830b0a` | evidence-honest Global Ask cutoff with revision-interval live-after semantics; exact-head checks/review required | -| #657 | `64f48679` | Dashboard case-metric contract work; exact-head checks/review required | -| #644 | `d9ff9980` | native-surface code splitting with modal-focus regression coverage; exact-head checks/review required | -| #643 | `0a1f8ec1` | accessible status-notice surfaces; exact-head checks/review required | -| #640 | `2d50fa01` | operations-dashboard contract alignment; exact-head checks/review required | -| #639 | `aee02dca` | exact-head checks/review required | -| #632 | `a4059113` | active semantic provenance repair head; exact-head checks/review required | -| #631 | `1d9ac825` | documentation decomposition; branch is DIRTY against current main and requires conflict repair | -| #629 | `4b4d6707` | exact-head checks/review required | +| #672 | `5387acb4` | indexed persisted semantic/KG candidate nomination (ADR 0225); exact-head checks/review required | +| #668 | `f272f4b0` | evidence-bound project history in post detail; exact-head checks/review required | +| #667 | `6d680a17` | current baseline refresh plus stacked cancelled-run guidance; exact-head checks/review required | +| #663 | `7ac1483e` | project ontology traversal plus cutoff/snapshot-frozen project focus and labels; exact-head checks/review required | +| #658 | `f497a6e8` | evidence-honest Global Ask cutoff with revision-interval live-after semantics; exact-head checks/review required | +| #657 | `a59a2023` | TEPP asynchronous lifecycle evidence; exact-head checks/review required | +| #644 | `ed8d97f3` | native-surface code splitting with modal-focus regression coverage; exact-head checks/review required | +| #643 | `3453ab08` | accessible token-backed status notices; exact-head checks/review required | +| #640 | `fa604e79` | operations-dashboard contract alignment; exact-head checks/review required | +| #639 | `8da485d3` | Running action and Compose contracts; exact-head checks/review required | +| #632 | `cad4debf` | active semantic provenance repair head; exact-head checks/review required | +| #631 | `e6b4f0c4` | documentation decomposition; exact-head checks/review required | +| #629 | `48496ff6` | web provider release and bounded landing reads; exact-head checks/review required | No row above is merge evidence. Immediately before any lifecycle action, re-fetch the head, unresolved threads, formal reviews, rulesets, and same-head From 6852439a684ddfa55a5bb4fbe4f10a5ad10b5147 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 08:48:27 +0900 Subject: [PATCH 03/24] docs(gap): reconcile semantic evidence queue --- docs/product-technical-gap-baseline.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 39ab482de..f4e8084dc 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -60,14 +60,14 @@ only aggregate, non-identifying evidence to this repository. ### Exact open-PR boundary -At this snapshot there were 11 open PRs and 10 open issues. The exact-head +At this snapshot there were 13 open PRs and 10 open issues. The exact-head inventory in section 1 is authoritative for this snapshot. Every open head remained blocked on hosted gates and/or independent review. These observations are not merge readiness. Re-fetch exact heads, unresolved threads, checks, approvals, rulesets, and merge SHA before any lifecycle claim. -> Audit snapshot: 2026-08-26 08:42 KST (refreshed by the autonomous merge +> Audit snapshot: 2026-08-26 09:00 KST (refreshed by the autonomous merge > loop). This repository records synthetic fixtures and aggregate, > non-identifying runtime evidence only. Open PRs and local checks are not > protected-default-branch release evidence. Identifying post identifiers, @@ -86,13 +86,13 @@ context only. | ---: | --- | --- | | #672 | `5387acb4` | indexed persisted semantic/KG candidate nomination (ADR 0225); exact-head checks/review required | | #668 | `f272f4b0` | evidence-bound project history in post detail; exact-head checks/review required | -| #667 | `6d680a17` | current baseline refresh plus stacked cancelled-run guidance; exact-head checks/review required | +| #667 | `e05d138c` | current baseline refresh plus stacked cancelled-run guidance; exact-head checks/review required | | #663 | `7ac1483e` | project ontology traversal plus cutoff/snapshot-frozen project focus and labels; exact-head checks/review required | | #658 | `f497a6e8` | evidence-honest Global Ask cutoff with revision-interval live-after semantics; exact-head checks/review required | | #657 | `a59a2023` | TEPP asynchronous lifecycle evidence; exact-head checks/review required | | #644 | `ed8d97f3` | native-surface code splitting with modal-focus regression coverage; exact-head checks/review required | | #643 | `3453ab08` | accessible token-backed status notices; exact-head checks/review required | -| #640 | `fa604e79` | operations-dashboard contract alignment; exact-head checks/review required | +| #640 | `dda5cf48` | operations-dashboard contract alignment and ABAC/UUID repairs; exact-head checks/review required | | #639 | `8da485d3` | Running action and Compose contracts; exact-head checks/review required | | #632 | `cad4debf` | active semantic provenance repair head; exact-head checks/review required | | #631 | `e6b4f0c4` | documentation decomposition; exact-head checks/review required | From 5468c04da0bc9aaf33594488da9af87250411ab6 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 08:56:34 +0900 Subject: [PATCH 04/24] docs: assign unique semantic nomination ADR number --- CHANGELOG.md | 2 +- ....md => 0227-global-ask-semantic-candidate-nomination.md} | 2 +- docs/adr/README.md | 2 +- docs/doctoring/GLOBAL_ASK_SEMANTIC_CANDIDATE_REFERENCES.md | 2 +- docs/product-technical-gap-baseline.md | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) rename docs/adr/{0225-global-ask-semantic-candidate-nomination.md => 0227-global-ask-semantic-candidate-nomination.md} (97%) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1072fcd2..260d48e2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ All notable changes to this project are documented here. Format follows - Global Ask now nominates bounded post IDs from indexed persisted project, role, person, organization, team, and Knowledge Graph evidence before its existing embedding channel, then repeats eligibility and authorization - before reading any source (ADR 0225; issue #272 internal-search slice). + before reading any source (ADR 0227; issue #272 internal-search slice). - Persist explicit paragraph, list, table, MathML formula, and caller-parsed conversation-turn semantic-unit kinds without inferring absent boundaries. - Event Lineage now persists each reconstructed connection's independent diff --git a/docs/adr/0225-global-ask-semantic-candidate-nomination.md b/docs/adr/0227-global-ask-semantic-candidate-nomination.md similarity index 97% rename from docs/adr/0225-global-ask-semantic-candidate-nomination.md rename to docs/adr/0227-global-ask-semantic-candidate-nomination.md index 1b74e6811..de9df73a5 100644 --- a/docs/adr/0225-global-ask-semantic-candidate-nomination.md +++ b/docs/adr/0227-global-ask-semantic-candidate-nomination.md @@ -1,4 +1,4 @@ -# ADR 0225: Global Ask nominates persisted semantic candidates +# ADR 0227: Global Ask nominates persisted semantic candidates - Status: Accepted - Date: 2026-08-26 diff --git a/docs/adr/README.md b/docs/adr/README.md index 51a30902a..ee4052036 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -22,7 +22,7 @@ decision from them. | 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) | | [`python-mathematical-compute-boundary-audit.md`](../doctoring/python-mathematical-compute-boundary-audit.md) | [0208](0208-externalize-local-mathematical-compute.md) | -| [`GLOBAL_ASK_SEMANTIC_CANDIDATE_REFERENCES.md`](../doctoring/GLOBAL_ASK_SEMANTIC_CANDIDATE_REFERENCES.md) | [0225](0225-global-ask-semantic-candidate-nomination.md) | +| [`GLOBAL_ASK_SEMANTIC_CANDIDATE_REFERENCES.md`](../doctoring/GLOBAL_ASK_SEMANTIC_CANDIDATE_REFERENCES.md) | [0227](0227-global-ask-semantic-candidate-nomination.md) | [0011](0011-prov-o-standard-relations.md) and [0065](0065-prov-o-provenance-boundary.md) cite the dated W3C PROV-O and PROV-DM Recommendations (https://www.w3.org/TR/2013/REC-prov-o-20130430/ and https://www.w3.org/TR/2013/REC-prov-dm-20130430/). diff --git a/docs/doctoring/GLOBAL_ASK_SEMANTIC_CANDIDATE_REFERENCES.md b/docs/doctoring/GLOBAL_ASK_SEMANTIC_CANDIDATE_REFERENCES.md index 678afd63c..868a44349 100644 --- a/docs/doctoring/GLOBAL_ASK_SEMANTIC_CANDIDATE_REFERENCES.md +++ b/docs/doctoring/GLOBAL_ASK_SEMANTIC_CANDIDATE_REFERENCES.md @@ -1,6 +1,6 @@ # Global Ask semantic candidate research register -ADR 0225 is normative. This register records the adopted platform evidence. +ADR 0227 is normative. This register records the adopted platform evidence. | Decision need | Adopted evidence | Product consequence | |---|---|---| diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index f4e8084dc..5b2a18932 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -84,7 +84,7 @@ context only. | PR | Exact observed head | Merge/check state at this snapshot | | ---: | --- | --- | -| #672 | `5387acb4` | indexed persisted semantic/KG candidate nomination (ADR 0225); exact-head checks/review required | +| #672 | `5387acb4` | indexed persisted semantic/KG candidate nomination (ADR 0227); exact-head checks/review required | | #668 | `f272f4b0` | evidence-bound project history in post detail; exact-head checks/review required | | #667 | `e05d138c` | current baseline refresh plus stacked cancelled-run guidance; exact-head checks/review required | | #663 | `7ac1483e` | project ontology traversal plus cutoff/snapshot-frozen project focus and labels; exact-head checks/review required | @@ -354,7 +354,7 @@ this file per §3.5 of the prior snapshot). | #87 | Milestone 2.1 normalized runtime-analysis schema bridge | related analysis-run work | | #269 | Authenticated Global Ask MCP browser-safe and admission-bounded | Ask stack | | #271 | Evidence-honest knowledge-cutoff scope on Global Ask | Ask stack | -| #272 | Verify Global Ask KG/ontology/semantic claims with public SearXNG evidence; ADR 0225 candidate implements the internal indexed semantic/KG nomination slice only | Ask stack; external public verification remains open | +| #272 | Verify Global Ask KG/ontology/semantic claims with public SearXNG evidence; ADR 0227 candidate implements the internal indexed semantic/KG nomination slice only | Ask stack; external public verification remains open | | #274 | Persist and explain Event Lineage channel evidence | #387 | | #277 | TEPP: persist accepted receipts, poll completed results, keep measurement authority distinct | #468, #417 | | #280 | Full project-lifecycle history and handover intervals | Tracked with issue #284; no active delivery PR confirmed | @@ -379,7 +379,7 @@ this file per §3.5 of the prior snapshot). | Authorized-corpus runtime | Repository tests use synthetic fixtures; private records remain outside git | Authenticated runtime validation returning only aggregate, non-identifying evidence | | Concurrent web responsiveness | ADR 0204 releases pooled transactions during provider work, and the synthetic Compose boundary has an authenticated k6 E2E harness for Ask enqueue, concurrent reads, and job polling. An older-image local observation found repeated post-filter queries while `/api/posts` exceeded 30 seconds; ADR 0212 combines two authorized filter-option queries into one round trip without narrowing ABAC-visible options. The observation is not exact-head evidence or a product guarantee, and no physical scan reduction is claimed without an exact-head plan | Rebuild an exact-head application image, run `make load-http` with declared environment concurrency/window, and retain raw distributions and resource configuration. Compare the post-list database plan and latency with ADR 0212 while preserving the complete authorized filter set; set no SLO until representative capacity evidence is approved | | Image understanding | Region, OCR, and description work exists across active heads (#405, #419), but current runtime acceptance has not yet proved table-image structure, complete region coverage, or summary/image readiness together | Orchestrator-backed rendered workflow, original/derived asset provenance, region-before-OCR processing, and honest unsupported states; reconcile ADR 0052's image-bearing summary readiness with ADR 0098 before changing sequencing | -| Semantic source rendering and retrieval | ADR 0223 and migration 0221 give new paragraph, list, table, MathML formula, and caller-parsed conversation-turn units explicit persisted kinds without rewriting historical rows; image regions remain ordered normalized children under ADR 0091. ADR 0225's candidate adds indexed project/role/person/organization/team/KG nomination before embedding and repeats the final authorization boundary. Neither branch is protected-main delivery | Land both exact-head candidates, prove an authorized semantic-only query retrieves each persisted evidence family and unit kind, then gather authenticated browser evidence that nesting, continuation alignment, formula units, and image regions retain source order | +| Semantic source rendering and retrieval | ADR 0223 and migration 0221 give new paragraph, list, table, MathML formula, and caller-parsed conversation-turn units explicit persisted kinds without rewriting historical rows; image regions remain ordered normalized children under ADR 0091. ADR 0227's candidate adds indexed project/role/person/organization/team/KG nomination before embedding and repeats the final authorization boundary. Neither branch is protected-main delivery | Land both exact-head candidates, prove an authorized semantic-only query retrieves each persisted evidence family and unit kind, then gather authenticated browser evidence that nesting, continuation alignment, formula units, and image regions retain source order | | Event and project semantics | Multi-project mentions, project-bound actions, 5W1H, requester/processor, and semantic relations exist in ADR 0036/0052/0100/0111/0129 and active stacks | Aggregate authenticated evidence must show distinct projects and events, explicit requester/processor and real R&R, normalized relative time, and product/entity relations without promoting attendance or co-occurrence | | 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 | From fa24548356d3a81cee233da3e2da07be02f6b18e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 18:28:27 -0700 Subject: [PATCH 05/24] feat(ask): verify typed public semantic claims (#682) * feat(ask): verify public semantic claims (#641) * feat(ask): verify public semantic claims * docs(gaps): record public verification stack --------- Co-authored-by: seonghobae * docs(ui): retain public verification audit screenshots * fix(ui): keep implementation boundaries out of customer copy * fix(i18n): translate evidence next actions * fix(public-claims): filter cited claims before cap * fix(ask): keep internal provenance out of public claims * docs(gaps): record public verification exact head * fix(ask): preserve answers when verification is unavailable * fix(public-claims): preserve answer and complete evidence boundary --------- Co-authored-by: seonghobae Co-authored-by: Codex --- backend/app/global_ask_queue.py | 104 ++++- backend/app/main.py | 25 ++ backend/app/post_chat_ingestion.py | 143 ++++++- backend/tests/test_api.py | 86 ++++ ...28-global-ask-public-claim-verification.md | 71 ++++ docs/adr/README.md | 5 +- ...OBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md | 30 ++ docs/product-requirements.md | 16 + docs/product-technical-gap-baseline.md | 2 +- ...obal-ask-public-verification-three-way.png | Bin 0 -> 46428 bytes ...public-verification-unavailable-mobile.png | Bin 0 -> 7945 bytes docs/storybook-inventory.md | 1 + frontend/src/App.test.tsx | 2 +- frontend/src/App.tsx | 20 +- frontend/src/AskAgentPanel.test.tsx | 119 ++++++ frontend/src/api.ts | 24 +- .../components/PublicClaimVerification.css | 30 ++ .../PublicClaimVerification.stories.tsx | 72 ++++ .../components/PublicClaimVerification.tsx | 52 +++ frontend/src/i18n.ts | 56 ++- frontend/src/styles/tokens.test.ts | 16 + lineageweave/claim_verification.py | 375 ++++++++++++++++++ .../0211_global_ask_public_verification.sql | 22 + .../0211_global_ask_public_verification.sql | 2 + tests/test_claim_verification.py | 292 ++++++++++++++ tests/test_global_ask_queue.py | 133 +++++++ tests/test_global_ask_sources.py | 8 + tests/test_migration_replay.py | 14 + tests/test_post_chat.py | 105 ++++- 29 files changed, 1794 insertions(+), 31 deletions(-) create mode 100644 docs/adr/0228-global-ask-public-claim-verification.md create mode 100644 docs/doctoring/GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md create mode 100644 docs/screenshots/global-ask-public-verification-three-way.png create mode 100644 docs/screenshots/global-ask-public-verification-unavailable-mobile.png create mode 100644 frontend/src/AskAgentPanel.test.tsx create mode 100644 frontend/src/components/PublicClaimVerification.css create mode 100644 frontend/src/components/PublicClaimVerification.stories.tsx create mode 100644 frontend/src/components/PublicClaimVerification.tsx create mode 100644 lineageweave/claim_verification.py create mode 100644 migrations/0211_global_ask_public_verification.sql create mode 100644 migrations/rollback/0211_global_ask_public_verification.sql create mode 100644 tests/test_claim_verification.py diff --git a/backend/app/global_ask_queue.py b/backend/app/global_ask_queue.py index c7e570d81..9170333b2 100644 --- a/backend/app/global_ask_queue.py +++ b/backend/app/global_ask_queue.py @@ -29,10 +29,22 @@ from fastapi import HTTPException, status from lineageweave.ask_delivery import build_ask_delivery +from lineageweave.claim_verification import ( + CLAIM_NOT_ENOUGH_INFORMATION, + VERIFICATION_COMPLETED, + VERIFICATION_NO_PUBLIC_CLAIMS, + VERIFICATION_SKIPPED, + VERIFICATION_UNAVAILABLE, + ClaimVerificationClient, + ClaimVerificationResult, + NullClaimVerificationClient, + public_claim_candidates, +) from lineageweave.embedding_client import EmbeddingClient, NullEmbeddingClient from lineageweave.http_client import HttpClientError from lineageweave.observability import record_server_failure from lineageweave.post_chat import ( + ChatSourceDocument, PostChatClient, cited_post_evidence, cited_post_summaries, @@ -92,6 +104,7 @@ async def enqueue_global_ask_job( *, requesting_account_id: str, question_text: str, + verify_external_requested: bool, corporate_entity_ids: frozenset[str], process_unit_ids: frozenset[str], ) -> str: @@ -104,11 +117,13 @@ async def enqueue_global_ask_job( async with conn.transaction(): job_id = await conn.fetchval( """ - insert into global_ask_job (requesting_account_id, question_text) - values ($1, $2) returning global_ask_job_id + 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, ) await conn.executemany( """ @@ -141,6 +156,55 @@ async def enqueue_global_ask_job( return str(job_id) +def _verification_next_action(status_code: str) -> str | None: + """Name the next evidence action without promoting web results to authority.""" + + return { + VERIFICATION_SKIPPED: None, + VERIFICATION_UNAVAILABLE: "Review the cited posts or try the public information check again later.", + VERIFICATION_NO_PUBLIC_CLAIMS: "Open the cited posts to review the available evidence.", + VERIFICATION_COMPLETED: "Compare the public sources with the cited posts before deciding what to do next.", + CLAIM_NOT_ENOUGH_INFORMATION: "Find another reliable source before relying on this claim.", + }.get(status_code, "Open the cited posts and review their evidence.") + + +async def _verify_public_claims( + sources: list[ChatSourceDocument], + cited_post_ids: list[str], + *, + verify_external: bool, + client: ClaimVerificationClient, +) -> tuple[str, tuple[ClaimVerificationResult, ...]]: + """Verify only cited claims explicitly marked safe for public egress.""" + + if not verify_external: + return VERIFICATION_SKIPPED, () + cited_ids = frozenset(cited_post_ids) + claims = tuple( + claim + for claim in public_claim_candidates( + sources, allowed_source_post_ids=cited_ids + ) + ) + if not claims: + return VERIFICATION_NO_PUBLIC_CLAIMS, () + if not client.available: + return VERIFICATION_UNAVAILABLE, () + try: + results = tuple( + await asyncio.gather( + *(asyncio.to_thread(client.verify, claim) for claim in claims) + ) + ) + except (HttpClientError, IndexError, KeyError, OSError, TypeError, ValueError): + return VERIFICATION_UNAVAILABLE, () + return VERIFICATION_COMPLETED, tuple( + result + for result in results + if set(result.source_post_ids).issubset(cited_ids) + ) + + async def load_job_visibility( conn: asyncpg.Connection, job_id: str, account_id: str ) -> tuple[set[str], set[str], bool, bool]: @@ -215,6 +279,8 @@ async def compute_global_ask_answer( process_scope_limited: bool, chat_client: PostChatClient, embedding_client: EmbeddingClient | None = None, + verify_external: bool = False, + claim_verification_client: ClaimVerificationClient | None = None, ) -> dict[str, Any]: """Assemble one complete Ask answer payload from authorized evidence. @@ -254,7 +320,14 @@ def can_see(row: asyncpg.Record) -> bool: status.HTTP_503_SERVICE_UNAVAILABLE, "Ask Agent is unavailable: authorized evidence could not be assembled", ) from exc + verification_client = claim_verification_client or NullClaimVerificationClient() if not sources: + verification_status, external_claims = await _verify_public_claims( + sources, + [], + verify_external=verify_external, + client=verification_client, + ) delivery = build_ask_delivery("", (), ()) return { "answer_text": "", @@ -264,6 +337,8 @@ def can_see(row: asyncpg.Record) -> bool: "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.", "delivery": delivery, } @@ -300,6 +375,12 @@ def can_see(row: asyncpg.Record) -> bool: "Ask Agent is unavailable: contextual-orchestrator could not complete the answer", ) from exc cited_ids = list(answer.cited_post_ids) + verification_status, external_claims = await _verify_public_claims( + 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) @@ -314,6 +395,11 @@ def can_see(row: asyncpg.Record) -> bool: "source_post_ids": [source.post_id for source in sources], "lineage_graph": lineage_graph, "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) if verify_external else None + ), } @@ -350,6 +436,9 @@ async def process_global_ask_job( job_id: str, chat_factory: Callable[[], PostChatClient], embedding_factory: Callable[[], EmbeddingClient] = NullEmbeddingClient, + claim_verification_factory: Callable[ + [], ClaimVerificationClient + ] = NullClaimVerificationClient, ) -> None: """Claim, answer, and settle one Ask job. @@ -363,7 +452,7 @@ 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 + returning requesting_account_id, question_text, verify_external_requested """, job_id, RUNNING, @@ -397,6 +486,8 @@ async def process_global_ask_job( process_scope_limited=process_scope_limited, chat_client=chat_client, embedding_client=embedding_factory(), + verify_external=bool(row["verify_external_requested"]), + claim_verification_client=claim_verification_factory(), ), timeout=JOB_DEADLINE_SECONDS, ) @@ -511,6 +602,7 @@ async def consume_global_ask_stream_once( last_id: str, chat_factory: Callable[[], PostChatClient], embedding_factory: Callable[[], EmbeddingClient] = NullEmbeddingClient, + claim_verification_factory: Callable[[], ClaimVerificationClient] = NullClaimVerificationClient, limiter: asyncio.Semaphore | None = None, tasks: set[asyncio.Task] | None = None, ) -> str: @@ -535,6 +627,7 @@ async def consume_global_ask_stream_once( job_id=job_id, chat_factory=chat_factory, embedding_factory=embedding_factory, + claim_verification_factory=claim_verification_factory, ) else: await limiter.acquire() @@ -544,6 +637,7 @@ async def consume_global_ask_stream_once( job_id=job_id, chat_factory=chat_factory, embedding_factory=embedding_factory, + claim_verification_factory=claim_verification_factory, limiter=limiter, ) ) @@ -560,6 +654,7 @@ async def _process_and_release( job_id: str, chat_factory: Callable[[], PostChatClient], embedding_factory: Callable[[], EmbeddingClient], + claim_verification_factory: Callable[[], ClaimVerificationClient], limiter: asyncio.Semaphore, ) -> None: """Run one dispatched job and free its concurrency slot afterwards.""" @@ -569,6 +664,7 @@ async def _process_and_release( job_id=job_id, chat_factory=chat_factory, embedding_factory=embedding_factory, + claim_verification_factory=claim_verification_factory, ) finally: limiter.release() @@ -590,6 +686,7 @@ async def run_global_ask_worker( *, chat_factory: Callable[[], PostChatClient], embedding_factory: Callable[[], EmbeddingClient] = NullEmbeddingClient, + claim_verification_factory: Callable[[], ClaimVerificationClient] = NullClaimVerificationClient, ) -> None: """Run the at-least-once Ask consumer with periodic queued-row recovery.""" last_id = await _stream_tail(client) @@ -609,6 +706,7 @@ async def run_global_ask_worker( last_id=last_id, chat_factory=chat_factory, embedding_factory=embedding_factory, + claim_verification_factory=claim_verification_factory, limiter=limiter, tasks=tasks, ) diff --git a/backend/app/main.py b/backend/app/main.py index 08ff32428..9a9c1a9c8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -34,6 +34,11 @@ from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel +from lineageweave.claim_verification import ( + NullClaimVerificationClient, + SearxngOrchestratedClaimVerificationClient, +) + from backend.app.activity_stream import ( create_valkey_client, get_valkey, @@ -295,6 +300,7 @@ async def lifespan(app: FastAPI): timeout=load_settings().orchestrator_answer_timeout_seconds ), embedding_factory=_embedding_client, + claim_verification_factory=lambda: _claim_verification_client(), ) ) app.state.global_ask_worker = global_ask_worker @@ -371,6 +377,23 @@ def _relation_verification_client(): return SearxngRelationVerificationClient(base_url=settings.searxng_base_url) +def _claim_verification_client(): + """Return the public-evidence verifier, or its unavailable null channel.""" + + settings = load_settings() + if not ( + settings.searxng_base_url + and settings.orchestrator_base_url + and settings.orchestrator_api_key + ): + return NullClaimVerificationClient() + return SearxngOrchestratedClaimVerificationClient( + settings.searxng_base_url, + settings.orchestrator_base_url, + settings.orchestrator_api_key, + ) + + def _organization_name_resolution_client(): """Live orchestrator client when configured; otherwise the unavailable null.""" settings = load_settings() @@ -2960,6 +2983,7 @@ class GlobalAskRequest(BaseModel): """JSON body for the buyer's source-grounded Global Ask Agent.""" question: str + verify_external: bool = False @app.get("/api/posts/{post_id}/chat") @@ -3117,6 +3141,7 @@ async def ask_agent( valkey, requesting_account_id=account.user_account_id, question_text=question, + verify_external_requested=request.verify_external, 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 192f205e2..a37739a63 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -26,6 +26,10 @@ import asyncpg from lineageweave.ask_time_axis import row_matches_time_range, time_axis_evidence_fact +from lineageweave.claim_verification import ( + GlobalAskSourceDocument, + PublicClaimCandidate, +) from lineageweave.embedding_client import EmbeddingClient, NullEmbeddingClient from lineageweave.image_content import ImageContentClient, NullImageContentClient from lineageweave.knowledge_graph import ( @@ -63,6 +67,14 @@ class LinkedPostIds: indirect: frozenset[str] +@dataclass(frozen=True) +class _GraphEvidenceProjection: + """Rendered graph facts and typed public-egress claims from the same rows.""" + + facts: tuple[str, ...] + public_claims: tuple[PublicClaimCandidate, ...] + + async def _normalize_post_body_text( body: str, vision_client: ImageContentClient, @@ -76,28 +88,37 @@ async def _normalize_post_body_text( return normalized.text -async def _graph_facts_for_posts( +async def _graph_evidence_projection( conn: asyncpg.Connection, visible_post_ids: list[str], -) -> tuple[str, ...]: - """Render persisted, ontology-annotated graph facts for visible posts. + public_post_ids: frozenset[str] = frozenset(), +) -> _GraphEvidenceProjection: + """Project persisted graph rows without parsing rendered fact strings. The evidence join is deliberate: a graph edge without a visible evidence post must never enter an LLM prompt. This is the chat-side trust boundary in addition to the post-level ABAC check. """ if not visible_post_ids: - return () + return _GraphEvidenceProjection((), ()) edge_rows = await conn.fetch( """ select edge.source_node_type_code, edge.source_node_id, edge.target_node_type_code, edge.target_node_id, edge.edge_type_code, edge.edge_weight, - array_agg(distinct evidence.evidence_post_id::text) as evidence_post_ids + array_agg(distinct evidence.evidence_post_id::text) + filter (where evidence.evidence_post_id = any($1::uuid[])) + as visible_evidence_post_ids, + array_agg(distinct evidence.evidence_post_id::text) as all_evidence_post_ids from knowledge_graph_edge edge join knowledge_graph_edge_evidence evidence on evidence.knowledge_graph_edge_id = edge.knowledge_graph_edge_id - where evidence.evidence_post_id = any($1::uuid[]) + where exists ( + select 1 + from knowledge_graph_edge_evidence visible_evidence + where visible_evidence.knowledge_graph_edge_id = edge.knowledge_graph_edge_id + and visible_evidence.evidence_post_id = any($1::uuid[]) + ) group by edge.source_node_type_code, edge.source_node_id, edge.target_node_type_code, edge.target_node_id, edge.edge_type_code, edge.edge_weight @@ -108,7 +129,7 @@ async def _graph_facts_for_posts( visible_post_ids, ) if not edge_rows: - return () + return _GraphEvidenceProjection((), ()) endpoint_keys = { node_key(row["source_node_type_code"], str(row["source_node_id"])) @@ -127,6 +148,7 @@ async def _graph_facts_for_posts( } facts: list[str] = [] + public_claims: list[PublicClaimCandidate] = [] for row in edge_rows: source_type = row["source_node_type_code"] source_id = str(row["source_node_id"]) @@ -141,13 +163,43 @@ async def _graph_facts_for_posts( edge_name = row["edge_type_code"] if ontology_iri: edge_name = f"{edge_name} ({ontology_iri})" - evidence_ids = ",".join(sorted(str(value) for value in row["evidence_post_ids"])) - facts.append( + visible_evidence_post_ids = tuple( + sorted(str(value) for value in row["visible_evidence_post_ids"]) + ) + all_evidence_post_ids = tuple( + sorted(str(value) for value in row["all_evidence_post_ids"]) + ) + evidence_ids = ",".join(visible_evidence_post_ids) + claim_text = ( f'{source_type} "{source["label"]}" ' - f'--{edge_name}--> {target_type} "{target["label"]}" ' - f"[evidence_post_id={evidence_ids}]" + f'--{edge_name}--> {target_type} "{target["label"]}"' ) - return tuple(dict.fromkeys(facts)) + facts.append(f"{claim_text} [evidence_post_id={evidence_ids}]") + if ( + source_type != "node_person" + and target_type != "node_person" + and all_evidence_post_ids + and set(all_evidence_post_ids).issubset(public_post_ids) + ): + public_claims.append( + PublicClaimCandidate( + claim_text=claim_text, + claim_kind="knowledge_graph_relation", + source_post_ids=all_evidence_post_ids, + ) + ) + return _GraphEvidenceProjection( + tuple(dict.fromkeys(facts)), tuple(dict.fromkeys(public_claims)) + ) + + +async def _graph_facts_for_posts( + conn: asyncpg.Connection, + visible_post_ids: list[str], +) -> tuple[str, ...]: + """Render persisted graph facts for an already-authorized post set.""" + + return (await _graph_evidence_projection(conn, visible_post_ids)).facts _SOURCE_HINT_FIELDS = ( @@ -240,6 +292,44 @@ async def _semantic_facts_for_posts( return {post_id: tuple(dict.fromkeys(values)) for post_id, values in facts.items()} +async def _public_project_claims_for_posts( + conn: asyncpg.Connection, + public_post_ids: list[str], +) -> dict[str, tuple[PublicClaimCandidate, ...]]: + """Project typed public claims from normalized project-mention rows.""" + + if not public_post_ids: + return {} + rows = await conn.fetch( + """ + select post_id::text as post_id, project_name, ontology_iri + from post_project_mention + where post_id = any($1::uuid[]) + order by post_id, project_key, ontology_iri + limit 64 + """, + public_post_ids, + ) + claims: dict[str, list[PublicClaimCandidate]] = {} + for row in rows: + post_id = str(row["post_id"]) + claim_text = ( + f'Project "{str(row["project_name"]).strip()}" ' + f'has ontology type {str(row["ontology_iri"]).strip()}' + ) + claims.setdefault(post_id, []).append( + PublicClaimCandidate( + claim_text=claim_text[:800], + claim_kind="semantic_project", + source_post_ids=(post_id,), + ) + ) + return { + post_id: tuple(dict.fromkeys(post_claims)) + for post_id, post_claims in claims.items() + } + + async def find_linked_post_ids(conn: asyncpg.Connection, post_id: str) -> LinkedPostIds: """Both link kinds for `post_id`, NOT yet ABAC-filtered -- callers must check `can_see_post` on each id before showing or using it as chat @@ -576,9 +666,18 @@ async def gather_global_chat_sources( if can_see_post(row) and row_matches_time_range(row, resolved_time_range) ][:limit] visible_ids = [str(row["post_id"]) for row in visible_rows] + public_ids = [ + str(row["post_id"]) + for row in visible_rows + if row["visibility_code"] == "public" + ] anchor_is_visible = lineage_anchor_id in visible_ids semantic_facts = await _semantic_facts_for_posts(conn, visible_ids) - graph_facts = (await _graph_facts_for_posts(conn, visible_ids))[:16] + public_project_claims = await _public_project_claims_for_posts(conn, public_ids) + graph_projection = await _graph_evidence_projection( + conn, visible_ids, frozenset(public_ids) + ) + graph_facts = graph_projection.facts[:16] time_filter_active = resolved_time_range is not None sources: list[ChatSourceDocument] = [] for index, row in enumerate(visible_rows): @@ -594,8 +693,23 @@ async def gather_global_chat_sources( if post_id in lineage_neighbor_id_set and anchor_is_visible else () ) + source_type = ( + GlobalAskSourceDocument + if row["visibility_code"] == "public" + else ChatSourceDocument + ) + source_arguments: dict[str, Any] = {} + if source_type is GlobalAskSourceDocument: + source_arguments["external_claims"] = ( + public_project_claims.get(post_id, ()) + + tuple( + claim + for claim in graph_projection.public_claims + if post_id in claim.source_post_ids + ) + ) sources.append( - ChatSourceDocument( + source_type( post_id, row["post_title"], normalized_body, @@ -604,6 +718,7 @@ async def gather_global_chat_sources( + semantic_facts.get(post_id, ()) + lineage_fact + time_axis_evidence_fact(row, time_filter_active=time_filter_active), + **source_arguments, ) ) return sources diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 45ed5d9d4..a345ea43b 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -192,6 +192,11 @@ / "migrations" / "0203_global_ask_authorization_scope.sql" ) +_GLOBAL_ASK_PUBLIC_VERIFICATION_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0211_global_ask_public_verification.sql" +) _LEFTOVER_MAP_AXIS_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" @@ -366,6 +371,7 @@ def seeded_db(demo_analyst_token): cur.execute(_LEFTOVER_MAP_COVERAGE_MIGRATION.read_text()) 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(_EVENT_OCCURRED_AT_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_AXIS_MIGRATION.read_text()) cur.execute(_CHANNEL_EVIDENCE_MIGRATION.read_text()) @@ -5137,6 +5143,86 @@ async def _source(*_args, **_kwargs): assert "lineage_graph" in answer and "cited_post_images" in answer +def test_ask_public_verification_is_opt_in_and_separate_from_post_citations( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """A cited public semantic claim can be refuted without changing its post id.""" + + import time as _time + + from lineageweave import claim_verification as cv + from lineageweave.post_chat import ChatAnswer + + class _FakeChatClient: + available = True + + def answer(self, question, sources): # noqa: ARG002 - contract shape + return ChatAnswer("Internal answer.", (sources[0].post_id,)) + + class _FakeVerificationClient: + available = True + + def verify(self, claim): + return cv.ClaimVerificationResult( + claim.claim_text, + claim.claim_kind, + cv.CLAIM_REFUTED, + "The selected public evidence conflicts with the claim.", + claim.source_post_ids, + ( + cv.ExternalEvidenceDocument( + "Public evidence", + "https://example.com/public-evidence", + "The published record describes a conflicting state.", + ), + ), + ) + + with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: + cur.execute( + """ + insert into post_project_mention + (post_id, project_key, project_name, evidence_text, confidence, + ontology_iri, extraction_method) + values (%s, 'synthetic-apollo', 'Apollo', 'Public project evidence', + 1.0, 'https://contextualwisdomlab.github.io/LineageWeave/ontology#Project', + 'synthetic_test') + """, + (seeded_db["public_post_id"],), + ) + conn.commit() + + monkeypatch.setattr("backend.app.main._post_chat_client", lambda **_kwargs: _FakeChatClient()) + monkeypatch.setattr( + "backend.app.main._claim_verification_client", + lambda: _FakeVerificationClient(), + ) + headers = {"Authorization": f"Bearer {demo_analyst_token}"} + submitted = client.post( + "/api/ask", + json={"question": "Apollo", "verify_external": True}, + headers=headers, + ) + assert submitted.status_code == 202 + job_id = submitted.json()["ask_job_id"] + + deadline = _time.monotonic() + 30 + body: dict = {} + while _time.monotonic() < deadline: + body = client.get(f"/api/ask/jobs/{job_id}", headers=headers).json() + if body["job_status_code"] in ("succeeded", "failed"): + break + _time.sleep(0.25) + + assert body.get("job_status_code") == "succeeded", body + answer = body["answer"] + assert answer["source_post_ids"] == [seeded_db["public_post_id"]] + assert answer["external_verification_status"] == cv.VERIFICATION_COMPLETED + assert answer["external_claims"][0]["status_code"] == cv.CLAIM_REFUTED + assert answer["cited_post_ids"] == [seeded_db["public_post_id"]] + assert "https://example.com/public-evidence" not in answer["cited_post_ids"] + + def test_ask_job_reads_are_owner_scoped( client, demo_analyst_token, seeded_db, monkeypatch ) -> None: diff --git a/docs/adr/0228-global-ask-public-claim-verification.md b/docs/adr/0228-global-ask-public-claim-verification.md new file mode 100644 index 000000000..b7fb500b4 --- /dev/null +++ b/docs/adr/0228-global-ask-public-claim-verification.md @@ -0,0 +1,71 @@ +# ADR 0228: Global Ask verifies typed public claims outside internal authority + +## Status + +Accepted + +## Context + +ADR 0047 lets normalized semantic and Knowledge Graph evidence nominate an +authorized source post. Nomination and an internal citation do not establish +that a real-world claim is publicly corroborated. Conversely, sending private +post bodies, people facts, measurement payloads, or source hints to a public +search service would cross the authorization boundary. + +FEVER distinguishes supported, refuted, and not-enough-information judgments +and requires cited evidence for the first two. PROV-O requires internal source +evidence, external retrieval evidence, and the verification activity to remain +distinguishable. SearXNG's current Search API supports bounded JSON results from +`GET /search` when that output format is enabled. + +## Decision + +Public verification is explicit opt-in and defaults to false. The choice is +persisted on the asynchronous `global_ask_job`; the worker never reconstructs +consent from later state. + +Only a source post whose persisted `visibility_code` is `public` receives the +`GlobalAskSourceDocument` egress capability. Claim kind, text, and evidence-post +references are projected directly from normalized `post_project_mention` and +`knowledge_graph_edge_evidence` rows after the ordinary source eligibility and +ABAC gates. No rendered-fact parsing, keyword/token overlap, confidence +threshold, or local relevance heuristic admits a claim. Eligible claims are +limited to project/ontology assertions and non-person Knowledge Graph relations +whose complete evidence-post set is public and cited. Private sources, +Keyman/person facts, raw source hints, source bodies, TEPP artifacts, +fast-mlsirm artifacts, prompts, credentials, and uncited facts never form a +public query. + +SearXNG retrieves at most five bounded snippets for at most four claims. Result +URLs must be HTTP(S), must not be search pages, localhost, `.local`, or literal +non-global addresses, and are never fetched by LineageWeave. The untrusted +snippets cross contextual-orchestrator with `mode="auto"` and +`reasoning_effort="auto"`; contextual-orchestrator retains the paper-grounded +decision over single-model versus multi-agent verification. A supported or +refuted response without selected evidence is downgraded to not enough +information. + +External URLs remain `external_claims[].evidence`; internal post identifiers +remain `cited_post_ids`. Verification never mutates ontology, Knowledge Graph, +Event Lineage, TEPP, or fast-mlsirm state. Unconfigured or failed retrieval is +an explicit unavailable state, not a negative judgment. + +## Consequences + +- Readers can request public corroboration without exporting private evidence. +- Conflicting evidence is visible without changing internal graph authority. +- Async HTTP responsiveness is retained; public retrieval runs in the worker. +- Search snippets remain evidence inputs, not trusted instructions or facts. + +## 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/ + +SearXNG. (2026). *Search API*. https://docs.searxng.org/dev/search_api.html + +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* (Vol. 1, pp. 809–819). +Association for Computational Linguistics. https://doi.org/10.18653/v1/N18-1074 diff --git a/docs/adr/README.md b/docs/adr/README.md index ee4052036..aaf05402d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -17,8 +17,9 @@ decision from them. | [`ONTOLOGY_NAMESPACE_INVENTORY.md`](../doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md) | [0207](0207-repository-case-ontology-namespace-canonical.md), [0157](0157-public-ontology-namespace-identity.md) | | [`image-content-schema.md`](../image-content-schema.md) | [0066](0066-position-preserving-image-content.md) | | [`storybook-inventory.md`](../storybook-inventory.md) | [0118](0118-uiux-standard-guide-v3-design-overhaul.md), [0184](0184-ontology-provenance-explorer.md) | -| [`POSTGRESQL_CONCURRENCY_REFERENCES.md`](../doctoring/POSTGRESQL_CONCURRENCY_REFERENCES.md) | [0204](0204-analysis-run-short-transaction-delivery.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) | +| [`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) | [0228](0228-global-ask-public-claim-verification.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) | | [`python-mathematical-compute-boundary-audit.md`](../doctoring/python-mathematical-compute-boundary-audit.md) | [0208](0208-externalize-local-mathematical-compute.md) | diff --git a/docs/doctoring/GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md b/docs/doctoring/GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md new file mode 100644 index 000000000..d9eef2592 --- /dev/null +++ b/docs/doctoring/GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md @@ -0,0 +1,30 @@ +# Global Ask public-verification research register + +ADR 0228 adopts three distinct contracts: + +- FEVER supplies the evidence-dependent `supported`, `refuted`, and + `not_enough_information` outcome model. +- W3C PROV-O keeps internal source evidence, external web evidence, and the + verification activity separate. +- SearXNG's Search API defines the bounded JSON retrieval transport; public + instance defaults are not assumed. The checked 2026-08-26 documentation + states that `/search` accepts GET query parameters and that JSON output must + be enabled by the instance; disabled formats return HTTP 403. + +The implementation does not infer claim eligibility from question-token +overlap or rendered-string patterns. It projects typed project and non-person +graph claims from normalized PostgreSQL evidence after authorization. Model +and orchestration selection remains contextual-orchestrator authority. + +## APA 7 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/ + +SearXNG. (2026). *Search API*. https://docs.searxng.org/dev/search_api.html + +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* (Vol. 1, pp. 809–819). +Association for Computational Linguistics. https://doi.org/10.18653/v1/N18-1074 diff --git a/docs/product-requirements.md b/docs/product-requirements.md index 75cba0410..bf3f03f66 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -90,6 +90,22 @@ content never becomes an external query or citation. Acceptance: each state tells the user the next valid action and never displays stale evidence from a previously opened post. +### PRD-FR-5A — Opt-in public claim verification + +- Persist an explicit per-question opt-in before any external search begins. +- Nominate only cited, public semantic/KG facts; source bodies, private facts, + personal facts, and measurement outputs never become external queries. +- Retrieve bounded public evidence through SearXNG and adjudicate through + contextual-orchestrator's adaptive orchestration boundary. +- Report supported, refuted, and not-enough-information outcomes without + promoting public pages to internal ontology authority. +- Keep external URLs visually and structurally separate from authorized + internal post citations. + +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-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 5b2a18932..1d828ffc2 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -354,7 +354,7 @@ this file per §3.5 of the prior snapshot). | #87 | Milestone 2.1 normalized runtime-analysis schema bridge | related analysis-run work | | #269 | Authenticated Global Ask MCP browser-safe and admission-bounded | Ask stack | | #271 | Evidence-honest knowledge-cutoff scope on Global Ask | Ask stack | -| #272 | Verify Global Ask KG/ontology/semantic claims with public SearXNG evidence; ADR 0227 candidate implements the internal indexed semantic/KG nomination slice only | Ask stack; external public verification remains open | +| #272 | Verify Global Ask KG/ontology/semantic claims with public SearXNG evidence; stacked PR #682 exact head `b9cf3f01` adds the ADR 0228 opt-in candidate and keeps internal post provenance out of the public evidence payload | #682 is in progress, not protected delivery; require terminal exact-head checks, independent review, and authenticated egress/runtime evidence before closing | | #274 | Persist and explain Event Lineage channel evidence | #387 | | #277 | TEPP: persist accepted receipts, poll completed results, keep measurement authority distinct | #468, #417 | | #280 | Full project-lifecycle history and handover intervals | Tracked with issue #284; no active delivery PR confirmed | diff --git a/docs/screenshots/global-ask-public-verification-three-way.png b/docs/screenshots/global-ask-public-verification-three-way.png new file mode 100644 index 0000000000000000000000000000000000000000..285796091b805b52eae39e3c6ddd32e364e57ddb GIT binary patch literal 46428 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 O2$idv$eb&;{Qd<6`XVv_ literal 0 HcmV?d00001 diff --git a/docs/screenshots/global-ask-public-verification-unavailable-mobile.png b/docs/screenshots/global-ask-public-verification-unavailable-mobile.png new file mode 100644 index 0000000000000000000000000000000000000000..e16a3a13f01e3a1b27fc37875bb33c215dae21ce GIT binary patch literal 7945 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 DNVF#H literal 0 HcmV?d00001 diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 7ec497477..d81337acf 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` | | `Evidence/OntologyExplorer` | Distinguish Post, Person, Organization, and Team by shape and text, use the token-backed surface as a secondary cue, then open the exact-value table or cited evidence. Compare desktop, narrow, drawer, empty, truncated, denied, stale, and rejected states. | `--ontology-node-*-fill`, `OntologyExplorer` | +| `Ask Agent/Public claim verification` | Compare supported, refuted, not-enough-information, and unavailable states; open only the external evidence link, then review the separate internal citation before changing governed graph state. Audited screenshots: [`three-way desktop`](screenshots/global-ask-public-verification-three-way.png), [`unavailable mobile`](screenshots/global-ask-public-verification-unavailable-mobile.png). | `--space-panel-block`, `--space-control-gap`, `--color-border`, `--size-control-min`, `PublicClaimVerification` | 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.test.tsx b/frontend/src/App.test.tsx index 2dee4513d..1d33f8f70 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -2696,7 +2696,7 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: /verify against web search/i })); await waitFor(() => - expect(screen.getByText("Verification unavailable (search is not configured).")).toBeInTheDocument(), + expect(screen.getByText("Public information could not be checked. Try again later.")).toBeInTheDocument(), ); expect(screen.queryByText(/HTTP 503/)).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /verify against web search/i })).not.toBeInTheDocument(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 76ff51dec..429437b27 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -94,6 +94,7 @@ import { CutoffKnownBody } from "./components/CutoffKnownBody"; import { LineageEntityPicker } from "./components/LineageEntityPicker"; import { OntologyExplorer } from "./components/OntologyExplorer"; import { AskEvidenceLayerPopup } from "./components/AskEvidenceLayerPopup"; +import { PublicClaimVerification } from "./components/PublicClaimVerification"; import { PopupCloseButton } from "./components/PopupCloseButton"; import { SimilarVocPanel } from "./components/SimilarVocPanel"; import { chatEvidenceKindLabel } from "./evidenceKindLabels"; @@ -156,7 +157,7 @@ function LanguageSwitcher({ accessToken }: { accessToken?: string }) { function searchUnavailableMessage(err: unknown): string { if (err instanceof BackendError && err.status === 503) { - return t("Verification unavailable (search is not configured)."); + return t("Public information could not be checked. Try again later."); } return String(err); } @@ -4813,7 +4814,7 @@ function CustomerMasterPanel({ ); } -function AskAgentPanel({ +export function AskAgentPanel({ accessToken, onOpenPost, }: { @@ -4824,6 +4825,7 @@ function AskAgentPanel({ const [answer, setAnswer] = useState(null); const [error, setError] = useState(null); const [asking, setAsking] = useState(false); + const [verifyExternal, setVerifyExternal] = useState(false); const [evidenceLayerPostId, setEvidenceLayerPostId] = useState(null); async function handleAsk() { @@ -4832,7 +4834,7 @@ function AskAgentPanel({ setAsking(true); setError(null); try { - setAnswer(await askAgent(accessToken, normalized)); + setAnswer(await askAgent(accessToken, normalized, verifyExternal)); } catch (err) { setAnswer(null); setError(orchestratorUnavailableMessage(err, t("Ask Agent"))); @@ -4856,6 +4858,14 @@ function AskAgentPanel({ rows={4} /> + @@ -4864,6 +4874,10 @@ function AskAgentPanel({

{t("Answer")}

{answer.answer_text ?

{answer.answer_text}

: null} {answer.next_action ?

{t(answer.next_action)}

: null} + {answer.delivery ? (