Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
## [Unreleased]
- **Performance (Bolt)**: `NetworkGraph` 컴포넌트의 세 옵션 생성 경로를 bounded `for...of` 순회로 바꿔 제품 상한에서 조기에 멈추고 불필요한 중간 컬렉션 생성을 줄였습니다.
- 긴 이메일·첨부 본문을 의미 단위 청크로 임베딩한 뒤 기존 email/attachment 벡터 계약으로 평균화하고, 청크 요청·벡터 누적을 제한된 창으로 처리합니다. OpenAI `text-embedding-3-*`에는 저장 차원(`1536`)을 직접 요청하도록 보강했습니다. 합성 메일 fixture 5건(70청크)과 provider 요청 계약으로 1,536차원 벡터 경로를 검증했으며, 실행 시 선택한 임베딩 제공자에 본문·파싱된 첨부 텍스트를 전송할 수 있습니다. 회사 기밀 데이터는 fixture·commit·PR·log에 포함하지 않습니다.
- EmailDetail 테스트가 지원하지 않는 스레드 병합/분리 버튼을 `textContent`뿐 아니라 `aria-label`과 `title` 접근 가능 이름으로도 검출하도록 바꿔, 아이콘 전용 버튼 회귀를 놓치지 않습니다.

Expand Down
29 changes: 29 additions & 0 deletions frontend/src/components/NetworkGraph.map-lookup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,33 @@ describe("NetworkGraph constant-time selection lookup contract", () => {
expect(networkGraphSource).toContain("firstGraphEntryById(nodes");
expect(networkGraphSource).not.toMatch(/new Map\((edges|nodes)\.map\(/);
});

it("bounds option construction before traversing complete graph collections", () => {
const nodeLabels = sourceBetween("const nodeLabels = useMemo", "const firstEdge =");
const relationshipOptions = sourceBetween(
"const relationshipOptions = useMemo",
"const nodeOptions = useMemo",
);
const nodeOptions = sourceBetween(
"const nodeOptions = useMemo",
"const selectRelationship =",
);

expect(nodeLabels).toContain("for (const node of nodes)");
expect(nodeLabels).toContain("if (result.length >= 5) break;");
expect(nodeLabels).not.toContain(".map(");
expect(nodeLabels).not.toContain(".filter(");

expect(relationshipOptions).toContain("for (const edge of edgeMap.values())");
expect(relationshipOptions).toContain("if (result.length >= 5) break;");
expect(relationshipOptions).not.toContain(".map(");
expect(relationshipOptions).not.toContain(".filter(");
expect(relationshipOptions).not.toContain("Array.from(");

expect(nodeOptions).toContain("for (const node of nodeInstanceMap.values())");
expect(nodeOptions).toContain("if (result.length >= 8) break;");
expect(nodeOptions).not.toContain(".map(");
expect(nodeOptions).not.toContain(".filter(");
expect(nodeOptions).not.toContain("Array.from(");
Comment thread
seonghobae marked this conversation as resolved.
});
});
45 changes: 31 additions & 14 deletions frontend/src/components/NetworkGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -278,27 +278,44 @@ export default function NetworkGraph() {
}, [nodes, edges, nodeMap, edgeMap]);

const nodeLabels = useMemo(() => {
return nodes
.map((node) => String(node.label ?? node.id))
.filter(Boolean)
.slice(0, 5);
const result: string[] = [];
for (const node of nodes) {
const label = String(node.label ?? node.id);
if (label) {
result.push(label);
if (result.length >= 5) break;
}
}
return result;
}, [nodes]);

const firstEdge = edges[0] ?? null;
const relationshipOptions = useMemo(() => {
return Array.from(edgeMap.values()).slice(0, 5).map((edge, index) => ({
edge,
id: String(edge.id),
label: `관계 ${index + 1}: ${describeEdge(edge, nodeMap)}`,
}));
const result = [];
let index = 0;
for (const edge of edgeMap.values()) {
result.push({
edge,
id: String(edge.id),
label: `관계 ${index + 1}: ${describeEdge(edge, nodeMap)}`,
});
index++;
if (result.length >= 5) break;
}
return result;
}, [edgeMap, nodeMap]);

const nodeOptions = useMemo(() => {
return Array.from(nodeInstanceMap.values()).slice(0, 8).map((node) => ({
id: String(node.id),
label: `노드: ${String(node.label ?? node.id)}`,
node,
}));
const result = [];
for (const node of nodeInstanceMap.values()) {
result.push({
id: String(node.id),
label: `노드: ${String(node.label ?? node.id)}`,
node,
});
if (result.length >= 8) break;
}
return result;
}, [nodeInstanceMap]);

const selectRelationship = (edge: Edge, status: string) => {
Expand Down