diff --git a/backend/app/customer_hint_ingestion.py b/backend/app/customer_hint_ingestion.py index 497c66728..c50a619aa 100644 --- a/backend/app/customer_hint_ingestion.py +++ b/backend/app/customer_hint_ingestion.py @@ -6,6 +6,21 @@ search did not corroborate. An uncorroborated or unresolved guess leaves the hint exactly as unresolved as it started; it never invents a Customer Master entity from a single ungrounded LLM answer. + +A newly resolved name is created through +:func:`backend.app.corporate_entity_ingestion.get_or_create_corporate_entity` +-- the same "통합 고객사 계열 tree AI" hierarchy-inference pipeline +`keyman_ingestion.py`'s affiliation resolution already uses -- rather than +a bare same-level insert, so a SAP-sourced customer code that resolves to +e.g. "Acme Electronics South Plant" gets its inferred parent chain +(plant -> company -> group) at resolution time instead of landing as a +permanently flat, unparented row. + +A genuine similarity tie among *existing* catalog entities is checked for +separately, before that hierarchy-aware path runs, and stays unbound per +ADR 0026 (a tie must never create a third same-named row) -- it is not +treated as "no hierarchy channel configured" and does not fall back to a +flat insert. """ from __future__ import annotations @@ -15,12 +30,16 @@ import asyncpg +from lineageweave.corporate_hierarchy_inference import CorporateHierarchyInferenceClient +from lineageweave.corporate_hierarchy_resolution import RESOLUTION_TIE, score_corporate_entity from lineageweave.customer_hint_resolution import CustomerHintResolutionClient from lineageweave.image_content import NullImageContentClient from lineageweave.organization_name_resolution import resolve_and_verify_organization_name from lineageweave.post_content_normalization import normalize_post_body from lineageweave.relation_verification import STATUS_CORROBORATED, RelationVerificationClient +from .corporate_entity_ingestion import get_or_create_corporate_entity +from .keyman_ingestion import _load_corporate_entity_candidates _EXCERPT_LENGTH = 1500 @@ -29,6 +48,7 @@ async def resolve_customer_hint( conn: asyncpg.Connection, resolution_client: CustomerHintResolutionClient, verification_client: RelationVerificationClient, + hierarchy_inference_client: CorporateHierarchyInferenceClient, hint_code: str, ) -> dict[str, Any] | None: """Resolve one `source_customer_code` hint to a real `corporate_entity`. @@ -111,32 +131,59 @@ async def resolve_customer_hint( return None entity_name = resolution.resolved_organization_name - existing = await conn.fetchrow( - "select corporate_entity_id from corporate_entity where lower(entity_name) = lower($1)", + candidates = await _load_corporate_entity_candidates(conn) + if score_corporate_entity(entity_name, candidates).kind == RESOLUTION_TIE: + # ADR 0026: a tied top similarity score among *existing* catalog + # entities must stay unbound, never create a third same-named row. + # This is checked before get_or_create_corporate_entity runs so a + # tie is never mistaken for "no hierarchy channel configured" below + # and quietly given a flat fallback entity anyway. + return None + entity_id = await get_or_create_corporate_entity( + conn, entity_name, + excerpts, + hierarchy_inference_client, + verification_client, + candidates, ) - if existing is not None: - entity_id = existing["corporate_entity_id"] - else: - # ON CONFLICT, not a plain INSERT: re-resolving the same hint_code - # is not guaranteed to get byte-identical LLM phrasing back, so the - # name-based lookup above can miss an entity this same hint already - # created -- corporate_entity_code (deterministic from hint_code) - # is the stable identity key a retry must key off instead. - entity_code = f"HINT-{hint_code}" - # Safe SQL: the statement is a literal migration-shaped query; both observed values are bound. - created = await conn.fetchrow( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli - """ - insert into corporate_entity (corporate_entity_code, entity_name, entity_level_code) - values ($1, $2, 'company') - on conflict (corporate_entity_code) - do update set entity_name = excluded.entity_name - returning corporate_entity_id - """, - entity_code, + if entity_id is None: + # get_or_create_corporate_entity declined for a genuine miss -- no + # hierarchy inference channel configured, or an inferred placement + # that did not corroborate (a tie was already ruled out above). + # `resolve_and_verify_organization_name` above already independently + # corroborated the NAME itself, so a declined *placement* must not + # regress this hint back to fully unresolved: fall back to the + # flat, unparented entity this pathway always created before + # hierarchy inference existed. A later re-resolve (once a hierarchy + # channel is configured) can still enrich it with a real parent by + # matching this same name. + existing = await conn.fetchrow( + "select corporate_entity_id from corporate_entity where lower(entity_name) = lower($1)", entity_name, ) - entity_id = created["corporate_entity_id"] + if existing is not None: + entity_id = existing["corporate_entity_id"] + else: + # ON CONFLICT, not a plain INSERT: re-resolving the same hint_code + # is not guaranteed to get byte-identical LLM phrasing back, so the + # name-based lookup above can miss an entity this same hint already + # created -- corporate_entity_code (deterministic from hint_code) + # is the stable identity key a retry must key off instead. + entity_code = f"HINT-{hint_code}" + # Safe SQL: the statement is a literal migration-shaped query; both observed values are bound. + created = await conn.fetchrow( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + """ + insert into corporate_entity (corporate_entity_code, entity_name, entity_level_code) + values ($1, $2, 'company') + on conflict (corporate_entity_code) + do update set entity_name = excluded.entity_name + returning corporate_entity_id + """, + entity_code, + entity_name, + ) + entity_id = created["corporate_entity_id"] # `corporate_entity_id` is NOT NULL, so a bulk-imported real record # never sits at NULL waiting to be resolved -- it defaults to whatever @@ -159,9 +206,20 @@ async def resolve_customer_hint( entity_id, hint_code, ) + # get_or_create_corporate_entity may have bound this hint to an + # existing entity via fuzzy similarity matching, whose stored name can + # differ from the freshly LLM-resolved name (e.g. punctuation/casing); + # the exact-match fallback above can differ too (its lookup is + # case-insensitive). Report the entity's actual catalog name, not the + # possibly-divergent resolved name, so the response never claims a + # name that disagrees with what is actually bound. + canonical_name = await conn.fetchval( + "select entity_name from corporate_entity where corporate_entity_id = $1", + entity_id, + ) return { "corporate_entity_id": str(entity_id), - "entity_name": entity_name, + "entity_name": canonical_name or entity_name, "linked_post_count": len(linked), "verification_evidence_url": resolution.verification_evidence_url, } diff --git a/backend/app/main.py b/backend/app/main.py index e3351db83..30d79750c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1245,6 +1245,7 @@ async def resolve_customer_master_hint( conn, _customer_hint_resolution_client(), _relation_verification_client(), + _corporate_hierarchy_inference_client(), request.hint_code, ) except (HttpClientError, OSError) as exc: diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 0d4a9f3da..77dcf9f76 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1522,6 +1522,24 @@ describe("App, authenticated", () => { }), ); } + if (url.endsWith("/api/corporate-entities/corp-demo/related")) { + return Promise.resolve( + jsonResponse({ + corporate_entity_id: "corp-demo", + entity_name: "Demo Corp", + related: [ + { + node_id: "post-2", + node_type_code: "node_post", + ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Post", + ontology_label: "Post", + label: "Linked post", + relevance: 0.6, + }, + ], + }), + ); + } if (url.endsWith("/api/posts/post-1/affiliate-tree")) { return Promise.resolve( jsonResponse({ @@ -2066,6 +2084,35 @@ describe("App, authenticated", () => { expect(parentRow?.contains(subsidiaryRow)).toBe(true); }); + it("opens a customer's related post in place instead of jumping to the Board", async () => { + // Bug report: clicking a customer, then one of its related posts, sent + // the whole workspace to the Board destination instead of showing the + // post's content next to the customer -- a jarring, unrequested + // navigation. The post must open in this same screen's own + // right-docked popup, leaving Customer Master as the active destination. + stubBackend(); + render(); + expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Customer master" })); + + expect(await screen.findByText("Demo Corp")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: /Demo Corp/ })); + + const relatedPostButton = await screen.findByRole("button", { name: "Open related post: Linked post" }); + await userEvent.click(relatedPostButton); + + await waitFor(() => + expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), + ); + // Still on Customer Master -- the Board never mounted. + expect(screen.getByRole("heading", { name: "Customer master" })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Board" })).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + expect(screen.queryByText("The evidence panel should show exactly this text.")).not.toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Customer master" })).toBeInTheDocument(); + }); + it("filters the customer master tree by scope facet", async () => { // ADR 0125: 자사 속성은 필터로 접근해야 한다 -- an entity's own-company, // granted-customer, observed, or unclassified facet must be a real diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 141d088d3..2c6629f77 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5252,10 +5252,8 @@ function customerMasterScopeBuckets(entity: CustomerMasterEntity): CustomerMaste function CustomerMasterPanel({ accessToken, - onOpenPost, }: { accessToken: string; - onOpenPost: (postId: string) => void; }) { const [master, setMaster] = useState(null); const [scopeFilter, setScopeFilter] = useState>( @@ -5265,6 +5263,12 @@ function CustomerMasterPanel({ const [expandedEntityId, setExpandedEntityId] = useState(null); const [relatedByEntity, setRelatedByEntity] = useState>({}); const [relatedLoading, setRelatedLoading] = useState(null); + // A related post opens in this panel's own right-docked popup -- it must + // never hand off to the Board's destination/selection state (that + // hand-off was the reported bug: clicking a customer's post jumped the + // whole workspace to the Board instead of showing the post here). + const [selectedPostId, setSelectedPostId] = useState(null); + const [selectedPostGraph, setSelectedPostGraph] = useState(null); const [resolvingHint, setResolvingHint] = useState(null); const [resolveError, setResolveError] = useState(null); // Fetched independently, same pattern as PostList's own canRebuild -- @@ -5329,6 +5333,28 @@ function CustomerMasterPanel({ } } + useEffect(() => { + if (!selectedPostId) { + setSelectedPostGraph(null); + return; + } + let active = true; + fetchLineageGraph(accessToken, selectedPostId) + .then((nextGraph) => { + if (active) setSelectedPostGraph(nextGraph); + }) + .catch(() => { + if (active) setSelectedPostGraph({ nodes: [], edges: [] }); + }); + return () => { + active = false; + }; + }, [accessToken, selectedPostId]); + + function openPost(postId: string) { + setSelectedPostId(postId); + } + const filteredEntities = (master?.corporate_entities ?? []).filter((entity) => customerMasterScopeBuckets(entity).some((bucket) => scopeFilter.has(bucket)), ); @@ -5381,7 +5407,7 @@ function CustomerMasterPanel({ relatedByEntity={relatedByEntity} relatedLoading={relatedLoading} onToggle={toggleEntity} - onOpenPost={onOpenPost} + onOpenPost={openPost} /> ))} @@ -5451,7 +5477,7 @@ function CustomerMasterPanel({ postTitle={post.post_title} postBodyExcerpt={post.post_body_excerpt} postBodyTruncated={post.post_body_truncated} - onOpenPost={onOpenPost} + onOpenPost={openPost} /> ))} @@ -5504,7 +5530,7 @@ function CustomerMasterPanel({ postTitle={post.post_title} postBodyExcerpt={post.post_body_excerpt} postBodyTruncated={post.post_body_truncated} - onOpenPost={onOpenPost} + onOpenPost={openPost} /> ))} @@ -5530,6 +5556,16 @@ function CustomerMasterPanel({ ) : null} + {selectedPostId && ( + setSelectedPostId(null)} + onSelectPost={openPost} + /> + )} ); } @@ -6256,13 +6292,7 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean /> ) : null} {activeDestination === "customers" ? ( - { - setPostToOpen(postId); - changeDestination("board"); - }} - /> + ) : null} {activeDestination === "calendar" ? ( str: + return next((os.environ.get(name, "").strip() for name in names if os.environ.get(name, "").strip()), "") + + +def _orchestrator_config() -> tuple[str, str]: + base_url = _first_env("ORCHESTRATOR_BASE_URL", "LLM_GATEWAY_API_URL", "LLM_GATEWAY_URL") + api_key = _first_env("ORCHESTRATOR_API_KEY", "LLM_GATEWAY_API_KEY") + if not base_url or not api_key: + raise RuntimeError("contextual-orchestrator gateway configuration is unavailable") + return base_url, api_key + + +async def _select_unresolved_hint_codes( + conn: asyncpg.Connection, *, limit: int, hint_code: str | None +) -> list[str]: + """One explicit hint code, or a bounded batch of still-unresolved ones. + + A hint code is "still unresolved" when at least one eligible post + bearing it sits at its own account's default placeholder entity -- + the exact same predicate `resolve_customer_hint`'s own reclaim UPDATE + uses, so this never re-selects a code every one of whose posts a + prior resolution already reclaimed. + """ + if hint_code: + return [hint_code] + rows = await conn.fetch( + """ + select distinct post.source_customer_code as hint_code + from source_post post + where nullif(btrim(post.source_customer_code), '') is not null + and nullif(btrim(post.source_draft_code), '') is null + and nullif(btrim(post.source_deleted_flag), '') is null + and not ( + ( + nullif(btrim(post.source_author_code), '') is null + and nullif(btrim(post.source_author_name), '') is null + and nullif(btrim(post.source_company_code), '') is null + and nullif(btrim(post.source_company_name), '') is null + and nullif(btrim(post.source_process_unit_code), '') is null + and nullif(btrim(post.source_process_unit_name), '') is null + and nullif(btrim(post.source_sales_pool_code), '') is null + and nullif(btrim(post.source_sales_pool_name), '') is null + and nullif(btrim(post.source_customer_code), '') is null + and nullif(btrim(post.source_customer_name), '') is null + and nullif(btrim(post.source_project_code), '') is null + and nullif(btrim(post.source_project_name), '') is null + ) + and exists ( + select 1 + from source_post real_post + where ( + nullif(btrim(real_post.source_author_code), '') is not null + or nullif(btrim(real_post.source_author_name), '') is not null + or nullif(btrim(real_post.source_company_code), '') is not null + or nullif(btrim(real_post.source_company_name), '') is not null + or nullif(btrim(real_post.source_process_unit_code), '') is not null + or nullif(btrim(real_post.source_process_unit_name), '') is not null + or nullif(btrim(real_post.source_sales_pool_code), '') is not null + or nullif(btrim(real_post.source_sales_pool_name), '') is not null + or nullif(btrim(real_post.source_customer_code), '') is not null + or nullif(btrim(real_post.source_customer_name), '') is not null + or nullif(btrim(real_post.source_project_code), '') is not null + or nullif(btrim(real_post.source_project_name), '') is not null + ) + ) + ) + and post.corporate_entity_id in ( + select corporate_entity_id from account_affiliation + where user_account_id = post.author_account_id + ) + order by post.source_customer_code + limit $1::bigint + """, + limit, + ) + return [row["hint_code"] for row in rows] + + +async def _resolve_batch( + conn: asyncpg.Connection, + resolution_client: object, + verification_client: object, + hierarchy_client: object, + hint_codes: list[str], + hint_timeout: float, +) -> dict[str, object]: + """Resolve each hint code in order, aggregating outcomes. + + Isolated from pool/connection setup so the aggregation itself (a + declined resolution is not a failure; a timeout or provider error is + counted but does not abort the remaining batch) is unit-testable + without a real database or orchestrator. + """ + failures: Counter[str] = Counter() + declined = 0 + resolved: list[dict[str, object]] = [] + for hint_code in hint_codes: + try: + async with asyncio.timeout(hint_timeout): + outcome = await resolve_customer_hint( + conn, + resolution_client, + verification_client, + hierarchy_client, + hint_code, + ) + if outcome is None: + declined += 1 + else: + resolved.append({"hint_code": hint_code, **outcome}) + except TimeoutError: + failures["TimeoutError"] += 1 + except (HttpClientError, OSError, RuntimeError, ValueError, asyncpg.PostgresError) as exc: + failures[type(exc).__name__] += 1 + return { + "requested_hint_codes": len(hint_codes), + "resolved_hint_codes": len(resolved), + "declined_hint_codes": declined, + "linked_post_count": sum(int(entry["linked_post_count"]) for entry in resolved), + "resolved": resolved, + "failed_hint_codes": sum(failures.values()), + "failure_types": dict(sorted(failures.items())), + } + + +async def _run(args: argparse.Namespace) -> dict[str, object]: + if args.hint_code and args.all: + raise ValueError("--hint-code and --all cannot be combined") + # The resolution channel itself must be configured; hierarchy inference + # and verification are separately optional -- resolve_customer_hint + # falls back to a flat entity when they decline, same as its own + # single-hint API route already does. + _orchestrator_config() + settings = load_settings() + resolution_client = _customer_hint_resolution_client() + verification_client = _relation_verification_client() + hierarchy_client = _corporate_hierarchy_inference_client() + limit = 1 if args.hint_code or not args.all else args.limit + + pool = await asyncpg.create_pool(settings.database_url, min_size=1, max_size=1) + try: + async with pool.acquire() as conn: + hint_codes = await _select_unresolved_hint_codes(conn, limit=limit, hint_code=args.hint_code) + return await _resolve_batch( + conn, resolution_client, verification_client, hierarchy_client, hint_codes, args.hint_timeout + ) + finally: + await pool.close() + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + selector = parser.add_mutually_exclusive_group() + selector.add_argument("--hint-code", help="Re-resolve one explicit source_customer_code") + selector.add_argument("--all", action="store_true", help="Process the explicit --limit batch") + parser.add_argument("--limit", type=int, default=25, help="Maximum hint codes for --all (default: 25)") + parser.add_argument( + "--hint-timeout", + type=float, + default=120.0, + help="Maximum seconds per hint code including provider calls (default: 120)", + ) + args = parser.parse_args() + if args.limit < 1: + parser.error("--limit must be positive") + if args.hint_timeout <= 0: + parser.error("--hint-timeout must be positive") + print(json.dumps(asyncio.run(_run(args)), ensure_ascii=False, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_backfill_customer_hints.py b/tests/test_backfill_customer_hints.py new file mode 100644 index 000000000..b895665fa --- /dev/null +++ b/tests/test_backfill_customer_hints.py @@ -0,0 +1,95 @@ +"""Tests for scripts/backfill_customer_hints.py's batch aggregation. + +`_resolve_batch` is the pure per-hint orchestration loop, isolated from +pool/connection setup precisely so it can be exercised here without a +real database or orchestrator -- same reasoning as +tests/test_customer_hint_ingestion.py's fakes. +""" + +from __future__ import annotations + +import argparse +import asyncio + +import asyncpg + +import scripts.backfill_customer_hints as backfill +from lineageweave.http_client import HttpClientError + + +def test_run_rejects_hint_code_and_all_combined() -> None: + args = argparse.Namespace(hint_code="CUST-1", all=True, limit=25, hint_timeout=120.0) + try: + asyncio.run(backfill._run(args)) + except ValueError as exc: + assert "cannot be combined" in str(exc) + else: + raise AssertionError("expected ValueError") + + +def test_resolve_batch_counts_resolved_declined_and_failed(monkeypatch) -> None: + outcomes = { + "CUST-1": {"corporate_entity_id": "e-1", "entity_name": "Acme", "linked_post_count": 3, + "verification_evidence_url": "https://evidence.example/acme"}, + "CUST-2": None, # declined this run -- not a failure + "CUST-3": TimeoutError(), + "CUST-4": HttpClientError("gateway unavailable"), + } + + async def fake_resolve_customer_hint(conn, resolution_client, verification_client, hierarchy_client, hint_code): + outcome = outcomes[hint_code] + if isinstance(outcome, Exception): + raise outcome + return outcome + + monkeypatch.setattr(backfill, "resolve_customer_hint", fake_resolve_customer_hint) + + result = asyncio.run( + backfill._resolve_batch( + object(), object(), object(), object(), list(outcomes.keys()), hint_timeout=5.0 + ) + ) + + assert result["requested_hint_codes"] == 4 + assert result["resolved_hint_codes"] == 1 + assert result["declined_hint_codes"] == 1 + assert result["failed_hint_codes"] == 2 + assert result["failure_types"] == {"HttpClientError": 1, "TimeoutError": 1} + assert result["linked_post_count"] == 3 + assert result["resolved"] == [ + { + "hint_code": "CUST-1", + "corporate_entity_id": "e-1", + "entity_name": "Acme", + "linked_post_count": 3, + "verification_evidence_url": "https://evidence.example/acme", + } + ] + + +def test_resolve_batch_empty_hint_codes_is_a_clean_no_op() -> None: + result = asyncio.run( + backfill._resolve_batch(object(), object(), object(), object(), [], hint_timeout=5.0) + ) + assert result == { + "requested_hint_codes": 0, + "resolved_hint_codes": 0, + "declined_hint_codes": 0, + "linked_post_count": 0, + "resolved": [], + "failed_hint_codes": 0, + "failure_types": {}, + } + + +def test_resolve_batch_surfaces_a_postgres_error_as_a_counted_failure(monkeypatch) -> None: + async def fake_resolve_customer_hint(conn, resolution_client, verification_client, hierarchy_client, hint_code): + raise asyncpg.PostgresError("connection reset") + + monkeypatch.setattr(backfill, "resolve_customer_hint", fake_resolve_customer_hint) + + result = asyncio.run( + backfill._resolve_batch(object(), object(), object(), object(), ["CUST-9"], hint_timeout=5.0) + ) + assert result["failed_hint_codes"] == 1 + assert result["failure_types"] == {"PostgresError": 1} diff --git a/tests/test_customer_hint_ingestion.py b/tests/test_customer_hint_ingestion.py index d1f63d80f..336b4fad1 100644 --- a/tests/test_customer_hint_ingestion.py +++ b/tests/test_customer_hint_ingestion.py @@ -2,6 +2,12 @@ Deterministic FakeConnection, same style as tests/test_organization_name_resolution_ingestion.py. +Entity creation with a real hierarchy placement (inference, similarity +matching, the advisory-lock insert) is +`get_or_create_corporate_entity`'s own responsibility and is covered by +its own tests -- these tests monkeypatch it to isolate +`resolve_customer_hint`'s wiring, its unresolved/fail-closed branches, and +its flat-entity fallback for when a hierarchy placement is declined. """ from __future__ import annotations @@ -10,6 +16,7 @@ from types import SimpleNamespace import backend.app.customer_hint_ingestion as ingestion +from lineageweave.corporate_hierarchy_resolution import CorporateEntityCandidate from lineageweave.relation_verification import STATUS_CORROBORATED, STATUS_UNCORROBORATED @@ -22,9 +29,14 @@ class _UnavailableClient: class _Connection: - def __init__(self, *, sample_rows=None, existing_entity=None) -> None: + def __init__(self, *, sample_rows=None, existing_entity=None, canonical_name=None) -> None: self._sample_rows = sample_rows or [] self._existing_entity = existing_entity + # None means "no divergence to simulate" -- resolve_customer_hint + # falls back to the resolved name it already has when fetchval + # returns nothing, same as a real corporate_entity row always + # having a name would. + self._canonical_name = canonical_name self.executed: list[tuple[str, tuple[object, ...]]] = [] async def fetch(self, query: str, *args: object): @@ -43,6 +55,11 @@ async def fetchrow(self, query: str, *args: object): return {"corporate_entity_id": "new-entity-id"} return None + async def fetchval(self, query: str, *args: object): + if "select entity_name from corporate_entity" in query: + return self._canonical_name + return None + def _resolution(status: str): return SimpleNamespace( @@ -53,17 +70,26 @@ def _resolution(status: str): ) +def _fake_load_candidates(candidates: list[object] | None = None): + async def _load(conn): + return candidates or [] + + return _load + + def test_unavailable_client_resolves_nothing() -> None: conn = _Connection() result = asyncio.run( - ingestion.resolve_customer_hint(conn, _UnavailableClient(), _Client(), "0019999999") + ingestion.resolve_customer_hint(conn, _UnavailableClient(), _Client(), _Client(), "0019999999") ) assert result is None def test_no_sample_posts_resolves_nothing() -> None: conn = _Connection(sample_rows=[]) - result = asyncio.run(ingestion.resolve_customer_hint(conn, _Client(), _Client(), "0019999999")) + result = asyncio.run( + ingestion.resolve_customer_hint(conn, _Client(), _Client(), _Client(), "0019999999") + ) assert result is None @@ -72,18 +98,118 @@ def test_uncorroborated_resolution_does_not_create_or_link_an_entity(monkeypatch ingestion, "resolve_and_verify_organization_name", lambda *_args: _resolution(STATUS_UNCORROBORATED) ) conn = _Connection(sample_rows=[{"post_title": "Visit", "post_body": "

