Skip to content

feat: verify Global Ask semantic claims with public evidence - #276

Merged
seonghobae merged 140 commits into
feat/gnb-event-lineage-focus-keyman-v2190from
feat/global-ask-public-claim-verification-v2200
Aug 21, 2026
Merged

feat: verify Global Ask semantic claims with public evidence#276
seonghobae merged 140 commits into
feat/gnb-event-lineage-focus-keyman-v2190from
feat/global-ask-public-claim-verification-v2200

Conversation

@seonghobae

@seonghobae seonghobae commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Fixes #272

Buyer problem

Global Ask answers from authorized internal evidence and persisted Knowledge Graph / semantic evidence. This stack adds semantic/KG nomination without access escalation and keeps explicitly requested public corroboration separate from internal authority, citations, PII/Keyman facts, TEPP evidence, and fast-mlsirm measurement artifacts.

Current exact candidate

  • Head: 139cd24cbdc5282230ce0619617d113914efa7d6
  • Base: feat/gnb-event-lineage-focus-keyman-v2190@259b3b0d073eaa4c050ee5459a95ebb815a43f4f
  • Remote concurrent commits were integrated with ordinary merges; no force-push or protected-branch bypass.
  • Valid unresolved review threads: zero as last audited; exact-head formal approval and terminal Checks are still required.

Production boundaries

  • Weighted title/body/source-field retrieval, bounded semantic/KG nomination, direct Event-Lineage expansion, final visibility/tenant/application ABAC, and no unrelated-recent-post fallback for unmatched non-empty questions.
  • verify_external is backward-compatible and defaults to false; SearXNG runs only after explicit opt-in and only cited public non-person claims may egress. External evidence remains separate from internal citations and never mutates Knowledge Graph or ontology authority.
  • contextual-orchestrator owns model discovery, mode="auto", reasoning effort, schema validation, and provider boundary; this repository has no direct provider fallback.
  • Project-history projection now emits the strict HTTP contract it validates: authoritative identity keys, display names, source post IDs, time basis, event type metadata, responsibility provenance, and optional focus fallback. The regression path includes real PostgreSQL rows and Pydantic validation.
  • Knowledge-graph and Customer Master ontology/SHACL updates remain evidence-bound and are documented in ADR/doctoring/Storybook artifacts.

Exact-head local verification

  • Python: 796 passed, 16 skipped, 4 warnings.
  • Frontend: 179 passed; lint, TypeScript production build, and Storybook build passed.
  • Project-history changed modules: 100% statement and branch coverage in focused coverage run.
  • Compose/PostgreSQL/Keycloak API integration recovered after the Docker PostgreSQL disk-pressure incident; API integration passed 98 passed, 5 skipped. Dangling images only were pruned to restore Docker capacity; no product data volume was deleted.
  • git diff --check and CodeGraph sync passed.

Exact-head merge gate

Merge only when this exact head has terminal required Tests, PostgreSQL/integration, frontend, Storybook, coverage, SAST, Security, supply-chain, OpenCode/Noema/Strix organization gates, zero valid unresolved threads, and an independent qualifying formal approval. Queued, pending, skipped, cancelled, absent, predecessor-head, model-only, or author-only evidence is not success. Do not self-approve or bypass protection.

Research and standards

  • W3C PROV-O keeps internal source evidence, public retrieval evidence, and adjudication activity distinct.
  • Thorne et al. (2018), FEVER: supported/refuted judgments require evidence; otherwise return not enough information.
  • PostgreSQL pg_trgm supports indexed substring search; SearXNG remains bounded self-hosted retrieval.

Open in Devin Review

seonghobae and others added 28 commits August 19, 2026 15:26
@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6309c7f9-5d25-46c5-aa18-f5b7d61a9788

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

seonghobae commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by the current exact-head GREEN integration instruction in #276 comment 5350114633. Do not use the deleted bootstrap payload/workflow or create wrapper copies of main.py / post_chat_ingestion.py. Work only from the current PR head, preserve #264 behavior, apply minimal direct production edits, and keep the PR Draft until full exact-head validation.

@seonghobae
seonghobae marked this pull request as ready for review August 20, 2026 22:12
@seonghobae

Copy link
Copy Markdown
Contributor Author

