Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
12 changes: 8 additions & 4 deletions docs/adr/0005-relation-verification-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,14 @@ observed from LLM classification), not "this specific relationship
claim is definitely true" -- a genuinely false relationship between two
REAL organizations still returns results about each organization
separately, so this is an existence/plausibility check, not a full
relationship-truth adjudicator. That is a real upgrade path once real
usage shows the coarser signal under- or over-trusting results in
practice, not implemented here because nothing yet demonstrates the
need for it over this cheaper stage.
relationship-truth adjudicator. A result is accepted only when every
distinctive token in the proposed organization name occurs in the
result's non-search host or content snippet; the result title is excluded
because search engines echo the query there. This prevents one generic
word in an unrelated result from validating an invented multi-token name.
That is a real upgrade path once real usage shows the coarser signal under-
or over-trusting results in practice, not implemented here because
nothing yet demonstrates the need for full NLI over this cheaper stage.

The real implementation, `SearxngRelationVerificationClient`, queries a
**self-hosted** Searxng instance (`docker/searxng/`), never a
Expand Down
88 changes: 58 additions & 30 deletions lineageweave/relation_verification.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@
"and",
}
)
_HANGUL_TOKEN = re.compile(r"[가-힣]+")
_KOREAN_PARTICLE_SUFFIX = re.compile(
r"(?:에게서|한테서|에서는|으로는|이라고|에서|에게|한테|께서|부터|까지|처럼|보다|만큼|"
r"으로|이랑|라고|이|가|은|는|을|를|의|에|께|와|과|도|만|로|랑|하고)+"
)
Comment on lines +64 to +68

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 suffix regex uses unbounded + over overlapping alternatives

_KOREAN_PARTICLE_SUFFIX (relation_verification.py) is a (?:...)+ over many overlapping single- and multi-char alternatives, evaluated via fullmatch on observed[len(expected):]. For a long non-matching Hangul suffix this can backtrack heavily. In practice the observed token is a single Hangul run drawn from a bounded search snippet, so the risk is low, but if snippet content is ever large/attacker-influenced this could become a performance concern. Also note multi-particle sequences like 은는 would fullmatch (two repetitions), so garbage suffixes composed only of particle characters would be accepted — acceptable for the intended coarse signal but a slight over-permissiveness.

Open in Devin Review

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


STATUS_PENDING = "verify_pending"
STATUS_CORROBORATED = "verify_corroborated"
Expand Down Expand Up @@ -108,7 +113,7 @@ class NullRelationVerificationClient:
available = False

def verify(self, organization_name: str, relationship_label: str) -> RelationVerificationResult: # pragma: no cover
"""Verify whether the relationship has supporting external evidence."""
"""Reject verification because this client has no search transport."""
raise RuntimeError(
"NullRelationVerificationClient has no search channel; check .available first"
)
Expand All @@ -118,28 +123,32 @@ class SearxngRelationVerificationClient:
"""Queries a self-hosted Searxng instance's JSON API for corroborating
evidence of a claimed organization/relationship.

The presence/absence signal is deliberately coarse: any search result
for "``<organization_name>`` ``<relationship_label>``" is treated as
corroboration that the named organization has a real-world footprint
consistent with the claim, not proof the specific relationship is
true (a genuinely false relationship between two REAL organizations
would still return results about each organization separately). This
catches the failure mode actually observed from LLM classification --
an invented organization name with zero web footprint -- rather than
claiming to adjudicate relationship truth from search snippets alone.
The presence/absence signal is deliberately coarse: a result whose
host or snippet contains every distinctive token in the organization
name is treated as corroboration that the named organization has a
real-world footprint consistent with the claim, not proof the specific
relationship is true (a genuinely false relationship between two REAL
organizations would still return results about each organization
separately). Requiring every token prevents an unrelated page that
happens to contain one common word from corroborating an invented name.
This catches the failure mode actually observed from LLM
classification -- an invented organization name with zero web
footprint -- rather than claiming to adjudicate relationship truth from
search snippets alone.
"""

available = True