Visit notes

"}]) - result = asyncio.run(ingestion.resolve_customer_hint(conn, _Client(), _Client(), "0019999999")) + result = asyncio.run( + ingestion.resolve_customer_hint(conn, _Client(), _Client(), _Client(), "0019999999") + ) + + assert result is None + assert conn.executed == [] + + +def test_corroborated_resolution_uses_the_hierarchy_aware_placement(monkeypatch) -> None: + # When get_or_create_corporate_entity finds or creates a placement + # (a real hierarchy chain, or a fuzzy-matched existing entity), that + # id is used as-is -- no flat fallback insert runs. + monkeypatch.setattr( + ingestion, "resolve_and_verify_organization_name", lambda *_args: _resolution(STATUS_CORROBORATED) + ) + monkeypatch.setattr(ingestion, "_load_corporate_entity_candidates", _fake_load_candidates()) + + async def fake_get_or_create(conn, entity_name, context_text, hierarchy_client, verification_client, candidates): + assert entity_name == "Northridge Grid" + return "hierarchy-placed-entity-id" + + monkeypatch.setattr(ingestion, "get_or_create_corporate_entity", fake_get_or_create) + conn = _Connection(sample_rows=[{"post_title": "Visit", "post_body": "

Visit notes

"}]) + result = asyncio.run( + ingestion.resolve_customer_hint(conn, _Client(), _Client(), _Client(), "0019999999") + ) + + assert result == { + "corporate_entity_id": "hierarchy-placed-entity-id", + "entity_name": "Northridge Grid", + "linked_post_count": 2, + "verification_evidence_url": "https://evidence.example/result", + } + assert all("insert into corporate_entity" not in call[0] for call in conn.executed) + + +def test_response_reports_the_bound_entitys_actual_catalog_name(monkeypatch) -> None: + # get_or_create_corporate_entity can bind this hint to an existing + # entity via fuzzy similarity matching whose stored name differs from + # the freshly LLM-resolved name (punctuation, casing, a legal suffix). + # The response must report that entity's real catalog name, not the + # possibly-divergent resolved name -- otherwise it claims a name that + # disagrees with what is actually bound. + monkeypatch.setattr( + ingestion, "resolve_and_verify_organization_name", lambda *_args: _resolution(STATUS_CORROBORATED) + ) + monkeypatch.setattr(ingestion, "_load_corporate_entity_candidates", _fake_load_candidates()) + + async def fake_get_or_create(conn, entity_name, context_text, hierarchy_client, verification_client, candidates): + return "hierarchy-placed-entity-id" + + monkeypatch.setattr(ingestion, "get_or_create_corporate_entity", fake_get_or_create) + conn = _Connection( + sample_rows=[{"post_title": "Visit", "post_body": "

