From 7148067f1da44801ebeac2dd0065f4567da989b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 08:09:41 -0700 Subject: [PATCH 1/9] feat(ask): verify public semantic claims (#641) * feat(ask): verify public semantic claims * docs(gaps): record public verification stack --------- Co-authored-by: seonghobae --- backend/app/global_ask_queue.py | 101 ++++- backend/app/main.py | 25 ++ backend/app/post_chat_ingestion.py | 128 ++++++- backend/tests/test_api.py | 86 +++++ ...28-global-ask-public-claim-verification.md | 71 ++++ docs/adr/README.md | 5 +- ...OBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md | 30 ++ docs/product-requirements.md | 16 + docs/storybook-inventory.md | 1 + frontend/src/App.tsx | 18 +- frontend/src/AskAgentPanel.test.tsx | 119 ++++++ frontend/src/api.ts | 25 +- .../components/PublicClaimVerification.css | 30 ++ .../PublicClaimVerification.stories.tsx | 70 ++++ .../components/PublicClaimVerification.tsx | 44 +++ frontend/src/i18n.ts | 36 ++ frontend/src/styles/tokens.test.ts | 16 + lineageweave/claim_verification.py | 362 ++++++++++++++++++ .../0211_global_ask_public_verification.sql | 22 ++ .../0211_global_ask_public_verification.sql | 2 + tests/test_claim_verification.py | 230 +++++++++++ tests/test_global_ask_queue.py | 103 +++++ tests/test_global_ask_sources.py | 8 + tests/test_migration_replay.py | 14 + tests/test_post_chat.py | 63 +++ 25 files changed, 1604 insertions(+), 21 deletions(-) create mode 100644 docs/adr/0228-global-ask-public-claim-verification.md create mode 100644 docs/doctoring/GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md create mode 100644 frontend/src/AskAgentPanel.test.tsx create mode 100644 frontend/src/components/PublicClaimVerification.css create mode 100644 frontend/src/components/PublicClaimVerification.stories.tsx create mode 100644 frontend/src/components/PublicClaimVerification.tsx create mode 100644 lineageweave/claim_verification.py create mode 100644 migrations/0211_global_ask_public_verification.sql create mode 100644 migrations/rollback/0211_global_ask_public_verification.sql create mode 100644 tests/test_claim_verification.py diff --git a/backend/app/global_ask_queue.py b/backend/app/global_ask_queue.py index c7e570d81..e867bc247 100644 --- a/backend/app/global_ask_queue.py +++ b/backend/app/global_ask_queue.py @@ -29,10 +29,22 @@ from fastapi import HTTPException, status from lineageweave.ask_delivery import build_ask_delivery +from lineageweave.claim_verification import ( + CLAIM_NOT_ENOUGH_INFORMATION, + VERIFICATION_COMPLETED, + VERIFICATION_NO_PUBLIC_CLAIMS, + VERIFICATION_SKIPPED, + VERIFICATION_UNAVAILABLE, + ClaimVerificationClient, + ClaimVerificationResult, + NullClaimVerificationClient, + public_claim_candidates, +) from lineageweave.embedding_client import EmbeddingClient, NullEmbeddingClient from lineageweave.http_client import HttpClientError from lineageweave.observability import record_server_failure from lineageweave.post_chat import ( + ChatSourceDocument, PostChatClient, cited_post_evidence, cited_post_summaries, @@ -92,6 +104,7 @@ async def enqueue_global_ask_job( *, requesting_account_id: str, question_text: str, + verify_external_requested: bool, corporate_entity_ids: frozenset[str], process_unit_ids: frozenset[str], ) -> str: @@ -104,11 +117,13 @@ async def enqueue_global_ask_job( async with conn.transaction(): job_id = await conn.fetchval( """ - insert into global_ask_job (requesting_account_id, question_text) - values ($1, $2) returning global_ask_job_id + insert into global_ask_job + (requesting_account_id, question_text, verify_external_requested) + values ($1, $2, $3) returning global_ask_job_id """, requesting_account_id, question_text, + verify_external_requested, ) await conn.executemany( """ @@ -141,6 +156,54 @@ 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( + 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) + 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 (HttpClientError, KeyError, OSError, TypeError, ValueError): + return VERIFICATION_UNAVAILABLE, () + return VERIFICATION_COMPLETED, tuple( + result + for result in results + if set(result.source_post_ids).issubset(cited_ids) + ) + + async def load_job_visibility( conn: asyncpg.Connection, job_id: str, account_id: str ) -> tuple[set[str], set[str], bool, bool]: @@ -215,6 +278,8 @@ async def compute_global_ask_answer( process_scope_limited: bool, chat_client: PostChatClient, embedding_client: EmbeddingClient | None = None, + verify_external: bool = False, + claim_verification_client: ClaimVerificationClient | None = None, ) -> dict[str, Any]: """Assemble one complete Ask answer payload from authorized evidence. @@ -254,7 +319,14 @@ def can_see(row: asyncpg.Record) -> bool: status.HTTP_503_SERVICE_UNAVAILABLE, "Ask Agent is unavailable: authorized evidence could not be assembled", ) from exc + verification_client = claim_verification_client or NullClaimVerificationClient() if not sources: + verification_status, external_claims = await _verify_public_claims( + sources, + [], + verify_external=verify_external, + client=verification_client, + ) delivery = build_ask_delivery("", (), ()) return { "answer_text": "", @@ -264,6 +336,8 @@ def can_see(row: asyncpg.Record) -> bool: "cited_post_evidence": [], "lineage_graph": {"nodes": [], "edges": [], "truncated": False}, "cited_post_images": [], + "external_verification_status": verification_status, + "external_claims": [claim.to_payload() for claim in external_claims], "next_action": "No authorized source posts are available for this question.", "delivery": delivery, } @@ -300,6 +374,12 @@ def can_see(row: asyncpg.Record) -> bool: "Ask Agent is unavailable: contextual-orchestrator could not complete the answer", ) from exc cited_ids = list(answer.cited_post_ids) + verification_status, external_claims = await _verify_public_claims( + sources, + cited_ids, + verify_external=verify_external, + client=verification_client, + ) async with pool.acquire() as conn: lineage_graph = await lineage_graphs_for_posts(conn, can_see, cited_ids) images = await cited_post_images(conn, cited_ids) @@ -314,6 +394,9 @@ def can_see(row: asyncpg.Record) -> bool: "source_post_ids": [source.post_id for source in sources], "lineage_graph": lineage_graph, "delivery": build_ask_delivery(answer.answer_text, cited_posts, cited_evidence), + "external_verification_status": verification_status, + "external_claims": [claim.to_payload() for claim in external_claims], + "next_action": _verification_next_action(verification_status), } @@ -350,6 +433,9 @@ async def process_global_ask_job( job_id: str, chat_factory: Callable[[], PostChatClient], embedding_factory: Callable[[], EmbeddingClient] = NullEmbeddingClient, + claim_verification_factory: Callable[ + [], ClaimVerificationClient + ] = NullClaimVerificationClient, ) -> None: """Claim, answer, and settle one Ask job. @@ -363,7 +449,7 @@ async def process_global_ask_job( """ update global_ask_job set job_status_code = $2, updated_at = now() where global_ask_job_id = $1 and job_status_code = $3 - returning requesting_account_id, question_text + returning requesting_account_id, question_text, verify_external_requested """, job_id, RUNNING, @@ -397,6 +483,8 @@ async def process_global_ask_job( process_scope_limited=process_scope_limited, chat_client=chat_client, embedding_client=embedding_factory(), + verify_external=bool(row["verify_external_requested"]), + claim_verification_client=claim_verification_factory(), ), timeout=JOB_DEADLINE_SECONDS, ) @@ -511,6 +599,7 @@ async def consume_global_ask_stream_once( last_id: str, chat_factory: Callable[[], PostChatClient], embedding_factory: Callable[[], EmbeddingClient] = NullEmbeddingClient, + claim_verification_factory: Callable[[], ClaimVerificationClient] = NullClaimVerificationClient, limiter: asyncio.Semaphore | None = None, tasks: set[asyncio.Task] | None = None, ) -> str: @@ -535,6 +624,7 @@ async def consume_global_ask_stream_once( job_id=job_id, chat_factory=chat_factory, embedding_factory=embedding_factory, + claim_verification_factory=claim_verification_factory, ) else: await limiter.acquire() @@ -544,6 +634,7 @@ async def consume_global_ask_stream_once( job_id=job_id, chat_factory=chat_factory, embedding_factory=embedding_factory, + claim_verification_factory=claim_verification_factory, limiter=limiter, ) ) @@ -560,6 +651,7 @@ async def _process_and_release( job_id: str, chat_factory: Callable[[], PostChatClient], embedding_factory: Callable[[], EmbeddingClient], + claim_verification_factory: Callable[[], ClaimVerificationClient], limiter: asyncio.Semaphore, ) -> None: """Run one dispatched job and free its concurrency slot afterwards.""" @@ -569,6 +661,7 @@ async def _process_and_release( job_id=job_id, chat_factory=chat_factory, embedding_factory=embedding_factory, + claim_verification_factory=claim_verification_factory, ) finally: limiter.release() @@ -590,6 +683,7 @@ async def run_global_ask_worker( *, chat_factory: Callable[[], PostChatClient], embedding_factory: Callable[[], EmbeddingClient] = NullEmbeddingClient, + claim_verification_factory: Callable[[], ClaimVerificationClient] = NullClaimVerificationClient, ) -> None: """Run the at-least-once Ask consumer with periodic queued-row recovery.""" last_id = await _stream_tail(client) @@ -609,6 +703,7 @@ async def run_global_ask_worker( last_id=last_id, chat_factory=chat_factory, embedding_factory=embedding_factory, + claim_verification_factory=claim_verification_factory, limiter=limiter, tasks=tasks, ) diff --git a/backend/app/main.py b/backend/app/main.py index 08ff32428..9a9c1a9c8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -34,6 +34,11 @@ from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel +from lineageweave.claim_verification import ( + NullClaimVerificationClient, + SearxngOrchestratedClaimVerificationClient, +) + from backend.app.activity_stream import ( create_valkey_client, get_valkey, @@ -295,6 +300,7 @@ async def lifespan(app: FastAPI): timeout=load_settings().orchestrator_answer_timeout_seconds ), embedding_factory=_embedding_client, + claim_verification_factory=lambda: _claim_verification_client(), ) ) app.state.global_ask_worker = global_ask_worker @@ -371,6 +377,23 @@ def _relation_verification_client(): return SearxngRelationVerificationClient(base_url=settings.searxng_base_url) +def _claim_verification_client(): + """Return the public-evidence verifier, or its unavailable null channel.""" + + settings = load_settings() + if not ( + settings.searxng_base_url + and settings.orchestrator_base_url + and settings.orchestrator_api_key + ): + return NullClaimVerificationClient() + return SearxngOrchestratedClaimVerificationClient( + settings.searxng_base_url, + settings.orchestrator_base_url, + settings.orchestrator_api_key, + ) + + def _organization_name_resolution_client(): """Live orchestrator client when configured; otherwise the unavailable null.""" settings = load_settings() @@ -2960,6 +2983,7 @@ class GlobalAskRequest(BaseModel): """JSON body for the buyer's source-grounded Global Ask Agent.""" question: str + verify_external: bool = False @app.get("/api/posts/{post_id}/chat") @@ -3117,6 +3141,7 @@ async def ask_agent( valkey, requesting_account_id=account.user_account_id, question_text=question, + verify_external_requested=request.verify_external, corporate_entity_ids=account.corporate_entity_ids, process_unit_ids=account.process_unit_ids, ) diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index 192f205e2..4c4f8577b 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -26,6 +26,10 @@ import asyncpg from lineageweave.ask_time_axis import row_matches_time_range, time_axis_evidence_fact +from lineageweave.claim_verification import ( + GlobalAskSourceDocument, + PublicClaimCandidate, +) from lineageweave.embedding_client import EmbeddingClient, NullEmbeddingClient from lineageweave.image_content import ImageContentClient, NullImageContentClient from lineageweave.knowledge_graph import ( @@ -63,6 +67,14 @@ class LinkedPostIds: indirect: frozenset[str] +@dataclass(frozen=True) +class _GraphEvidenceProjection: + """Rendered graph facts and typed public-egress claims from the same rows.""" + + facts: tuple[str, ...] + public_claims: tuple[PublicClaimCandidate, ...] + + async def _normalize_post_body_text( body: str, vision_client: ImageContentClient, @@ -76,18 +88,19 @@ async def _normalize_post_body_text( return normalized.text -async def _graph_facts_for_posts( +async def _graph_evidence_projection( conn: asyncpg.Connection, visible_post_ids: list[str], -) -> tuple[str, ...]: - """Render persisted, ontology-annotated graph facts for visible posts. + public_post_ids: frozenset[str] = frozenset(), +) -> _GraphEvidenceProjection: + """Project persisted graph rows without parsing rendered fact strings. The evidence join is deliberate: a graph edge without a visible evidence post must never enter an LLM prompt. This is the chat-side trust boundary in addition to the post-level ABAC check. """ if not visible_post_ids: - return () + return _GraphEvidenceProjection((), ()) edge_rows = await conn.fetch( """ select edge.source_node_type_code, edge.source_node_id, @@ -108,7 +121,7 @@ async def _graph_facts_for_posts( visible_post_ids, ) if not edge_rows: - return () + return _GraphEvidenceProjection((), ()) endpoint_keys = { node_key(row["source_node_type_code"], str(row["source_node_id"])) @@ -127,6 +140,7 @@ async def _graph_facts_for_posts( } facts: list[str] = [] + public_claims: list[PublicClaimCandidate] = [] for row in edge_rows: source_type = row["source_node_type_code"] source_id = str(row["source_node_id"]) @@ -141,13 +155,40 @@ async def _graph_facts_for_posts( edge_name = row["edge_type_code"] if ontology_iri: edge_name = f"{edge_name} ({ontology_iri})" - evidence_ids = ",".join(sorted(str(value) for value in row["evidence_post_ids"])) - facts.append( + evidence_post_ids = tuple( + sorted(str(value) for value in row["evidence_post_ids"]) + ) + evidence_ids = ",".join(evidence_post_ids) + claim_text = ( f'{source_type} "{source["label"]}" ' - f'--{edge_name}--> {target_type} "{target["label"]}" ' - f"[evidence_post_id={evidence_ids}]" + f'--{edge_name}--> {target_type} "{target["label"]}"' ) - return tuple(dict.fromkeys(facts)) + facts.append(f"{claim_text} [evidence_post_id={evidence_ids}]") + if ( + source_type != "node_person" + and target_type != "node_person" + and evidence_post_ids + and set(evidence_post_ids).issubset(public_post_ids) + ): + public_claims.append( + PublicClaimCandidate( + claim_text=claim_text, + claim_kind="knowledge_graph_relation", + source_post_ids=evidence_post_ids, + ) + ) + return _GraphEvidenceProjection( + tuple(dict.fromkeys(facts)), tuple(dict.fromkeys(public_claims)) + ) + + +async def _graph_facts_for_posts( + conn: asyncpg.Connection, + visible_post_ids: list[str], +) -> tuple[str, ...]: + """Render persisted graph facts for an already-authorized post set.""" + + return (await _graph_evidence_projection(conn, visible_post_ids)).facts _SOURCE_HINT_FIELDS = ( @@ -240,6 +281,44 @@ async def _semantic_facts_for_posts( return {post_id: tuple(dict.fromkeys(values)) for post_id, values in facts.items()} +async def _public_project_claims_for_posts( + conn: asyncpg.Connection, + public_post_ids: list[str], +) -> dict[str, tuple[PublicClaimCandidate, ...]]: + """Project typed public claims from normalized project-mention rows.""" + + if not public_post_ids: + return {} + rows = await conn.fetch( + """ + select post_id::text as post_id, project_name, ontology_iri + from post_project_mention + where post_id = any($1::uuid[]) + order by post_id, project_key, ontology_iri + limit 64 + """, + public_post_ids, + ) + claims: dict[str, list[PublicClaimCandidate]] = {} + for row in rows: + post_id = str(row["post_id"]) + claim_text = ( + f'Project "{str(row["project_name"]).strip()}" ' + f'has ontology type {str(row["ontology_iri"]).strip()}' + ) + claims.setdefault(post_id, []).append( + PublicClaimCandidate( + claim_text=claim_text[:800], + claim_kind="semantic_project", + source_post_ids=(post_id,), + ) + ) + return { + post_id: tuple(dict.fromkeys(post_claims)) + for post_id, post_claims in claims.items() + } + + async def find_linked_post_ids(conn: asyncpg.Connection, post_id: str) -> LinkedPostIds: """Both link kinds for `post_id`, NOT yet ABAC-filtered -- callers must check `can_see_post` on each id before showing or using it as chat @@ -576,9 +655,18 @@ async def gather_global_chat_sources( if can_see_post(row) and row_matches_time_range(row, resolved_time_range) ][:limit] visible_ids = [str(row["post_id"]) for row in visible_rows] + public_ids = [ + str(row["post_id"]) + for row in visible_rows + if row["visibility_code"] == "public" + ] anchor_is_visible = lineage_anchor_id in visible_ids semantic_facts = await _semantic_facts_for_posts(conn, visible_ids) - graph_facts = (await _graph_facts_for_posts(conn, visible_ids))[:16] + public_project_claims = await _public_project_claims_for_posts(conn, public_ids) + graph_projection = await _graph_evidence_projection( + conn, visible_ids, frozenset(public_ids) + ) + graph_facts = graph_projection.facts[:16] time_filter_active = resolved_time_range is not None sources: list[ChatSourceDocument] = [] for index, row in enumerate(visible_rows): @@ -594,8 +682,23 @@ async def gather_global_chat_sources( if post_id in lineage_neighbor_id_set and anchor_is_visible else () ) + source_type = ( + GlobalAskSourceDocument + if row["visibility_code"] == "public" + else ChatSourceDocument + ) + source_arguments: dict[str, Any] = {} + if source_type is GlobalAskSourceDocument: + source_arguments["external_claims"] = ( + public_project_claims.get(post_id, ()) + + tuple( + claim + for claim in graph_projection.public_claims + if post_id in claim.source_post_ids + ) + ) sources.append( - ChatSourceDocument( + source_type( post_id, row["post_title"], normalized_body, @@ -604,6 +707,7 @@ async def gather_global_chat_sources( + semantic_facts.get(post_id, ()) + lineage_fact + time_axis_evidence_fact(row, time_filter_active=time_filter_active), + **source_arguments, ) ) return sources diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 45ed5d9d4..a345ea43b 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -192,6 +192,11 @@ / "migrations" / "0203_global_ask_authorization_scope.sql" ) +_GLOBAL_ASK_PUBLIC_VERIFICATION_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0211_global_ask_public_verification.sql" +) _LEFTOVER_MAP_AXIS_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" @@ -366,6 +371,7 @@ def seeded_db(demo_analyst_token): cur.execute(_LEFTOVER_MAP_COVERAGE_MIGRATION.read_text()) cur.execute(_GLOBAL_ASK_JOB_MIGRATION.read_text()) cur.execute(_GLOBAL_ASK_SCOPE_MIGRATION.read_text()) + cur.execute(_GLOBAL_ASK_PUBLIC_VERIFICATION_MIGRATION.read_text()) cur.execute(_EVENT_OCCURRED_AT_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_AXIS_MIGRATION.read_text()) cur.execute(_CHANNEL_EVIDENCE_MIGRATION.read_text()) @@ -5137,6 +5143,86 @@ async def _source(*_args, **_kwargs): assert "lineage_graph" in answer and "cited_post_images" in answer +def test_ask_public_verification_is_opt_in_and_separate_from_post_citations( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """A cited public semantic claim can be refuted without changing its post id.""" + + import time as _time + + from lineageweave import claim_verification as cv + from lineageweave.post_chat import ChatAnswer + + class _FakeChatClient: + available = True + + def answer(self, question, sources): # noqa: ARG002 - contract shape + return ChatAnswer("Internal answer.", (sources[0].post_id,)) + + class _FakeVerificationClient: + available = True + + def verify(self, claim): + return cv.ClaimVerificationResult( + claim.claim_text, + claim.claim_kind, + cv.CLAIM_REFUTED, + "The selected public evidence conflicts with the claim.", + claim.source_post_ids, + ( + cv.ExternalEvidenceDocument( + "Public evidence", + "https://example.com/public-evidence", + "The published record describes a conflicting state.", + ), + ), + ) + + with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: + cur.execute( + """ + insert into post_project_mention + (post_id, project_key, project_name, evidence_text, confidence, + ontology_iri, extraction_method) + values (%s, 'synthetic-apollo', 'Apollo', 'Public project evidence', + 1.0, 'https://contextualwisdomlab.github.io/LineageWeave/ontology#Project', + 'synthetic_test') + """, + (seeded_db["public_post_id"],), + ) + conn.commit() + + monkeypatch.setattr("backend.app.main._post_chat_client", lambda **_kwargs: _FakeChatClient()) + monkeypatch.setattr( + "backend.app.main._claim_verification_client", + lambda: _FakeVerificationClient(), + ) + headers = {"Authorization": f"Bearer {demo_analyst_token}"} + submitted = client.post( + "/api/ask", + json={"question": "Apollo", "verify_external": True}, + headers=headers, + ) + assert submitted.status_code == 202 + job_id = submitted.json()["ask_job_id"] + + deadline = _time.monotonic() + 30 + body: dict = {} + while _time.monotonic() < deadline: + body = client.get(f"/api/ask/jobs/{job_id}", headers=headers).json() + if body["job_status_code"] in ("succeeded", "failed"): + break + _time.sleep(0.25) + + assert body.get("job_status_code") == "succeeded", body + answer = body["answer"] + assert answer["source_post_ids"] == [seeded_db["public_post_id"]] + assert answer["external_verification_status"] == cv.VERIFICATION_COMPLETED + assert answer["external_claims"][0]["status_code"] == cv.CLAIM_REFUTED + assert answer["cited_post_ids"] == [seeded_db["public_post_id"]] + assert "https://example.com/public-evidence" not in answer["cited_post_ids"] + + def test_ask_job_reads_are_owner_scoped( client, demo_analyst_token, seeded_db, monkeypatch ) -> None: diff --git a/docs/adr/0228-global-ask-public-claim-verification.md b/docs/adr/0228-global-ask-public-claim-verification.md new file mode 100644 index 000000000..b7fb500b4 --- /dev/null +++ b/docs/adr/0228-global-ask-public-claim-verification.md @@ -0,0 +1,71 @@ +# ADR 0228: Global Ask verifies typed public claims outside internal authority + +## Status + +Accepted + +## Context + +ADR 0047 lets normalized semantic and Knowledge Graph evidence nominate an +authorized source post. Nomination and an internal citation do not establish +that a real-world claim is publicly corroborated. Conversely, sending private +post bodies, people facts, measurement payloads, or source hints to a public +search service would cross the authorization boundary. + +FEVER distinguishes supported, refuted, and not-enough-information judgments +and requires cited evidence for the first two. PROV-O requires internal source +evidence, external retrieval evidence, and the verification activity to remain +distinguishable. SearXNG's current Search API supports bounded JSON results from +`GET /search` when that output format is enabled. + +## Decision + +Public verification is explicit opt-in and defaults to false. The choice is +persisted on the asynchronous `global_ask_job`; the worker never reconstructs +consent from later state. + +Only a source post whose persisted `visibility_code` is `public` receives the +`GlobalAskSourceDocument` egress capability. Claim kind, text, and evidence-post +references are projected directly from normalized `post_project_mention` and +`knowledge_graph_edge_evidence` rows after the ordinary source eligibility and +ABAC gates. No rendered-fact parsing, keyword/token overlap, confidence +threshold, or local relevance heuristic admits a claim. Eligible claims are +limited to project/ontology assertions and non-person Knowledge Graph relations +whose complete evidence-post set is public and cited. Private sources, +Keyman/person facts, raw source hints, source bodies, TEPP artifacts, +fast-mlsirm artifacts, prompts, credentials, and uncited facts never form a +public query. + +SearXNG retrieves at most five bounded snippets for at most four claims. Result +URLs must be HTTP(S), must not be search pages, localhost, `.local`, or literal +non-global addresses, and are never fetched by LineageWeave. The untrusted +snippets cross contextual-orchestrator with `mode="auto"` and +`reasoning_effort="auto"`; contextual-orchestrator retains the paper-grounded +decision over single-model versus multi-agent verification. A supported or +refuted response without selected evidence is downgraded to not enough +information. + +External URLs remain `external_claims[].evidence`; internal post identifiers +remain `cited_post_ids`. Verification never mutates ontology, Knowledge Graph, +Event Lineage, TEPP, or fast-mlsirm state. Unconfigured or failed retrieval is +an explicit unavailable state, not a negative judgment. + +## Consequences + +- Readers can request public corroboration without exporting private evidence. +- Conflicting evidence is visible without changing internal graph authority. +- Async HTTP responsiveness is retained; public retrieval runs in the worker. +- Search snippets remain evidence inputs, not trusted instructions or facts. + +## References + +Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV +ontology*. World Wide Web Consortium. https://www.w3.org/TR/prov-o/ + +SearXNG. (2026). *Search API*. https://docs.searxng.org/dev/search_api.html + +Thorne, J., Vlachos, A., Christodoulopoulos, C., & Mittal, A. (2018). FEVER: A +large-scale dataset for fact extraction and verification. In *Proceedings of +the 2018 Conference of the North American Chapter of the Association for +Computational Linguistics: Human Language Technologies* (Vol. 1, pp. 809–819). +Association for Computational Linguistics. https://doi.org/10.18653/v1/N18-1074 diff --git a/docs/adr/README.md b/docs/adr/README.md index ee4052036..aaf05402d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -17,8 +17,9 @@ decision from them. | [`ONTOLOGY_NAMESPACE_INVENTORY.md`](../doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md) | [0207](0207-repository-case-ontology-namespace-canonical.md), [0157](0157-public-ontology-namespace-identity.md) | | [`image-content-schema.md`](../image-content-schema.md) | [0066](0066-position-preserving-image-content.md) | | [`storybook-inventory.md`](../storybook-inventory.md) | [0118](0118-uiux-standard-guide-v3-design-overhaul.md), [0184](0184-ontology-provenance-explorer.md) | -| [`POSTGRESQL_CONCURRENCY_REFERENCES.md`](../doctoring/POSTGRESQL_CONCURRENCY_REFERENCES.md) | [0204](0204-analysis-run-short-transaction-delivery.md) | -| [`operability/http-concurrency-evidence.md`](../operability/http-concurrency-evidence.md) | [0204](0204-analysis-run-short-transaction-delivery.md), [0212](0212-single-query-authorized-post-filter-options.md) | +| [`POSTGRESQL_CONCURRENCY_REFERENCES.md`](../doctoring/POSTGRESQL_CONCURRENCY_REFERENCES.md) | [0204](0204-analysis-run-short-transaction-delivery.md), [0213](0213-global-ask-embedding-pool-release.md) | +| [`GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md`](../doctoring/GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md) | [0228](0228-global-ask-public-claim-verification.md) | +| [`operability/http-concurrency-evidence.md`](../operability/http-concurrency-evidence.md) | [0204](0204-analysis-run-short-transaction-delivery.md), [0212](0212-single-query-authorized-post-filter-options.md), [0213](0213-global-ask-embedding-pool-release.md) | | Evidence operations Dashboard (`/`) | [0206](0206-evidence-operations-dashboard.md) | | [`temporal-topic-context-influence-research.md`](../temporal-topic-context-influence-research.md) | [0210](0210-temporal-topic-context-influence-dashboard.md) | | [`python-mathematical-compute-boundary-audit.md`](../doctoring/python-mathematical-compute-boundary-audit.md) | [0208](0208-externalize-local-mathematical-compute.md) | diff --git a/docs/doctoring/GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md b/docs/doctoring/GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md new file mode 100644 index 000000000..d9eef2592 --- /dev/null +++ b/docs/doctoring/GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md @@ -0,0 +1,30 @@ +# Global Ask public-verification research register + +ADR 0228 adopts three distinct contracts: + +- FEVER supplies the evidence-dependent `supported`, `refuted`, and + `not_enough_information` outcome model. +- W3C PROV-O keeps internal source evidence, external web evidence, and the + verification activity separate. +- SearXNG's Search API defines the bounded JSON retrieval transport; public + instance defaults are not assumed. The checked 2026-08-26 documentation + states that `/search` accepts GET query parameters and that JSON output must + be enabled by the instance; disabled formats return HTTP 403. + +The implementation does not infer claim eligibility from question-token +overlap or rendered-string patterns. It projects typed project and non-person +graph claims from normalized PostgreSQL evidence after authorization. Model +and orchestration selection remains contextual-orchestrator authority. + +## APA 7 references + +Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV +ontology*. World Wide Web Consortium. https://www.w3.org/TR/prov-o/ + +SearXNG. (2026). *Search API*. https://docs.searxng.org/dev/search_api.html + +Thorne, J., Vlachos, A., Christodoulopoulos, C., & Mittal, A. (2018). FEVER: A +large-scale dataset for fact extraction and verification. In *Proceedings of +the 2018 Conference of the North American Chapter of the Association for +Computational Linguistics: Human Language Technologies* (Vol. 1, pp. 809–819). +Association for Computational Linguistics. https://doi.org/10.18653/v1/N18-1074 diff --git a/docs/product-requirements.md b/docs/product-requirements.md index 75cba0410..bf3f03f66 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -90,6 +90,22 @@ content never becomes an external query or citation. Acceptance: each state tells the user the next valid action and never displays stale evidence from a previously opened post. +### PRD-FR-5A — Opt-in public claim verification + +- Persist an explicit per-question opt-in before any external search begins. +- Nominate only cited, public semantic/KG facts; source bodies, private facts, + personal facts, and measurement outputs never become external queries. +- Retrieve bounded public evidence through SearXNG and adjudicate through + contextual-orchestrator's adaptive orchestration boundary. +- Report supported, refuted, and not-enough-information outcomes without + promoting public pages to internal ontology authority. +- Keep external URLs visually and structurally separate from authorized + internal post citations. + +Acceptance: leaving the control off causes no public request; hidden or +uncited facts cause no public request; unavailable services fail closed; and +each displayed public judgment retains its originating internal evidence IDs. + ### PRD-FR-6 — Measurement boundary - Consume TEPP accepted/completed wire contracts and fast-mlsirm outputs; do diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 7ec497477..d75358852 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -16,6 +16,7 @@ operator-facing control you can click before changing product CSS. | `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` | | `Workspace/WorkspaceCalendar` | Read observed Naruon events, or open a commitment to land on that post. Fail-closed copy stays `이 범위의 일정을 아직 받을 수 없습니다`. | `--color-chip-border`, `WorkspaceCalendar`, `EvidenceStatusMark` | | `Evidence/OntologyExplorer` | Distinguish Post, Person, Organization, and Team by shape and text, use the token-backed surface as a secondary cue, then open the exact-value table or cited evidence. Compare desktop, narrow, drawer, empty, truncated, denied, stale, and rejected states. | `--ontology-node-*-fill`, `OntologyExplorer` | +| `Ask Agent/Public claim verification` | Compare supported, refuted, 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` | 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.tsx b/frontend/src/App.tsx index 76ff51dec..9ebbe6b22 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -94,6 +94,7 @@ import { CutoffKnownBody } from "./components/CutoffKnownBody"; import { LineageEntityPicker } from "./components/LineageEntityPicker"; import { OntologyExplorer } from "./components/OntologyExplorer"; import { AskEvidenceLayerPopup } from "./components/AskEvidenceLayerPopup"; +import { PublicClaimVerification } from "./components/PublicClaimVerification"; import { PopupCloseButton } from "./components/PopupCloseButton"; import { SimilarVocPanel } from "./components/SimilarVocPanel"; import { chatEvidenceKindLabel } from "./evidenceKindLabels"; @@ -4813,7 +4814,7 @@ function CustomerMasterPanel({ ); } -function AskAgentPanel({ +export function AskAgentPanel({ accessToken, onOpenPost, }: { @@ -4824,6 +4825,7 @@ function AskAgentPanel({ const [answer, setAnswer] = useState(null); const [error, setError] = useState(null); const [asking, setAsking] = useState(false); + const [verifyExternal, setVerifyExternal] = useState(false); const [evidenceLayerPostId, setEvidenceLayerPostId] = useState(null); async function handleAsk() { @@ -4832,7 +4834,7 @@ function AskAgentPanel({ setAsking(true); setError(null); try { - setAnswer(await askAgent(accessToken, normalized)); + setAnswer(await askAgent(accessToken, normalized, verifyExternal)); } catch (err) { setAnswer(null); setError(orchestratorUnavailableMessage(err, t("Ask Agent"))); @@ -4856,6 +4858,14 @@ function AskAgentPanel({ rows={4} /> + @@ -4864,6 +4874,10 @@ function AskAgentPanel({

{t("Answer")}

{answer.answer_text ?

{answer.answer_text}

: null} {answer.next_action ?

{t(answer.next_action)}

: null} + {answer.delivery ? (