diff --git a/backend/app/customer_hint_ingestion.py b/backend/app/customer_hint_ingestion.py index 497c66728..64f5310ad 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 diff --git a/backend/app/main.py b/backend/app/main.py index eddc9a7e7..2c68fd813 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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, @@ -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": [], @@ -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 ( @@ -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 @@ -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: diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index cd7ed3a8d..e072c8870 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -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" diff --git a/frontend/src/App.css b/frontend/src/App.css index 7fd0925ba..24fee17b2 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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); @@ -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; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index d060cf643..4e653913f 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1129,6 +1129,15 @@ describe("App, authenticated", () => { source_detail_state_code: options?.sourceDetailStateCode, visibility_code: "public", visibility_label: "Public", + source_lineage_hints: { + combination_code: "1000", + commercial_context_code: "customer_only_candidate", + inference_status_code: "inferred_from_field_presence", + present_fields: ["customer"], + missing_fields: ["order_pool", "sales_order", "sales_order_item"], + lifecycle_vector: "Z-A-I-ALIVE", + deleted_marker_present: false, + }, created_at: "2026-01-01T00:00:00Z", }, ], @@ -1160,6 +1169,15 @@ describe("App, authenticated", () => { source_detail_state_code: options?.sourceDetailStateCode, visibility_code: "public", visibility_label: "Public", + source_lineage_hints: { + combination_code: "1000", + commercial_context_code: "customer_only_candidate", + inference_status_code: "inferred_from_field_presence", + present_fields: ["customer"], + missing_fields: ["order_pool", "sales_order", "sales_order_item"], + lifecycle_vector: "Z-A-I-ALIVE", + deleted_marker_present: false, + }, project_evidence: [ { project_key: "source-project", @@ -1522,6 +1540,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({ @@ -1795,7 +1831,9 @@ describe("App, authenticated", () => { }), ); } - if (url.endsWith("/api/customer-master") && method === "GET") { + const customerMasterUrl = new URL(url, "https://backend.test"); + if (customerMasterUrl.pathname === "/api/customer-master" && method === "GET") { + const requestedCustomerHint = customerMasterUrl.searchParams.get("hint_code"); return Promise.resolve( jsonResponse({ corporate_entities: options?.customerScopeFacets @@ -1888,7 +1926,7 @@ describe("App, authenticated", () => { resolution_status: resolvedHintCode === `CUST-${index}` ? "resolved" : "hint_only", hint_trust: "normal", provenance: "source_post.source_customer_code", - })) + })).filter((hint) => !requestedCustomerHint || hint.customer_code === requestedCustomerHint) : [], source_author_hints: [], relationship_network: [ @@ -2066,6 +2104,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 @@ -2199,6 +2266,20 @@ describe("App, authenticated", () => { expect(screen.queryByText("CUST-44")).not.toBeInTheDocument(); }); + it("finds an observed customer code outside the ranked first page", async () => { + const fetchMock = stubBackend({ manyCustomerHints: 45 }); + render(); + expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Customer master" })); + + await userEvent.type(screen.getByRole("searchbox", { name: "Find source customer code" }), "CUST-44"); + await userEvent.click(screen.getByRole("button", { name: "Find" })); + + expect(await screen.findByText("CUST-44")).toBeInTheDocument(); + expect(screen.queryByText("CUST-0")).not.toBeInTheDocument(); + expect(fetchMock.mock.calls.some(([url]) => String(url).includes("hint_code=CUST-44"))).toBe(true); + }); + it("searches the board from a semantic project mention", async () => { stubBackend(); render(); @@ -2412,6 +2493,9 @@ describe("App, authenticated", () => { ); expect(listButton).toHaveTextContent("Voice of Customer"); expect(listButton).toHaveTextContent("Public"); + expect(listButton).toHaveTextContent("Combination code"); + expect(listButton).toHaveTextContent("1000"); + expect(within(listButton).getByLabelText("Field combination: 1000, Customer only candidate")).toBeInTheDocument(); await userEvent.click(listButton); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7c2ae2ec9..e78c818be 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,6 @@ import { AdminPanel, type AdminBoardTool } from "./components/AdminPanel"; -import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react"; import { useAuth } from "react-oidc-context"; import { askPostChat, @@ -1770,6 +1770,7 @@ const SEMANTIC_RELATION_LABELS: Record = { org_member_of: "Organization member of", org_unit_of: "Organization unit of", org_suborganization_of: "Sub-organization of", + lw_responsible_for: "Responsible for", lw_supports: "Supports", }; @@ -4787,7 +4788,7 @@ function PostList({ {post.source_lineage_hints ? ( <> - {t("Source context")}: {sourceLineageContextLabel(post.source_lineage_hints)} · {t("Combination code")}: {post.source_lineage_hints.combination_code} + {t("Source context")}: {sourceLineageContextLabel(post.source_lineage_hints)} `${sourceLineageFieldLabel(field)}: ${sourceLineageFieldIsPresent(post.source_lineage_hints!, field) ? t("Present") : t("Not present")}`).join(", ")}> {t("Source fields")}: @@ -4814,6 +4815,16 @@ function PostList({ {t(post.voc_type_label ?? post.voc_type_code)} {t(post.visibility_label ?? post.visibility_code)} + {post.source_lineage_hints ? ( + + {t("Combination code")} + {post.source_lineage_hints.combination_code} + {sourceLineageContextLabel(post.source_lineage_hints)} + + ) : null} {sourceDetailState ? ( {t("Source detail state")}: {sourceDetailState.code} · {sourceDetailState.description} @@ -5117,10 +5128,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>( @@ -5130,8 +5139,16 @@ 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); + const [hintCodeInput, setHintCodeInput] = useState(""); + const [searchedHintCode, setSearchedHintCode] = useState(""); // Fetched independently, same pattern as PostList's own canRebuild -- // CustomerMasterPanel is a sibling of PostList under App, not a child, // so it cannot read PostList's local post_admin check. @@ -5153,10 +5170,10 @@ function CustomerMasterPanel({ const loadMaster = useCallback(() => { setError(null); - return fetchCustomerMaster(accessToken) + return fetchCustomerMaster(accessToken, searchedHintCode) .then(setMaster) .catch(() => setError(t("Customer master could not be loaded."))); - }, [accessToken]); + }, [accessToken, searchedHintCode]); useEffect(() => { setMaster(null); @@ -5176,6 +5193,11 @@ function CustomerMasterPanel({ } } + function handleHintSearch(event: FormEvent) { + event.preventDefault(); + setSearchedHintCode(hintCodeInput.trim()); + } + async function toggleEntity(entityId: string) { if (expandedEntityId === entityId) { setExpandedEntityId(null); @@ -5194,6 +5216,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)), ); @@ -5208,6 +5252,25 @@ function CustomerMasterPanel({ {master?.corporate_entities.length === 0 ? (

{t("No customer entities are connected to this account.")}

) : null} +
+ +
+ setHintCodeInput(event.target.value)} + placeholder={t("Paste an observed customer code")} + /> + +
+

{t("Searches all authorized source hints, not only the ranked first page.")}

+
+ {searchedHintCode && master && master.source_customer_hints.length === 0 ? ( +

+ {tf("No source customer evidence matches {code}.", { code: searchedHintCode })} +

+ ) : null} {master && master.corporate_entities.length > 0 ? (
{t("Filter by scope")} @@ -5246,7 +5309,7 @@ function CustomerMasterPanel({ relatedByEntity={relatedByEntity} relatedLoading={relatedLoading} onToggle={toggleEntity} - onOpenPost={onOpenPost} + onOpenPost={openPost} /> ))} @@ -5316,7 +5379,7 @@ function CustomerMasterPanel({ postTitle={post.post_title} postBodyExcerpt={post.post_body_excerpt} postBodyTruncated={post.post_body_truncated} - onOpenPost={onOpenPost} + onOpenPost={openPost} /> ))} @@ -5369,7 +5432,7 @@ function CustomerMasterPanel({ postTitle={post.post_title} postBodyExcerpt={post.post_body_excerpt} postBodyTruncated={post.post_body_truncated} - onOpenPost={onOpenPost} + onOpenPost={openPost} /> ))} @@ -5395,6 +5458,16 @@ function CustomerMasterPanel({ ) : null} + {selectedPostId && ( + setSelectedPostId(null)} + onSelectPost={openPost} + /> + )} ); } @@ -6121,13 +6194,7 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean /> ) : null} {activeDestination === "customers" ? ( - { - setPostToOpen(postId); - changeDestination("board"); - }} - /> + ) : null} {activeDestination === "calendar" ? ( { - return backendFetch("/api/customer-master", accessToken); +export function fetchCustomerMaster( + accessToken: string, + hintCode?: string, +): Promise { + const normalizedHintCode = hintCode?.trim(); + const query = normalizedHintCode + ? `?hint_code=${encodeURIComponent(normalizedHintCode)}` + : ""; + return backendFetch(`/api/customer-master${query}`, accessToken); } export interface CustomerHintResolution { diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 3297cb9a6..85232410b 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -68,6 +68,7 @@ const TRANSLATIONS: Partial>> = { "Organization member of": "조직 구성원", "Organization unit of": "조직 단위", "Sub-organization of": "하위 조직", + "Responsible for": "책임 수행", Supports: "지원함", "Negated condition": "부정 조건", "Normalized date": "정규화 날짜", @@ -221,6 +222,11 @@ const TRANSLATIONS: Partial>> = { "Unclassified": "미분류", "No entities match the current scope filter.": "현재 범위 필터와 일치하는 엔터티가 없습니다.", "Observed customer evidence": "관찰된 고객 증거", + "Find source customer code": "원천 고객 코드 찾기", + "Paste an observed customer code": "관찰된 고객 코드를 입력하세요", + Find: "찾기", + "Searches all authorized source hints, not only the ranked first page.": "순위가 매겨진 첫 페이지만이 아니라 권한이 있는 모든 원천 힌트를 검색합니다.", + "No source customer evidence matches {code}.": "{code}와 일치하는 원천 고객 증거가 없습니다.", "Relationship network": "관계 네트워크", "A counterparty can hold more than one role over time -- a customer in one post can be a competitor, supplier, or partner in another. Every role observed for a name is listed, not just the most frequent.": "거래처는 시간에 따라 여러 역할을 동시에 가질 수 있습니다 -- 한 게시물에서는 고객이지만 다른 게시물에서는 경쟁사, 공급자, 파트너일 수 있습니다. 가장 빈번한 역할만이 아니라 관측된 모든 역할을 표시합니다.", diff --git a/lineageweave/post_summary.py b/lineageweave/post_summary.py index dbfac6357..4c8fea027 100644 --- a/lineageweave/post_summary.py +++ b/lineageweave/post_summary.py @@ -616,7 +616,10 @@ def summarize_with_hints( a canonical comparison name, the shortest supporting evidence phrase, and a confidence from 0 to 1. Do not invent a project from a generic topic. Keep ambiguous candidates with confidence below 0.7 so the UI can - show uncertainty, but they must not be used as a report grouping. + show uncertainty, but they must not be used as a report grouping. A + meeting, call, workshop, report, or introductory session is an event, not + a project; do not emit the event title as a project unless the body + explicitly identifies it as a project. 5. A list of 5W1H evidence items. Explicitly extract specific 'when', 'where', 'why', and 'how' facts from the text. For each fact, return the slot code, the extracted value, and the exact supporting phrase. Do not infer anything not in the text. @@ -871,6 +874,11 @@ def summarize_with_hints( canonical name in PROJECTS or be NONE. Requester and processor must be actor names also present in ROLES. Use NONE only when the post does not name that actor. Do not invent actors, projects, affiliations, actions, or confidence. +An introductory meeting, call, workshop, report, or other event is not a +project. Keep it in KEY EVENTS/CLUES and emit no PROJECTS row for the event +itself unless the body explicitly identifies that named item as a project. +Do not promote a meeting title into a project merely because it is the post +title. Only write EVIDENCE rows when the post explicitly supports the value; do not turn the record's filing timestamp into an event time and do not infer a place, reason, or method from a title alone. @@ -910,9 +918,12 @@ def summarize_with_hints( the shortest exact supporting phrase. In particular: - use org_member_of for an explicit organization membership or group-joining statement; -- use lw_supports for an explicit organization-to-named-project statement - such as "provided installation support for [project]", "worked on - [project]", or "was the main contractor for [project]"; +- use lw_responsible_for for an explicit organization-to-named-project + responsibility such as "was the main contractor for [project]", "owned + the EPC scope for [project]", or "was responsible for [project]"; +- use lw_supports for an explicit organization-to-named-project support + statement such as "provided installation support for [project]" or + "supported [project]"; generic work wording is not enough; - emit separate organization-to-project rows when the source explicitly assigns different organizations different project responsibilities; - do not turn attendance, an affiliation field, or a project mention alone @@ -920,7 +931,7 @@ def summarize_with_hints( Fictional format examples only: Northwind Services | organization | org_member_of | Northwind Group | organization | joined Northwind Group | 0.98 Northwind Services | organization | lw_supports | Highland HVDC | project | provided installation support for Highland HVDC | 0.9 -Prime Contractor | organization | lw_supports | Highland HVDC | project | Prime Contractor was the main contractor | 0.95 +Prime Contractor | organization | lw_responsible_for | Highland HVDC | project | Prime Contractor was the main contractor | 0.95 A full predicate registry is supplied by the application contract; if no predicate fits, omit the relation rather than inventing a verb. Post title: {title} 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/scripts/backfill_post_summaries.py b/scripts/backfill_post_summaries.py index 95d11e97a..b525a1fca 100644 --- a/scripts/backfill_post_summaries.py +++ b/scripts/backfill_post_summaries.py @@ -244,7 +244,9 @@ async def backfill_post_summaries( vision_client = orchestrator_vision_client(base_url, api_key) if not vision_client.available: raise RuntimeError("VISION is unavailable; configure contextual-orchestrator before backfill") - summary_client = ContextualOrchestratorPostSummaryClient(base_url, api_key, timeout=180.0) + # Local orchestrator runs may perform two structured extraction passes; + # keep the operator backfill timeout aligned with the API route. + summary_client = ContextualOrchestratorPostSummaryClient(base_url, api_key, timeout=300.0) embedding_model = os.environ.get("LLM_GATEWAY_EMBEDDING_MODEL", "").strip() embedding_client = orchestrator_embedding_client(base_url, api_key, embedding_model) structure_client = ( 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..f123710ab 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 @@ -53,17 +60,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 +88,91 @@ 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(), "0019999999")) + 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_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, 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(), _Client(), "0019999999") + ) assert result == { "corporate_entity_id": "new-entity-id", @@ -102,15 +191,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) diff --git a/tests/test_post_summary.py b/tests/test_post_summary.py index 62ec3ae71..6139451c4 100644 --- a/tests/test_post_summary.py +++ b/tests/test_post_summary.py @@ -75,6 +75,14 @@ def test_summary_prompt_requires_naming_actual_people_not_generic_titles() -> No assert "홍길동" in _SUMMARY_REQUEST_PROMPT_TEMPLATE +def test_details_prompt_separates_events_from_projects_and_support_from_responsibility() -> None: + from lineageweave.post_summary import _DETAILS_REQUEST_PROMPT_TEMPLATE + + assert "meeting title into a project" in _DETAILS_REQUEST_PROMPT_TEMPLATE + assert "lw_responsible_for" in _DETAILS_REQUEST_PROMPT_TEMPLATE + assert "lw_supports" in _DETAILS_REQUEST_PROMPT_TEMPLATE + + def test_summary_requires_imported_source_body() -> None: assert require_summary_source_body(" body ") == " body " with pytest.raises(ValueError, match="source post body is empty"): @@ -155,6 +163,18 @@ def test_plain_relation_parser_drops_unallowlisted_predicates() -> None: assert relations[0].predicate_code == "org_unit_of" +def test_plain_relation_parser_keeps_explicit_responsibility_predicate() -> None: + relations = _parse_plain_semantic_relationships( + "RELATIONS:\n" + "Prime Contractor | organization | lw_responsible_for | Highland HVDC | project | main contractor | 0.95\n" + "Northwind Services | organization | lw_supports | Highland HVDC | project | installation support | 0.9" + ) + assert [relation.predicate_code for relation in relations] == [ + "lw_responsible_for", + "lw_supports", + ] + + def test_attendance_only_is_not_a_role_but_concrete_work_is() -> None: details = _parse_plain_summary_details( "ROLES:\n"