From 7978f5152009306e6aa6e884a464f8a188c3931d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 15:10:09 +0900 Subject: [PATCH 1/5] fix(customer-master): open related posts in place instead of jumping to Board CustomerMasterPanel's onOpenPost handler switched the workspace destination to "board" and relied on PostList's postIdToOpen to show the post, so clicking a customer's related post abandoned the Customer Master screen entirely. PostDetailPopup already renders as a right-docked panel (.popup-panel { position: fixed; right: 0 }), so mount it directly from CustomerMasterPanel with its own selected-post and lineage-graph state instead of routing away. Co-Authored-By: Claude Sonnet 5 --- frontend/src/App.test.tsx | 47 ++++++++++++++++++++++++++++++++++ frontend/src/App.tsx | 54 ++++++++++++++++++++++++++++++--------- 2 files changed, 89 insertions(+), 12 deletions(-) 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" ? ( Date: Sat, 22 Aug 2026 15:17:44 +0900 Subject: [PATCH 2/5] fix(customer-hint): wire hierarchy inference into customer-hint resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_customer_hint created every newly resolved customer as a bare, unparented company-level corporate_entity via its own raw INSERT, even though the exact hierarchy-inference pipeline it needs already exists and is wired into keyman/relation ingestion (lineageweave/corporate_hierarchy_inference.py via get_or_create_corporate_entity). A SAP-sourced customer code that resolves to e.g. "Acme Electronics South Plant" never got its inferred parent chain (plant -> company -> group) -- the "통합 고객사 계열 tree AI" requirement stayed unmet for this pathway specifically. Route entity creation through get_or_create_corporate_entity first, so a configured hierarchy-inference channel gets a real parent placement. When it declines (no channel configured, a tied similarity match, or an uncorroborated placement) fall back to the original flat/unparented insert -- the name itself was already independently corroborated by resolve_and_verify_organization_name, so a declined *placement* must not regress the hint back to fully unresolved. Co-Authored-By: Claude Sonnet 5 --- backend/app/customer_hint_ingestion.py | 77 ++++++++++++++++++------- backend/app/main.py | 1 + tests/test_customer_hint_ingestion.py | 80 +++++++++++++++++++++++--- 3 files changed, 129 insertions(+), 29 deletions(-) diff --git a/backend/app/customer_hint_ingestion.py b/backend/app/customer_hint_ingestion.py index 497c66728..7d642395f 100644 --- a/backend/app/customer_hint_ingestion.py +++ b/backend/app/customer_hint_ingestion.py @@ -6,6 +6,15 @@ 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. """ from __future__ import annotations @@ -15,12 +24,15 @@ import asyncpg +from lineageweave.corporate_hierarchy_inference import CorporateHierarchyInferenceClient 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 +41,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 +124,52 @@ 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) + 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 -- no hierarchy inference + # channel configured, a tied similarity match among existing + # entities, or an inferred placement that did not corroborate. + # `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, or the tie is deliberately reconciled) 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 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/tests/test_customer_hint_ingestion.py b/tests/test_customer_hint_ingestion.py index d1f63d80f..a3e403915 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 @@ -53,17 +59,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 +87,61 @@ 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_creates_and_links_a_new_entity(monkeypatch) -> None: +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_declined_hierarchy_placement_falls_back_to_a_flat_new_entity(monkeypatch) -> None: + # No hierarchy-inference channel configured, a tied similarity match, + # or an inferred placement that did not corroborate must not regress + # an already name-corroborated hint back to fully unresolved -- 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 +160,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) From 926a86a5f2442cc702e5634af8d8285a4434fd48 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 15:22:37 +0900 Subject: [PATCH 3/5] feat(customer-hints): add bulk backfill script for hint resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_customer_hint was reachable only one hint code at a time through the Customer Master "Resolve" button. A SAP-sourced import (e.g. public.zcrht811_export_rows, whose only customer field is an opaque SAP customer number with no name column at all) can carry thousands of distinct observed customer codes, so requiring an admin to click "Resolve" once per code does not scale and leaves the overwhelming majority permanently unresolved -- this is the concrete mechanism behind "고객 이름을 본문에서 유추해서 알 수 없는" reports. scripts/backfill_customer_hints.py mirrors backfill_post_keymen.py's shape: select a bounded batch of still-unresolved hint codes (or one explicit code), and resolve each through the exact same resolve-then-verify-then-place pipeline the API route uses (including the hierarchy inference wired in the prior commit), never guessing a name the pipeline itself would not corroborate. Co-Authored-By: Claude Sonnet 5 --- scripts/backfill_customer_hints.py | 207 ++++++++++++++++++++++++++ tests/test_backfill_customer_hints.py | 95 ++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 scripts/backfill_customer_hints.py create mode 100644 tests/test_backfill_customer_hints.py diff --git a/scripts/backfill_customer_hints.py b/scripts/backfill_customer_hints.py new file mode 100644 index 000000000..a5f6f4d40 --- /dev/null +++ b/scripts/backfill_customer_hints.py @@ -0,0 +1,207 @@ +"""Bounded operator backfill for evidence-backed customer-hint resolution. + +This is intentionally an operator script, not a reader-facing HTTP route -- +same shape as ``backfill_post_keymen.py``. It exists because +``resolve_customer_hint`` was previously reachable only one hint at a time +through the Customer Master "Resolve" button: a SAP-sourced import (e.g. +``public.zcrht811_export_rows``, whose only customer field is an opaque +SAP customer number with no name at all) can carry thousands of distinct +observed customer codes, so requiring an admin to click "Resolve" once per +code does not scale and leaves the overwhelming majority permanently +unresolved. This script reuses the exact same resolve-then-verify-then- +place pipeline (including corporate hierarchy inference) across a bounded +batch of still-unresolved codes. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +from collections import Counter + +import asyncpg + +from backend.app.config import load_settings +from backend.app.customer_hint_ingestion import resolve_customer_hint +from backend.app.main import ( + _corporate_hierarchy_inference_client, + _customer_hint_resolution_client, + _relation_verification_client, +) +from lineageweave.http_client import HttpClientError + + +def _first_env(*names: str) -> 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} From 11c65c1a96a864e4f44acaa4ca4420f86eb69fe9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 15:41:17 +0900 Subject: [PATCH 4/5] fix(customer-hint): respect ADR 0026 on a tied similarity match Devin Review flagged that a genuine RESOLUTION_TIE from get_or_create_corporate_entity was falling through to the same flat fallback insert as "no hierarchy channel configured," creating a third same-named corporate_entity row for a name ADR 0026 says must stay unbound. Check score_corporate_entity directly before calling get_or_create_corporate_entity, and return None immediately on a tie -- a genuine miss (no channel, or an uncorroborated placement) is still the only case that falls back to the pre-existing flat insert. Co-Authored-By: Claude Sonnet 5 --- backend/app/customer_hint_ingestion.py | 24 +++++++++++---- tests/test_customer_hint_ingestion.py | 41 ++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/backend/app/customer_hint_ingestion.py b/backend/app/customer_hint_ingestion.py index 7d642395f..64f5310ad 100644 --- a/backend/app/customer_hint_ingestion.py +++ b/backend/app/customer_hint_ingestion.py @@ -15,6 +15,12 @@ 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 @@ -25,6 +31,7 @@ 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 @@ -125,6 +132,13 @@ async def resolve_customer_hint( entity_name = resolution.resolved_organization_name 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, @@ -134,16 +148,16 @@ async def resolve_customer_hint( candidates, ) if entity_id is None: - # get_or_create_corporate_entity declined -- no hierarchy inference - # channel configured, a tied similarity match among existing - # entities, or an inferred placement that did not corroborate. + # 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, or the tie is deliberately reconciled) can - # still enrich it with a real parent by matching this same name. + # 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, diff --git a/tests/test_customer_hint_ingestion.py b/tests/test_customer_hint_ingestion.py index a3e403915..f123710ab 100644 --- a/tests/test_customer_hint_ingestion.py +++ b/tests/test_customer_hint_ingestion.py @@ -16,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 @@ -123,12 +124,42 @@ async def fake_get_or_create(conn, entity_name, context_text, hierarchy_client, assert all("insert into corporate_entity" not in call[0] for call in conn.executed) +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_declined_hierarchy_placement_falls_back_to_a_flat_new_entity(monkeypatch) -> None: - # No hierarchy-inference channel configured, a tied similarity match, - # or an inferred placement that did not corroborate must not regress - # an already name-corroborated hint back to fully unresolved -- it - # falls back to the flat, unparented entity this pathway always - # created before hierarchy inference existed. + # 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) ) From 91b51417151e349e5c205e640b756b8765b52d82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 15:57:00 +0900 Subject: [PATCH 5/5] fix(customer-hint): report the actually-bound entity's catalog name Devin Review flagged that when get_or_create_corporate_entity binds a hint to an existing entity via fuzzy similarity matching, the response still returned resolve_and_verify_organization_name's freshly-resolved name rather than the bound entity's real stored name -- these can diverge (casing, punctuation, a legal suffix). Re-fetch the canonical entity_name by corporate_entity_id right before returning, so the response never claims a name that disagrees with what is actually bound, regardless of which path (hierarchy-aware match/create, or the flat fallback's own case-insensitive lookup) resolved it. Co-Authored-By: Claude Sonnet 5 --- backend/app/customer_hint_ingestion.py | 13 ++++++++- tests/test_customer_hint_ingestion.py | 39 +++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/backend/app/customer_hint_ingestion.py b/backend/app/customer_hint_ingestion.py index 64f5310ad..c50a619aa 100644 --- a/backend/app/customer_hint_ingestion.py +++ b/backend/app/customer_hint_ingestion.py @@ -206,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/tests/test_customer_hint_ingestion.py b/tests/test_customer_hint_ingestion.py index f123710ab..336b4fad1 100644 --- a/tests/test_customer_hint_ingestion.py +++ b/tests/test_customer_hint_ingestion.py @@ -29,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): @@ -50,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( @@ -124,6 +134,33 @@ async def fake_get_or_create(conn, entity_name, context_text, hierarchy_client, 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.