Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.d/2.12.7-korean-search-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# 2.12.7 — Preserve Korean search corroboration boundaries

## Fixed

- Accept a Korean organization token followed by a grammatical particle in a
natural Searxng snippet while still rejecting the token inside a longer
unrelated Hangul word.
50 changes: 23 additions & 27 deletions backend/app/analysis_run_start.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

import hashlib
import json
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
from uuid import UUID

Expand All @@ -21,15 +21,15 @@
AnalysisRunCreateError,
fetch_visible_analysis_run,
)
from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
from backend.app.analysis_run_outbox import (
latest_outbox_delivery_is_claimed,
latest_outbox_delivery_is_delivered,
outbox_request_digest,
)
from backend.app.lineage_ingestion import records_from_source_posts
from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
from lineageweave.adjudication_client import AdjudicationClient
from lineageweave.http_client import HttpClientError, post_json
from lineageweave.http_client import post_json
from lineageweave.lineage_persistence import lineage_edge_specs
from lineageweave.models import Edge
from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable
Expand Down Expand Up @@ -103,8 +103,8 @@ def transport(payload: dict[str, Any]) -> dict[str, Any]:
try:
headers = {"authorization": f"Bearer {api_key}"} if api_key.strip() else {}
return post_json(url, payload, headers=headers, timeout=30.0)
except (HttpClientError, OSError, ValueError, TypeError) as exc:
raise TeppNotAvailable("TEPP transport unavailable") from exc
except Exception as exc:
raise TeppNotAvailable("TEPP transport request failed") from exc

return TeppClient(transport=transport)

