Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 81 additions & 23 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 +134 to +148

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: Tie check re-scores candidates

The tie short-circuit scores entity_name against candidates, then get_or_create_corporate_entity scores the same inputs again internally. This is deliberate so a tie is not mistaken for a miss in the flat-fallback path; the duplication is harmless and side-effect-free.

Open in Devin Review

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

)
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
Expand All @@ -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,
}
1 change: 1 addition & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
47 changes: 47 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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(<App />);
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
Expand Down
54 changes: 42 additions & 12 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5252,10 +5252,8 @@ function customerMasterScopeBuckets(entity: CustomerMasterEntity): CustomerMaste

function CustomerMasterPanel({
accessToken,
onOpenPost,
}: {
accessToken: string;
onOpenPost: (postId: string) => void;
}) {
const [master, setMaster] = useState<CustomerMasterResponse | null>(null);
const [scopeFilter, setScopeFilter] = useState<Set<CustomerMasterScopeFilter>>(
Expand All @@ -5265,6 +5263,12 @@ function CustomerMasterPanel({
const [expandedEntityId, setExpandedEntityId] = useState<string | null>(null);
const [relatedByEntity, setRelatedByEntity] = useState<Record<string, RelatedNode[]>>({});
const [relatedLoading, setRelatedLoading] = useState<string | null>(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<string | null>(null);
const [selectedPostGraph, setSelectedPostGraph] = useState<LineageGraph | null>(null);
const [resolvingHint, setResolvingHint] = useState<string | null>(null);
const [resolveError, setResolveError] = useState<string | null>(null);
// Fetched independently, same pattern as PostList's own canRebuild --
Expand Down Expand Up @@ -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)),
);
Expand Down Expand Up @@ -5381,7 +5407,7 @@ function CustomerMasterPanel({
relatedByEntity={relatedByEntity}
relatedLoading={relatedLoading}
onToggle={toggleEntity}
onOpenPost={onOpenPost}
onOpenPost={openPost}
/>
))}
</ul>
Expand Down Expand Up @@ -5451,7 +5477,7 @@ function CustomerMasterPanel({
postTitle={post.post_title}
postBodyExcerpt={post.post_body_excerpt}
postBodyTruncated={post.post_body_truncated}
onOpenPost={onOpenPost}
onOpenPost={openPost}
/>
</li>
))}
Expand Down Expand Up @@ -5504,7 +5530,7 @@ function CustomerMasterPanel({
postTitle={post.post_title}
postBodyExcerpt={post.post_body_excerpt}
postBodyTruncated={post.post_body_truncated}
onOpenPost={onOpenPost}
onOpenPost={openPost}
/>
</li>
))}
Expand All @@ -5530,6 +5556,16 @@ function CustomerMasterPanel({
</ul>
</section>
) : null}
{selectedPostId && (
<PostDetailPopup
postId={selectedPostId}
accessToken={accessToken}
canExtract={canResolveHints}
graph={selectedPostGraph}
onClose={() => setSelectedPostId(null)}
onSelectPost={openPost}
/>
)}
Comment on lines +5559 to +5568

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: Popup lineage graph loads lazily here

graph={selectedPostGraph} is null until the fetch resolves, so the Event Lineage view shows a brief loading state. PostList instead falls back to its full graph via focusedGraph ?? graph. Behavior is otherwise correct since the popup fetches its own post data independently.

Open in Devin Review

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

</section>
);
}
Expand Down Expand Up @@ -6256,13 +6292,7 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
/>
) : null}
{activeDestination === "customers" ? (
<CustomerMasterPanel
accessToken={accessToken}
onOpenPost={(postId) => {
setPostToOpen(postId);
changeDestination("board");
}}
/>
<CustomerMasterPanel accessToken={accessToken} />
) : null}
{activeDestination === "calendar" ? (
<CalendarPanel
Expand Down
Loading