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)