diff --git a/CHANGELOG.md b/CHANGELOG.md index 641306055..fb4e39ac6 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 0233; 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_queue.py b/backend/app/global_ask_queue.py index 9bffd8502..81b16c0fe 100644 --- a/backend/app/global_ask_queue.py +++ b/backend/app/global_ask_queue.py @@ -565,7 +565,11 @@ async def process_global_ask_job( embedding_client=embedding_factory(), semantic_query_client=semantic_query_factory(), verify_external=bool(row["verify_external_requested"]), - claim_verification_client=claim_verification_factory(), + claim_verification_client=( + claim_verification_factory() + if bool(row["verify_external_requested"]) + else None + ), knowledge_cutoff=row["knowledge_cutoff"], ), timeout=JOB_DEADLINE_SECONDS, 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/main.py b/backend/app/main.py index 6457bbde1..4460392ec 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -292,8 +292,8 @@ async def lifespan(app: FastAPI): ) ) app.state.post_content_worker = content_worker - # Late-bound lambda so tests that monkeypatch _post_chat_client reach - # the worker too (the name resolves in module globals at call time). + # Late-bound lambdas keep worker factories aligned with runtime/test + # configuration changes (the names resolve in globals at call time). # Only this worker gets the long answer timeout; the per-post chat # endpoint keeps the client's interactive default. global_ask_worker = asyncio.create_task( diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index 4a208c13c..efa472d2c 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -27,7 +27,7 @@ import asyncpg from lineageweave.ask_time_axis import row_matches_time_range, time_axis_evidence_fact -from lineageweave.claim_verification import GlobalAskSourceDocument +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 ( @@ -51,6 +51,7 @@ from lineageweave.temporal_expressions import resolve_korean_relative_time from .config import load_settings +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 .source_post_revision import fetch_known_at_revisions @@ -67,6 +68,14 @@ class LinkedPostIds: indirect: frozenset[str] +@dataclass(frozen=True) +class _GraphEvidenceProjection: + """Rendered graph facts and typed public-egress claims from the same rows.""" + + facts: dict[str, tuple[str, ...]] + public_claims: tuple[PublicClaimCandidate, ...] + + async def _normalize_post_body_text( body: str, vision_client: ImageContentClient, @@ -80,11 +89,12 @@ 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], + public_post_ids: frozenset[str] = frozenset(), knowledge_cutoff: datetime | None = None, -) -> dict[str, tuple[str, ...]]: +) -> _GraphEvidenceProjection: """Render graph facts under each visible post that evidences them. The evidence join is deliberate: a graph edge without a visible evidence @@ -94,18 +104,28 @@ async def _graph_facts_for_posts( different source and then cited as though that source supported it. """ if not visible_post_ids or knowledge_cutoff is not None: - 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 - order by evidence.evidence_post_id::text) as evidence_post_ids + order by 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 + order by 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 @@ -116,7 +136,7 @@ async def _graph_facts_for_posts( visible_post_ids, ) if not edge_rows: - return {} + return _GraphEvidenceProjection({}, ()) visible_post_id_set = frozenset(visible_post_ids) edge_rows = [ @@ -132,7 +152,7 @@ async def _graph_facts_for_posts( ) ] if not edge_rows: - return {} + return _GraphEvidenceProjection({}, ()) endpoint_keys = { node_key(row["source_node_type_code"], str(row["source_node_id"])) @@ -151,6 +171,7 @@ async def _graph_facts_for_posts( } facts_by_post: dict[str, list[str]] = {} + public_claims: list[PublicClaimCandidate] = [] fact_count = 0 for row in edge_rows: source_type = row["source_node_type_code"] @@ -170,7 +191,25 @@ async def _graph_facts_for_posts( f'{source_type} "{source["label"]}" ' f'--{edge_name}--> {target_type} "{target["label"]}" ' ) - for evidence_post_id in row["evidence_post_ids"]: + visible_evidence_post_ids = tuple( + str(value) for value in row["visible_evidence_post_ids"] + ) + all_evidence_post_ids = tuple( + str(value) for value in row["all_evidence_post_ids"] + ) + claim_text = fact_prefix.rstrip() + 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, + )) + for evidence_post_id in visible_evidence_post_ids: post_id = str(evidence_post_id) post_facts = facts_by_post.setdefault(post_id, []) fact = f"{fact_prefix}[evidence_post_id={post_id}]" @@ -178,8 +217,27 @@ async def _graph_facts_for_posts( post_facts.append(fact) fact_count += 1 if fact_count >= 64: - return {key: tuple(value) for key, value in facts_by_post.items()} - return {key: tuple(value) for key, value in facts_by_post.items()} + return _GraphEvidenceProjection( + {key: tuple(value) for key, value in facts_by_post.items()}, + tuple(dict.fromkeys(public_claims)), + ) + return _GraphEvidenceProjection( + {key: tuple(value) for key, value in facts_by_post.items()}, + tuple(dict.fromkeys(public_claims)), + ) + + +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 the visible post that evidences each fact.""" + return ( + await _graph_evidence_projection( + conn, visible_post_ids, knowledge_cutoff=knowledge_cutoff + ) + ).facts _SOURCE_HINT_FIELDS = ( @@ -278,6 +336,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 @@ -538,6 +634,8 @@ 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() ) @@ -560,6 +658,19 @@ async def gather_global_chat_sources( embedding_enabled = validated_embedding is not None if supplied_question_embedding and not embedding_enabled: return [] + semantic_nominee_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, + ) + if knowledge_cutoff is None + else [] + ) question_vector, embedding_model_code, question_norm = validated_embedding or ( [], "", @@ -816,23 +927,26 @@ async def gather_global_chat_sources( embedding_enabled, ) embedding_candidate_ids: list[str] = [] - evidence_candidate_ids: list[str] = [] + evidence_candidate_ids: list[str] = list(semantic_nominee_ids) for row in candidate_rows: channel = str(row.get("candidate_channel") or "embedding") target = ( evidence_candidate_ids if channel == "evidence" else embedding_candidate_ids ) - target.append(str(row["post_id"])) + post_id = str(row["post_id"]) + if post_id not in target: + target.append(post_id) candidate_ids = _fuse_global_candidate_ids( embedding_candidate_ids, evidence_candidate_ids, limit ) candidate_id_set = frozenset(candidate_ids) - # One semantic match is still only one event snapshot. Expand the - # best-matching post through its direct Event Lineage neighbors + # One nominated match is still only one event snapshot. Expand the + # first authorized full-text-or-embedding candidate through its direct + # Event Lineage neighbors # (`post_lineage_edge`, `lineageweave.reconstruct`'s output), mirroring # `find_linked_post_ids`'s `.direct` set used by the post-scoped chat - # flow. Only the top match is expanded so lower-ranked semantic candidates + # flow. Only the first candidate is expanded so later candidates # 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 @@ -881,8 +995,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, @@ -895,10 +1009,26 @@ 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 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) + graph_projection = await _graph_evidence_projection( + conn, + visible_ids, + frozenset(public_ids), + knowledge_cutoff, + ) + graph_facts = graph_projection.facts + project_claims = ( + await _public_project_claims_for_posts(conn, public_ids) + if knowledge_cutoff is None + else {} + ) remaining_graph_facts = 16 time_filter_active = resolved_time_range is not None sources: list[ChatSourceDocument] = [] @@ -934,8 +1064,13 @@ async def gather_global_chat_sources( ) source_arguments: dict[str, Any] = {} if source_type is GlobalAskSourceDocument: - source_arguments["external_claim_facts"] = ( - semantic_facts.get(post_id, ()) + post_graph_facts + source_arguments["public_claims"] = ( + project_claims.get(post_id, ()) + + tuple( + claim + for claim in graph_projection.public_claims + if post_id in claim.source_post_ids + ) ) sources.append( source_type( diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 892c8231a..86470e3d4 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -15,6 +15,7 @@ import asyncio import math import os +import subprocess import uuid from contextlib import closing from pathlib import Path @@ -186,6 +187,11 @@ / "migrations" / "0165_global_ask_job.sql" ) +_GLOBAL_ASK_SEMANTIC_SEARCH_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0225_global_ask_semantic_candidate_search.sql" +) _GLOBAL_ASK_SCOPE_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" @@ -752,6 +758,18 @@ def _insert_post( ), ) conn.commit() + subprocess.run( + [ + "psql", + "-X", + "-v", + "ON_ERROR_STOP=1", + db_dsn, + "-f", + str(_GLOBAL_ASK_SEMANTIC_SEARCH_MIGRATION), + ], + check=True, + ) yield { "dsn": db_dsn, diff --git a/docs/adr/0233-global-ask-semantic-candidate-nomination.md b/docs/adr/0233-global-ask-semantic-candidate-nomination.md new file mode 100644 index 000000000..6d1086b4b --- /dev/null +++ b/docs/adr/0233-global-ask-semantic-candidate-nomination.md @@ -0,0 +1,59 @@ +# ADR 0233: 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. +Migration 0225 removes only an INVALID catalog entry left by an interrupted +concurrent build before replay. Healthy indexes remain in place, while `IF NOT +EXISTS` cannot mistake a failed build for a usable index. + +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/0234-global-ask-public-claim-verification.md b/docs/adr/0234-global-ask-public-claim-verification.md new file mode 100644 index 000000000..ecedbc862 --- /dev/null +++ b/docs/adr/0234-global-ask-public-claim-verification.md @@ -0,0 +1,71 @@ +# ADR 0234: Global Ask verifies typed public claims outside internal authority + +## Status + +Accepted + +## Context + +ADR 0233 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 83e56345c..4fdd255f0 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -18,7 +18,7 @@ decision from them. | [`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), [0222](0222-project-nodes-in-ontology-neighborhood.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_PUBLIC_VERIFICATION_REFERENCES.md`](../doctoring/GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md) | [0234](0234-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) | | [`GLOBAL_ASK_QUERY_REWRITE_REFERENCES.md`](../doctoring/GLOBAL_ASK_QUERY_REWRITE_REFERENCES.md) | [0217](0217-evidence-constrained-semantic-query-rewrite.md) | | [`MCP_GLOBAL_ASK_REFERENCES.md`](../doctoring/MCP_GLOBAL_ASK_REFERENCES.md) | [0218](0218-current-contract-mcp-global-ask.md) | @@ -27,6 +27,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) | [0233](0233-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_PUBLIC_VERIFICATION_REFERENCES.md b/docs/doctoring/GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md index 18bfaf147..2cafbc9e7 100644 --- a/docs/doctoring/GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md +++ b/docs/doctoring/GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md @@ -1,5 +1,7 @@ # Global Ask public-verification research register +ADR 0234 adopts three distinct contracts: + ADR 0215 adopts three distinct contracts: - FEVER supplies the evidence-dependent `supported`, `refuted`, and @@ -7,6 +9,15 @@ ADR 0215 adopts three distinct contracts: - 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. + instance defaults are not assumed. ## APA 7 references 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..8eaeee497 --- /dev/null +++ b/docs/doctoring/GLOBAL_ASK_SEMANTIC_CANDIDATE_REFERENCES.md @@ -0,0 +1,21 @@ +# Global Ask semantic candidate research register + +ADR 0233 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-requirements.md b/docs/product-requirements.md index 0d456f2ae..8f7ec846d 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -103,6 +103,8 @@ stale evidence from a previously opened post. - 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. + contextual-orchestrator's verification mode. - Report supported, refuted, and not-enough-information outcomes without promoting public pages to internal ontology authority. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 7704fa748..cd3289671 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -369,6 +369,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. PR #633's measured landing-query and event-loop work merged into open parent #629 rather than protected `main`; its aggregate observation improved 25-VU throughput but did not establish a latency SLO. The current exact #629 also persists each completed relation verification before propagating a later provider failure | Land #629 through its refreshed protected gate, rebuild that exact-head application image, and repeat `make load-http` with declared environment concurrency/window and retained raw distributions/resource configuration; 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 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 0233'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 | Paragraph, table, list, formula, and indentation work exists across stacks (#394, #427, #448–#450); #515 adds synthetic backend/frontend parity for deterministic rows/cells, footnote boundaries, and encoded scripts | Land the #427 → #515 stack, then gather authenticated browser evidence that list nesting, continuation alignment, and formula units render without authoring-layout artifacts | | Event and project semantics | #663 is the largest current user-visible gap slice: evidence-backed Project nodes, bounded traversal, cutoff/snapshot fencing, exact-value table parity, and localized graph labels. Focus visibility, label-bound, and temporal test-double regressions are repaired. #666's heuristic removal is composed into this parent but is not separately protected-main evidence. #640 separately adds project journeys without claiming authoritative lifecycle status | Combined #663 must pass exact-head checks and independent approval before protected merge. Aggregate authenticated evidence must still prove distinct projects/events and handover intervals without promoting co-occurrence | | Knowledge Graph readability | #659 recreates the token-backed node-type repair on current `main`, including regression coverage; it is open and therefore not protected-main evidence | Merge #659 normally, then verify light/dark contrast, keyboard graph navigation, full labels, and evidence tables in the authenticated rendered surface | 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 000000000..285796091 Binary files /dev/null and b/docs/screenshots/global-ask-public-verification-three-way.png differ 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 000000000..e16a3a13f Binary files /dev/null and b/docs/screenshots/global-ask-public-verification-unavailable-mobile.png differ diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 81ab3a8af..c7fd42d01 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -16,7 +16,7 @@ operator-facing control you can click before changing product CSS. | `Lineage/LineageDag` | Open a reconstructed connection to read its inferred channel scores and Allen interval relation, or open the current branch node; compare empty, single-branch, grouped/forked, mobile-scroll, ungrouped, and long-title states before changing graph CSS. On narrow viewports, swipe the named viewport or focus it and use arrow keys to inspect the full lineage. | `--color-accent-background`, `--radius-control`, `--surface`, `--border`, `--color-focus-border`, `--size-control-min`, `LineageDag` | | `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/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` | | `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 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 fbba1d9f2..9db24cc3c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -157,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); } diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 390be07d8..367262709 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -76,7 +76,7 @@ const TRANSLATIONS: Partial>> = { "Stored semantic evidence": "저장된 의미 기반 근거", "Recorded evidence": "기록된 근거", "Lineage maintenance": "계보 관리", - "Verification unavailable (search is not configured).": "검증을 사용할 수 없습니다(검색이 설정되지 않았습니다).", + "Public information could not be checked. Try again later.": "공개 정보를 확인하지 못했습니다. 잠시 후 다시 시도하세요.", "No customer commitment found in this post.": "이 글에서 고객 약속을 찾지 못했습니다.", due: "기한", "Ticket created": "티켓 생성", @@ -603,7 +603,7 @@ const TRANSLATIONS: Partial>> = { "Stored semantic evidence": "已存储的语义证据", "Recorded evidence": "已记录的证据", "Lineage maintenance": "谱系维护", - "Verification unavailable (search is not configured).": "无法验证(未配置搜索)。", + "Public information could not be checked. Try again later.": "无法核验公开信息。请稍后重试。", "No customer commitment found in this post.": "未在此文章中找到客户承诺。", due: "截止日期", "Ticket created": "工单已创建", @@ -1146,7 +1146,7 @@ const TRANSLATIONS: Partial>> = { "Stored semantic evidence": "保存された意味的証拠", "Recorded evidence": "記録された証拠", "Lineage maintenance": "系譜管理", - "Verification unavailable (search is not configured).": "確認できません(検索が設定されていません)。", + "Public information could not be checked. Try again later.": "公開情報を確認できませんでした。しばらくしてから再試行してください。", "No customer commitment found in this post.": "この投稿に顧客コミットメントは見つかりませんでした。", due: "期限", "Ticket created": "チケットを作成", @@ -1668,7 +1668,7 @@ const TRANSLATIONS: Partial>> = { "Stored semantic evidence": "Bằng chứng ngữ nghĩa đã lưu", "Recorded evidence": "Bằng chứng đã ghi nhận", "Lineage maintenance": "Bảo trì dòng sự kiện", - "Verification unavailable (search is not configured).": "Không thể xác minh (chưa cấu hình tìm kiếm).", + "Public information could not be checked. Try again later.": "Chưa thể kiểm tra thông tin công khai. Hãy thử lại sau.", "No customer commitment found in this post.": "Không tìm thấy cam kết của khách hàng trong bài viết này.", due: "Hạn", "Ticket created": "Đã tạo phiếu", diff --git a/lineageweave/claim_verification.py b/lineageweave/claim_verification.py index e179e2a90..7424ffbf5 100644 --- a/lineageweave/claim_verification.py +++ b/lineageweave/claim_verification.py @@ -47,22 +47,14 @@ "yandex.", "searx", ) -_PROVENANCE_SUFFIX = re.compile( - r"\s*\[(?:evidence_post_id|provenance)=[^]]+\]\s*$" -) -_METADATA_SEGMENT = re.compile( - r"\s*\|\s*(?:extraction_method|confidence):\s*[^|\[]+" -) -_TOKEN = re.compile(r"[0-9A-Za-z가-힣_:/#.-]{2,}") _CODE_FENCE = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.DOTALL) -_EVIDENCE_POST_IDS = re.compile(r"\[evidence_post_id=([^]]+)\]") @dataclass(frozen=True) class GlobalAskSourceDocument(ChatSourceDocument): """Authorized Global Ask source plus facts explicitly safe for web egress.""" - external_claim_facts: tuple[str, ...] = field(default_factory=tuple) + public_claims: tuple[PublicClaimCandidate, ...] = field(default_factory=tuple) @dataclass(frozen=True) @@ -132,80 +124,47 @@ def verify(self, claim: PublicClaimCandidate) -> ClaimVerificationResult: raise RuntimeError("public claim verification is not configured") -def _clean_fact(fact: str) -> str: - """Remove storage and extraction metadata while preserving the assertion.""" - - cleaned = _PROVENANCE_SUFFIX.sub("", fact) - cleaned = _METADATA_SEGMENT.sub("", cleaned) - cleaned = re.split(r"\s*\|\s*evidence:", cleaned, maxsplit=1)[0] - return " ".join(cleaned.split()) - - -def _claim_kind(fact: str) -> str | None: - """Return the externally verifiable claim family, or ``None``.""" - - if "node_person" in fact or fact.startswith(("Keyman mention:", "actor:")): - return None - if "--" in fact and "-->" in fact: - return "knowledge_graph_relation" - if fact.startswith("project:"): - return "semantic_project" - if "ontology_iri:" in fact or "/ontology#" in fact: - return "ontology_reference" - return None - - -def _question_tokens(question: str) -> frozenset[str]: - return frozenset(token.casefold() for token in _TOKEN.findall(question)) - - def public_claim_candidates( sources: list[ChatSourceDocument] | tuple[ChatSourceDocument, ...], question: str, *, maximum_claims: int = 4, ) -> tuple[PublicClaimCandidate, ...]: - """Select bounded public claims relevant to ``question``. + """Return bounded public claims from egress-capable sources. Only :class:`GlobalAskSourceDocument` instances can contribute facts. This makes the public-egress capability explicit instead of adding an egress field to every post-scoped chat source. Person and Keyman claims are still excluded even when an upstream caller constructs a malformed subclass. + ``question`` is accepted for caller symmetry only; the cited-source gate, + rather than a local relevance heuristic, bounds egress. """ if maximum_claims <= 0: return () - query_tokens = _question_tokens(question) + del question merged: dict[tuple[str, str], list[str]] = {} for source in sources: if not isinstance(source, GlobalAskSourceDocument): continue - for raw_fact in source.external_claim_facts: - kind = _claim_kind(raw_fact) - if kind is None: - continue - claim_text = _clean_fact(raw_fact) - if not claim_text or len(claim_text) > 800: + for claim in source.public_claims: + if not claim.claim_text or len(claim.claim_text) > 800: continue - claim_tokens = _question_tokens(claim_text) - if query_tokens and not query_tokens.intersection(claim_tokens): + if claim.claim_kind not in { + "semantic_project", + "ontology_reference", + "knowledge_graph_relation", + }: continue - key = (kind, claim_text) + key = (claim.claim_kind, claim.claim_text) post_ids = merged.setdefault(key, []) - evidence_match = _EVIDENCE_POST_IDS.search(raw_fact) - evidence_ids = ( - [value.strip() for value in evidence_match.group(1).split(",")] - if evidence_match is not None - else [source.post_id] - ) - for post_id in evidence_ids: + for post_id in claim.source_post_ids: if post_id and post_id not in post_ids: post_ids.append(post_id) ranked = sorted( merged.items(), key=lambda item: ( - -len(query_tokens.intersection(_question_tokens(item[0][1]))), item[0][0], item[0][1].casefold(), ), diff --git a/migrations/0225_global_ask_semantic_candidate_search.sql b/migrations/0225_global_ask_semantic_candidate_search.sql new file mode 100644 index 000000000..fc56eabad --- /dev/null +++ b/migrations/0225_global_ask_semantic_candidate_search.sql @@ -0,0 +1,76 @@ +-- Native PostgreSQL full-text indexes for Global Ask semantic nomination. +-- The expressions match backend/app/global_ask_semantic_candidates.py. +-- An interrupted concurrent build leaves its catalog row INVALID. Remove only +-- those failed builds before IF NOT EXISTS evaluates the surviving indexes. +select format('drop index concurrently %I.%I', namespace.nspname, index_class.relname) +from pg_index as index_state +join pg_class as index_class on index_class.oid = index_state.indexrelid +join pg_namespace as namespace on namespace.oid = index_class.relnamespace +where not index_state.indisvalid + and namespace.nspname = current_schema() + and index_class.relname = any (array[ + 'post_project_mention_search_fts_idx', + 'post_summary_role_search_fts_idx', + 'cataloged_person_search_fts_idx', + 'post_person_mention_context_fts_idx', + 'corporate_entity_name_fts_idx', + 'cataloged_team_search_fts_idx', + 'knowledge_graph_edge_code_fts_idx' + ]) +\gexec + +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_claim_verification.py b/tests/test_claim_verification.py index fc1a3507f..c56042eb5 100644 --- a/tests/test_claim_verification.py +++ b/tests/test_claim_verification.py @@ -8,12 +8,12 @@ from lineageweave.post_chat import ChatSourceDocument -def _public_source(*facts: str) -> cv.GlobalAskSourceDocument: +def _public_source(*claims: cv.PublicClaimCandidate) -> cv.GlobalAskSourceDocument: return cv.GlobalAskSourceDocument( post_id="11111111-1111-1111-1111-111111111111", post_title="Public evidence", post_body="Acme semantic evidence", - external_claim_facts=tuple(facts), + public_claims=claims, ) @@ -29,9 +29,21 @@ def test_only_global_ask_sources_can_contribute_public_claims() -> None: def test_public_claim_candidates_keep_public_semantic_and_graph_claims_bounded() -> None: source = _public_source( - "project: Apollo | evidence: Acme launch | ontology_iri: https://example.test/ontology#Project | extraction_method: llm | confidence: 0.90 [provenance=post_project_mention]", - 'node_team "Apollo Team" --edge_team_affiliation (https://example.test/ontology#teamAffiliation)--> node_organization "Acme" [evidence_post_id=11111111-1111-1111-1111-111111111111]', - 'node_person "Alice" --edge_affiliation--> node_organization "Acme" [evidence_post_id=11111111-1111-1111-1111-111111111111]', + cv.PublicClaimCandidate( + 'Project "Apollo" has ontology type https://example.test/ontology#Project', + "semantic_project", + ("11111111-1111-1111-1111-111111111111",), + ), + cv.PublicClaimCandidate( + 'node_team "Apollo Team" --edge_team_affiliation--> node_organization "Acme"', + "knowledge_graph_relation", + ("11111111-1111-1111-1111-111111111111",), + ), + cv.PublicClaimCandidate( + 'node_person "Alice" --edge_affiliation--> node_organization "Acme"', + "person_relation", + ("11111111-1111-1111-1111-111111111111",), + ), ) claims = cv.public_claim_candidates([source], "Is Apollo at Acme?", maximum_claims=8) @@ -42,13 +54,16 @@ def test_public_claim_candidates_keep_public_semantic_and_graph_claims_bounded() ] assert all("node_person" not in claim.claim_text for claim in claims) assert claims[0].source_post_ids == (source.post_id,) - assert "extraction_method" not in claims[1].claim_text - assert "confidence" not in claims[1].claim_text + assert claims[1].claim_text.startswith('Project "Apollo"') -def test_public_claim_candidates_require_query_overlap_and_positive_budget() -> None: - source = _public_source("project: Apollo | evidence: Acme launch") - assert cv.public_claim_candidates([source], "Zephyr") == () +def test_public_claim_candidates_do_not_apply_local_relevance_and_require_positive_budget() -> None: + source = _public_source( + cv.PublicClaimCandidate( + "Project Apollo", "semantic_project", ("11111111-1111-1111-1111-111111111111",) + ) + ) + assert cv.public_claim_candidates([source], "Zephyr") == source.public_claims assert cv.public_claim_candidates([source], "Apollo", maximum_claims=0) == () diff --git a/tests/test_global_ask_queue.py b/tests/test_global_ask_queue.py index fed1b90d7..ae2dbfa2c 100644 --- a/tests/test_global_ask_queue.py +++ b/tests/test_global_ask_queue.py @@ -83,7 +83,9 @@ def test_public_verification_requires_public_capability_and_internal_citation() "public-post", "Public", "Public body", - external_claim_facts=("project: Apollo | evidence: public",), + public_claims=( + cv.PublicClaimCandidate("Project Apollo", "semantic_project", ("public-post",)), + ), ) private_status, private_results = asyncio.run( @@ -119,7 +121,9 @@ def test_public_verification_keeps_external_urls_out_of_internal_citations() -> "public-post", "Public", "Public body", - external_claim_facts=("project: Apollo | evidence: public",), + public_claims=( + cv.PublicClaimCandidate("Project Apollo", "semantic_project", ("public-post",)), + ), ) status_code, results = asyncio.run( @@ -144,7 +148,9 @@ def test_malformed_public_verification_is_unavailable() -> None: "public-post", "Public", "Public body", - external_claim_facts=("project: Apollo | evidence: public",), + public_claims=( + cv.PublicClaimCandidate("Project Apollo", "semantic_project", ("public-post",)), + ), ) for error in ( @@ -403,6 +409,41 @@ async def _fake_compute_global_ask_answer(*_args, **_kwargs): ) +def test_non_verification_job_does_not_build_public_search_client(monkeypatch) -> None: + """An ordinary Ask job is independent of optional public-search config.""" + connection = _Connection(_queued_row()) + pool = _Pool(connection) + + async def _fake_load_job_visibility(_conn, _job_id, _account_id): + return {"corp-1"}, set(), False, True + + async def _fake_compute_global_ask_answer(*_args, **kwargs): + assert kwargs["verify_external"] is False + assert kwargs["claim_verification_client"] is None + return {"answer_text": "synthetic answer"} + + def _unexpected_verification_factory(): + raise AssertionError("non-opt-in jobs must not build public search") + + monkeypatch.setattr(global_ask_queue, "load_job_visibility", _fake_load_job_visibility) + monkeypatch.setattr( + global_ask_queue, "compute_global_ask_answer", _fake_compute_global_ask_answer + ) + + asyncio.run( + global_ask_queue.process_global_ask_job( + pool, + job_id="job-1", + chat_factory=_AvailableClient, + claim_verification_factory=_unexpected_verification_factory, + ) + ) + + settle_query, settle_args = connection.executed[-1] + assert "failure_detail" not in settle_query + assert settle_args[1] == global_ask_queue.SUCCEEDED + + def test_permission_and_connection_errors_keep_their_pre_authored_safe_message( monkeypatch, ) -> None: diff --git a/tests/test_global_ask_semantic_candidates.py b/tests/test_global_ask_semantic_candidates.py new file mode 100644 index 000000000..0e70db701 --- /dev/null +++ b/tests/test_global_ask_semantic_candidates.py @@ -0,0 +1,100 @@ +"""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" / "0225_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 "not index_state.indisvalid" in sql + assert sql.index("\\gexec") < sql.index("create index concurrently if not exists") + 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 2167ff7ec..34a2aa348 100644 --- a/tests/test_global_ask_sources.py +++ b/tests/test_global_ask_sources.py @@ -5,6 +5,7 @@ from datetime import date, datetime, timezone from backend.app.post_chat_ingestion import ( + _GraphEvidenceProjection, _fuse_global_candidate_ids, _ontology_lookup_codes_in_question, gather_global_chat_sources as _gather_global_chat_sources, @@ -195,14 +196,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", ) ) @@ -236,6 +239,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( @@ -247,7 +252,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 ) @@ -288,6 +295,14 @@ async def fetch(self, query: str, *args): if "from source_post" in query: return rows if "from post_project_mention" in query: + if "project_name, ontology_iri" in query: + return [ + { + "post_id": "semantic-post", + "project_name": "semantic project", + "ontology_iri": "urn:test", + } + ] return [ { "post_id": "semantic-post", @@ -326,11 +341,16 @@ class FakeConnection: async def fetch(self, query: str, *args): return rows if "from source_post" in query else [] - async def fake_graph_facts(_conn, _visible_post_ids, _knowledge_cutoff=None): - return {"post-b": ("fact evidenced by post-b",)} + async def fake_graph_projection( + _conn, _visible_post_ids, _public_post_ids, _knowledge_cutoff=None + ): + return _GraphEvidenceProjection( + {"post-b": ("fact evidenced by post-b",)}, () + ) monkeypatch.setattr( - "backend.app.post_chat_ingestion._graph_facts_for_posts", fake_graph_facts + "backend.app.post_chat_ingestion._graph_evidence_projection", + fake_graph_projection, ) sources = asyncio.run( @@ -351,6 +371,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( @@ -373,6 +395,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( @@ -660,6 +684,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( diff --git a/tests/test_post_chat.py b/tests/test_post_chat.py index 767bfaf86..f86b73833 100644 --- a/tests/test_post_chat.py +++ b/tests/test_post_chat.py @@ -14,7 +14,9 @@ import pytest from backend.app.post_chat_ingestion import ( + _graph_evidence_projection, _graph_facts_for_posts, + _public_project_claims_for_posts, seeded_demo_chat, seeded_demo_commitment_chat, seeded_demo_exchanges, @@ -216,6 +218,7 @@ def test_chat_render_includes_persisted_graph_facts_with_source_evidence() -> No def test_graph_facts_are_hydrated_from_visible_evidence_posts(monkeypatch) -> None: class _Connection: async def fetch(self, _query, _visible_post_ids): + assert "exists" in _query.lower() return [ { "source_node_type_code": "node_person", @@ -224,7 +227,8 @@ async def fetch(self, _query, _visible_post_ids): "target_node_id": "corp-demo", "edge_type_code": "edge_affiliation", "edge_weight": 1.0, - "evidence_post_ids": ["post-graph"], + "visible_evidence_post_ids": ["post-graph"], + "all_evidence_post_ids": ["post-graph"], } ] @@ -263,7 +267,8 @@ async def fetch(self, _query, _visible_post_ids): "target_node_id": "corp-demo", "edge_type_code": "edge_affiliation", "edge_weight": 1.0, - "evidence_post_ids": ["post-b"], + "visible_evidence_post_ids": ["post-b"], + "all_evidence_post_ids": ["post-b"], } ] @@ -298,7 +303,8 @@ async def fetch(self, _query, _visible_post_ids): "target_node_id": "corp-demo", "edge_type_code": "edge_mention_organization", "edge_weight": 1.0, - "evidence_post_ids": ["post-visible"], + "visible_evidence_post_ids": ["post-visible"], + "all_evidence_post_ids": ["post-visible"], } ] @@ -314,6 +320,105 @@ async def fail_if_hydrated(_conn, _node_keys): assert facts == {} +def test_typed_graph_claim_requires_non_person_public_evidence(monkeypatch) -> None: + class _Connection: + async def fetch(self, _query, _visible_post_ids): + return [ + { + "source_node_type_code": "node_team", + "source_node_id": "team-demo", + "target_node_type_code": "node_corporate_entity", + "target_node_id": "corp-demo", + "edge_type_code": "edge_team_affiliation", + "edge_weight": 1.0, + "visible_evidence_post_ids": ["public-post"], + "all_evidence_post_ids": ["public-post"], + } + ] + + async def fake_hydrate(_conn, _node_keys): + return [ + {"node_type_code": "node_team", "node_id": "team-demo", "label": "Demo Team"}, + { + "node_type_code": "node_corporate_entity", + "node_id": "corp-demo", + "label": "Demo Corp", + }, + ] + + monkeypatch.setattr("backend.app.post_chat_ingestion.hydrate_related_nodes", fake_hydrate) + public = asyncio.run( + _graph_evidence_projection( + _Connection(), ["public-post"], frozenset({"public-post"}) + ) + ) + private = asyncio.run( + _graph_evidence_projection(_Connection(), ["public-post"], frozenset()) + ) + + assert public.public_claims[0].claim_kind == "knowledge_graph_relation" + assert public.public_claims[0].source_post_ids == ("public-post",) + assert private.public_claims == () + + +def test_typed_graph_claim_rejects_hidden_evidence_without_leaking_it(monkeypatch) -> None: + class _Connection: + async def fetch(self, _query, _visible_post_ids): + return [ + { + "source_node_type_code": "node_team", + "source_node_id": "team-demo", + "target_node_type_code": "node_corporate_entity", + "target_node_id": "corp-demo", + "edge_type_code": "edge_team_affiliation", + "edge_weight": 1.0, + "visible_evidence_post_ids": ["public-post"], + "all_evidence_post_ids": ["hidden-post", "public-post"], + } + ] + + async def fake_hydrate(_conn, _node_keys): + return [ + {"node_type_code": "node_team", "node_id": "team-demo", "label": "Demo Team"}, + { + "node_type_code": "node_corporate_entity", + "node_id": "corp-demo", + "label": "Demo Corp", + }, + ] + + monkeypatch.setattr("backend.app.post_chat_ingestion.hydrate_related_nodes", fake_hydrate) + projection = asyncio.run( + _graph_evidence_projection( + _Connection(), ["public-post"], frozenset({"public-post"}) + ) + ) + + assert projection.public_claims == () + assert "hidden-post" not in projection.facts["public-post"][0] + + +def test_typed_project_claim_comes_from_explicit_persisted_columns() -> None: + class _Connection: + async def fetch(self, query, post_ids): + assert "project_name, ontology_iri" in query + assert post_ids == ["public-post"] + return [ + { + "post_id": "public-post", + "project_name": "Synthetic Renewal", + "ontology_iri": "https://example.test/ontology#Project", + } + ] + + claims = asyncio.run( + _public_project_claims_for_posts(_Connection(), ["public-post"]) + ) + + assert claims["public-post"][0].claim_kind == "semantic_project" + assert claims["public-post"][0].source_post_ids == ("public-post",) + + def test_parses_a_well_formed_json_object() -> None: content = '{"answer_text": "The bid was submitted then revised.", "cited_source_numbers": [1, 2]}' answer = parse_chat_response(content, _SOURCES) diff --git a/tests/test_server_diagnostics.py b/tests/test_server_diagnostics.py index a81aea95e..9c39cf007 100644 --- a/tests/test_server_diagnostics.py +++ b/tests/test_server_diagnostics.py @@ -11,6 +11,7 @@ from backend.app import global_ask_queue, main from lineageweave import observability +from lineageweave.http_client import HttpClientError from tests.test_observability import attach_inmemory_tracer @@ -164,6 +165,41 @@ async def _sources(*args: object, **kwargs: object) -> list[object]: assert sensitive not in raised.value.detail +def test_global_ask_embedding_transport_failure_drops_only_that_channel( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A known embedding transport failure still permits native nominations.""" + + class _Embedding: + available = True + resolved_model = "synthetic-model" + + def embed(self, _text: str) -> list[float]: + raise HttpClientError("synthetic transport failure") + + async def _sources(*args: object, **kwargs: object) -> list[object]: + assert kwargs["question_embedding"] is None + return [] + + monkeypatch.setattr(global_ask_queue, "gather_global_chat_sources", _sources) + result = asyncio.run( + global_ask_queue.compute_global_ask_answer( + _Pool(), + question_text="synthetic question", + corporate_entity_ids=set(), + process_unit_ids=set(), + process_scope_limited=False, + chat_client=_FailingClient(RuntimeError("unused")), + embedding_client=_Embedding(), + ) + ) + + assert result["source_post_ids"] == [] + assert result["next_action"] == ( + "No authorized source posts are available for this question." + ) + + def test_global_ask_timeout_is_provider_unavailable( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: