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({
{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