Visit notes

"}], + canonical_name="Northridge Grid Co., Ltd.", + ) + result = asyncio.run( + ingestion.resolve_customer_hint(conn, _Client(), _Client(), _Client(), "0019999999") + ) + + assert result["entity_name"] == "Northridge Grid Co., Ltd." + + +def test_tied_similarity_match_stays_unresolved_and_creates_nothing(monkeypatch) -> None: + # ADR 0026: a tied top similarity score among *existing* catalog + # entities must stay unbound -- never create a third same-named row. + # Two distinct entities that already share the exact resolved name + # force a tie; resolve_customer_hint must return None before even + # calling get_or_create_corporate_entity, not fall back to a flat + # insert the way a genuine miss (no hierarchy channel) does. + monkeypatch.setattr( + ingestion, "resolve_and_verify_organization_name", lambda *_args: _resolution(STATUS_CORROBORATED) + ) + tied_candidates = [ + CorporateEntityCandidate("entity-a", "Northridge Grid"), + CorporateEntityCandidate("entity-b", "Northridge Grid"), + ] + monkeypatch.setattr(ingestion, "_load_corporate_entity_candidates", _fake_load_candidates(tied_candidates)) + + def fail_if_called(*args, **kwargs): + raise AssertionError("get_or_create_corporate_entity must not run for a tied match") + + monkeypatch.setattr(ingestion, "get_or_create_corporate_entity", fail_if_called) + conn = _Connection(sample_rows=[{"post_title": "Visit", "post_body": "