@opencode-agent Exact-head review requested for b1f0e8d8a2edc3b0912d753a2d92ddba8be0228f. Valid review threads are zero. Local exact-head evidence: 775 passed, 16 skipped; frontend lint and 168 tests; production and Storybook builds; lock, Compose, compile, and diff hygiene passed.

devin-ai-integration[bot]

This comment was marked as resolved.

@seonghobae

Copy link
Copy Markdown
Contributor Author

@opencode-agent Exact-head review requested for c8ffd9c86a6bffbccdbad5f954cb50d941be8d3e. Valid threads are zero. Exact local evidence: 782 passed, 16 skipped; frontend 168 tests and all-locale next-action consistency, lint, production build, Storybook, lock, Compose, and diff hygiene passed.

@seonghobae

Copy link
Copy Markdown
Contributor Author

Exact-head follow-up for 75c9d1208cf9bc2f6527e93c2f1f549fdaad273f: fixed the Semgrep python.lang.security.audit.sqli.asyncpg-sqli finding at backend/app/post_chat_ingestion.py:679 by replacing f-string SQL predicate assembly with a static query and a parameterized $4::boolean gate. Focused tests: 19 passed; local Semgrep: 0 findings; git diff --check: passed. Please review and run hosted Checks against this exact head. No merge or approval is being asserted from local evidence.

devin-ai-integration[bot]

This comment was marked as resolved.

@seonghobae

Copy link
Copy Markdown
Contributor Author

@opencode-agent @devin-ai-integration

Please review exact current head 139cd24cbdc5282230ce0619617d113914efa7d6 against base 259b3b0d073eaa4c050ee5459a95ebb815a43f4f. Concurrent project-history contract work was reconciled by normal merge; the strict HTTP projection regression now covers source identity/display names, optional focus, source IDs/time basis, responsibility provenance, and real PostgreSQL validation. Local exact-head evidence: Python 796 passed, 16 skipped; frontend 179 passed; lint/build/Storybook passed; focused changed-module statement/branch coverage 100%. Please leave a formal review outcome for this exact SHA.

devin-ai-integration[bot]

This comment was marked as resolved.

…man-v2190' into HEAD

# Conflicts:
#	docker/postgres-init/migrate.sh
@seonghobae

Copy link
Copy Markdown
Contributor Author

Restacked onto current #266 head 0beb110 with normal merge commit af7c0d5.

Local verification on the pushed tree: backend 839 passed, 16 skipped; frontend 190 passed; Global Ask verification and project-history targeted tests 63 backend plus 13 frontend passed; lint, build, Storybook build, compileall, actionlint, and diff checks passed. Migration allowlisting preserves both the semantic-search/project-history slice and the current Event Lineage migrations. Awaiting independent review and hosted Checks on the current head.

@seonghobae
seonghobae enabled auto-merge (squash) August 21, 2026 05:51
@seonghobae seonghobae added the enhancement New feature or request label Aug 21, 2026 — with ChatGPT Codex Connector

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 12 new potential issues.

Open in Devin Review

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Project-history frontend components are not wired into the app

ProjectHistoryDisclosure, ProjectHistoryTimeline, fetchProjectHistory, and groupProjectEvidence are exercised only by tests/stories; none are referenced in App.tsx. The backend /api/project-history route is mounted (main.py), but no buyer surface calls it. This is dead-but-tested code, not a defect, though reviewers may want to confirm the intended integration point is a follow-up.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread backend/app/main.py
Comment on lines +2739 to +2740
except (HttpClientError, KeyError, OSError, TypeError, ValueError):
return VERIFICATION_UNAVAILABLE, ()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Malformed verification response crashes the whole Ask answer

The except clause here catches only (HttpClientError, KeyError, OSError, TypeError, ValueError), but client.verify reads body["choices"][0] (claim_verification.py), which raises IndexError on an empty choices list. That escapes _verify_public_claims, running before persist_global_ask_turn, so the whole opt-in /api/ask request returns 500 and the already-computed answer is discarded. Other provider boundaries here fail closed with a broad except Exception.

