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
8 changes: 5 additions & 3 deletions backend/app/post_summary_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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": (
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion docker/postgres-init/migrate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
46 changes: 26 additions & 20 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <li> 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",
);
Expand Down
98 changes: 90 additions & 8 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}

Expand All @@ -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<string, PostRoleResponsibility>();
Expand All @@ -1840,19 +1853,48 @@ function buildRoleTree(roles: PostRoleResponsibility[]): RoleTreeNode[] {
organizationsByName.set(role.actor_name, role);
}
}
const unanchoredCounts = new Map<string, number>();
// 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<string, string>();
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);
}
}
Comment on lines +1856 to +1868

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: Anchor adopts first member's affiliated catalog id

buildRoleTree picks the first non-null affiliated_organization_catalog_id among an unanchored org's members (frontend/src/App.tsx:1865-1867). If per-row resolution assigns different catalog ids to the same org name, the shared anchor silently uses one. This matches the documented intent, but relies on resolution being consistent across rows.

Open in Devin Review

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

}
const nodesByRole = new Map<PostRoleResponsibility, RoleTreeNode>();
for (const role of sorted) nodesByRole.set(role, { role, children: [] });
const virtualAnchors = new Map<string, RoleTreeNode>();
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;
}
Expand Down Expand Up @@ -2359,6 +2401,45 @@ function PostDetailPopup({
<ul>
{(() => {
function renderRoleNode(node: RoleTreeNode, isChild: boolean): ReactNode {
if (node.role === null) {
const orgCatalogId = node.organizationCatalogId;
return (
<li
key={`org-anchor:${node.organizationName ?? ""}`}
className="ontology-role ontology-role-org-anchor"
>
<span className="actor-type-badge actor-type-prov_organization">
{t("Organization")}
</span>{" "}
{orgCatalogId ? (
<button
type="button"
className="keyman-select"
aria-label={tf("R&R organization: {name}", {
name: node.organizationName ?? "",
})}
onClick={() => {
setFocusPerson(null);
setFocusTeam(null);
setFocusEntity({
entityId: orgCatalogId,
entityName: node.organizationName ?? "",
});
}}
>
<strong>{node.organizationName}</strong>
</button>
) : (
<strong>{node.organizationName}</strong>
)}
{node.children.length > 0 ? (
<ul className="customer-master-tree-children">
{node.children.map((child) => renderRoleNode(child, true))}
</ul>
) : null}
</li>
);
}
const rr = node.role;
const isPerson = rr.actor_type_code === "prov_person";
const actorTypeLabel = t(
Expand Down Expand Up @@ -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 <li>
// already shows that relationship structurally
// -- repeating "· 소속: X" next to it would be
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/RoleEvidence.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type RoleEvidenceProps = {
actorTypeCode: string;
actorTypeLabel: string;
responsibility: string;
jobTitle?: string | null;
affiliationName: string | null;
affiliationCatalogId?: string | null;
affiliationLabel: string;
Expand All @@ -26,6 +27,7 @@ export function RoleEvidence({
actorTypeCode,
actorTypeLabel,
responsibility,
jobTitle,
affiliationName,
affiliationCatalogId,
affiliationLabel,
Expand All @@ -42,6 +44,7 @@ export function RoleEvidence({
<li className={genericTeam ? "ontology-role ontology-role-unresolved" : "ontology-role"}>
<span className={`actor-type-badge actor-type-${actorTypeCode}`}>{actorTypeLabel}</span>{" "}
{actorContent}
{jobTitle ? <span className="rr-job-title">{` (${jobTitle})`}</span> : null}
{affiliationName ? (
<span className="rr-affiliation">
{` · ${affiliationLabel}: `}
Expand Down
10 changes: 9 additions & 1 deletion lineageweave/keyman_extraction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

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: 15-minute client timeout depends on upstream limits

The client timeout rises 180s->900s (lineageweave/keyman_extraction.py:190). A user-clicked extraction can now hold one HTTP call open for 15 minutes. Any intervening proxy or gateway with a shorter timeout would still cut the connection first, so the real ceiling is the smallest upstream limit.

Open in Devin Review

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

) -> None:
self._base_url = base_url.rstrip("/")
self._api_key = api_key
Expand Down
Loading