Skip to content
Merged
91 changes: 69 additions & 22 deletions backend/app/customer_hint_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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`.
Expand Down Expand Up @@ -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,
)
Comment on lines +142 to 149

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Reported name can differ from the bound entity

When get_or_create_corporate_entity fuzzy-matches an existing entity whose stored name differs from the resolved name, the response still returns the resolved name (entity_name at customer_hint_ingestion.py) rather than the bound entity's catalog name. The old exact-match path always agreed, so this divergence is new. Cosmetic only.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 63768c9: re-fetches the canonical entity_name by corporate_entity_id right before returning, so the response always reports the actually-bound entity's real catalog name instead of the possibly-divergent resolved name. Added a regression test simulating the divergence (fuzzy match binds to an entity whose stored name differs from the resolved name).

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"]
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

# `corporate_entity_id` is NOT NULL, so a bulk-imported real record
# never sits at NULL waiting to be resolved -- it defaults to whatever
Expand Down
16 changes: 12 additions & 4 deletions backend/app/main.py

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Hint filter forces an extra source-context query

has_source_context is derived from the (now hint-filtered) source_customer_rows, so a filtered request that matches no code triggers the has_real_source_context fallback query (main.py). The fallback is global, so the result is unchanged, but an extra DB round-trip runs per filtered request.

(Refers to this code)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@
from backend.app.post_eligibility import (
SOURCE_POST_ELIGIBILITY_SQL,
SOURCE_POST_READER_ELIGIBILITY_SQL,
SOURCE_POST_VISIBILITY_SQL,
WRITING_SOURCE_DETAIL_STATE_CODE,
normalize_source_detail_state_code,
source_post_state_visibility_sql,
Expand Down Expand Up @@ -829,12 +830,15 @@ def _observed_hierarchy_ids(rows: list[asyncpg.Record]) -> set[str]:

@app.get("/api/customer-master")
async def read_customer_master(
hint_code: str | None = Query(default=None, max_length=255),
account: CurrentAccount = Depends(get_current_account),
pool: asyncpg.Pool = Depends(get_pool),
) -> dict[str, Any]:
"""Return the authorized customer catalog and its cataloged Keymen."""
_require_post_read(account)
if not account.corporate_entity_ids:
requested_hint_code = (hint_code or "").strip() or None
authorized_entity_ids = list(account.corporate_entity_ids)
if not authorized_entity_ids:
return {
"corporate_entities": [],
"keymen": [],
Expand All @@ -857,8 +861,10 @@ async def read_customer_master(
from source_post
where (nullif(btrim(source_customer_code), '') is not null
or nullif(btrim(source_customer_name), '') is not null)
and (visibility_code = 'public' or corporate_entity_id = any($1::uuid[]))
and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')}
and ($2::text is null
or nullif(btrim(source_customer_code), '') = $2::text)
and {SOURCE_POST_VISIBILITY_SQL.format(alias='source_post', authorized_entity_ids='$1')}
and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')}
), ranked as (
select scoped.*,
row_number() over (
Expand Down Expand Up @@ -902,7 +908,8 @@ async def read_customer_master(
and related.customer_name_group is not distinct from top_groups.customer_name_group
order by top_groups.post_count desc, top_groups.customer_code, top_groups.customer_name
""",
list(account.corporate_entity_ids),
authorized_entity_ids,
requested_hint_code,
)
# Safe SQL: the evidence query uses only closed schema fragments; authorized entity ids are bound.
source_author_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
Expand Down Expand Up @@ -1245,6 +1252,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:
Expand Down
12 changes: 12 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1500,6 +1500,18 @@ async def capture_relationship_entity_ids(conn, corporate_entity_ids):
"provenance": "source_post.source_customer_code/source_post.source_customer_name",
}
]
filtered_response = client.get(
"/api/customer-master?hint_code=TEST-CUSTOMER-001",
headers={"Authorization": f"Bearer {demo_analyst_token}"},
)
assert filtered_response.status_code == 200
assert filtered_response.json()["source_customer_hints"] == body["source_customer_hints"]
missing_response = client.get(
"/api/customer-master?hint_code=NOT-OBSERVED",
headers={"Authorization": f"Bearer {demo_analyst_token}"},
)
assert missing_response.status_code == 200
assert missing_response.json()["source_customer_hints"] == []
author_hint = body["source_author_hints"]
assert len(author_hint) == 1
assert author_hint[0]["author_code"] == "TEST-AUTHOR-001"
Expand Down
48 changes: 48 additions & 0 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -1122,6 +1122,34 @@ a:focus-visible {
margin: 1.25rem 0 0;
}

.customer-master-hint-search {
display: grid;
gap: 0.45rem;
margin-top: 1.25rem;
}

.customer-master-hint-search label {
color: var(--text-h);
font-size: 0.85rem;
font-weight: 700;
}

.customer-master-hint-search-row {
display: flex;
gap: 0.55rem;
}

.customer-master-hint-search-row input {
min-width: 0;
flex: 1;
min-height: 2.75rem;
padding: 0.45rem 0.65rem;
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--surface);
color: var(--text-h);
}

.customer-master-scope-filter {
display: grid;
grid-template-columns: auto minmax(12rem, 1fr);
Expand Down Expand Up @@ -1546,6 +1574,26 @@ a:focus-visible {
text-transform: uppercase;
}

.source-lineage-combination {
border-color: var(--color-accent-info);
background: var(--color-accent-info-background);
color: var(--text-h);
gap: 0.3rem;
}

.source-lineage-combination-code {
color: var(--color-primary);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.82rem;
letter-spacing: 0.08em;
}

.source-lineage-combination-label {
color: var(--text-muted);
font-weight: 600;
text-transform: none;
}

.post-card .post-list-item:focus-visible {
outline: 2px solid var(--color-focus-border);
outline-offset: -3px;
Expand Down
Loading