Suggested change
except (HttpClientError, KeyError, OSError, TypeError, ValueError):
return VERIFICATION_UNAVAILABLE, ()
except Exception: # noqa: BLE001 - provider boundary is fail-closed.
return VERIFICATION_UNAVAILABLE, ()
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 696 to +705
list(authorized_corporate_entity_ids),
candidate_ids,
limit,
not bool(question),
)
visible_rows = [row for row in rows if can_see_post(row)][:limit]
candidate_id_set = frozenset(candidate_ids)
visible_rows = [
row
for row in rows
if (not question or str(row["post_id"]) in candidate_id_set) and can_see_post(row)

@devin-ai-integration devin-ai-integration Bot Aug 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: No-unrelated-recency fallback correctly closes on empty candidate scores

gather_global_chat_sources adds if question and not candidate_scores: return [] (post_chat_ingestion.py) plus a candidate_predicate restricting the final query to candidate_ids (post_chat_ingestion.py) and a post-filter str(row['post_id']) in candidate_id_set (post_chat_ingestion.py). Together these prevent the previous behavior where a question with few/no lexical matches fell through to recent unrelated posts. When question is falsy, the predicate is empty but $2/$3 are still bound, so the SQL remains valid. This was verified against test_global_ask_public_integration.py.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread backend/app/main.py
Comment on lines 2982 to +2987
"cited_post_evidence": cited_post_evidence(sources, cited_ids),
"source_post_ids": [source.post_id for source in sources],
"timeline": global_ask_timeline(sources),
"external_verification_status": verification_status,
"external_claims": [claim.to_payload() for claim in external_claims],
"next_action": _verification_next_action(verification_status),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Main-branch Global Ask response now always carries next_action

Previously the cited-posts return path of POST /api/ask did not include next_action; it now always returns one (e.g. Enable public verification to check eligible public claims. when verify_external is false). The frontend renders answer.next_action unconditionally, so buyers will now always see a verification nudge alongside the existing 'Authorized cited posts are current...' message. This looks intentional per ADR 0106 but is a visible behavior change; confirm the double next-action messaging is desired UX.

(Refers to this code)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 750 to +753
if post_id == lineage_anchor_id
else "keyword_match"
),
external_claim_facts=external_facts,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: public_external_claim_facts receives the full shared graph_facts list for every row

In gather_global_chat_sources, public_external_claim_facts(row, semantic_facts.get(post_id), graph_facts, public_post_ids) is called for each source with the same full graph_facts list (post_chat_ingestion.py), whereas the LLM-prompt graph_facts is only attached to index == 0. This means every public source's external_claim_facts can carry the same graph relations. It is not a correctness bug because public_claim_candidates de-duplicates by (kind, claim_text) and each graph fact remains bound to its own evidence-post ids (which must be a subset of public_post_ids), so private evidence still cannot egress. Just noting the intentional asymmetry between prompt graph facts and egress graph facts.

(Refers to this code)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread backend/app/main.py
Comment on lines +2693 to +2708
def _verification_next_action(
status_code: str,
*,
has_authorized_sources: bool = True,
) -> str:
"""Give the Buyer a bounded action without treating web evidence as authority."""

if not has_authorized_sources:
return "No authorized source posts are available for this question."
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.")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: CLAIM_NOT_ENOUGH_INFORMATION branch in next-action map is unreachable via verification_status

_verification_next_action maps CLAIM_NOT_ENOUGH_INFORMATION to a buyer message (main.py), but the status_code passed to this function is always one of the VERIFICATION_* constants (skipped/unavailable/no_public_claims/completed), never a per-claim claim_* status. The CLAIM_NOT_ENOUGH_INFORMATION entry is therefore dead in production and only exercised directly by unit tests. The ADR's buyer-next-action table distinguishes supported/refuted/not-enough-information, but the implementation collapses all completed states to the single COMPLETED message, so per-claim verdicts do not drive distinct next actions. Not a correctness bug, but the ADR intent is only partially realized.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 696 to +706
list(authorized_corporate_entity_ids),
candidate_ids,
limit,
not bool(question),
)
visible_rows = [row for row in rows if can_see_post(row)][:limit]
candidate_id_set = frozenset(candidate_ids)
visible_rows = [
row
for row in rows
if (not question or str(row["post_id"]) in candidate_id_set) and can_see_post(row)
][:limit]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Lineage-neighbor expansion can crowd out semantic/lexical matches within the source limit