def __init__(self, base_url: str, *, timeout: float = 15.0) -> None:
"""Configure a validated Searxng base URL and request timeout."""
parsed = urlparse(base_url)
if parsed.scheme not in {"http", "https"}:
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:
"""Verify whether the relationship has supporting external evidence."""
"""Return the first corroborating result, or an explicit negative result."""
query = f"{organization_name} {relationship_label}"
body = get_json(
f"{self._base_url}/search?q={quote(query, safe='')}&format=json",
Expand All @@ -161,32 +170,51 @@ def corroborating_evidence_url(organization_name: str, result: dict[str, Any]) -
"""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.
corroboration. A result counts only when every distinctive name token
appears in the host or snippet, and the host is not itself a search
page. The title is intentionally excluded because search engines echo
the query there. 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 = [
token.lower()
for token in _ORG_TOKEN.findall(organization_name)
if token.lower() not in _ORG_TOKEN_STOPWORDS
]
organization_tokens = [token.lower() for token in _ORG_TOKEN.findall(organization_name)]
tokens = {token for token in organization_tokens if token not in _ORG_TOKEN_STOPWORDS}
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):
haystack_tokens = {
token.lower()
for token in _ORG_TOKEN.findall(f"{host} {result.get('content') or ''}")
}
if all(
any(_organization_token_matches(token, candidate) for candidate in haystack_tokens)
for token in tokens
) or _concatenated_hangul_name_matches(organization_tokens, haystack_tokens):
return url
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment on lines +198 to 202

@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.

🔍 Stricter all-token match also tightens downstream abbreviation/name-resolution corroboration

The any(...) -> all(...) change in corroborating_evidence_url (relation_verification.py) affects not just SearxngRelationVerificationClient.verify, but transitively every caller of .verify(): organization_name_resolution.py:190 and abbreviation_tree_corroboration.py:132. For the abbreviation-tree path, candidates are full canonical entity names, so requiring every distinctive token of the full name to appear in a single result's host+content may turn previously-unique corroborated matches into uncorroborated (unbound) ones for long multi-token names whose search results only mention a common short form. This is consistent with the PR's stated intent to reduce false corroboration, but reviewers should confirm the tightening does not over-suppress legitimate multi-token entity matches in those two pipelines (no dedicated regression test covers the multi-token effect on those callers).

Open in Devin Review

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

return None


def _concatenated_hangul_name_matches(expected_tokens: list[str], observed_tokens: set[str]) -> bool:
"""Accept a spaced Hangul name when a page writes its parts contiguously."""
if len(expected_tokens) < 2 or not all(_HANGUL_TOKEN.fullmatch(token) for token in expected_tokens):
return False
compact_name = "".join(expected_tokens)
return any(_organization_token_matches(compact_name, observed) for observed in observed_tokens)


def _organization_token_matches(expected: str, observed: str) -> bool:
"""Match exact tokens, or a Hangul token followed only by Korean particles."""
if expected == observed:
return True
if not _HANGUL_TOKEN.fullmatch(expected) or not observed.startswith(expected):
return False
return _KOREAN_PARTICLE_SUFFIX.fullmatch(observed[len(expected) :]) is not None
Comment on lines +214 to +220

@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: English tokens require exact match; plurals/inflections won't corroborate

_organization_token_matches (relation_verification.py) only tolerates suffixes for Hangul tokens; Latin tokens must match exactly. So a snippet containing Auroras or Aurora's (findall yields auroras/aurora) will match Aurora only in the apostrophe case, not the bare-plural case. Combined with the all-tokens requirement this can cause a valid English org to be judged uncorroborated. Likely acceptable for the coarse check but worth noting as a recall limitation of the new boundary matching.

Open in Devin Review

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

Comment on lines +214 to +220

@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 matching is one-directional and can miss same-entity compounds

_organization_token_matches (relation_verification.py) only tolerates suffixes that are pure grammatical particles, and only when the org token is a prefix of the observed haystack token. This correctly rejects unrelated longer names (e.g. 한빛그리드솔루션이), but it will also reject legitimate same-entity references where the org name given is a compound that appears abbreviated in content, or where the trailing characters are a non-particle noun suffix (e.g. org 하나 vs content 하나은행). These are false negatives (safe/uncorroborated direction) and align with the PR's stated intent of requiring complete corroboration, so not a bug — but the coarse signal now under-trusts more Korean cases than before.

Open in Devin Review

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

Loading
Loading