diff --git a/CHANGELOG.d/2.12.7-korean-search-boundary.md b/CHANGELOG.d/2.12.7-korean-search-boundary.md new file mode 100644 index 000000000..9ea0359d8 --- /dev/null +++ b/CHANGELOG.d/2.12.7-korean-search-boundary.md @@ -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. diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index 324c6cd67..180a660cf 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -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 @@ -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 @@ -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) @@ -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, ) @@ -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( @@ -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, @@ -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"], @@ -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 @@ -759,7 +757,7 @@ 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"]), @@ -767,17 +765,15 @@ async def _deliver_tepp_measurement( 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, diff --git a/docs/adr/0005-relation-verification-agent.md b/docs/adr/0005-relation-verification-agent.md index 1570d29d6..f9f6214c0 100644 --- a/docs/adr/0005-relation-verification-agent.md +++ b/docs/adr/0005-relation-verification-agent.md @@ -59,6 +59,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` diff --git a/docs/adr/0022-authorized-tepp-start.md b/docs/adr/0022-authorized-tepp-start.md index 84fc71635..fd541a58a 100644 --- a/docs/adr/0022-authorized-tepp-start.md +++ b/docs/adr/0022-authorized-tepp-start.md @@ -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. @@ -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 diff --git a/lineageweave/relation_verification.py b/lineageweave/relation_verification.py index 3d1b8c366..e670f2262 100644 --- a/lineageweave/relation_verification.py +++ b/lineageweave/relation_verification.py @@ -1,25 +1,10 @@ -"""Verifies whether an LLM-inferred Ontology relation has any real-world -corroborating evidence, via an external web search -- catching the case -where :mod:`lineageweave.entity_relationship_classification` (or any other -LLM-driven relation inference over the Knowledge Graph) names an -organization or relationship that does not actually exist, rather than -letting a hallucinated node/edge sit in the graph indistinguishable from a -verified one. - -Grounded in FEVER-style open-domain claim verification (Thorne, Vlachos, -Christodoulopoulos, & Mittal, 2018): retrieve external evidence for a -claim, then classify the claim as supported, refuted, or not-enough-info -against what was retrieved. This module implements the practical subset -that fits a same-request check -- retrieval plus a presence/absence -signal (:data:`STATUS_CORROBORATED` / :data:`STATUS_UNCORROBORATED`) -- -not full NLI-based entailment scoring against the retrieved passages; -that upgrade is a real one once real usage shows the presence/absence -signal under- or over-trusting results in practice, not implemented here -because nothing yet demonstrates the need for it over this cheaper stage. - -Same pluggable-client, never-fake-a-missing-channel discipline as every -other channel in this package: :class:`NullRelationVerificationClient` -makes the channel unavailable, never fabricates a verification result. +"""Verify LLM-inferred relations against external search evidence. + +This module implements the retrieval-and-presence subset of FEVER-style claim +verification (Thorne, Vlachos, Christodoulopoulos, & Mittal, 2018). It catches +invented organization names without claiming that a search hit proves the +specific relationship. Missing search transport remains unavailable rather +than becoming a fabricated negative result. """ from __future__ import annotations @@ -40,9 +25,8 @@ "yandex.", "searx", ) -# Distinctive name tokens only. Latin legal suffixes ("Corp", "Ltd") and -# 1-syllable Hangul particles must not corroborate a random host that -# happens to contain them. +# Distinctive name tokens only. Legal suffixes, fixture descriptors, and +# one-syllable Hangul particles cannot corroborate a random search result. _ORG_TOKEN = re.compile(r"[A-Za-z]{4,}|[가-힣]{2,}") _ORG_TOKEN_STOPWORDS = frozenset( { @@ -56,6 +40,12 @@ "group", "holdings", "limited", + "fictitious", + "nonexistent", + "placeholder", + "sample", + "example", + "demo", "foundation", "the", "and", @@ -74,45 +64,36 @@ @dataclass(frozen=True) class RelationVerificationResult: - """One claim's verification outcome. - - Attributes: - status_code: one of ``STATUS_CORROBORATED`` / ``STATUS_UNCORROBORATED`` - -- ``common_lookup_value.lookup_code`` for category - ``relation_verification_status``. - evidence_url: the first corroborating search result's URL, or - ``None`` when uncorroborated (there is nothing to cite). - """ + """One claim's verification outcome and its optional evidence URL.""" status_code: str evidence_url: str | None class RelationVerificationClient(Protocol): - """Checks a claimed organization/relationship against external search.""" + """Check a claimed organization/relationship against external search.""" available: bool - def verify(self, organization_name: str, relationship_label: str) -> RelationVerificationResult: - """Search for corroborating evidence of ``organization_name`` - having the relationship ``relationship_label`` describes. + def verify( + self, organization_name: str, relationship_label: str + ) -> RelationVerificationResult: + """Return search evidence or raise when the search itself fails. - Implementations must raise if the search itself fails (network - error, non-JSON response) -- a failed search is not the same - claim as "searched and found nothing," and must not be recorded - as ``STATUS_UNCORROBORATED``. Protocol stubs raise - ``NotImplementedError`` so a no-op body is never treated as a - successful result. + A failed search is not the same claim as "searched and found nothing" + and must not be recorded as :data:`STATUS_UNCORROBORATED`. """ raise NotImplementedError class NullRelationVerificationClient: - """No search provider configured -- the verification channel is skipped.""" + """No search provider configured; the verification channel is skipped.""" 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 """Reject verification because this client has no search transport.""" raise RuntimeError( "NullRelationVerificationClient has no search channel; check .available first" @@ -120,22 +101,7 @@ def verify(self, organization_name: str, relationship_label: str) -> RelationVer 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: 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. - """ + """Query a self-hosted Searxng JSON API for corroborating evidence.""" available = True @@ -143,12 +109,16 @@ 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'}") + 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: - """Return the first corroborating result, or an explicit negative result.""" + def verify( + self, organization_name: str, relationship_label: str + ) -> RelationVerificationResult: + """Return the first corroborating result or an explicit negative.""" query = f"{organization_name} {relationship_label}" body = get_json( f"{self._base_url}/search?q={quote(query, safe='')}&format=json", @@ -156,24 +126,30 @@ 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: - """Return ``result['url']`` when it is a real-world footprint of ``organization_name``. +def corroborating_evidence_url( + organization_name: str, result: dict[str, Any] +) -> str | None: + """Return a safe result URL when all distinctive name tokens are present. - Search engines echo the query in result titles, so "any hit" is not - 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. + Search engines echo query text in titles, so only the result host and + snippet are considered. A result must contain every distinctive token; + missing, search-host, non-HTTP, and title-only URLs are not evidence. """ url = result.get("url") if not isinstance(url, str) or not url.strip(): @@ -187,8 +163,12 @@ def corroborating_evidence_url(organization_name: str, result: dict[str, Any]) - return None if not host or any(marker in host for marker in _SEARCH_HOST_MARKERS): return None - 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} + 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_tokens = { @@ -196,23 +176,33 @@ def corroborating_evidence_url(organization_name: str, result: dict[str, Any]) - 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) + 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 return None -def _concatenated_hangul_name_matches(expected_tokens: list[str], observed_tokens: set[str]) -> bool: +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): + 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) + 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.""" + """Match exact tokens or a Hangul token followed only by particles.""" if expected == observed: return True if not _HANGUL_TOKEN.fullmatch(expected) or not observed.startswith(expected): diff --git a/lineageweave/tepp_client.py b/lineageweave/tepp_client.py index 7dbfd886f..72e996fb2 100644 --- a/lineageweave/tepp_client.py +++ b/lineageweave/tepp_client.py @@ -8,30 +8,31 @@ lineage scores as TEPP's calibrated psychometric measurement (they answer different questions -- see docs/lineage-bi-research-notes.md). -TEPP does not expose a live HTTP endpoint yet (as of this writing it is -Rust-crate-only; see ``docs/API_CONTRACT.md`` in that repo). This client -builds and validates the exact wire shape TEPP has published -(``schemas/analysis_run_request_v1.json``) so wiring in a real transport is -a one-line change (:meth:`TeppClient.__init__`'s ``transport`` argument) once -that endpoint exists, instead of a redesign. +TEPP's current protected main exposes Rust library/domain contracts and an +accepted target API contract, not a deployed HTTP service (see +``docs/API_CONTRACT.md`` in that repo). This client builds and validates the +exact wire shape TEPP has published +(``schemas/analysis_run_request_v1.json``), so an executable transport can be +added through :meth:`TeppClient.__init__` without a consumer redesign. """ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Callable +from typing import Any class TeppNotAvailable(RuntimeError): - """Raised by the default transport: TEPP has no live REST API yet.""" + """Raised when no executable TEPP transport is configured.""" def _no_transport(request: dict[str, Any]) -> dict[str, Any]: - """Implement the _no_transport operation for this channel.""" + """Fail closed while TEPP exposes no executable transport.""" raise TeppNotAvailable( - "TEPP has no live HTTP endpoint yet (Rust-crate-only as of this writing). " - "Pass a transport= callable to TeppClient once one exists, or consume TEPP " - "as a Rust crate directly per its own docs/API_CONTRACT.md." + "No executable TEPP transport is configured. TEPP currently publishes " + "Rust library/domain contracts and an accepted target API contract; " + "configure transport= when an executable service is available." ) @@ -51,8 +52,26 @@ class AnalysisRunRequest: output_profile: str contract_version: int = 1 + def __post_init__(self) -> None: + """Reject payloads that violate TEPP's v1 schema before transport.""" + if type(self.contract_version) is not int or self.contract_version != 1: + raise ValueError("TEPP AnalysisRunRequest requires contract_version=1") + for field_name in ( + "idempotency_key", + "tenant_workspace_id", + "snapshot_id", + "knowledge_cutoff", + "model_contract_version", + "output_profile", + ): + value = getattr(self, field_name) + if not isinstance(value, str) or not value.strip(): + raise ValueError( + f"TEPP AnalysisRunRequest field {field_name} must be non-blank text" + ) + def to_json(self) -> dict[str, Any]: - """Serialize the accepted TEPP result into its wire representation.""" + """Serialize the validated request into TEPP's v1 wire representation.""" return { "contract_version": self.contract_version, "idempotency_key": self.idempotency_key, @@ -80,4 +99,9 @@ def __init__(self, transport: Callable[[dict[str, Any]], dict[str, Any]] = _no_t def submit_analysis_run(self, request: AnalysisRunRequest) -> dict[str, Any]: """Submit a request; returns TEPP's ``AnalysisRunAccepted`` envelope.""" - return self._transport(request.to_json()) + try: + return self._transport(request.to_json()) + except TeppNotAvailable: + raise + except Exception as exc: + raise TeppNotAvailable("TEPP transport request failed") from exc diff --git a/tests/test_relation_verification.py b/tests/test_relation_verification.py index 83826838f..e68ef81c9 100644 --- a/tests/test_relation_verification.py +++ b/tests/test_relation_verification.py @@ -1,12 +1,4 @@ -"""Tests for lineageweave.relation_verification. - -SearxngRelationVerificationClient's tests run against a real local HTTP -server (same pattern as tests/test_http_client.py), not a mocked -transport -- proving the actual URL construction and response-shape -handling, not just that a mock was called correctly. A real Searxng -instance is exercised separately, gated behind docker compose (this -repo's discipline: never fake a channel it can instead genuinely run). -""" +"""Tests for the real HTTP and evidence boundaries of relation verification.""" from __future__ import annotations @@ -38,16 +30,31 @@ def do_GET(self) -> None: query = parse_qs(parsed.query) type(self).received_query = query.get("q", [""])[0] if "Malformed" in type(self).received_query: - payload = {"query": type(self).received_query, "results": {"unexpected": True}} + payload = { + "query": type(self).received_query, + "results": {"unexpected": True}, + } elif "Mixed" in type(self).received_query: payload = { "query": type(self).received_query, "results": [ None, {"url": "https://other.example/item", "content": "unrelated"}, - {"url": "https://mixed.example/item", "content": "Mixed Signal"}, + { + "url": "https://mixed.example/item", + "content": "Mixed Signal", + }, ], } + elif "NoList" in type(self).received_query: + payload = {"query": type(self).received_query, "results": {}} + elif "Skip" in type(self).received_query: + payload = {"query": type(self).received_query, "results": [None]} + elif "NoEvidence" in type(self).received_query: + payload = { + "query": type(self).received_query, + "results": [{"url": "https://example.com/item", "content": ""}], + } elif "Acme" in type(self).received_query: payload = { "query": type(self).received_query, @@ -68,7 +75,7 @@ def do_GET(self) -> None: self.end_headers() self.wfile.write(body) - def log_message(self, format: str, *args) -> None: + def log_message(self, format: str, *args: object) -> None: """Keep the test server quiet while preserving the stdlib signature.""" return @@ -100,8 +107,9 @@ def test_searxng_client_reports_corroborated_with_evidence_url() -> None: """A matching result returns corroboration and its evidence URL.""" server, base = _serve() try: - client = SearxngRelationVerificationClient(base_url=base) - result = client.verify("Acme Corp", "Voice of Customer") + result = SearxngRelationVerificationClient(base_url=base).verify( + "Acme Corp", "Voice of Customer" + ) finally: server.shutdown() @@ -115,8 +123,26 @@ def test_searxng_client_reports_uncorroborated_with_no_evidence_url_when_search_ """An empty result set remains explicitly uncorroborated.""" server, base = _serve() try: - client = SearxngRelationVerificationClient(base_url=base) - result = client.verify("Totally Fictitious Nonexistent Org", "Voice of Customer") + result = SearxngRelationVerificationClient(base_url=base).verify( + "Totally Fictitious Nonexistent Org", "Voice of Customer" + ) + finally: + server.shutdown() + + assert result.status_code == STATUS_UNCORROBORATED + assert result.evidence_url is None + + +@pytest.mark.parametrize("organization_name", ["NoList", "Skip", "NoEvidence"]) +def test_searxng_client_fails_closed_for_unusable_results( + organization_name: str, +) -> None: + """Malformed and unciting search results remain explicitly negative.""" + server, base = _serve() + try: + result = SearxngRelationVerificationClient(base_url=base).verify( + organization_name, "Voice of Customer" + ) finally: server.shutdown() @@ -185,7 +211,7 @@ def test_malformed_ipv6_url_is_not_evidence() -> None: def test_name_with_only_legal_suffixes_has_no_distinctive_token() -> None: - """Legal suffix stopwords alone cannot identify an organization.""" + """Legal suffixes alone cannot identify an organization.""" assert ( corroborating_evidence_url( "Corp Ltd", @@ -206,6 +232,21 @@ def test_org_token_in_result_host_is_corroboration() -> None: ) +def test_short_name_token_inside_another_word_is_not_corroboration() -> None: + """A search snippet must contain the organization token as a word.""" + assert ( + corroborating_evidence_url( + "Alpha Corp", + { + "url": "https://unrelated.example/news", + "title": "Alphabetical index", + "content": "An alphabetical index of sample terms.", + }, + ) + is None + ) + + def test_partial_multi_token_name_is_not_corroboration() -> None: """One generic token must not validate an invented multi-token name.""" assert ( @@ -293,7 +334,7 @@ def test_userinfo_tokens_are_not_hostname_evidence() -> None: def test_legal_suffix_alone_is_not_corroboration() -> None: - """'Corp' is in almost every corporate host; it is not evidence.""" + """A legal suffix in a host is not organization evidence.""" assert ( corroborating_evidence_url( "Acme Corp", @@ -303,30 +344,93 @@ def test_legal_suffix_alone_is_not_corroboration() -> None: ) -def test_hangul_org_name_token_is_corroboration() -> None: - """A complete Hangul organization token in content is evidence.""" +def test_fixture_descriptors_do_not_corrobate_an_unrelated_search_hit() -> None: + """Synthetic-data descriptors must not stand in for organization identity.""" + assert ( + corroborating_evidence_url( + "Zzqxvthorp Fictitious Nonexistent Org", + { + "url": "https://learn.microsoft.com/writing-style", + "title": "Fictitious names and addresses", + "content": "Documentation explains fictitious and nonexistent examples.", + }, + ) + is None + ) + + +def test_missing_url_and_all_generic_tokens_are_not_evidence() -> None: + """Missing URLs and fixture-only names cannot provide evidence.""" + assert corroborating_evidence_url("Acme Corp", {"content": "Acme"}) is None + assert ( + corroborating_evidence_url( + "Fictitious Nonexistent Org", + {"url": "https://example.com/item", "content": "Fictitious"}, + ) + is None + ) + + +def test_one_common_token_is_not_multi_token_corroboration() -> None: + """An unrelated page mentioning one name token is insufficient evidence.""" + assert ( + corroborating_evidence_url( + "Aurora Grid Power", + { + "url": "https://news.example/item", + "content": "The power outage affected the region.", + }, + ) + is None + ) + + +@pytest.mark.parametrize("particle", ["가", "에서", "으로"]) +def test_hangul_org_name_with_attached_particle_is_corroboration(particle: str) -> None: + """Attached Korean particles remain bounded evidence.""" assert ( corroborating_evidence_url( "한빛그리드", { "url": "https://news.example/item", "title": "News", - "content": "한빛그리드 announced a delivery window.", + "content": f"한빛그리드{particle} 발표했다.", }, ) == "https://news.example/item" ) -def test_hangul_org_name_with_particle_is_corroboration() -> None: - """A Korean particle attached to the complete name keeps the token match.""" +@pytest.mark.parametrize("particles", ["에서는", "으로는", "까지도"]) +def test_hangul_org_name_with_stacked_particles_is_corroboration( + particles: str, +) -> None: + """Stacked Korean particles after a complete name remain bounded evidence.""" assert ( corroborating_evidence_url( "한빛그리드", { "url": "https://news.example/item", "title": "News", - "content": "한빛그리드가 공급 일정을 발표했다.", + "content": f"한빛그리드{particles} 발표했다.", + }, + ) + == "https://news.example/item" + ) + + +@pytest.mark.parametrize("particle", ["가", "이", "으로"]) +def test_latin_org_name_with_attached_korean_particle_is_corroboration( + particle: str, +) -> None: + """Latin organization tokens also accept directly attached particles.""" + assert ( + corroborating_evidence_url( + "Acme", + { + "url": "https://news.example/item", + "title": "News", + "content": f"Acme{particle} 발표했다.", }, ) == "https://news.example/item" diff --git a/tests/test_tepp_client.py b/tests/test_tepp_client.py index ea87d5558..aa6b7c433 100644 --- a/tests/test_tepp_client.py +++ b/tests/test_tepp_client.py @@ -1,5 +1,7 @@ from __future__ import annotations +from dataclasses import replace + import pytest from backend.app.analysis_run_start import configured_tepp_client @@ -31,6 +33,35 @@ def test_to_json_matches_tepp_published_schema_shape() -> None: } +@pytest.mark.parametrize( + ("field_name", "value"), + [ + ("idempotency_key", " "), + ("tenant_workspace_id", None), + ("snapshot_id", "\t"), + ("knowledge_cutoff", ""), + ("model_contract_version", "\n"), + ("output_profile", None), + ], +) +def test_request_rejects_non_blank_schema_fields(field_name: str, value: object) -> None: + """A v1 request must not send blank or non-text required fields.""" + with pytest.raises(ValueError, match=field_name): + replace(_sample_request(), **{field_name: value}) + + +def test_request_rejects_unknown_contract_version() -> None: + """The adapter must not silently emit a request for another contract.""" + with pytest.raises(ValueError, match="contract_version=1"): + replace(_sample_request(), contract_version=2) + + +def test_request_rejects_boolean_contract_version() -> None: + """JSON booleans must not pass Python's integer type relationship.""" + with pytest.raises(ValueError, match="contract_version=1"): + replace(_sample_request(), contract_version=True) + + def test_default_transport_fails_closed_until_tepp_ships_http() -> None: client = TeppClient() with pytest.raises(TeppNotAvailable): @@ -52,6 +83,18 @@ def fake_transport(payload: dict) -> dict: assert received["snapshot_id"] == "demo-snapshot-1" +def test_custom_transport_provider_errors_are_not_exposed() -> None: + """Provider response text stays behind the stable unavailable error.""" + + def broken_transport(_payload: dict) -> dict: + raise RuntimeError("provider secret response body") + + with pytest.raises(TeppNotAvailable, match="transport request failed") as error: + TeppClient(transport=broken_transport).submit_analysis_run(_sample_request()) + + assert "provider secret" not in str(error.value) + + def test_configured_transport_sends_optional_bearer_key(monkeypatch: pytest.MonkeyPatch) -> None: received = {} @@ -66,3 +109,22 @@ def fake_post_json(url: str, payload: dict, *, headers: dict, timeout: float) -> assert received["headers"] == {"authorization": "Bearer test-key"} assert received["payload"] == _sample_request().to_json() + + +def test_configured_transport_provider_errors_are_not_exposed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The configured provider boundary does not return raw transport text.""" + + def broken_post_json(*args, **kwargs): + del args, kwargs + raise RuntimeError("provider secret response body") + + monkeypatch.setattr("backend.app.analysis_run_start.post_json", broken_post_json) + + with pytest.raises(TeppNotAvailable, match="transport request failed") as error: + configured_tepp_client("https://tepp.example/v1/analysis-runs").submit_analysis_run( + _sample_request() + ) + + assert "provider secret" not in str(error.value)