After ranking candidate_ids by score, the top match's direct lineage neighbors are inserted immediately after the anchor (post_chat_ingestion.py), and the final SQL orders by array_position($2, post_id) then limits to limit (default 4). When the anchor has several lineage neighbors, they occupy the top-limit window ahead of other strongly-scored semantic/lexical candidates, so those candidates may never become sources. This mirrors pre-existing behavior (the same expansion existed before this PR), so it is not introduced here, but the new semantic-nomination scoring makes the interaction more consequential since strong persisted-semantic matches can now be displaced by an anchor's lineage chain.

(Refers to this code)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread frontend/src/api.ts
Comment on lines 919 to +937
export function askAgent(
accessToken: string,
question: string,
verifyExternalOrSessionId: boolean | string = false,
sessionId?: string,
): Promise<AskAgentResponse> {
const verifyExternal = typeof verifyExternalOrSessionId === "boolean"
? verifyExternalOrSessionId
: undefined;
const existingSessionId = typeof verifyExternalOrSessionId === "string"
? verifyExternalOrSessionId
: sessionId;
return backendFetch("/api/ask", accessToken, {
method: "POST",
body: JSON.stringify({ question, ...(sessionId ? { session_id: sessionId } : {}) }),
body: JSON.stringify({
question,
...(verifyExternal !== undefined ? { verify_external: verifyExternal } : {}),
...(existingSessionId ? { session_id: existingSessionId } : {}),
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: askAgent overload preserves legacy 3-arg (sessionId) callers

The new askAgent signature (api.ts) accepts verifyExternalOrSessionId: boolean | string. A legacy call passing a string session id as the third argument is routed to existingSessionId and omits verify_external, while the sole app caller now passes a boolean plus the 4th sessionId. This preserves backward compatibility; verified there are no other callers passing a string third argument.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread backend/app/main.py
Comment on lines +2722 to +2745
authorized_ids = frozenset(str(post_id) for post_id in public_post_ids)
claims = tuple(
claim
for claim in public_claim_candidates(sources, question)
if set(claim.source_post_ids).issubset(authorized_ids)
)
if not claims:
return VERIFICATION_NO_PUBLIC_CLAIMS, ()
client = _claim_verification_client()
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(authorized_ids)
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Public-egress filter does not leak private/person data even with hallucinated cited ids

_verify_public_claims (main.py) sets authorized_ids from the caller-provided cited_ids and keeps only claims whose source_post_ids are a subset. Because public_external_claim_facts returns () for any non-public row (global_ask_retrieval.py), only public sources ever contribute external_claim_facts, and public_claim_candidates further drops person/Keyman/actor facts. Consequently, even if the LLM returns a hallucinated or private post id in cited_ids, no private-post data can enter a SearXNG query. The double subset check on returned results (main.py) is redundant but harmless.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread backend/app/main.py
Comment on lines +2734 to +2737
results = tuple(
await asyncio.gather(
*(asyncio.to_thread(client.verify, claim) for claim in claims)
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: verify_external can extend /api/ask latency substantially when enabled

When verify_external=True and public claims are eligible, _verify_public_claims fans out up to maximum_claims (4) client.verify calls via asyncio.gather/to_thread (main.py). Each SearxngOrchestratedClaimVerificationClient.verify does a SearXNG GET (15s) plus an orchestrator adjudication POST with a 180s timeout (claim_verification.py). In the worst case a single opt-in Ask request can block for up to ~180s before responding. This is opt-in and bounded, but reviewers should confirm the request-level timeout/UX is acceptable.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

* feat: search verified multilingual organization labels

* fix: replay organization label indexes

* fix: make organization label indexes reversible
@seonghobae
seonghobae merged commit d0ecc3d into feat/gnb-event-lineage-focus-keyman-v2190 Aug 21, 2026
3 of 4 checks passed

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Open in Devin Review

Comment on lines +635 to +636
for post_id in semantic_candidate_ids:
candidate_scores[post_id] = candidate_scores.get(post_id, 0.0) + 4.0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Semantic score outranks exact title match

Each semantic/KG candidate adds a flat +4.0, above the title weight of 3.0, so any persisted semantic match outranks an exact title match when selecting the lineage anchor and ordering sources. The inline comment only claims it outranks a weak body hit; the effect is broader.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Buyer Gap] Verify Global Ask KG/ontology/semantic claims with public SearXNG evidence

2 participants