Visit notes

"}]) + result = asyncio.run( + ingestion.resolve_customer_hint(conn, _Client(), _Client(), _Client(), "0019999999") + ) assert result is None assert conn.executed == [] -def test_corroborated_resolution_creates_and_links_a_new_entity(monkeypatch) -> None: +def test_declined_hierarchy_placement_falls_back_to_a_flat_new_entity(monkeypatch) -> None: + # No hierarchy-inference channel configured, or an inferred placement + # that did not corroborate, must not regress an already + # name-corroborated hint back to fully unresolved (a genuine + # similarity tie is handled separately above and does not reach this + # fallback) -- it falls back to the flat, unparented entity this + # pathway always created before hierarchy inference existed. monkeypatch.setattr( ingestion, "resolve_and_verify_organization_name", lambda *_args: _resolution(STATUS_CORROBORATED) ) + monkeypatch.setattr(ingestion, "_load_corporate_entity_candidates", _fake_load_candidates()) + + async def fake_get_or_create(conn, entity_name, context_text, hierarchy_client, verification_client, candidates): + return None + + monkeypatch.setattr(ingestion, "get_or_create_corporate_entity", fake_get_or_create) conn = _Connection(sample_rows=[{"post_title": "Visit", "post_body": "

Visit notes

"}]) - result = asyncio.run(ingestion.resolve_customer_hint(conn, _Client(), _Client(), "0019999999")) + result = asyncio.run( + ingestion.resolve_customer_hint(conn, _Client(), _Client(), _Client(), "0019999999") + ) assert result == { "corporate_entity_id": "new-entity-id", @@ -102,15 +228,23 @@ def test_corroborated_resolution_creates_and_links_a_new_entity(monkeypatch) -> assert "on conflict (corporate_entity_code)" in insert_calls[0][0] -def test_corroborated_resolution_reuses_an_existing_entity_by_name(monkeypatch) -> None: +def test_declined_hierarchy_placement_reuses_an_existing_entity_by_name(monkeypatch) -> None: monkeypatch.setattr( ingestion, "resolve_and_verify_organization_name", lambda *_args: _resolution(STATUS_CORROBORATED) ) + monkeypatch.setattr(ingestion, "_load_corporate_entity_candidates", _fake_load_candidates()) + + async def fake_get_or_create(conn, entity_name, context_text, hierarchy_client, verification_client, candidates): + return None + + monkeypatch.setattr(ingestion, "get_or_create_corporate_entity", fake_get_or_create) conn = _Connection( sample_rows=[{"post_title": "Visit", "post_body": "

Visit notes

"}], existing_entity={"corporate_entity_id": "existing-entity-id"}, ) - result = asyncio.run(ingestion.resolve_customer_hint(conn, _Client(), _Client(), "0019999999")) + result = asyncio.run( + ingestion.resolve_customer_hint(conn, _Client(), _Client(), _Client(), "0019999999") + ) assert result["corporate_entity_id"] == "existing-entity-id" assert all("insert into corporate_entity" not in call[0] for call in conn.executed)