Expand All @@ -119,12 +119,12 @@ def tepp_run_request(
"""Build TEPP's published request from the frozen run, never a theta."""
cutoff = knowledge_cutoff
if cutoff.tzinfo is None:
cutoff = cutoff.replace(tzinfo=timezone.utc)
cutoff = cutoff.replace(tzinfo=UTC)
return AnalysisRunRequest(
idempotency_key=idempotency_key,
tenant_workspace_id=str(corporate_entity_id),
snapshot_id=snapshot_sha256,
knowledge_cutoff=cutoff.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
knowledge_cutoff=cutoff.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
model_contract_version=_TEPP_MODEL_CONTRACT,
output_profile=_TEPP_OUTPUT_PROFILE,
)
Expand Down Expand Up @@ -610,7 +610,7 @@ async def deliver_queued_analysis_run(
return await _visible_or_404(
conn, analysis_run_id, account_id, affiliated_entity_ids
)
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
try:
if not latest_outbox_delivery_is_claimed(latest):
await _append_outbox_delivery(
Expand All @@ -636,9 +636,8 @@ async def deliver_queued_analysis_run(
affiliated_entity_ids=affiliated_entity_ids,
adjudication_client=adjudication_client,
)
finished = datetime.now(timezone.utc)
if finished < now:
finished = now
finished = datetime.now(UTC)
finished = max(finished, now)
await _append_outbox_delivery(
conn,
analysis_run_id,
Expand Down Expand Up @@ -699,7 +698,7 @@ async def _deliver_lineage_reconstruction(
adjudication_client: AdjudicationClient | None = None,
) -> None:
"""Persist ThreadWeave parent choices for the frozen bag."""
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
member_rows = await _snapshot_member_posts(
conn,
locked["analysis_source_snapshot_id"],
Expand All @@ -715,9 +714,8 @@ async def _deliver_lineage_reconstruction(
)
edges = lineage_edge_specs(records_from_source_posts(rows), llm=adjudication_client)
digest = reconstruction_result_digest(edges)
finished = datetime.now(timezone.utc)
if finished < now:
finished = now
finished = datetime.now(UTC)
finished = max(finished, now)
await conn.execute(
"""
insert into analysis_run_reconstruction
Expand Down Expand Up @@ -759,25 +757,23 @@ async def _deliver_tepp_measurement(
tepp_client: TeppClient,
) -> None:
"""Submit the frozen snapshot through ``tepp_client``. Never persist a theta."""
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
request = tepp_run_request(
idempotency_key=str(locked["idempotency_key"]),
snapshot_sha256=str(locked["snapshot_sha256"]),
knowledge_cutoff=locked["knowledge_cutoff"],
corporate_entity_id=str(locked["corporate_entity_id"]),
)
status_code, failure_code, envelope = _tepp_submission(tepp_client, request)
if status_code == _SUCCEEDED and envelope is not None:
if not await _persist_tepp_result(
conn,
analysis_run_id=analysis_run_id,
envelope=envelope,
):
status_code = _FAILED
failure_code = "tepp_result_not_persisted"
finished = datetime.now(timezone.utc)
if finished < now:
finished = now
if status_code == _SUCCEEDED and envelope is not None and not await _persist_tepp_result(
conn,
analysis_run_id=analysis_run_id,
envelope=envelope,
):
status_code = _FAILED
failure_code = "tepp_result_not_persisted"
finished = datetime.now(UTC)
finished = max(finished, now)
await _append_status(
conn,
analysis_run_id,
Expand Down
6 changes: 6 additions & 0 deletions docs/adr/0005-relation-verification-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ keeps the channel unavailable (never fabricates a verification result)
when `SEARXNG_BASE_URL` is unset, same discipline as every other
pluggable client in this repo.

Evidence token selection excludes generic fixture descriptors such as
`fictitious`, `nonexistent`, `placeholder`, `sample`, `example`, and `demo`.
Those words can appear in unrelated search results and are not organization
identity. This keeps synthetic demo data out of the corroboration signal while
retaining distinctive organization tokens and cited URLs.

Persistence: `post_counterparty_entity` gains
`verification_status_code` (`common_lookup_value` category
`relation_verification_status`: `verify_pending` / `verify_corroborated`
Expand Down
6 changes: 6 additions & 0 deletions docs/adr/0022-authorized-tepp-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ authorized transaction:
4. submits through `TeppClient`. An empty `TEPP_TRANSPORT_URL` keeps the
default unavailable transport. A set URL POSTs the published wire
payload through the http(s)-only helper. File URLs stay unavailable;
`AnalysisRunRequest` rejects non-v1 versions and blank/non-text required
fields before the transport is called, matching TEPP's v1 JSON Schema;
5. appends Failed / `tepp_not_available` when the transport is missing
or refused, or Failed / `tepp_result_not_persisted` when TEPP accepts
an envelope this product cannot store yet.
Expand Down Expand Up @@ -93,3 +95,7 @@ World Wide Web Consortium. https://www.w3.org/TR/prov-dm/

World Wide Web Consortium. (2013). *PROV-O: The PROV ontology* (W3C
Recommendation). https://www.w3.org/TR/prov-o/

ContextualWisdomLab. (2026). *TEPP API and modular integration contract*
([Computer software documentation]). GitHub.
https://github.com/ContextualWisdomLab/TEPP/blob/main/docs/API_CONTRACT.md
77 changes: 59 additions & 18 deletions lineageweave/relation_verification.py
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -56,17 +56,37 @@
"group",
"holdings",
"limited",
# Fixture descriptors are search vocabulary, not organization identity.
"fictitious",
"nonexistent",
"placeholder",
"sample",
"example",
"demo",
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Outdated

@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: New stopwords make demo/example-named orgs permanently uncorroborated

Adding fictitious, nonexistent, placeholder, sample, example, demo to _ORG_TOKEN_STOPWORDS (relation_verification.py) means an organization whose only distinctive tokens are these words yields an empty token list and always returns STATUS_UNCORROBORATED. This is intentional per ADR 0005 (keeping synthetic demo data out of the corroboration signal). I confirmed no fixture organization names in lineageweave/fixtures.py consist of these words and existing corroboration tests (Acme on host acme.example) still match on the acme token, so no legitimate verification regresses.

Open in Devin Review

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

"foundation",
"the",
"and",
}
)
# Korean postpositions attach directly to nouns. They are allowed only when
# the suffix itself ends at a non-word boundary; a longer Hangul word must not
# turn a substring into corroborating evidence.
_HANGUL_PARTICLE = r"(?:으로|에서|에게|한테|까지|부터|처럼|보다|마다|조차|마저|밖에|이랑|랑|은|는|이|가|을|를|에|와|과|로|의|도|만|뿐)"

STATUS_PENDING = "verify_pending"
STATUS_CORROBORATED = "verify_corroborated"
STATUS_UNCORROBORATED = "verify_uncorroborated"


def _contains_org_token(token: str, haystack: str) -> bool:
"""Match one organization token without breaking Korean particle syntax."""
if re.search(r"[가-힣]", token):
pattern = rf"(?<!\w){re.escape(token)}(?:{_HANGUL_PARTICLE})?(?!\w)"
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Outdated
else:
pattern = rf"(?<!\w){re.escape(token)}(?!\w)"
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Outdated
return re.search(pattern, haystack) is not None

@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: Korean particle list is a curated subset

_HANGUL_PARTICLE (relation_verification.py) omits some real postpositions such as 로서/로써, 께서, and 이라고. Because 로 matches greedily and leaves 서, a snippet like 한빛그리드로서 … fails to corroborate. Deliberate coarseness, not a defect, but the gap limits recall on otherwise-natural Korean snippets.

Open in Devin Review

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



@dataclass(frozen=True)
class RelationVerificationResult:
"""One claim's verification outcome.
Expand All @@ -88,7 +108,9 @@ class RelationVerificationClient(Protocol):

available: bool

def verify(self, organization_name: str, relationship_label: str) -> RelationVerificationResult:
def verify(
self, organization_name: str, relationship_label: str
) -> RelationVerificationResult:
"""Search for corroborating evidence of ``organization_name``
having the relationship ``relationship_label`` describes.

Expand All @@ -107,7 +129,9 @@ class NullRelationVerificationClient:

available = False

def verify(self, organization_name: str, relationship_label: str) -> RelationVerificationResult: # pragma: no cover
def verify(
self, organization_name: str, relationship_label: str
) -> RelationVerificationResult: # pragma: no cover
"""Verify whether the relationship has supporting external evidence."""
raise RuntimeError(
"NullRelationVerificationClient has no search channel; check .available first"
Expand All @@ -134,11 +158,15 @@ class SearxngRelationVerificationClient:
def __init__(self, base_url: str, *, timeout: float = 15.0) -> None:
parsed = urlparse(base_url)
if parsed.scheme not in {"http", "https"}:
raise ValueError(f"unsupported Searxng base URL scheme: {parsed.scheme or 'missing'}")
raise ValueError(
f"unsupported Searxng base URL scheme: {parsed.scheme or 'missing'}"
)
self._base_url = base_url.rstrip("/")
self._timeout = timeout

def verify(self, organization_name: str, relationship_label: str) -> RelationVerificationResult:
def verify(
self, organization_name: str, relationship_label: str
) -> RelationVerificationResult:
"""Verify whether the relationship has supporting external evidence."""
query = f"{organization_name} {relationship_label}"
body = get_json(
Expand All @@ -147,33 +175,46 @@ def verify(self, organization_name: str, relationship_label: str) -> RelationVer
)
results = body.get("results")
if not isinstance(results, list):
return RelationVerificationResult(status_code=STATUS_UNCORROBORATED, evidence_url=None)
return RelationVerificationResult(
status_code=STATUS_UNCORROBORATED, evidence_url=None
)
for result in results:
if not isinstance(result, dict):
continue
evidence_url = corroborating_evidence_url(organization_name, result)
if evidence_url is not None:
return RelationVerificationResult(status_code=STATUS_CORROBORATED, evidence_url=evidence_url)
return RelationVerificationResult(status_code=STATUS_UNCORROBORATED, evidence_url=None)
return RelationVerificationResult(
status_code=STATUS_CORROBORATED, evidence_url=evidence_url
)
return RelationVerificationResult(
status_code=STATUS_UNCORROBORATED, evidence_url=None
)


def corroborating_evidence_url(organization_name: str, result: dict[str, Any]) -> str | None:
def corroborating_evidence_url(
organization_name: str, result: dict[str, Any]
) -> str | None:
"""Return ``result['url']`` when it is a real-world footprint of ``organization_name``.

Search engines echo the query in result titles, so "any hit" is not
corroboration. A single distinctive token is not enough either -- an
invented name can still contain an ordinary dictionary word (e.g.
"Fictitious", "Nonexistent") that coincidentally appears on an
unrelated page, so a genuine multi-token name requires a majority of
its tokens to co-occur in the same result; a one-token name has no
majority to require and falls back to that single token. The host
must also not itself be a search page. Missing or empty URLs are not
evidence.
unrelated page, so every distinctive token in a multi-token name must
co-occur in the same result. A one-token name falls back to that single
token. The host must also not itself be a search page. Missing or empty
URLs are not evidence.
"""
url = result.get("url")
if not isinstance(url, str) or not url.strip():
return None
host = urlparse(url).netloc.lower()
try:
parsed_url = urlparse(url)
host = (parsed_url.hostname or "").lower()
except ValueError:
return None
if parsed_url.scheme not in {"http", "https"}:
return None
if not host or any(marker in host for marker in _SEARCH_HOST_MARKERS):
return None
tokens = [
Expand All @@ -184,9 +225,9 @@ def corroborating_evidence_url(organization_name: str, result: dict[str, Any]) -
if not tokens:
return None
haystack = f"{host} {result.get('content') or ''}".lower()
# Every distinctive token must occur in the same result. Matching one
# token lets generic pages about words such as "fictitious" corroborate a
# made-up multi-word organization.
if all(token in haystack for token in tokens):
# Substring matches turn ``Alpha`` into a false hit for ``alphabetical``.
# Word boundaries keep host labels, punctuation, and Hangul names usable
# without accepting a token embedded inside an unrelated word.
if all(_contains_org_token(token, haystack) for token in tokens):

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: Host matching now requires word boundaries

corroborating_evidence_url previously accepted any substring hit in host or content; _contains_org_token now demands non-word boundaries. A run-together host such as acmeindustries.com no longer corroborates the token acme on the host alone, shifting reliance onto content text. Intended for the alphabetical false positive, but it lowers recall for concatenated host labels.

Open in Devin Review

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

@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: Corroboration now requires all tokens, not a majority

The evidence check switched from majority co-occurrence to requiring every distinctive token in a multi-token name. Documented and intentional, but a genuine result that omits one token of a long name will now read as uncorroborated.

Open in Devin Review

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

return url
return None
Loading
Loading