diff --git a/AGENTS.md b/AGENTS.md index c927f9e61..8a499cd4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -295,6 +295,14 @@ when the event instant is missing. Cited evidence names **Time axis** so the reader can open that post and see which clock matched. Do not invent an event date or a theta. +Public-claim verification (ADR 0229 / issue #272) is opt-in +(`verify_external`). Admission is a persisted `public_claim_envelope` +bound to a public post. Question-token overlap is not admission. +Person, Keyman, TEPP, and fast-mlsirm kinds cannot be stored. +External URLs stay off `cited_post_ids`. Missing SearXNG is +unavailable, not a guessed query. Never force +`contextual-orchestrator` `mode="verify"`. + Organization chips show a unique search-corroborated SKOS companion (`Demo Corp (DC)`) and stay unlabeled on a miss or tie (ADR 0008 / ADR 0170). Do not invent an abbreviation from letters. Synthetic diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 399894584..fc00b4ebb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -79,6 +79,7 @@ flowchart LR | `commitment_extraction.py` | Pluggable LLM derivation of a customer commitment (promise + deadline) from a post; `Null` default, `ContextualOrchestrator` real impl | | `temporal_expressions.py` | Pure Korean relative-time resolver for Global Ask (ADR 0150) | | `ask_time_axis.py` | Event-time vs ingestion-time clock choice for that window (ADR 0202) | +| `public_claim_verification.py` | Typed public-claim envelopes for Global Ask; opt-in SearXNG URLs stay off `cited_post_ids` (ADR 0229) | | `ontology.py` | Loads `docs/ontology/lineageweave-kg.ttl`, the formal OWL 2/RDFS/SKOS vocabulary for the Knowledge Graph's node/edge types (ADR 0004) | | `ontology_neighborhood.py` | Bounded typed ontology/provenance neighborhood (ADR 0184); PostgreSQL stays authoritative, OWL subclass is not an instance edge | | `ontology_source_cursor.py` | Opaque HMAC source-window continuation (ADR 0124); keyset pagination, never OFFSET | @@ -87,18 +88,6 @@ flowchart LR | `server.py` | Legacy stdlib HTTP server for the library-level synthetic fixture demo; production uses FastAPI/PostgreSQL | | `web/index.html` | Legacy self-contained SVG DAG viewer; production UI is the React/Vite frontend | -> **Known local-test-environment limitation:** `adjudication_client.py`'s -> `mode="verify"` call depends on contextual-orchestrator's -> `TaskOrchestrator.route_and_verify`, which as of this writing is still -> an open, unmerged upstream PR -> (`ContextualWisdomLab/contextual-orchestrator#149`). Until it merges, -> the four adjudication/chat tests that exercise `mode="verify"` against -> a real orchestrator fail with `invalid_mode` (the deployed `main` only -> accepts `auto`/`route`/`conduct`) -- confirmed by reproducing the same -> `400` directly against the orchestrator's own `/v1/chat/completions`, -> not caused by anything in this repo. `mode="route"` (every other -> pluggable client) is unaffected. - ## Design decisions worth naming - **Pluggable, never faked, channels.** `NullEmbeddingClient` and @@ -234,6 +223,10 @@ Each direct edge includes `interval_relation_code` / Global Ask merges cited threads from one post/edge fetch pair and caps the payload at the landing node bound, keeping cited posts first (ADR 0169). Open a cited post to read the focused thread. +Opt-in public-claim verification (ADR 0229) loads persisted +egress-eligible envelopes only. External URLs stay off +`cited_post_ids`. Missing search is unavailable, not a question-token +query, and this repository never forces `mode="verify"`. `POST /api/lineage/rebuild` (`post_admin`) re-runs `reconstruct()` over every `source_post` and atomically rewrites edges, channel signals, and Allen interval relations. Reconstruct grouping is diff --git a/CHANGELOG.d/2.22.0-public-claim-envelope.md b/CHANGELOG.d/2.22.0-public-claim-envelope.md new file mode 100644 index 000000000..eb875b032 --- /dev/null +++ b/CHANGELOG.d/2.22.0-public-claim-envelope.md @@ -0,0 +1,20 @@ +# 2.22.0 — Persisted public-claim envelope + +## Added + +- Typed `public_claim_envelope` rows admit Global Ask public verification + (issue #272 / [ADR 0229]). Closed kinds are organization presence, public + event, and public relationship. Person, Keyman, TEPP, and fast-mlsirm + evidence cannot be stored. +- Ask Agent `verify_external` is opt-in. Off omits the projection. On + loads currently authorized egress-eligible envelopes and never nominates + a claim from question tokens. SearXNG URLs stay on + `external_evidence_urls` and never enter `cited_post_ids`. +- Organization-presence distinctive-token footprint can read **Supported**. + Other polarity stays unavailable until contextual-orchestrator classifies + retrieved evidence. This repository does not force `mode="verify"`. +- After `make seed`, the Demo public post envelope sits above the Ask + answer. A click opens that post. Web verification is unavailable when + SearXNG is unset. + +[ADR 0229]: docs/adr/0229-public-claim-envelope.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 641306055..63fc9b45b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ All notable changes to this project are documented here. Format follows ### Added +- Persist typed public-claim envelopes for Global Ask web verification + (issue #272 / ADR 0229). Ask Agent can opt into `verify_external`; + only egress-eligible public organization-presence, public-event, and + public-relationship claims are admitted. Person, Keyman, TEPP, and + fast-mlsirm evidence stay inside the workspace. SearXNG URLs never + enter `cited_post_ids`, and this repository does not force + `mode="verify"`. After `make seed`, the Demo public post envelope sits + above the answer; a click opens that post. + - 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/CLAUDE.md b/CLAUDE.md index eb9e85eab..ac662650a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ cutoff. Create/start endpoint rules (ADR 0017 / 0021), tie-vs-miss similarity (ADR 0026), R&R catalog ids (ADR 0019 / 0027), leftover pairs -(ADR 0048–0164 / 0182 / 0201), the text-channel embedding swap and cosine +(ADR 0048–0164 / 0182 / 0201), public-claim envelopes (ADR 0229), the text-channel embedding swap and cosine clamp (ADR 0190), per-edge channel-score persistence (ADR 0195), migration replay (ADR 0166), docstring coverage, and the measurement boundary are all stated in [AGENTS.md](AGENTS.md) -- read it before diff --git a/backend/app/global_ask_queue.py b/backend/app/global_ask_queue.py index 9bffd8502..61940e8d7 100644 --- a/backend/app/global_ask_queue.py +++ b/backend/app/global_ask_queue.py @@ -29,28 +29,24 @@ 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, ask_grounding_status, cited_post_evidence, cited_post_summaries, historical_body_limitations, ) +from lineageweave.public_claim_verification import ( + NullPublicClaimSearchClient, + PublicClaimSearchClient, + SearxngPublicClaimSearchClient, + cited_post_ids_exclude_external, + envelope_from_authorized_row, + verify_public_claims, +) from lineageweave.semantic_query import NullSemanticQueryClient, SemanticQueryClient from lineageweave.temporal_expressions import resolve_korean_relative_time @@ -63,6 +59,7 @@ gather_global_chat_sources, prepare_global_question_embedding, ) +from .post_eligibility import SOURCE_POST_ELIGIBILITY_SQL GLOBAL_ASK_STREAM_KEY = "global_ask_request_stream" @@ -97,6 +94,32 @@ _logger = logging.getLogger(__name__) +_AUTHORIZED_PUBLIC_CLAIM_ENVELOPES_SQL = """ + select envelope.public_claim_envelope_id, + envelope.source_post_id, + post.post_title as source_post_title, + envelope.claim_kind_code, + envelope.subject_label, + envelope.claim_text, + envelope.truth_status_code, + envelope.event_occurred_at, + envelope.egress_eligible, + post.visibility_code, + post.corporate_entity_id, + post.process_unit_id + from public_claim_envelope envelope + join source_post post on post.post_id = envelope.source_post_id + where envelope.egress_eligible + and post.visibility_code = 'public' + and {source_post_eligibility} + and ($1::timestamptz is null or ( + envelope.created_at <= $1 and post.created_at <= $1 + )) + order by envelope.created_at, envelope.public_claim_envelope_id +""".format( + source_post_eligibility=SOURCE_POST_ELIGIBILITY_SQL.format(alias="post") +) + class _SafeJobError(Exception): """Failure whose bounded message is safe to persist for the requester.""" @@ -163,56 +186,6 @@ async def enqueue_global_ask_job( return str(job_id) -def _verification_next_action(status_code: str) -> str: - """Name the next evidence action without promoting web results to authority.""" - - return { - VERIFICATION_SKIPPED: "Enable public verification to check eligible public claims.", - VERIFICATION_UNAVAILABLE: "Configure public search and contextual-orchestrator, then retry.", - VERIFICATION_NO_PUBLIC_CLAIMS: "Inspect the internal cited posts; no public claim was eligible.", - VERIFICATION_COMPLETED: "Inspect public evidence separately before any governed graph review.", - CLAIM_NOT_ENOUGH_INFORMATION: "Collect stronger authoritative evidence before accepting the claim.", - }.get(status_code, "Inspect the authorized cited posts and their evidence.") - - -async def _verify_public_claims( - question: str, - 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, question) - if set(claim.source_post_ids).issubset(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 Exception: - _logger.exception("public claim verification is unavailable") - 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]: @@ -289,7 +262,7 @@ async def compute_global_ask_answer( embedding_client: EmbeddingClient | None = None, semantic_query_client: SemanticQueryClient | None = None, verify_external: bool = False, - claim_verification_client: ClaimVerificationClient | None = None, + claim_search_client: PublicClaimSearchClient | None = None, knowledge_cutoff: datetime | None = None, ) -> dict[str, Any]: """Assemble one complete Ask answer payload from authorized evidence. @@ -318,7 +291,7 @@ def can_see(row: asyncpg.Record) -> bool: if rewriter.available: try: search_phrases = await asyncio.to_thread(rewriter.rewrite, question_text) - except Exception as exc: + except Exception as exc: # noqa: BLE001 - optional provider boundary # Query rewriting is an optional recall channel. Any provider # or envelope defect retains the original authorized query; # cancellation remains outside Exception and still propagates. @@ -348,7 +321,7 @@ def can_see(row: asyncpg.Record) -> bool: record_server_failure("global_ask", exc, outcome="internal_error") raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Ask Agent is unavailable: authorized evidence could not be assembled", + "Ask Agent could not complete this question. Try again later.", ) from exc cutoff_text = knowledge_cutoff.isoformat() if knowledge_cutoff else None grounding_status = ask_grounding_status(sources, cutoff_text) @@ -358,17 +331,9 @@ def can_see(row: asyncpg.Record) -> bool: if knowledge_cutoff else sources ) - verification_client = claim_verification_client or NullClaimVerificationClient() if not usable_sources: - verification_status, external_claims = await _verify_public_claims( - question_text, - usable_sources, - [], - verify_external=verify_external, - client=verification_client, - ) delivery = build_ask_delivery("", (), ()) - return { + payload: dict[str, Any] = { "answer_text": "", "cited_post_ids": [], "cited_posts": [], @@ -376,8 +341,6 @@ 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": ( "Review unavailable historical channels before relying on this cutoff answer." if limitations @@ -388,6 +351,18 @@ def can_see(row: asyncpg.Record) -> bool: "grounding_status": grounding_status, "limitations": limitations, } + if verify_external: + search_client = claim_search_client or NullPublicClaimSearchClient() + async with pool.acquire() as conn: + envelopes = await load_authorized_public_claim_envelopes( + conn, can_see, knowledge_cutoff=knowledge_cutoff + ) + verification = await asyncio.to_thread( + verify_public_claims, envelopes, search_client + ) + cited_post_ids_exclude_external([], verification) + payload["public_claim_verification"] = verification + return payload try: answer = await asyncio.to_thread( chat_client.answer, @@ -404,7 +379,7 @@ def can_see(row: asyncpg.Record) -> bool: record_server_failure("global_ask", exc, outcome="provider_unavailable") raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Ask Agent is unavailable: contextual-orchestrator could not complete the answer", + "Ask Agent could not complete this question. Try again later.", ) from exc except (KeyError, ValueError) as exc: # Contract/schema fault: the orchestrator responded but its payload @@ -413,7 +388,7 @@ def can_see(row: asyncpg.Record) -> bool: record_server_failure("global_ask", exc, outcome="provider_unavailable") raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Ask Agent is unavailable: contextual-orchestrator could not complete the answer", + "Ask Agent could not complete this question. Try again later.", ) from exc except Exception as exc: # Unexpected defect. Keep the customer boundary and emit a full @@ -422,16 +397,9 @@ def can_see(row: asyncpg.Record) -> bool: record_server_failure("global_ask", exc, outcome="internal_error") raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Ask Agent is unavailable: contextual-orchestrator could not complete the answer", + "Ask Agent could not complete this question. Try again later.", ) from exc cited_ids = list(answer.cited_post_ids) - verification_status, external_claims = await _verify_public_claims( - question_text, - usable_sources, - cited_ids, - verify_external=verify_external, - client=verification_client, - ) if knowledge_cutoff is None: async with pool.acquire() as conn: lineage_graph = await lineage_graphs_for_posts(conn, can_see, cited_ids) @@ -441,14 +409,14 @@ def can_see(row: asyncpg.Record) -> bool: images = [] cited_posts = cited_post_summaries(usable_sources, cited_ids) cited_evidence = cited_post_evidence(usable_sources, cited_ids) - next_action = _verification_next_action(verification_status) + next_action = "Open a cited post to review the evidence behind this answer." if knowledge_cutoff is not None: next_action = ( "Review unavailable historical channels before relying on this cutoff answer." if limitations else "Compare these cutoff-grounded citations with live evidence next." ) - return { + payload = { "answer_text": answer.answer_text, "cited_post_ids": cited_ids, "cited_posts": cited_posts, @@ -457,13 +425,58 @@ 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": next_action, "knowledge_cutoff": cutoff_text, "grounding_status": grounding_status, "limitations": limitations, } + if verify_external: + search_client = claim_search_client or NullPublicClaimSearchClient() + async with pool.acquire() as conn: + envelopes = await load_authorized_public_claim_envelopes( + conn, can_see, knowledge_cutoff=knowledge_cutoff + ) + verification = await asyncio.to_thread( + verify_public_claims, envelopes, search_client + ) + cited_post_ids_exclude_external(cited_ids, verification) + payload["public_claim_verification"] = verification + return payload + + +async def load_authorized_public_claim_envelopes( + conn: asyncpg.Connection, + can_see: Callable[[asyncpg.Record], bool], + *, + knowledge_cutoff: datetime | None = None, +) -> tuple: + """Load public envelopes that existed within the requested evidence view.""" + # The statement is assembled once from module-owned SQL fragments; the only + # runtime value remains the asyncpg $1 parameter. + # Semgrep cannot prove that this module constant contains only repository- + # owned fragments; the sole runtime input remains asyncpg parameter $1. + rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + _AUTHORIZED_PUBLIC_CLAIM_ENVELOPES_SQL, knowledge_cutoff + ) + return tuple( + envelope + for row in rows + if can_see(row) + if (envelope := envelope_from_authorized_row(row)) is not None + ) + + +def _public_claim_search_client() -> PublicClaimSearchClient: + """Return the configured public search channel, or an unavailable client.""" + from backend.app.config import load_settings + + base_url = load_settings().searxng_base_url + if not base_url: + return NullPublicClaimSearchClient() + try: + return SearxngPublicClaimSearchClient(base_url) + except ValueError: + return NullPublicClaimSearchClient() def _temporally_grounded_question( @@ -512,9 +525,7 @@ async def process_global_ask_job( chat_factory: Callable[[], PostChatClient], embedding_factory: Callable[[], EmbeddingClient] = NullEmbeddingClient, semantic_query_factory: Callable[[], SemanticQueryClient] = NullSemanticQueryClient, - claim_verification_factory: Callable[ - [], ClaimVerificationClient - ] = NullClaimVerificationClient, + claim_search_factory: Callable[[], PublicClaimSearchClient] | None = None, ) -> None: """Claim, answer, and settle one Ask job. @@ -551,9 +562,7 @@ async def process_global_ask_job( raise _SafeJobError("account lacks the post_read permission") chat_client = chat_factory() if not chat_client.available: - raise _SafeJobError( - "Ask Agent is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY" - ) + raise _SafeJobError("Ask Agent could not complete this question. Try again later.") payload = await asyncio.wait_for( compute_global_ask_answer( pool, @@ -565,7 +574,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_search_client=( + (claim_search_factory or _public_claim_search_client)() + if bool(row["verify_external_requested"]) + else None + ), knowledge_cutoff=row["knowledge_cutoff"], ), timeout=JOB_DEADLINE_SECONDS, @@ -588,16 +601,13 @@ async def process_global_ask_job( # state / missing config) — never a provider-boundary leak. detail = str(exc) elif isinstance(exc, asyncio.TimeoutError): - detail = f"job exceeded the {JOB_DEADLINE_SECONDS}s deadline" + detail = "Ask Agent took too long to answer. Try the question again." else: # Provider responses/exceptions can carry credentials, gateway # diagnostics, or model output (ADR 0123): never persist the # raw exception text as a durable `failure_detail`. The # traceback just logged keeps it for operator debugging only. - detail = ( - "Ask Agent is unavailable: contextual-orchestrator returned " - "no complete evidence object" - ) + detail = "Ask Agent could not complete this question. Try again later." async with pool.acquire() as conn: await conn.execute( """ @@ -682,7 +692,7 @@ async def consume_global_ask_stream_once( chat_factory: Callable[[], PostChatClient], embedding_factory: Callable[[], EmbeddingClient] = NullEmbeddingClient, semantic_query_factory: Callable[[], SemanticQueryClient] = NullSemanticQueryClient, - claim_verification_factory: Callable[[], ClaimVerificationClient] = NullClaimVerificationClient, + claim_search_factory: Callable[[], PublicClaimSearchClient] | None = None, limiter: asyncio.Semaphore | None = None, tasks: set[asyncio.Task] | None = None, ) -> str: @@ -708,7 +718,7 @@ async def consume_global_ask_stream_once( chat_factory=chat_factory, embedding_factory=embedding_factory, semantic_query_factory=semantic_query_factory, - claim_verification_factory=claim_verification_factory, + claim_search_factory=claim_search_factory, ) else: await limiter.acquire() @@ -719,7 +729,7 @@ async def consume_global_ask_stream_once( chat_factory=chat_factory, embedding_factory=embedding_factory, semantic_query_factory=semantic_query_factory, - claim_verification_factory=claim_verification_factory, + claim_search_factory=claim_search_factory, limiter=limiter, ) ) @@ -737,7 +747,7 @@ async def _process_and_release( chat_factory: Callable[[], PostChatClient], embedding_factory: Callable[[], EmbeddingClient], semantic_query_factory: Callable[[], SemanticQueryClient], - claim_verification_factory: Callable[[], ClaimVerificationClient], + claim_search_factory: Callable[[], PublicClaimSearchClient] | None, limiter: asyncio.Semaphore, ) -> None: """Run one dispatched job and free its concurrency slot afterwards.""" @@ -748,7 +758,7 @@ async def _process_and_release( chat_factory=chat_factory, embedding_factory=embedding_factory, semantic_query_factory=semantic_query_factory, - claim_verification_factory=claim_verification_factory, + claim_search_factory=claim_search_factory, ) finally: limiter.release() @@ -771,7 +781,7 @@ async def run_global_ask_worker( chat_factory: Callable[[], PostChatClient], embedding_factory: Callable[[], EmbeddingClient] = NullEmbeddingClient, semantic_query_factory: Callable[[], SemanticQueryClient] = NullSemanticQueryClient, - claim_verification_factory: Callable[[], ClaimVerificationClient] = NullClaimVerificationClient, + claim_search_factory: Callable[[], PublicClaimSearchClient] | None = None, ) -> None: """Run the at-least-once Ask consumer with periodic queued-row recovery.""" last_id = await _stream_tail(client) @@ -792,7 +802,7 @@ async def run_global_ask_worker( chat_factory=chat_factory, embedding_factory=embedding_factory, semantic_query_factory=semantic_query_factory, - claim_verification_factory=claim_verification_factory, + claim_search_factory=claim_search_factory, limiter=limiter, tasks=tasks, ) diff --git a/backend/app/main.py b/backend/app/main.py index 6457bbde1..9b2920a63 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -34,11 +34,6 @@ 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, @@ -305,7 +300,6 @@ async def lifespan(app: FastAPI): ), embedding_factory=_embedding_client, semantic_query_factory=_semantic_query_client, - claim_verification_factory=_claim_verification_client_factory, ) ) app.state.global_ask_worker = global_ask_worker @@ -382,28 +376,6 @@ 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 _claim_verification_client_factory(): - """Resolve the verifier late so runtime overrides reach the worker.""" - return _claim_verification_client() - - def _organization_name_resolution_client(): """Live orchestrator client when configured; otherwise the unavailable null.""" settings = load_settings() diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index 4a208c13c..4672ef23f 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -27,7 +27,6 @@ import asyncpg from lineageweave.ask_time_axis import row_matches_time_range, time_axis_evidence_fact -from lineageweave.claim_verification import GlobalAskSourceDocument from lineageweave.embedding_client import EmbeddingClient, NullEmbeddingClient from lineageweave.image_content import ImageContentClient, NullImageContentClient from lineageweave.knowledge_graph import ( @@ -927,18 +926,8 @@ async def gather_global_chat_sources( ) post_graph_facts = graph_facts.get(post_id, ())[:remaining_graph_facts] remaining_graph_facts -= len(post_graph_facts) - source_type = ( - GlobalAskSourceDocument - if row["visibility_code"] == "public" - else ChatSourceDocument - ) - source_arguments: dict[str, Any] = {} - if source_type is GlobalAskSourceDocument: - source_arguments["external_claim_facts"] = ( - semantic_facts.get(post_id, ()) + post_graph_facts - ) sources.append( - source_type( + ChatSourceDocument( post_id, source_title, normalized_body, @@ -963,7 +952,6 @@ async def gather_global_chat_sources( if historical_body_unavailable else (("semantic_role", "semantic_keyman", "knowledge_graph", "lineage", "image") if knowledge_cutoff else ()) ), - **source_arguments, ) ) return sources diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 892c8231a..108643b83 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -201,6 +201,11 @@ / "migrations" / "0218_global_ask_public_verification.sql" ) +_PUBLIC_CLAIM_ENVELOPE_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0224_public_claim_envelope.sql" +) _GLOBAL_ASK_KNOWLEDGE_CUTOFF_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" @@ -386,6 +391,7 @@ def seeded_db(demo_analyst_token): cur.execute(_GLOBAL_ASK_EVIDENCE_SEARCH_MIGRATION.read_text()) cur.execute(_GLOBAL_ASK_KNOWLEDGE_CUTOFF_MIGRATION.read_text()) cur.execute(_GLOBAL_ASK_PUBLIC_VERIFICATION_MIGRATION.read_text()) + cur.execute(_PUBLIC_CLAIM_ENVELOPE_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()) @@ -5171,11 +5177,10 @@ def answer(self, question, sources): # noqa: ARG002 - contract shape 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.""" + """A persisted public envelope verifies without changing internal post ids.""" import time as _time - from lineageweave import claim_verification as cv from lineageweave.post_chat import ChatAnswer class _FakeChatClient: @@ -5184,34 +5189,21 @@ class _FakeChatClient: def answer(self, question, sources): # noqa: ARG002 - contract shape return ChatAnswer("Internal answer.", (sources[0].post_id,)) - class _FakeVerificationClient: + class _FakeSearchClient: 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.", - ), - ), - ) + def search_urls(self, _claim_text): + return ("https://northridge-grid.example/about",) 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') + insert into public_claim_envelope + (source_post_id, claim_kind_code, subject_label, claim_text, + truth_status_code, egress_eligible) + values (%s, 'claim_organization_presence', 'Northridge Grid', + 'Northridge Grid is named by this public post.', + 'truth_observed', true) """, (seeded_db["public_post_id"],), ) @@ -5219,13 +5211,13 @@ def verify(self, claim): monkeypatch.setattr("backend.app.main._post_chat_client", lambda **_kwargs: _FakeChatClient()) monkeypatch.setattr( - "backend.app.main._claim_verification_client", - lambda: _FakeVerificationClient(), + "backend.app.global_ask_queue._public_claim_search_client", + lambda: _FakeSearchClient(), ) headers = {"Authorization": f"Bearer {demo_analyst_token}"} submitted = client.post( "/api/ask", - json={"question": "Apollo", "verify_external": True}, + json={"question": "Northridge Grid", "verify_external": True}, headers=headers, ) assert submitted.status_code == 202 @@ -5242,11 +5234,10 @@ def verify(self, claim): 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["public_claim_verification"]["status_code"] == "claim_supported" + assert answer["public_claim_verification"]["claims"][0]["status_code"] == "claim_supported" assert answer["cited_post_ids"] == [seeded_db["public_post_id"]] - assert "https://example.com/public-evidence" not in answer["cited_post_ids"] - + assert "https://northridge-grid.example/about" not in answer["cited_post_ids"] def test_ask_job_reads_are_owner_scoped( client, demo_analyst_token, seeded_db, monkeypatch diff --git a/docs/adr/0215-global-ask-public-claim-verification.md b/docs/adr/0215-global-ask-public-claim-verification.md index ca70f566e..26abfad70 100644 --- a/docs/adr/0215-global-ask-public-claim-verification.md +++ b/docs/adr/0215-global-ask-public-claim-verification.md @@ -2,7 +2,11 @@ ## Status -Accepted +Superseded by [ADR 0229](0229-public-claim-envelope.md) + +ADR 0229 replaces question-derived claim admission and forced orchestration +modes with persisted typed claim envelopes and provider-neutral evidence +retrieval. The remainder of this record is retained as historical context. ## Context diff --git a/docs/adr/0229-public-claim-envelope.md b/docs/adr/0229-public-claim-envelope.md new file mode 100644 index 000000000..92c2119c6 --- /dev/null +++ b/docs/adr/0229-public-claim-envelope.md @@ -0,0 +1,70 @@ +# ADR 0229: Persisted public-claim envelope for Global Ask verification + +**Status:** Accepted +**Date:** 2026-08-26 + +## Context + +Issue #272 requires FEVER-style public verification of Global Ask claims +(Thorne, Vlachos, Christodoulopoulos, & Mittal, 2018). A closed stack +selected claims by question-token overlap and forced +`contextual-orchestrator` `mode="verify"`. That is heuristic admission and +it contradicts ADR 0076: orchestration policy belongs to the upstream +gateway, with `reasoning_effort="auto"`. + +Until a typed, persisted, egress-eligible envelope exists, the honest +product state is **unavailable**, not a search query invented from the +question string. + +## Decision + +1. Persist `public_claim_envelope` in 3NF: one row names one claim kind, + the exact public `source_post`, subject label, claim text, ontology + truth status, optional event time, and `egress_eligible`. +2. Admitted claim kinds are a closed lookup: `claim_organization_presence`, + `claim_public_event`, `claim_public_relationship`. Person, Keyman, TEPP, + and fast-mlsirm kinds are not in the vocabulary and cannot be stored. +3. `egress_eligible` may be true only when the source post is `public`. A + trigger fail-closes private or missing posts. Application code re-checks + visibility and ABAC before any SearXNG dispatch. +4. Global Ask `verify_external` is opt-in and reuses the durable + `global_ask_job.verify_external_requested` consent field from migration + 0218. Off omits the projection. On loads currently authorized + egress-eligible envelopes and never nominates a claim from question tokens. + For a knowledge-cutoff answer, both the source post and envelope must have + existed no later than the cutoff; later live claims cannot enter an earlier + evidence view. +5. SearXNG may retrieve a bounded list of public HTTP(S) URLs for the + persisted claim text. Search pages, localhost, and literal + private-network hosts are dropped. Those URLs are `external_evidence_urls` + and must never enter `cited_post_ids`. +6. Classification: + - search channel unavailable → `claim_unavailable`; + - no usable URL → `claim_not_enough_information`; + - `claim_organization_presence` with a distinctive-token footprint on a + retrieved URL → `claim_supported` (the same FEVER presence subset + ADR 0005 already ships); + - `claim_refuted` and NLI polarity for other kinds stay unavailable + until contextual-orchestrator classifies retrieved evidence. This + repository does not force `mode="verify"`. +7. Private source text, raw source hints, credentials, PII, TEPP payloads, + and fast-mlsirm respondent or item data never leave the trust boundary. + +## Consequences + +- After `make seed`, Ask Agent can opt into public-claim verification. + The Demo public post envelope sits above the answer; a click opens that + post. Web verification is unavailable when SearXNG is unset. +- A later orchestrator-owned polarity slice can fill `claim_refuted` + without rewriting admission. +- Heuristic token-overlap claim selection remains forbidden. + +## References + +Thorne, J., Vlachos, A., Christodoulopoulos, C., & Mittal, A. (2018). +FEVER: A large-scale dataset for fact extraction and verification. +*Proceedings of NAACL-HLT 2018*, 809–819. +https://doi.org/10.18653/v1/N18-1074 + +World Wide Web Consortium. (2013, April 30). *PROV-O: The PROV ontology* +(W3C Recommendation). https://www.w3.org/TR/2013/REC-prov-o-20130430/ diff --git a/docs/adr/README.md b/docs/adr/README.md index 83e56345c..20adfb5da 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -11,7 +11,7 @@ decision from them. |---|---| | [`product-requirements.md`](../product-requirements.md) | Product requirements projection across the ADR set; ADRs remain normative | | [`product-technical-gap-baseline.md`](../product-technical-gap-baseline.md) | Product/technical traceability projection across the ADR set; ADRs remain normative | -| [`lineage-bi-research-notes.md`](../lineage-bi-research-notes.md) | [0084](0084-lineage-research-grounding.md), [0062](0062-semantic-unit-embedding.md), [0064](0064-lineage-evidence-and-tree-assembly.md), [0024](0024-rankweave-fusion-fail-closed.md), [0165](0165-quantity-script-display.md), [0167](0167-rankweave-ranking-channel-evidence.md), [0169](0169-ask-batched-lineage-graph.md), [0172](0172-event-lineage-channel-evidence.md), [0202](0202-ask-event-time-filter.md), [0223](0223-explicit-semantic-content-unit-kinds.md) | +| [`lineage-bi-research-notes.md`](../lineage-bi-research-notes.md) | [0084](0084-lineage-research-grounding.md), [0062](0062-semantic-unit-embedding.md), [0064](0064-lineage-evidence-and-tree-assembly.md), [0024](0024-rankweave-fusion-fail-closed.md), [0165](0165-quantity-script-display.md), [0167](0167-rankweave-ranking-channel-evidence.md), [0169](0169-ask-batched-lineage-graph.md), [0172](0172-event-lineage-channel-evidence.md), [0202](0202-ask-event-time-filter.md), [0223](0223-explicit-semantic-content-unit-kinds.md), [0229](0229-public-claim-envelope.md) | | [`PROV_O_IMPLEMENTATION.md`](../PROV_O_IMPLEMENTATION.md) | [0065](0065-prov-o-provenance-boundary.md) | | [`PROV_O_IMPLEMENTATION_MATRIX.md`](../PROV_O_IMPLEMENTATION_MATRIX.md) | [0065](0065-prov-o-provenance-boundary.md) | | [`ONTOLOGY_NAMESPACE_INVENTORY.md`](../doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md) | [0207](0207-repository-case-ontology-namespace-canonical.md), [0157](0157-public-ontology-namespace-identity.md) | diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 7704fa748..ac60840ca 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -350,7 +350,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 | #658; still open and not protected-main evidence | -| #272 | Verify Global Ask KG/ontology/semantic claims with public SearXNG evidence | #632 preserves internal provenance; public verification acceptance remains open | +| #272 | Verify Global Ask KG/ontology/semantic claims with public SearXNG evidence | #679 adds the typed persisted envelope and supersedes question-token claim admission; not protected-main evidence until exact-head gates merge it | | #277 | TEPP: persist accepted receipts, poll completed results, keep measurement authority distinct | #657 consumer lifecycle; executable producer route remains unavailable | | #280 | Full project-lifecycle history and handover intervals | #640 adds case/project journeys and #663 adds evidence-backed Project exploration; authoritative lifecycle reconciliation remains #284 | | #284 | Authoritative lifecycle ingestion and idempotent reconciliation | No active delivery PR confirmed | diff --git a/docs/screenshots/public-claim-aggregate-desktop.png b/docs/screenshots/public-claim-aggregate-desktop.png new file mode 100644 index 000000000..35ae341a0 Binary files /dev/null and b/docs/screenshots/public-claim-aggregate-desktop.png differ diff --git a/docs/screenshots/public-claim-aggregate-mobile.png b/docs/screenshots/public-claim-aggregate-mobile.png new file mode 100644 index 000000000..8c7cbe4ee Binary files /dev/null and b/docs/screenshots/public-claim-aggregate-mobile.png differ diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 81ab3a8af..0acfa804e 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -8,6 +8,7 @@ operator-facing control you can click before changing product CSS. | `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, or repeat-issue fact. `EvidenceReady`, `NarrowViewport`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover populated, mobile, unavailable-evidence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard` | | `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` | | `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` | +| `Evidence/PublicClaimList` | Read the public-claim status, then open that source post. External URLs stay links and never become cited post ids. Compare supported, unavailable-search, and empty states. | `--color-chip-border`, `PublicClaimList` | | `Evidence/OrganizationAliasChip` | Click a cataloged org; the parenthetical is the unique corroborated SKOS companion. | `--color-chip-border`, `--radius-chip`, `OrganizationAliasChip` | | `Evidence/OntologyExplorer` | Distinguish Event Lineage from typed ontology facts, inspect Post/Person/Organization/Team/Project shapes, token-backed secondary cues, and truth labels, then open authorized evidence. The named exact-values region supports keyboard scrolling; `LongLabelsAndEvidenceTable` proves complete labels wrap without character-count truncation. Desktop, narrow, drawers, legend/filter, empty, truncated, partial, denied, stale, and rejected scenes cover ADR 0184/0222 states. | `OntologyExplorer`, `ontologyLayout`, `--ontology-node-*-fill`, `--color-table-border` | | `AnalysisRun/CutoffKnownBody` | Read the cutoff-known sentence, then compare it with the live body below. | `--color-accent-border`, `--space-panel-block`, `--radius-panel`, `CutoffKnownBody` | @@ -16,8 +17,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/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` | +| `Ask Agent/Knowledge cutoff` | Exercise partial historical grounding, retained-revision provenance, later-live-change disclosure, aggregate and per-claim next actions, and the narrow viewport before relying on a historical answer. Verified captures: [`public-claim-aggregate-desktop.png`](screenshots/public-claim-aggregate-desktop.png) and [`public-claim-aggregate-mobile.png`](screenshots/public-claim-aggregate-mobile.png). | Native `datetime-local`, `--space-panel-block`, `--space-control-gap`, `--color-border`, `--size-control-min` | Repeated web objects must use `frontend/src/styles/tokens.css` and a module under `frontend/src/components/`. Do not add a second Node package manager; diff --git a/frontend/src/App.css b/frontend/src/App.css index 901b17b1a..03b498917 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -972,6 +972,21 @@ gap: 0.5rem; } +.public-claim-list-item { + align-items: stretch; + flex-direction: column; +} + +.public-claim-list-row { + flex-wrap: wrap; + gap: 0.5rem; + overflow-wrap: anywhere; +} + +.public-claim-list-item a { + overflow-wrap: anywhere; +} + .ranking-hit { flex-direction: column; align-items: stretch; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 2dee4513d..777b459e7 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -120,6 +120,8 @@ describe("App, authenticated", () => { askLineageGraph?: boolean; askImageCitation?: boolean; askDelivery?: boolean; + askNoPublicClaims?: boolean; + askNoSources?: boolean; lineageIsolationReason?: "comparison_candidates_available" | "no_comparison_group"; }): ReturnType & { releaseMe: () => void; releasePostOne: () => void } { const statusLabel: Record = { @@ -146,6 +148,7 @@ describe("App, authenticated", () => { let createdPendingTepp: Record | null = null; let resolvedHintCode: string | null = null; let contentRequests = 0; + let lastAskVerifyExternal = false; let releaseMe = () => {}; const demoOrgAlias = options?.organizationAliases ? { organization_alias: "DC" } : {}; @@ -1724,6 +1727,8 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/ask") && method === "POST") { + const body = JSON.parse(String(init?.body ?? "{}")); + lastAskVerifyExternal = Boolean(body.verify_external); return Promise.resolve( jsonResponse({ ask_job_id: "ask-job-1", job_status_code: "queued" }), ); @@ -1734,7 +1739,12 @@ describe("App, authenticated", () => { ask_job_id: "ask-job-1", job_status_code: "succeeded", answer: { - answer_text: "The cited project is supported by the stored semantic evidence.", + answer_text: options?.askNoSources + ? "" + : "The cited project is supported by the stored semantic evidence.", + next_action: options?.askNoSources + ? "No authorized source posts are available for this question." + : undefined, cited_post_ids: ["post-2"], cited_posts: [{ post_id: "post-2", post_title: "Linked post" }], cited_post_evidence: [ @@ -1807,6 +1817,27 @@ describe("App, authenticated", () => { eligible: true, watched_resource_uris: ["lineageweave://posts/post-2"], }, } : undefined, + public_claim_verification: lastAskVerifyExternal + ? { + status_code: "claim_supported", + next_action: options?.askNoPublicClaims + ? "No authorized public claims are available to verify. Turn off web verification and ask again." + : "Public web evidence supports this claim. Open that post.", + claims: options?.askNoPublicClaims ? [] : [ + { + public_claim_envelope_id: "env-demo-public", + source_post_id: "post-1", + source_post_title: "Public post", + claim_kind_code: "claim_organization_presence", + subject_label: "Northridge Grid", + claim_text: "Northridge Grid is a power utility named on the Demo public post.", + status_code: "claim_supported", + external_evidence_urls: ["https://northridgegrid.example/about"], + next_action: "Public web evidence supports this claim. Open that post.", + }, + ], + } + : undefined, }, }), ); @@ -2051,6 +2082,58 @@ describe("App, authenticated", () => { expect(screen.getByRole("button", { name: "View evidence" })).toBeInTheDocument(); }); + it("opts into public-claim verification and opens that source post", async () => { + stubBackend(); + render(); + expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Ask Agent" })); + + const verify = screen.getByRole("checkbox", { name: "Check eligible public claims" }); + expect(verify).not.toBeChecked(); + await userEvent.click(verify); + await userEvent.type(screen.getByRole("textbox", { name: "Ask a question" }), "Does Northridge Grid exist?"); + await userEvent.click(screen.getByRole("button", { name: "Ask" })); + + expect(await screen.findByLabelText("Public claims")).toBeInTheDocument(); + const claim = screen.getByRole("button", { name: "Open public claim: Public post" }); + expect(claim).toHaveTextContent("Organization presence: Public post · Northridge Grid"); + expect(claim).toHaveTextContent("Supported"); + expect( + screen.getAllByText("Public web evidence supports this claim. Open that post."), + ).toHaveLength(2); + await userEvent.click(claim); + expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); + }); + + it("shows the next valid action when no public claim can be verified", async () => { + stubBackend({ askNoPublicClaims: true }); + render(); + await screen.findByRole("button", { name: "View post: Public post" }); + await userEvent.click(screen.getByRole("button", { name: "Ask Agent" })); + await userEvent.click(screen.getByRole("checkbox", { name: "Check eligible public claims" })); + await userEvent.type(screen.getByRole("textbox", { name: "Ask a question" }), "What is public?"); + await userEvent.click(screen.getByRole("button", { name: "Ask" })); + + expect(await screen.findByText( + "No authorized public claims are available to verify. Turn off web verification and ask again.", + )).toBeInTheDocument(); + }); + + it("keeps the no-source action visible beside public verification", async () => { + stubBackend({ askNoSources: true }); + render(); + await screen.findByRole("button", { name: "View post: Public post" }); + await userEvent.click(screen.getByRole("button", { name: "Ask Agent" })); + await userEvent.click(screen.getByRole("checkbox", { name: "Check eligible public claims" })); + await userEvent.type(screen.getByRole("textbox", { name: "Ask a question" }), "What is public?"); + await userEvent.click(screen.getByRole("button", { name: "Ask" })); + + expect(await screen.findByText( + "No authorized source posts are available for this question.", + )).toBeInTheDocument(); + expect(screen.getByLabelText("Public claims")).toBeInTheDocument(); + }); + it("labels the Customer Master entity level and Keymen side, never the raw lookup code", async () => { // Live UI finding (2026-08-19): read_customer_master() skipped the // common_lookup_value join both endpoints elsewhere already use, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fbba1d9f2..7f5195f27 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -94,7 +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 { PublicClaimList } from "./components/PublicClaimList"; import { PopupCloseButton } from "./components/PopupCloseButton"; import { SimilarVocPanel } from "./components/SimilarVocPanel"; import { chatEvidenceKindLabel } from "./evidenceKindLabels"; @@ -4879,6 +4879,11 @@ export function AskAgentPanel({ /> {t("Check eligible public claims")} +

+ {t( + "Only authorized public claims are sent for web verification. Other workspace evidence is not sent.", + )} +