@chigichan24/crune 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -5
- package/dist-cli/__tests__/facets-reader.test.js +180 -0
- package/dist-cli/__tests__/merge-narrow-clusters.test.js +171 -0
- package/dist-cli/__tests__/reusability-scoring.test.js +175 -0
- package/dist-cli/analyze-sessions.js +57 -13
- package/dist-cli/cli.js +2 -2
- package/dist-cli/knowledge-graph/clustering.js +108 -0
- package/dist-cli/knowledge-graph/facets-reader.js +188 -0
- package/dist-cli/knowledge-graph/index.js +22 -7
- package/dist-cli/knowledge-graph/reusability.js +42 -6
- package/dist-cli/knowledge-graph/topic-nodes.js +27 -4
- package/dist-cli/knowledge-graph-builder.js +4 -2
- package/dist-cli/session-parser.js +19 -0
- package/dist-cli/skill-server.js +2 -2
- package/dist-cli/skill-synthesizer.js +38 -3
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ Decipher the traces etched in past sessions and resurrect them as reusable skill
|
|
|
17
17
|
- **Overview Dashboard** --- Activity heatmap, project distribution, tool usage trends, duration distribution, model usage, and top edited files
|
|
18
18
|
- **Semantic Knowledge Graph** --- TF-IDF + Tool-IDF + structural features, Truncated SVD, agglomerative clustering, Louvain community detection, Brandes centrality ([algorithm details](docs/knowledge-graph-algorithm.md))
|
|
19
19
|
- **Tacit Knowledge** --- Extracted workflow patterns, tool sequences, and pain points (long sessions, hot files)
|
|
20
|
-
- **Session Summarization** --- Automatic session summary and classification (no LLM required)
|
|
20
|
+
- **Session Summarization** --- Automatic session summary and classification (no LLM required), enriched with `/insights` facets data when available
|
|
21
21
|
- **Skill Synthesis** --- Synthesize reusable skill definitions from the knowledge graph ([algorithm details](docs/skill-generation-algorithm.md))
|
|
22
22
|
|
|
23
23
|
### Overview Dashboard
|
|
@@ -77,8 +77,10 @@ npm run dev
|
|
|
77
77
|
-> parse & build turns
|
|
78
78
|
-> extract metadata, subagents, linked plans
|
|
79
79
|
-> session summarization (centrality-based representative prompt, workType classification)
|
|
80
|
-
-> TF-IDF + Tool-IDF + structural features -> Truncated SVD -> agglomerative clustering
|
|
81
|
-
->
|
|
80
|
+
-> TF-IDF + Tool-IDF + structural features -> Truncated SVD -> agglomerative clustering
|
|
81
|
+
-> /insights facets integration (narrow cluster merging, goal-based labeling)
|
|
82
|
+
-> Louvain community detection
|
|
83
|
+
-> skill synthesis (reusability score top-N -> claude -p, enriched with facets insights)
|
|
82
84
|
-> output:
|
|
83
85
|
public/data/sessions/index.json (session list)
|
|
84
86
|
public/data/sessions/overview.json (cross-session analytics + knowledge graph)
|
|
@@ -91,9 +93,20 @@ Custom paths:
|
|
|
91
93
|
npm run analyze-sessions -- --sessions-dir /path/to/sessions --output-dir /path/to/output
|
|
92
94
|
```
|
|
93
95
|
|
|
96
|
+
### /insights Integration
|
|
97
|
+
|
|
98
|
+
Before analysis, `analyze-sessions` automatically runs `/insights` to refresh facets data. This enriches the pipeline with:
|
|
99
|
+
- **Better topic labels** using LLM-generated `underlying_goal` instead of TF-IDF keywords
|
|
100
|
+
- **Narrow cluster merging** based on shared goal categories
|
|
101
|
+
- **Quality-aware reusability scoring** using session outcome and helpfulness
|
|
102
|
+
- **Richer synthesis prompts** with friction details and success rates
|
|
103
|
+
- **Session summaries** using `brief_summary` from facets
|
|
104
|
+
|
|
105
|
+
Skip with `--skip-facets` or customize the facets path with `--facets-dir <path>`.
|
|
106
|
+
|
|
94
107
|
## Session Summarization
|
|
95
108
|
|
|
96
|
-
The session list displays auto-generated summaries, processed
|
|
109
|
+
The session list displays auto-generated summaries. When `/insights` facets data is available, the LLM-generated `brief_summary` is used. Otherwise, summaries are processed locally without LLM.
|
|
97
110
|
|
|
98
111
|
- **Representative prompt selection**: Selects the most representative prompt from plan mode turns using Jaccard centrality with position weighting
|
|
99
112
|
- **workType classification**: Automatically classifies each session into one of four types:
|
|
@@ -138,13 +151,15 @@ npm run analyze-sessions -- --skip-synthesis
|
|
|
138
151
|
| `npm run skill-server` | Skill synthesis local server (localhost:3456) |
|
|
139
152
|
| `npm run dev:full` | skill-server + Vite dev server together |
|
|
140
153
|
|
|
141
|
-
###
|
|
154
|
+
### Options for analyze-sessions
|
|
142
155
|
|
|
143
156
|
| Flag | Description |
|
|
144
157
|
|------|-------------|
|
|
145
158
|
| `--synthesis-model <model>` | Model to use for synthesis (e.g. `haiku` for speed) |
|
|
146
159
|
| `--synthesis-count <n>` | Number of candidates to synthesize (default: 5) |
|
|
147
160
|
| `--skip-synthesis` | Skip LLM synthesis |
|
|
161
|
+
| `--facets-dir <path>` | Custom facets directory (default: `~/.claude/usage-data/facets`) |
|
|
162
|
+
| `--skip-facets` | Skip `/insights` refresh and facets integration |
|
|
148
163
|
|
|
149
164
|
## Tech Stack
|
|
150
165
|
|
|
@@ -172,6 +187,8 @@ scripts/
|
|
|
172
187
|
skill-synthesizer.ts # Skill synthesis (claude -p)
|
|
173
188
|
skill-server.ts # Synthesis HTTP server
|
|
174
189
|
knowledge-graph-builder.ts # Semantic embedding + graph construction
|
|
190
|
+
knowledge-graph/
|
|
191
|
+
facets-reader.ts # /insights facets data reader + normalization
|
|
175
192
|
public/
|
|
176
193
|
data/sessions/ # Generated JSON (gitignored)
|
|
177
194
|
```
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { normalizeGoalCategory, helpfulnessToScore, aggregateFacetsForTopic, } from "../knowledge-graph-builder.js";
|
|
3
|
+
describe("normalizeGoalCategory", () => {
|
|
4
|
+
it("maps feature_implementation → feature", () => {
|
|
5
|
+
expect(normalizeGoalCategory("feature_implementation")).toBe("feature");
|
|
6
|
+
});
|
|
7
|
+
it("maps fix_build_errors → bugfix", () => {
|
|
8
|
+
expect(normalizeGoalCategory("fix_build_errors")).toBe("bugfix");
|
|
9
|
+
});
|
|
10
|
+
it("maps debugging → bugfix", () => {
|
|
11
|
+
expect(normalizeGoalCategory("debugging")).toBe("bugfix");
|
|
12
|
+
});
|
|
13
|
+
it("maps refactoring → refactoring", () => {
|
|
14
|
+
expect(normalizeGoalCategory("refactoring")).toBe("refactoring");
|
|
15
|
+
});
|
|
16
|
+
it("maps code_cleanup → refactoring", () => {
|
|
17
|
+
expect(normalizeGoalCategory("code_cleanup")).toBe("refactoring");
|
|
18
|
+
});
|
|
19
|
+
it("maps documentation_update → documentation", () => {
|
|
20
|
+
expect(normalizeGoalCategory("documentation_update")).toBe("documentation");
|
|
21
|
+
});
|
|
22
|
+
it("maps code_review → review", () => {
|
|
23
|
+
expect(normalizeGoalCategory("code_review")).toBe("review");
|
|
24
|
+
});
|
|
25
|
+
it("maps test_writing → testing", () => {
|
|
26
|
+
expect(normalizeGoalCategory("test_writing")).toBe("testing");
|
|
27
|
+
});
|
|
28
|
+
it("maps ci_setup → ci", () => {
|
|
29
|
+
expect(normalizeGoalCategory("ci_setup")).toBe("ci");
|
|
30
|
+
});
|
|
31
|
+
it("maps git_operations → git_ops", () => {
|
|
32
|
+
expect(normalizeGoalCategory("git_operations")).toBe("git_ops");
|
|
33
|
+
});
|
|
34
|
+
it("maps create_pr → git_ops", () => {
|
|
35
|
+
expect(normalizeGoalCategory("create_pr")).toBe("git_ops");
|
|
36
|
+
});
|
|
37
|
+
it("maps pr_creation → git_ops", () => {
|
|
38
|
+
expect(normalizeGoalCategory("pr_creation")).toBe("git_ops");
|
|
39
|
+
});
|
|
40
|
+
it("maps setup_deployment → setup", () => {
|
|
41
|
+
expect(normalizeGoalCategory("setup_deployment")).toBe("setup");
|
|
42
|
+
});
|
|
43
|
+
it("maps npm_publishing_guidance → setup", () => {
|
|
44
|
+
expect(normalizeGoalCategory("npm_publishing_guidance")).toBe("setup");
|
|
45
|
+
});
|
|
46
|
+
it("maps unknown_category → other", () => {
|
|
47
|
+
expect(normalizeGoalCategory("unknown_category")).toBe("other");
|
|
48
|
+
});
|
|
49
|
+
it("maps quick_question → other", () => {
|
|
50
|
+
expect(normalizeGoalCategory("quick_question")).toBe("other");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe("helpfulnessToScore", () => {
|
|
54
|
+
it("returns 1.0 for essential", () => {
|
|
55
|
+
expect(helpfulnessToScore("essential")).toBe(1.0);
|
|
56
|
+
});
|
|
57
|
+
it("returns 0.8 for very_helpful", () => {
|
|
58
|
+
expect(helpfulnessToScore("very_helpful")).toBe(0.8);
|
|
59
|
+
});
|
|
60
|
+
it("returns 0.5 for moderately_helpful", () => {
|
|
61
|
+
expect(helpfulnessToScore("moderately_helpful")).toBe(0.5);
|
|
62
|
+
});
|
|
63
|
+
it("returns 0.25 for slightly_helpful", () => {
|
|
64
|
+
expect(helpfulnessToScore("slightly_helpful")).toBe(0.25);
|
|
65
|
+
});
|
|
66
|
+
it("returns 0.0 for unhelpful", () => {
|
|
67
|
+
expect(helpfulnessToScore("unhelpful")).toBe(0.0);
|
|
68
|
+
});
|
|
69
|
+
it("returns 0.5 for unknown string", () => {
|
|
70
|
+
expect(helpfulnessToScore("something_else")).toBe(0.5);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
describe("aggregateFacetsForTopic", () => {
|
|
74
|
+
function makeFacets(overrides = {}) {
|
|
75
|
+
return {
|
|
76
|
+
sessionId: "s1",
|
|
77
|
+
underlyingGoal: "Implement feature X",
|
|
78
|
+
goalCategories: { feature_implementation: 1 },
|
|
79
|
+
outcome: "fully_achieved",
|
|
80
|
+
claudeHelpfulness: "very_helpful",
|
|
81
|
+
sessionType: "implementation",
|
|
82
|
+
frictionCounts: {},
|
|
83
|
+
frictionDetail: "",
|
|
84
|
+
primarySuccess: "Feature works",
|
|
85
|
+
briefSummary: "Implemented feature X",
|
|
86
|
+
...overrides,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
it("returns undefined when no sessions have facets", () => {
|
|
90
|
+
const facetsMap = new Map();
|
|
91
|
+
const result = aggregateFacetsForTopic(["s1", "s2"], facetsMap);
|
|
92
|
+
expect(result).toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
it("aggregates goals from multiple sessions", () => {
|
|
95
|
+
const facetsMap = new Map([
|
|
96
|
+
["s1", makeFacets({ sessionId: "s1", underlyingGoal: "Goal A" })],
|
|
97
|
+
["s2", makeFacets({ sessionId: "s2", underlyingGoal: "Goal B" })],
|
|
98
|
+
["s3", makeFacets({ sessionId: "s3", underlyingGoal: "Goal C" })],
|
|
99
|
+
["s4", makeFacets({ sessionId: "s4", underlyingGoal: "Goal D" })],
|
|
100
|
+
]);
|
|
101
|
+
const result = aggregateFacetsForTopic(["s1", "s2", "s3", "s4"], facetsMap);
|
|
102
|
+
expect(result).toBeDefined();
|
|
103
|
+
// max 3 goals
|
|
104
|
+
expect(result.aggregatedGoals).toHaveLength(3);
|
|
105
|
+
expect(result.aggregatedGoals).toContain("Goal A");
|
|
106
|
+
expect(result.aggregatedGoals).toContain("Goal B");
|
|
107
|
+
expect(result.aggregatedGoals).toContain("Goal C");
|
|
108
|
+
});
|
|
109
|
+
it("computes successRate correctly with mix of achieved and not", () => {
|
|
110
|
+
const facetsMap = new Map([
|
|
111
|
+
["s1", makeFacets({ sessionId: "s1", outcome: "fully_achieved" })],
|
|
112
|
+
["s2", makeFacets({ sessionId: "s2", outcome: "mostly_achieved" })],
|
|
113
|
+
["s3", makeFacets({ sessionId: "s3", outcome: "not_achieved" })],
|
|
114
|
+
["s4", makeFacets({ sessionId: "s4", outcome: "partially_achieved" })],
|
|
115
|
+
]);
|
|
116
|
+
const result = aggregateFacetsForTopic(["s1", "s2", "s3", "s4"], facetsMap);
|
|
117
|
+
expect(result).toBeDefined();
|
|
118
|
+
// 2 out of 4 achieved
|
|
119
|
+
expect(result.successRate).toBe(0.5);
|
|
120
|
+
});
|
|
121
|
+
it("computes helpfulnessScore correctly", () => {
|
|
122
|
+
const facetsMap = new Map([
|
|
123
|
+
[
|
|
124
|
+
"s1",
|
|
125
|
+
makeFacets({ sessionId: "s1", claudeHelpfulness: "essential" }),
|
|
126
|
+
],
|
|
127
|
+
[
|
|
128
|
+
"s2",
|
|
129
|
+
makeFacets({ sessionId: "s2", claudeHelpfulness: "unhelpful" }),
|
|
130
|
+
],
|
|
131
|
+
]);
|
|
132
|
+
const result = aggregateFacetsForTopic(["s1", "s2"], facetsMap);
|
|
133
|
+
expect(result).toBeDefined();
|
|
134
|
+
// (1.0 + 0.0) / 2 = 0.5
|
|
135
|
+
expect(result.helpfulnessScore).toBe(0.5);
|
|
136
|
+
});
|
|
137
|
+
it("merges and sorts friction counts", () => {
|
|
138
|
+
const facetsMap = new Map([
|
|
139
|
+
[
|
|
140
|
+
"s1",
|
|
141
|
+
makeFacets({
|
|
142
|
+
sessionId: "s1",
|
|
143
|
+
frictionCounts: { unclear_spec: 3, slow_response: 1 },
|
|
144
|
+
}),
|
|
145
|
+
],
|
|
146
|
+
[
|
|
147
|
+
"s2",
|
|
148
|
+
makeFacets({
|
|
149
|
+
sessionId: "s2",
|
|
150
|
+
frictionCounts: { unclear_spec: 2, wrong_tool: 5 },
|
|
151
|
+
}),
|
|
152
|
+
],
|
|
153
|
+
]);
|
|
154
|
+
const result = aggregateFacetsForTopic(["s1", "s2"], facetsMap);
|
|
155
|
+
expect(result).toBeDefined();
|
|
156
|
+
// unclear_spec: 5, wrong_tool: 5, slow_response: 1
|
|
157
|
+
expect(result.commonFrictions[0]).toBe("unclear_spec");
|
|
158
|
+
expect(result.commonFrictions).toContain("wrong_tool");
|
|
159
|
+
expect(result.commonFrictions).toContain("slow_response");
|
|
160
|
+
});
|
|
161
|
+
it("handles sessions without facets with neutral 0.5 for scores", () => {
|
|
162
|
+
const facetsMap = new Map([
|
|
163
|
+
[
|
|
164
|
+
"s1",
|
|
165
|
+
makeFacets({
|
|
166
|
+
sessionId: "s1",
|
|
167
|
+
outcome: "fully_achieved",
|
|
168
|
+
claudeHelpfulness: "essential",
|
|
169
|
+
}),
|
|
170
|
+
],
|
|
171
|
+
]);
|
|
172
|
+
// s2 has no facets — should get 0.5 for both scores
|
|
173
|
+
const result = aggregateFacetsForTopic(["s1", "s2"], facetsMap);
|
|
174
|
+
expect(result).toBeDefined();
|
|
175
|
+
// successRate: (1.0 + 0.5) / 2 = 0.75
|
|
176
|
+
expect(result.successRate).toBe(0.75);
|
|
177
|
+
// helpfulnessScore: (1.0 + 0.5) / 2 = 0.75
|
|
178
|
+
expect(result.helpfulnessScore).toBe(0.75);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mergeNarrowClusters } from "../knowledge-graph-builder.js";
|
|
3
|
+
// Helper: build a FacetsData object
|
|
4
|
+
function makeFacets(sessionId, goalCategories, outcome = "fully_achieved") {
|
|
5
|
+
return {
|
|
6
|
+
sessionId,
|
|
7
|
+
underlyingGoal: "test goal",
|
|
8
|
+
goalCategories,
|
|
9
|
+
outcome,
|
|
10
|
+
claudeHelpfulness: "essential",
|
|
11
|
+
sessionType: "single_task",
|
|
12
|
+
frictionCounts: {},
|
|
13
|
+
frictionDetail: "",
|
|
14
|
+
primarySuccess: "multi_file_changes",
|
|
15
|
+
briefSummary: "test summary",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
// Helper: build a distance map with key format `${min}:${max}`
|
|
19
|
+
function buildDistMap(entries) {
|
|
20
|
+
const m = new Map();
|
|
21
|
+
for (const [i, j, d] of entries) {
|
|
22
|
+
const lo = Math.min(i, j);
|
|
23
|
+
const hi = Math.max(i, j);
|
|
24
|
+
m.set(`${lo}:${hi}`, d);
|
|
25
|
+
}
|
|
26
|
+
return m;
|
|
27
|
+
}
|
|
28
|
+
// ─── mergeNarrowClusters ────────────────────────────────────────────────────
|
|
29
|
+
describe("mergeNarrowClusters", () => {
|
|
30
|
+
it("returns unchanged when no narrow clusters exist", () => {
|
|
31
|
+
// All clusters have > maxNarrowSize (2) members
|
|
32
|
+
const clusters = [
|
|
33
|
+
[0, 1, 2],
|
|
34
|
+
[3, 4, 5],
|
|
35
|
+
];
|
|
36
|
+
const sessionIds = ["s0", "s1", "s2", "s3", "s4", "s5"];
|
|
37
|
+
const facetsMap = new Map();
|
|
38
|
+
const dist = buildDistMap([
|
|
39
|
+
[0, 1, 0.1], [0, 2, 0.1], [1, 2, 0.1],
|
|
40
|
+
[3, 4, 0.1], [3, 5, 0.1], [4, 5, 0.1],
|
|
41
|
+
[0, 3, 0.9], [0, 4, 0.9], [0, 5, 0.9],
|
|
42
|
+
[1, 3, 0.9], [1, 4, 0.9], [1, 5, 0.9],
|
|
43
|
+
[2, 3, 0.9], [2, 4, 0.9], [2, 5, 0.9],
|
|
44
|
+
]);
|
|
45
|
+
const result = mergeNarrowClusters(clusters, sessionIds, facetsMap, dist);
|
|
46
|
+
expect(result).toEqual(clusters);
|
|
47
|
+
});
|
|
48
|
+
it("returns unchanged when narrow clusters have no facets data", () => {
|
|
49
|
+
const clusters = [[0], [1]];
|
|
50
|
+
const sessionIds = ["s0", "s1"];
|
|
51
|
+
const facetsMap = new Map(); // empty
|
|
52
|
+
const dist = buildDistMap([[0, 1, 0.3]]);
|
|
53
|
+
const result = mergeNarrowClusters(clusters, sessionIds, facetsMap, dist);
|
|
54
|
+
// Both clusters survive unmerged (no facets to match on)
|
|
55
|
+
expect(result.length).toBe(2);
|
|
56
|
+
});
|
|
57
|
+
it("merges two narrow clusters with shared goal categories and close distance", () => {
|
|
58
|
+
const clusters = [[0], [1]];
|
|
59
|
+
const sessionIds = ["s0", "s1"];
|
|
60
|
+
const facetsMap = new Map([
|
|
61
|
+
["s0", makeFacets("s0", { feature_add_button: 1 })],
|
|
62
|
+
["s1", makeFacets("s1", { feature_update_header: 1 })],
|
|
63
|
+
]);
|
|
64
|
+
// Both normalize to "feature"
|
|
65
|
+
const dist = buildDistMap([[0, 1, 0.3]]);
|
|
66
|
+
const result = mergeNarrowClusters(clusters, sessionIds, facetsMap, dist);
|
|
67
|
+
expect(result.length).toBe(1);
|
|
68
|
+
expect([...result[0]].sort()).toEqual([0, 1]);
|
|
69
|
+
});
|
|
70
|
+
it("does not merge when goal categories differ", () => {
|
|
71
|
+
const clusters = [[0], [1]];
|
|
72
|
+
const sessionIds = ["s0", "s1"];
|
|
73
|
+
const facetsMap = new Map([
|
|
74
|
+
["s0", makeFacets("s0", { feature_add_button: 1 })],
|
|
75
|
+
["s1", makeFacets("s1", { fix_bug_crash: 1 })],
|
|
76
|
+
]);
|
|
77
|
+
// "feature" vs "bugfix" - no overlap
|
|
78
|
+
const dist = buildDistMap([[0, 1, 0.3]]);
|
|
79
|
+
const result = mergeNarrowClusters(clusters, sessionIds, facetsMap, dist);
|
|
80
|
+
expect(result.length).toBe(2);
|
|
81
|
+
});
|
|
82
|
+
it("does not merge when distance exceeds threshold", () => {
|
|
83
|
+
const clusters = [[0], [1]];
|
|
84
|
+
const sessionIds = ["s0", "s1"];
|
|
85
|
+
const facetsMap = new Map([
|
|
86
|
+
["s0", makeFacets("s0", { feature_add_button: 1 })],
|
|
87
|
+
["s1", makeFacets("s1", { feature_update_header: 1 })],
|
|
88
|
+
]);
|
|
89
|
+
// Shared category "feature" but distance too far
|
|
90
|
+
const dist = buildDistMap([[0, 1, 0.9]]);
|
|
91
|
+
const result = mergeNarrowClusters(clusters, sessionIds, facetsMap, dist, 2, // maxNarrowSize
|
|
92
|
+
0.7);
|
|
93
|
+
expect(result.length).toBe(2);
|
|
94
|
+
});
|
|
95
|
+
it("respects maxMergedSize limit", () => {
|
|
96
|
+
// 3 narrow clusters of size 2 each; maxMergedSize = 3
|
|
97
|
+
const clusters = [[0, 1], [2, 3], [4, 5]];
|
|
98
|
+
const sessionIds = ["s0", "s1", "s2", "s3", "s4", "s5"];
|
|
99
|
+
const facetsMap = new Map([
|
|
100
|
+
["s0", makeFacets("s0", { feature_x: 1 })],
|
|
101
|
+
["s1", makeFacets("s1", { feature_y: 1 })],
|
|
102
|
+
["s2", makeFacets("s2", { feature_z: 1 })],
|
|
103
|
+
["s3", makeFacets("s3", { feature_w: 1 })],
|
|
104
|
+
["s4", makeFacets("s4", { feature_v: 1 })],
|
|
105
|
+
["s5", makeFacets("s5", { feature_u: 1 })],
|
|
106
|
+
]);
|
|
107
|
+
// All close distances
|
|
108
|
+
const entries = [];
|
|
109
|
+
for (let i = 0; i < 6; i++) {
|
|
110
|
+
for (let j = i + 1; j < 6; j++) {
|
|
111
|
+
entries.push([i, j, 0.2]);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const dist = buildDistMap(entries);
|
|
115
|
+
// maxMergedSize = 3 means first merge (2+2=4) already exceeds limit
|
|
116
|
+
const result = mergeNarrowClusters(clusters, sessionIds, facetsMap, dist, 2, // maxNarrowSize
|
|
117
|
+
0.7, // distanceThreshold
|
|
118
|
+
3);
|
|
119
|
+
// No merges possible since 2+2=4 > 3
|
|
120
|
+
expect(result.length).toBe(3);
|
|
121
|
+
});
|
|
122
|
+
it("handles chain merges: A+B then (A+B)+C", () => {
|
|
123
|
+
// Three singleton narrow clusters, all share "feature" category and are close
|
|
124
|
+
const clusters = [[0], [1], [2]];
|
|
125
|
+
const sessionIds = ["s0", "s1", "s2"];
|
|
126
|
+
const facetsMap = new Map([
|
|
127
|
+
["s0", makeFacets("s0", { feature_a: 1 })],
|
|
128
|
+
["s1", makeFacets("s1", { feature_b: 1 })],
|
|
129
|
+
["s2", makeFacets("s2", { feature_c: 1 })],
|
|
130
|
+
]);
|
|
131
|
+
const dist = buildDistMap([
|
|
132
|
+
[0, 1, 0.2],
|
|
133
|
+
[0, 2, 0.3],
|
|
134
|
+
[1, 2, 0.25],
|
|
135
|
+
]);
|
|
136
|
+
const result = mergeNarrowClusters(clusters, sessionIds, facetsMap, dist, 2, // maxNarrowSize
|
|
137
|
+
0.7, // distanceThreshold
|
|
138
|
+
8);
|
|
139
|
+
// All three should be merged into one cluster
|
|
140
|
+
expect(result.length).toBe(1);
|
|
141
|
+
expect([...result[0]].sort()).toEqual([0, 1, 2]);
|
|
142
|
+
});
|
|
143
|
+
it("leaves large clusters untouched and only merges narrow ones", () => {
|
|
144
|
+
// One large cluster [0,1,2], two narrow singletons [3], [4]
|
|
145
|
+
const clusters = [[0, 1, 2], [3], [4]];
|
|
146
|
+
const sessionIds = ["s0", "s1", "s2", "s3", "s4"];
|
|
147
|
+
const facetsMap = new Map([
|
|
148
|
+
["s3", makeFacets("s3", { fix_bug_a: 1 })],
|
|
149
|
+
["s4", makeFacets("s4", { fix_bug_b: 1 })],
|
|
150
|
+
]);
|
|
151
|
+
// Narrow clusters are close
|
|
152
|
+
const entries = [];
|
|
153
|
+
for (let i = 0; i < 5; i++) {
|
|
154
|
+
for (let j = i + 1; j < 5; j++) {
|
|
155
|
+
entries.push([i, j, i < 3 && j < 3 ? 0.1 : 0.3]);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const dist = buildDistMap(entries);
|
|
159
|
+
const result = mergeNarrowClusters(clusters, sessionIds, facetsMap, dist, 2, // maxNarrowSize
|
|
160
|
+
0.7, // distanceThreshold
|
|
161
|
+
8);
|
|
162
|
+
// Large cluster stays as-is, two narrow clusters merge into one
|
|
163
|
+
expect(result.length).toBe(2);
|
|
164
|
+
// The large cluster should be present
|
|
165
|
+
const sorted = result
|
|
166
|
+
.map((c) => [...c].sort((a, b) => a - b))
|
|
167
|
+
.sort((a, b) => a[0] - b[0]);
|
|
168
|
+
expect(sorted[0]).toEqual([0, 1, 2]);
|
|
169
|
+
expect(sorted[1]).toEqual([3, 4]);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { computeReusabilityScores, } from "../knowledge-graph-builder.js";
|
|
3
|
+
function makeTopic(overrides = {}) {
|
|
4
|
+
return {
|
|
5
|
+
id: "topic-001",
|
|
6
|
+
label: "test",
|
|
7
|
+
keywords: ["test"],
|
|
8
|
+
project: "proj",
|
|
9
|
+
projects: ["proj"],
|
|
10
|
+
sessionIds: ["s1"],
|
|
11
|
+
sessionCount: 1,
|
|
12
|
+
totalDurationMinutes: 60,
|
|
13
|
+
totalToolCalls: 10,
|
|
14
|
+
firstSeen: "2026-01-01T00:00:00Z",
|
|
15
|
+
lastSeen: "2026-03-01T00:00:00Z",
|
|
16
|
+
betweennessCentrality: 0,
|
|
17
|
+
degreeCentrality: 0,
|
|
18
|
+
communityId: 0,
|
|
19
|
+
representativePrompts: [],
|
|
20
|
+
suggestedPrompt: "",
|
|
21
|
+
toolSignature: [],
|
|
22
|
+
dominantRole: "user-driven",
|
|
23
|
+
reusabilityScore: { overall: 0, frequency: 0, timeCost: 0, crossProjectScore: 0, recency: 0 },
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function makeFacets(sessionId, outcome, helpfulness) {
|
|
28
|
+
return {
|
|
29
|
+
sessionId,
|
|
30
|
+
underlyingGoal: "test",
|
|
31
|
+
goalCategories: {},
|
|
32
|
+
outcome,
|
|
33
|
+
claudeHelpfulness: helpfulness,
|
|
34
|
+
sessionType: "single_task",
|
|
35
|
+
frictionCounts: {},
|
|
36
|
+
frictionDetail: "",
|
|
37
|
+
primarySuccess: "",
|
|
38
|
+
briefSummary: "",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// ─── No facetsMap (backward compatible) ─────────────────────────────────────
|
|
42
|
+
describe("computeReusabilityScores without facets", () => {
|
|
43
|
+
it("uses original formula weights (0.35/0.25/0.25/0.15)", () => {
|
|
44
|
+
// Single topic: frequency=1, timeCost=1, crossProject=0, recency=1
|
|
45
|
+
const topic = makeTopic({
|
|
46
|
+
sessionCount: 1,
|
|
47
|
+
totalDurationMinutes: 60,
|
|
48
|
+
projects: ["proj"],
|
|
49
|
+
lastSeen: "2026-03-17T00:00:00Z",
|
|
50
|
+
});
|
|
51
|
+
const now = new Date("2026-03-17T00:00:00Z");
|
|
52
|
+
computeReusabilityScores([topic], now);
|
|
53
|
+
// frequency = 1/1 = 1, timeCost = 60/60 = 1, crossProject = 0 (single project), recency = 1 (same day)
|
|
54
|
+
// overall = 0.35*1 + 0.25*1 + 0.25*0 + 0.15*1 = 0.75
|
|
55
|
+
expect(topic.reusabilityScore.overall).toBe(0.75);
|
|
56
|
+
expect(topic.reusabilityScore.frequency).toBe(1);
|
|
57
|
+
expect(topic.reusabilityScore.timeCost).toBe(1);
|
|
58
|
+
expect(topic.reusabilityScore.crossProjectScore).toBe(0);
|
|
59
|
+
expect(topic.reusabilityScore.recency).toBe(1);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
// ─── Empty facetsMap (backward compatible) ──────────────────────────────────
|
|
63
|
+
describe("computeReusabilityScores with empty facetsMap", () => {
|
|
64
|
+
it("behaves same as no facetsMap", () => {
|
|
65
|
+
const topic = makeTopic({
|
|
66
|
+
sessionCount: 1,
|
|
67
|
+
totalDurationMinutes: 60,
|
|
68
|
+
projects: ["proj"],
|
|
69
|
+
lastSeen: "2026-03-17T00:00:00Z",
|
|
70
|
+
});
|
|
71
|
+
const now = new Date("2026-03-17T00:00:00Z");
|
|
72
|
+
computeReusabilityScores([topic], now, new Map());
|
|
73
|
+
expect(topic.reusabilityScore.overall).toBe(0.75);
|
|
74
|
+
expect(topic.reusabilityScore.successRate).toBeUndefined();
|
|
75
|
+
expect(topic.reusabilityScore.helpfulness).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
// ─── FacetsMap with full coverage ───────────────────────────────────────────
|
|
79
|
+
describe("computeReusabilityScores with full facets coverage", () => {
|
|
80
|
+
it("uses new weights (0.30/0.20/0.20/0.10/0.10/0.10) with successRate=1 and helpfulness=1", () => {
|
|
81
|
+
const topic = makeTopic({
|
|
82
|
+
sessionIds: ["s1", "s2"],
|
|
83
|
+
sessionCount: 2,
|
|
84
|
+
totalDurationMinutes: 120,
|
|
85
|
+
projects: ["proj"],
|
|
86
|
+
lastSeen: "2026-03-17T00:00:00Z",
|
|
87
|
+
});
|
|
88
|
+
const now = new Date("2026-03-17T00:00:00Z");
|
|
89
|
+
const facetsMap = new Map();
|
|
90
|
+
facetsMap.set("s1", makeFacets("s1", "fully_achieved", "essential"));
|
|
91
|
+
facetsMap.set("s2", makeFacets("s2", "mostly_achieved", "essential"));
|
|
92
|
+
computeReusabilityScores([topic], now, facetsMap);
|
|
93
|
+
// frequency=1, timeCost=1, crossProject=0, recency=1, successRate=1, helpfulness=1
|
|
94
|
+
// overall = 0.30*1 + 0.20*1 + 0.20*0 + 0.10*1 + 0.10*1 + 0.10*1 = 0.80
|
|
95
|
+
expect(topic.reusabilityScore.overall).toBe(0.8);
|
|
96
|
+
expect(topic.reusabilityScore.successRate).toBe(1);
|
|
97
|
+
expect(topic.reusabilityScore.helpfulness).toBe(1);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
// ─── FacetsMap with partial coverage ────────────────────────────────────────
|
|
101
|
+
describe("computeReusabilityScores with partial facets coverage", () => {
|
|
102
|
+
it("defaults missing sessions to 0.5 for successRate and helpfulness", () => {
|
|
103
|
+
const topic = makeTopic({
|
|
104
|
+
sessionIds: ["s1", "s2"],
|
|
105
|
+
sessionCount: 2,
|
|
106
|
+
totalDurationMinutes: 120,
|
|
107
|
+
projects: ["proj"],
|
|
108
|
+
lastSeen: "2026-03-17T00:00:00Z",
|
|
109
|
+
});
|
|
110
|
+
const now = new Date("2026-03-17T00:00:00Z");
|
|
111
|
+
// Only s1 has facets
|
|
112
|
+
const facetsMap = new Map();
|
|
113
|
+
facetsMap.set("s1", makeFacets("s1", "fully_achieved", "essential"));
|
|
114
|
+
computeReusabilityScores([topic], now, facetsMap);
|
|
115
|
+
// successRate = (1.0 + 0.5) / 2 = 0.75
|
|
116
|
+
// helpfulness = (1.0 + 0.5) / 2 = 0.75
|
|
117
|
+
expect(topic.reusabilityScore.successRate).toBe(0.75);
|
|
118
|
+
expect(topic.reusabilityScore.helpfulness).toBe(0.75);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
// ─── Not achieved + unhelpful lowers score ──────────────────────────────────
|
|
122
|
+
describe("computeReusabilityScores with negative facets", () => {
|
|
123
|
+
it("sets successRate=0 and helpfulness=0 for not_achieved + unhelpful", () => {
|
|
124
|
+
const topic = makeTopic({
|
|
125
|
+
sessionIds: ["s1"],
|
|
126
|
+
sessionCount: 1,
|
|
127
|
+
totalDurationMinutes: 60,
|
|
128
|
+
projects: ["proj"],
|
|
129
|
+
lastSeen: "2026-03-17T00:00:00Z",
|
|
130
|
+
});
|
|
131
|
+
const now = new Date("2026-03-17T00:00:00Z");
|
|
132
|
+
const facetsMap = new Map();
|
|
133
|
+
facetsMap.set("s1", makeFacets("s1", "not_achieved", "unhelpful"));
|
|
134
|
+
computeReusabilityScores([topic], now, facetsMap);
|
|
135
|
+
expect(topic.reusabilityScore.successRate).toBe(0);
|
|
136
|
+
expect(topic.reusabilityScore.helpfulness).toBe(0);
|
|
137
|
+
// overall = 0.30*1 + 0.20*1 + 0.20*0 + 0.10*1 + 0.10*0 + 0.10*0 = 0.60
|
|
138
|
+
expect(topic.reusabilityScore.overall).toBe(0.6);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
// ─── Score fields set on ReusabilityScore ───────────────────────────────────
|
|
142
|
+
describe("ReusabilityScore fields with facets", () => {
|
|
143
|
+
it("sets successRate and helpfulness fields when facetsMap is provided", () => {
|
|
144
|
+
const topic = makeTopic({
|
|
145
|
+
sessionIds: ["s1"],
|
|
146
|
+
sessionCount: 1,
|
|
147
|
+
totalDurationMinutes: 60,
|
|
148
|
+
projects: ["proj"],
|
|
149
|
+
lastSeen: "2026-03-17T00:00:00Z",
|
|
150
|
+
});
|
|
151
|
+
const now = new Date("2026-03-17T00:00:00Z");
|
|
152
|
+
const facetsMap = new Map();
|
|
153
|
+
facetsMap.set("s1", makeFacets("s1", "fully_achieved", "very_helpful"));
|
|
154
|
+
computeReusabilityScores([topic], now, facetsMap);
|
|
155
|
+
expect(topic.reusabilityScore.successRate).toBeDefined();
|
|
156
|
+
expect(topic.reusabilityScore.helpfulness).toBeDefined();
|
|
157
|
+
expect(typeof topic.reusabilityScore.successRate).toBe("number");
|
|
158
|
+
expect(typeof topic.reusabilityScore.helpfulness).toBe("number");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
// ─── Score fields NOT set when no facets ────────────────────────────────────
|
|
162
|
+
describe("ReusabilityScore fields without facets", () => {
|
|
163
|
+
it("does not set successRate and helpfulness when no facetsMap", () => {
|
|
164
|
+
const topic = makeTopic({
|
|
165
|
+
sessionCount: 1,
|
|
166
|
+
totalDurationMinutes: 60,
|
|
167
|
+
projects: ["proj"],
|
|
168
|
+
lastSeen: "2026-03-17T00:00:00Z",
|
|
169
|
+
});
|
|
170
|
+
const now = new Date("2026-03-17T00:00:00Z");
|
|
171
|
+
computeReusabilityScores([topic], now);
|
|
172
|
+
expect(topic.reusabilityScore.successRate).toBeUndefined();
|
|
173
|
+
expect(topic.reusabilityScore.helpfulness).toBeUndefined();
|
|
174
|
+
});
|
|
175
|
+
});
|