-
Notifications
You must be signed in to change notification settings - Fork 1
fix: require complete organization token corroboration #327
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fd52f4d
3b9fe2e
021f98e
5cc0a67
6a26683
db5dfda
c44f097
142819b
e6ef7cc
5beaa49
8859e25
813ab63
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -61,6 +61,11 @@ | |
| "and", | ||
| } | ||
| ) | ||
| _HANGUL_TOKEN = re.compile(r"[가-힣]+") | ||
| _KOREAN_PARTICLE_SUFFIX = re.compile( | ||
| r"(?:에게서|한테서|에서는|으로는|이라고|에서|에게|한테|께서|부터|까지|처럼|보다|만큼|" | ||
| r"으로|이랑|라고|이|가|은|는|을|를|의|에|께|와|과|도|만|로|랑|하고)+" | ||
| ) | ||
|
|
||
| STATUS_PENDING = "verify_pending" | ||
| STATUS_CORROBORATED = "verify_corroborated" | ||
|
|
@@ -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" | ||
| ) | ||
|
|
@@ -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", | ||
|
|
@@ -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 | ||
|
seonghobae marked this conversation as resolved.
coderabbitai[bot] marked this conversation as resolved.
devin-ai-integration[bot] marked this conversation as resolved.
Comment on lines
+198
to
202
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 Stricter all-token match also tightens downstream abbreviation/name-resolution corroboration The 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: English tokens require exact match; plurals/inflections won't corroborate
Was this helpful? React with 👍 or 👎 to provide feedback.
Comment on lines
+214
to
+220
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Was this helpful? React with 👍 or 👎 to provide feedback. |
||
There was a problem hiding this comment.
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 viafullmatchonobserved[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.Was this helpful? React with 👍 or 👎 to provide feedback.