diff --git a/backend/app/post_summary_ingestion.py b/backend/app/post_summary_ingestion.py index f7287ade3..cdacbe4b7 100644 --- a/backend/app/post_summary_ingestion.py +++ b/backend/app/post_summary_ingestion.py @@ -141,7 +141,7 @@ async def fetch_persisted_summary( roles = await conn.fetch( """ select role.actor_name, role.responsibility_text, role.actor_type_code, - role.affiliated_organization_name, + role.affiliated_organization_name, role.job_title_text, role.cataloged_team_id, role.cataloged_corporate_entity_id, role.cataloged_person_id, @@ -256,6 +256,7 @@ async def fetch_persisted_summary( "responsibility": row["responsibility_text"], "actor_type_code": row["actor_type_code"], "affiliated_organization_name": row["affiliated_organization_name"], + "job_title": row["job_title_text"], "catalog_node_id": catalog_node_id, "catalog_node_type_code": catalog_node_type_code, "affiliated_organization_catalog_id": ( @@ -723,15 +724,16 @@ async def _replace_summary_projection( await conn.execute( "insert into post_summary_role " "(post_id, actor_name, responsibility_text, actor_type_code, " - "affiliated_organization_name, cataloged_team_id, " + "affiliated_organization_name, job_title_text, cataloged_team_id, " "cataloged_corporate_entity_id, cataloged_person_id, " "cataloged_affiliated_corporate_entity_id) values " - "($1, $2, $3, $4, $5, $6, $7, $8, $9)", + "($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", post_id, role.actor_name, role.responsibility, role.actor_type_code, role.affiliated_organization_name, + role.job_title, cataloged_team_id, cataloged_corporate_entity_id, cataloged_person_id, diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index cd7ed3a8d..bb72ab477 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -85,6 +85,9 @@ _SOURCE_COMMERCIAL_CONTEXT_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" / "0130_source_commercial_context.sql" ) +_ROLE_JOB_TITLE_MIGRATION = ( + Path(__file__).resolve().parents[2] / "migrations" / "0131_role_job_title.sql" +) _MEMBER_LOCALE_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" / "0044_member_locale_preference.sql" ) @@ -267,6 +270,7 @@ def seeded_db(demo_analyst_token): cur.execute(_SOURCE_NAMED_HINTS_MIGRATION.read_text()) cur.execute(_SOURCE_ORG_NAMED_HINTS_MIGRATION.read_text()) cur.execute(_SOURCE_COMMERCIAL_CONTEXT_MIGRATION.read_text()) + cur.execute(_ROLE_JOB_TITLE_MIGRATION.read_text()) cur.execute( (Path(__file__).resolve().parents[2] / "migrations" / "0040_post_summary_contract.sql") .read_text() diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index 125b89506..703480325 100644 --- a/docker/postgres-init/migrate.sh +++ b/docker/postgres-init/migrate.sh @@ -18,7 +18,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do migration_name=${migration##*/} case "$migration_name" in 0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;; - 0060_*|0100_*|0101_*|0102_*|0103_*|0104_*|0105_*|0106_*|0107_*|0108_*|0109_*|0110_*|0111_*|0112_*|0113_*|0114_*|0130_*) ;; + 0060_*|0100_*|0101_*|0102_*|0103_*|0104_*|0105_*|0106_*|0107_*|0108_*|0109_*|0110_*|0111_*|0112_*|0113_*|0114_*|0130_*|0131_*) ;; *) continue ;; esac printf 'Applying %s\n' "$migration_name" diff --git a/frontend/src/App.css b/frontend/src/App.css index 7fd0925ba..2673f30f1 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2130,6 +2130,11 @@ a:focus-visible { font-size: 0.9rem; } +.rr-job-title { + color: var(--text-muted); + font-size: 0.9rem; +} + .ontology-role { padding: 0.3rem 0; } diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index d060cf643..4be820752 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -2504,29 +2504,35 @@ describe("App, authenticated", () => { expect(screen.getByText("첫 번째 이벤트")).toBeInTheDocument(); expect(screen.getByText(/우리 측 후속/)).toBeInTheDocument(); expect(screen.getByRole("button", { name: "R&R Keyman: Ada West" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "R&R affiliation: Demo Corp" })).toBeInTheDocument(); + // Ada West and 설계팀 both name "Demo Corp" as their affiliation, and + // "Demo Corp" has no ROLES row of its own -- they now nest under a + // synthetic organization-anchor
  • instead of each repeating a flat + // "· 소속: Demo Corp" link next to them. + const demoCorpAnchorButton = screen.getByRole("button", { name: "R&R organization: Demo Corp" }); + expect(demoCorpAnchorButton).toBeInTheDocument(); + const demoCorpAnchorItem = demoCorpAnchorButton.closest("li"); + expect(demoCorpAnchorItem).not.toBeNull(); + expect(within(demoCorpAnchorItem as HTMLElement).getByRole("button", { name: "R&R Keyman: Ada West" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "R&R person: Priya Nair" })).toBeInTheDocument(); expect(screen.getByText("당사").closest("li")).toHaveTextContent("Organization"); expect(screen.queryByRole("button", { name: "R&R Keyman: 당사" })).not.toBeInTheDocument(); - // R&R groups by affiliated organization, then orders each group - // organization-first, then team, then person (ADR 0004's PROV-O - // broader/narrower direction) -- not raw extraction order. "Northridge - // Grid Devices" is itself an organization row, but it is affiliated - // with "Northridge Grid" and must cluster with Priya Nair under that - // parent, not stand as its own separate group. - const rrList = screen.getByText("당사").closest("ul"); - const rrOrder = within(rrList as HTMLElement) - .getAllByRole("listitem") - .map((item) => item.textContent); - const demoCorpGroup = rrOrder.slice( - rrOrder.findIndex((text) => text?.includes("설계팀")), - rrOrder.findIndex((text) => text?.includes("Ada West")) + 1, - ); - expect(demoCorpGroup[0]).toContain("설계팀"); - expect(demoCorpGroup[1]).toContain("Ada West"); - const northridgeGroupStart = rrOrder.findIndex((text) => text?.includes("Northridge Grid Devices")); - expect(rrOrder[northridgeGroupStart]).toContain("Northridge Grid Devices"); - expect(rrOrder[northridgeGroupStart + 1]).toContain("Priya Nair"); + // 설계팀 shares Demo Corp's synthetic anchor with Ada West (ADR 0004's + // PROV-O broader/narrower direction) instead of merely sorting adjacent + // to it. + expect(within(demoCorpAnchorItem as HTMLElement).getByText(/설계팀/)).toBeInTheDocument(); + // "Northridge Grid Devices" is itself an organization row, but it is + // affiliated with "Northridge Grid" -- which has no ROLES row of its + // own -- so it clusters with Priya Nair under a synthetic anchor too, + // not as its own separate top-level group. + const northridgeGridDevicesItem = screen.getByText(/Northridge Grid Devices/).closest("li"); + expect(northridgeGridDevicesItem).not.toBeNull(); + const northridgeAnchorItem = northridgeGridDevicesItem + ?.closest("ul.customer-master-tree-children") + ?.parentElement; + expect(northridgeAnchorItem).not.toBeNull(); + expect( + within(northridgeAnchorItem as HTMLElement).getByRole("button", { name: "R&R person: Priya Nair" }), + ).toBeInTheDocument(); const relatedPosts = screen.getByRole("heading", { name: "Related posts", level: 3 }).closest( ".related-posts-section", ); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7c2ae2ec9..709ce5605 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1823,7 +1823,13 @@ function sortRolesByOntologyOrder( } interface RoleTreeNode { - role: PostRoleResponsibility; + // null marks a synthetic organization-anchor node: the affiliated + // organization has no ROLES row of its own (no org-level action was + // extracted for it), so there is no PostRoleResponsibility to show -- + // only a grouping heading for its members. + role: PostRoleResponsibility | null; + organizationName?: string; + organizationCatalogId?: string | null; children: RoleTreeNode[]; } @@ -1832,6 +1838,13 @@ interface RoleTreeNode { // under that row instead of repeating "· 소속: X" as a flat, disconnected // bullet next to it -- two researchers at the same institute now share a // visual parent instead of just sorting adjacent to each other. +// +// When 2+ people/teams share an affiliated_organization_name that has no +// ROLES row of its own (the source names attendees and their org but +// never describes the org acting on its own), they still get grouped +// under a synthetic anchor for that name -- inventing a heading is not +// inventing a fact (ADR 0010 fail-closed: no responsibility text is +// attributed to the org, only its already-stated name is repeated). function buildRoleTree(roles: PostRoleResponsibility[]): RoleTreeNode[] { const sorted = sortRolesByOntologyOrder(roles); const organizationsByName = new Map(); @@ -1840,19 +1853,48 @@ function buildRoleTree(roles: PostRoleResponsibility[]): RoleTreeNode[] { organizationsByName.set(role.actor_name, role); } } + const unanchoredCounts = new Map(); + // Any member row can carry the resolved catalog id for its unanchored + // organization (ADR 0009/0010's resolution runs per-row); take it from + // whichever row has it rather than assuming the first-encountered one does. + const unanchoredCatalogIds = new Map(); + for (const role of sorted) { + const orgName = role.affiliated_organization_name; + if (orgName && !organizationsByName.has(orgName)) { + unanchoredCounts.set(orgName, (unanchoredCounts.get(orgName) ?? 0) + 1); + if (role.affiliated_organization_catalog_id && !unanchoredCatalogIds.has(orgName)) { + unanchoredCatalogIds.set(orgName, role.affiliated_organization_catalog_id); + } + } + } const nodesByRole = new Map(); for (const role of sorted) nodesByRole.set(role, { role, children: [] }); + const virtualAnchors = new Map(); const roots: RoleTreeNode[] = []; for (const role of sorted) { - const parent = role.affiliated_organization_name - ? organizationsByName.get(role.affiliated_organization_name) - : undefined; const node = nodesByRole.get(role) as RoleTreeNode; - if (parent && parent !== role) { - (nodesByRole.get(parent) as RoleTreeNode).children.push(node); - } else { - roots.push(node); + const orgName = role.affiliated_organization_name; + const realParent = orgName ? organizationsByName.get(orgName) : undefined; + if (realParent && realParent !== role) { + (nodesByRole.get(realParent) as RoleTreeNode).children.push(node); + continue; + } + if (orgName && (unanchoredCounts.get(orgName) ?? 0) >= 2) { + let anchor = virtualAnchors.get(orgName); + if (!anchor) { + anchor = { + role: null, + organizationName: orgName, + organizationCatalogId: unanchoredCatalogIds.get(orgName) ?? null, + children: [], + }; + virtualAnchors.set(orgName, anchor); + roots.push(anchor); + } + anchor.children.push(node); + continue; } + roots.push(node); } return roots; } @@ -2359,6 +2401,45 @@ function PostDetailPopup({
      {(() => { function renderRoleNode(node: RoleTreeNode, isChild: boolean): ReactNode { + if (node.role === null) { + const orgCatalogId = node.organizationCatalogId; + return ( +
    • + + {t("Organization")} + {" "} + {orgCatalogId ? ( + + ) : ( + {node.organizationName} + )} + {node.children.length > 0 ? ( +
        + {node.children.map((child) => renderRoleNode(child, true))} +
      + ) : null} +
    • + ); + } const rr = node.role; const isPerson = rr.actor_type_code === "prov_person"; const actorTypeLabel = t( @@ -2448,6 +2529,7 @@ function PostDetailPopup({ actorTypeCode={rr.actor_type_code} actorTypeLabel={actorTypeLabel} responsibility={rr.responsibility} + jobTitle={rr.job_title} // A row nested under its affiliated org's
    • // already shows that relationship structurally // -- repeating "· 소속: X" next to it would be diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 3b11e5d7a..c550a79c7 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -200,6 +200,7 @@ export interface PostRoleResponsibility { responsibility: string; actor_type_code: string; affiliated_organization_name: string | null; + job_title?: string | null; catalog_node_id?: string | null; catalog_node_type_code?: string | null; affiliated_organization_catalog_id?: string | null; diff --git a/frontend/src/components/RoleEvidence.tsx b/frontend/src/components/RoleEvidence.tsx index 405fb8958..8ede086af 100644 --- a/frontend/src/components/RoleEvidence.tsx +++ b/frontend/src/components/RoleEvidence.tsx @@ -7,6 +7,7 @@ export type RoleEvidenceProps = { actorTypeCode: string; actorTypeLabel: string; responsibility: string; + jobTitle?: string | null; affiliationName: string | null; affiliationCatalogId?: string | null; affiliationLabel: string; @@ -26,6 +27,7 @@ export function RoleEvidence({ actorTypeCode, actorTypeLabel, responsibility, + jobTitle, affiliationName, affiliationCatalogId, affiliationLabel, @@ -42,6 +44,7 @@ export function RoleEvidence({
    • {actorTypeLabel}{" "} {actorContent} + {jobTitle ? {` (${jobTitle})`} : null} {affiliationName ? ( {` · ${affiliationLabel}: `} diff --git a/lineageweave/keyman_extraction.py b/lineageweave/keyman_extraction.py index c9afd9057..39b69d286 100644 --- a/lineageweave/keyman_extraction.py +++ b/lineageweave/keyman_extraction.py @@ -178,8 +178,16 @@ class ContextualOrchestratorKeymanExtractionClient: available = True + # 2026-08-22 live finding: ``mode="auto"`` can route to deep multi-agent + # orchestration (Fugu/Conductor/TRINITY test-time compute allocation -- + # see AGENTS.md's paper-grounded model policy), which legitimately runs + # past 180s for a long post body. Orchestrator logs showed the request + # actually completed and then hit BrokenPipeError trying to write the + # response, because this client had already closed the socket on + # timeout. Accuracy, not latency, is the requirement here (a real user + # click, not a hot path), so the timeout is generous rather than tight. def __init__( - self, base_url: str, api_key: str, *, reasoning_effort: str = "auto", timeout: float = 180.0 + self, base_url: str, api_key: str, *, reasoning_effort: str = "auto", timeout: float = 900.0 ) -> None: self._base_url = base_url.rstrip("/") self._api_key = api_key diff --git a/lineageweave/post_summary.py b/lineageweave/post_summary.py index dbfac6357..766deb749 100644 --- a/lineageweave/post_summary.py +++ b/lineageweave/post_summary.py @@ -218,7 +218,7 @@ ) # Stored rows without this contract version are legacy summaries and must be # regenerated from the current source body before the popup treats them as evidence. -POST_SUMMARY_CONTRACT_VERSION = 13 +POST_SUMMARY_CONTRACT_VERSION = 14 _GENERIC_TEAM_ACTOR_NAMES = frozenset( {"사업부", "부서", "팀", "business unit", "department", "division"} @@ -285,12 +285,17 @@ class RoleResponsibility: name already answers "which organization"). A team actor without this is an unplaced team -- the text should usually support it since a team is always someone's team. + job_title: a person actor's stated position or title (e.g. "PM", + "Sales Director"), kept separate from ``responsibility`` so a + title never stands in for a concrete responsibility. ``None`` + when the text states no title. """ actor_name: str responsibility: str actor_type_code: str = ACTOR_TYPE_PERSON affiliated_organization_name: str | None = None + job_title: str | None = None def __post_init__(self) -> None: if self.actor_type_code not in _VALID_ACTOR_TYPE_CODES: @@ -808,7 +813,7 @@ def summarize_with_hints( ROLES: -actor name | responsibility | person, organization, team, or software_agent | affiliation or NONE +actor name | responsibility | person, organization, team, or software_agent | affiliation or NONE | job title or NONE Column 1 is always the actor's own name (a person's name, an organization's name, a team's name, or a named software agent) -- never a role description or a category label @@ -823,6 +828,17 @@ def summarize_with_hints( several actors have concrete work, write one row per actor rather than merging them. +Column 5 is a person actor's stated position or title (e.g. PM, PRO, Sales +Director), exactly as the post names it, or NONE when the post states none. +A bare job title is not a responsibility: never write column 2 as a copy or +paraphrase of column 5. When a person's only source-grounded detail beyond +attendance is their stated title, ground column 2 in what the post says +happened in their presence instead -- what their side proposed, requested, +reviewed, or was told -- and keep the title itself only in column 5. If the +post gives no such grounded detail for that person at all, still write the +row (a stated name, organization, and title are themselves source-grounded +facts) rather than dropping them to CLUES, but do not invent an action. + Technical terms are not actors. Do not write a ROLES row for a material, product, process, method, equipment, acronym, or parenthetical expansion. An expansion in parentheses after a technical description is part of that @@ -841,9 +857,10 @@ def summarize_with_hints( -- an account name appearing only in the hints is not post evidence. Worked examples (fictional names, format only, not real post's content): -홍길동 | 견적 승인 검토 | person | Acme Electronics -Acme Renewables | 기술 세미나에서 제품 설명 | organization | NONE -설계팀 | 도면 검토 지원 | team | Acme Electronics +홍길동 | 견적 승인 검토 | person | Acme Electronics | NONE +Acme Renewables | 기술 세미나에서 제품 설명 | organization | NONE | NONE +설계팀 | 도면 검토 지원 | team | Acme Electronics | NONE +김민수 | Acme Renewables의 기술 지원 제안 청취 | person | Acme Electronics | PM PROJECTS: project name | canonical name | shortest supporting evidence | confidence from 0 to 1 @@ -1330,9 +1347,14 @@ def _parse_plain_summary_details( row.casefold() for row in ( "actor name | responsibility | person, organization, or team | affiliation or none", + "actor name | responsibility | person, organization, team, or software_agent | affiliation or none | job title or none", "홍길동 | 견적 승인 검토 | person | acme electronics", "acme renewables | 기술 세미나 참석 | organization | none", "설계팀 | 도면 검토 지원 | team | acme electronics", + "홍길동 | 견적 승인 검토 | person | acme electronics | none", + "acme renewables | 기술 세미나에서 제품 설명 | organization | none | none", + "설계팀 | 도면 검토 지원 | team | acme electronics | none", + "김민수 | acme renewables의 기술 지원 제안 청취 | person | acme electronics | pm", ) ) hallucinated_account_name = _hallucinated_account_name(context_hints) @@ -1341,12 +1363,15 @@ def _parse_plain_summary_details( row = raw_row.strip().lstrip("-* ").strip() if not row or row.casefold() in empty_values or row.casefold() in _template_echo_rows: continue - parts = [part.strip() for part in row.split("|", 3)] + parts = [part.strip() for part in row.split("|", 4)] + job_title_raw = "" if len(parts) == 3: actor_name, responsibility, affiliation = parts actor_type = "person" elif len(parts) == 4: actor_name, responsibility, actor_type, affiliation = parts + elif len(parts) == 5: + actor_name, responsibility, actor_type, affiliation, job_title_raw = parts else: continue if ( @@ -1391,6 +1416,9 @@ def _parse_plain_summary_details( affiliated_organization_name=( None if affiliation.casefold() in empty_values else affiliation ), + job_title=( + None if job_title_raw.casefold() in empty_values else job_title_raw + ), ) ) diff --git a/migrations/0131_role_job_title.sql b/migrations/0131_role_job_title.sql new file mode 100644 index 000000000..099491f5f --- /dev/null +++ b/migrations/0131_role_job_title.sql @@ -0,0 +1,12 @@ +begin; + +-- A person actor's stated title (PM, PRO, Sales Director, ...) is source +-- evidence distinct from what they did. Storing it only inside +-- responsibility_text left no way to render it separately, so the popup +-- showed a bare title standing in for a responsibility. +alter table post_summary_role add column if not exists job_title_text text; + +comment on column post_summary_role.job_title_text is + 'Source-stated position/title for a person actor, kept separate from responsibility_text so a title is never mistaken for a concrete responsibility.'; + +commit; diff --git a/tests/test_ingestion_transaction_contracts.py b/tests/test_ingestion_transaction_contracts.py index 339d95363..40ef14877 100644 --- a/tests/test_ingestion_transaction_contracts.py +++ b/tests/test_ingestion_transaction_contracts.py @@ -246,6 +246,7 @@ async def fetch(self, query: str, *args: Any) -> list[dict[str, Any]]: "responsibility_text": "도면 검토", "actor_type_code": ACTOR_TYPE_TEAM, "affiliated_organization_name": "Synthetic Energy", + "job_title_text": None, "cataloged_team_id": None, "cataloged_corporate_entity_id": None, "cataloged_person_id": None, @@ -559,6 +560,7 @@ async def fetch(self, query: str, *args: Any) -> list[dict[str, Any]]: "responsibility_text": "고객 측 수신", "actor_type_code": ACTOR_TYPE_PERSON, "affiliated_organization_name": "Northridge Grid", + "job_title_text": None, "cataloged_team_id": None, "cataloged_corporate_entity_id": None, "cataloged_person_id": person_id, diff --git a/tests/test_person_mention_projection.py b/tests/test_person_mention_projection.py index be4269a93..21ff09c9a 100644 --- a/tests/test_person_mention_projection.py +++ b/tests/test_person_mention_projection.py @@ -87,6 +87,9 @@ / "migrations" / "0130_source_commercial_context.sql" ) +_ROLE_JOB_TITLE_MIGRATION = ( + Path(__file__).resolve().parents[1] / "migrations" / "0131_role_job_title.sql" +) _SEMANTIC_SEARCH_MIGRATION = ( Path(__file__).resolve().parents[1] / "migrations" / "0032_semantic_search_trigram.sql" ) @@ -221,6 +224,7 @@ def projection_database() -> str: cursor.execute(_PROJECT_BOUND_ACTION_MIGRATION.read_text(encoding="utf-8")) cursor.execute(_PROJECT_BOUND_EVENT_MIGRATION.read_text(encoding="utf-8")) cursor.execute(_IDENTIFIER_MIGRATION.read_text(encoding="utf-8")) + cursor.execute(_ROLE_JOB_TITLE_MIGRATION.read_text(encoding="utf-8")) cursor.execute( """ insert into common_lookup_value