escribano 0.4.5 → 0.5.0
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 +46 -26
- package/dist/actions/generate-artifact-v3.js +5 -3
- package/dist/actions/generate-summary-v3.js +29 -4
- package/dist/adapters/cap.adapter.js +94 -0
- package/dist/adapters/intelligence.adapter.js +202 -0
- package/dist/adapters/intelligence.mlx.adapter.js +258 -185
- package/dist/adapters/storage.adapter.js +81 -0
- package/dist/adapters/whisper.adapter.js +168 -0
- package/dist/batch-context.js +91 -34
- package/dist/config.js +12 -1
- package/dist/db/repositories/subject.sqlite.js +1 -1
- package/dist/domain/context.js +97 -0
- package/dist/domain/index.js +2 -0
- package/dist/domain/observation.js +17 -0
- package/dist/python-utils.js +28 -10
- package/dist/services/subject-grouping.js +36 -9
- package/dist/test-classification-prompts.js +181 -0
- package/dist/tests/cap.adapter.test.js +75 -0
- package/dist/tests/intelligence.adapter.test.js +102 -0
- package/dist/tests/intelligence.mlx.adapter.test.js +13 -8
- package/dist/utils/model-detector.js +105 -2
- package/migrations/010_llm_backend_metadata.sql +25 -0
- package/migrations/011_llm_debug_log.sql +19 -0
- package/migrations/012_llm_debug_log_prompt_result.sql +20 -0
- package/package.json +1 -1
- package/scripts/mlx_bridge.py +574 -74
package/dist/python-utils.js
CHANGED
|
@@ -10,6 +10,16 @@ import { resolve } from 'node:path';
|
|
|
10
10
|
export const ESCRIBANO_HOME = resolve(homedir(), '.escribano');
|
|
11
11
|
export const ESCRIBANO_VENV = resolve(ESCRIBANO_HOME, 'venv');
|
|
12
12
|
export const ESCRIBANO_VENV_PYTHON = resolve(ESCRIBANO_VENV, 'bin', 'python3');
|
|
13
|
+
/**
|
|
14
|
+
* Check if a path is inside the current working directory (project-local).
|
|
15
|
+
* Used to skip VIRTUAL_ENV/UV_PROJECT_ENVIRONMENT that are dev venvs for
|
|
16
|
+
* the project itself, not suitable as Escribano's Python runtime.
|
|
17
|
+
*/
|
|
18
|
+
function isInsideCwd(path) {
|
|
19
|
+
const absPath = resolve(path);
|
|
20
|
+
const cwd = process.cwd();
|
|
21
|
+
return absPath.startsWith(`${cwd}/`) || absPath.startsWith(`${cwd}\\`);
|
|
22
|
+
}
|
|
13
23
|
/**
|
|
14
24
|
* Get explicitly configured Python path.
|
|
15
25
|
* Returns null when nothing is explicitly configured or found via well-known
|
|
@@ -19,29 +29,37 @@ export const ESCRIBANO_VENV_PYTHON = resolve(ESCRIBANO_VENV, 'bin', 'python3');
|
|
|
19
29
|
*
|
|
20
30
|
* Priority:
|
|
21
31
|
* 1. ESCRIBANO_PYTHON_PATH env var (explicit override)
|
|
22
|
-
* 2.
|
|
23
|
-
* 3.
|
|
24
|
-
* 4.
|
|
25
|
-
* 5.
|
|
26
|
-
* 6.
|
|
32
|
+
* 2. ~/.escribano/venv (managed venv, if it exists — preferred once created)
|
|
33
|
+
* 3. Active virtual environment (VIRTUAL_ENV, unless inside CWD)
|
|
34
|
+
* 4. UV_PROJECT_ENVIRONMENT (uv project-synced venv, unless inside CWD)
|
|
35
|
+
* 5. Project-local .venv (created by `uv venv` in CWD)
|
|
36
|
+
* 6. ~/.venv/bin/python3 (home-level venv)
|
|
37
|
+
* 7. null — no environment detected; auto-venv will be created
|
|
27
38
|
*/
|
|
28
39
|
export function getPythonPath() {
|
|
40
|
+
// 1. Explicit override always wins
|
|
29
41
|
if (process.env.ESCRIBANO_PYTHON_PATH) {
|
|
30
42
|
return process.env.ESCRIBANO_PYTHON_PATH;
|
|
31
43
|
}
|
|
32
|
-
|
|
44
|
+
// 2. Escribano's managed venv — preferred once it exists
|
|
45
|
+
if (existsSync(ESCRIBANO_VENV_PYTHON)) {
|
|
46
|
+
return ESCRIBANO_VENV_PYTHON;
|
|
47
|
+
}
|
|
48
|
+
// 3. Active virtual environment (skip if it's a project-local dev venv)
|
|
49
|
+
if (process.env.VIRTUAL_ENV && !isInsideCwd(process.env.VIRTUAL_ENV)) {
|
|
33
50
|
return resolve(process.env.VIRTUAL_ENV, 'bin', 'python3');
|
|
34
51
|
}
|
|
35
|
-
// UV_PROJECT_ENVIRONMENT
|
|
36
|
-
if (process.env.UV_PROJECT_ENVIRONMENT
|
|
52
|
+
// 4. UV_PROJECT_ENVIRONMENT (skip if inside CWD)
|
|
53
|
+
if (process.env.UV_PROJECT_ENVIRONMENT &&
|
|
54
|
+
!isInsideCwd(process.env.UV_PROJECT_ENVIRONMENT)) {
|
|
37
55
|
return resolve(process.env.UV_PROJECT_ENVIRONMENT, 'bin', 'python3');
|
|
38
56
|
}
|
|
39
|
-
//
|
|
57
|
+
// 5. Project-local .venv (created by `uv venv` in the current working directory)
|
|
40
58
|
const localVenv = resolve(process.cwd(), '.venv', 'bin', 'python3');
|
|
41
59
|
if (existsSync(localVenv)) {
|
|
42
60
|
return localVenv;
|
|
43
61
|
}
|
|
44
|
-
//
|
|
62
|
+
// 6. Home-level venv (e.g., `uv venv ~/.venv`)
|
|
45
63
|
const uvHomeVenv = resolve(homedir(), '.venv', 'bin', 'python3');
|
|
46
64
|
if (existsSync(uvHomeVenv)) {
|
|
47
65
|
return uvHomeVenv;
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { readFileSync } from 'node:fs';
|
|
8
8
|
import { dirname, resolve } from 'node:path';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { step } from '../pipeline/context.js';
|
|
10
11
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
12
|
const PERSONAL_APPS = new Set([
|
|
12
13
|
'WhatsApp',
|
|
@@ -24,7 +25,7 @@ const PERSONAL_APPS = new Set([
|
|
|
24
25
|
'Messages',
|
|
25
26
|
]);
|
|
26
27
|
const PERSONAL_APP_THRESHOLD = 0.5;
|
|
27
|
-
const SUBJECT_GROUPING_MODEL = process.env.ESCRIBANO_SUBJECT_GROUPING_MODEL
|
|
28
|
+
const SUBJECT_GROUPING_MODEL = process.env.ESCRIBANO_SUBJECT_GROUPING_MODEL;
|
|
28
29
|
export async function groupTopicBlocksIntoSubjects(topicBlocks, intelligence, recordingId) {
|
|
29
30
|
if (topicBlocks.length === 0) {
|
|
30
31
|
return {
|
|
@@ -35,16 +36,42 @@ export async function groupTopicBlocksIntoSubjects(topicBlocks, intelligence, re
|
|
|
35
36
|
}
|
|
36
37
|
const blocksForGrouping = topicBlocks.map(extractBlockForGrouping);
|
|
37
38
|
const prompt = buildGroupingPrompt(blocksForGrouping);
|
|
38
|
-
|
|
39
|
+
const modelInfo = SUBJECT_GROUPING_MODEL
|
|
40
|
+
? ` (model: ${SUBJECT_GROUPING_MODEL})`
|
|
41
|
+
: ' (auto-detected)';
|
|
42
|
+
console.log(`[subject-grouping] Grouping ${topicBlocks.length} blocks into subjects${modelInfo}`);
|
|
39
43
|
try {
|
|
40
|
-
const response = await
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
const response = await step('llm_subject_grouping', async () => {
|
|
45
|
+
return intelligence.generateText(prompt, {
|
|
46
|
+
expectJson: false,
|
|
47
|
+
model: SUBJECT_GROUPING_MODEL || undefined,
|
|
48
|
+
numPredict: 2000,
|
|
49
|
+
think: false,
|
|
50
|
+
debugContext: {
|
|
51
|
+
recordingId,
|
|
52
|
+
callType: 'subject_grouping',
|
|
53
|
+
},
|
|
54
|
+
});
|
|
45
55
|
});
|
|
46
|
-
|
|
47
|
-
|
|
56
|
+
// Strip thinking leakage if present
|
|
57
|
+
let cleaned = response.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
|
58
|
+
if (cleaned.includes('</think>')) {
|
|
59
|
+
// Handle orphan </think> tag (Qwen3.5 behavior)
|
|
60
|
+
cleaned = cleaned.split('</think>')[1].trim();
|
|
61
|
+
}
|
|
62
|
+
// Strip "Thinking Process:" prose (Qwen3.5-OptiQ format)
|
|
63
|
+
const tpMatch = cleaned.match(/(?:^|\n)Thinking Process:/);
|
|
64
|
+
if (tpMatch !== null) {
|
|
65
|
+
const after = cleaned.slice((tpMatch.index ?? 0) + tpMatch[0].length);
|
|
66
|
+
const heading = after.match(/\n(#\s|\*\*|Group\s)/);
|
|
67
|
+
cleaned =
|
|
68
|
+
heading?.index !== undefined ? after.slice(heading.index).trim() : '';
|
|
69
|
+
}
|
|
70
|
+
if (cleaned.length < 10) {
|
|
71
|
+
console.warn('[subject-grouping] Thinking leakage detected or response too short — parseGroupingResponse will fall back');
|
|
72
|
+
}
|
|
73
|
+
console.log(`[subject-grouping] LLM response (${cleaned.length} chars after stripping):\n${cleaned.slice(0, 500)}${cleaned.length > 500 ? '...' : ''}`);
|
|
74
|
+
const grouping = parseGroupingResponse(cleaned || response, topicBlocks);
|
|
48
75
|
console.log(`[subject-grouping] Parsed ${grouping.groups.length} groups: ${grouping.groups.map((g) => g.label).join(', ')}`);
|
|
49
76
|
const subjects = grouping.groups.map((group, index) => {
|
|
50
77
|
const subjectId = `subject-${recordingId}-${index}`;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* Test different classification prompts to find the best approach
|
|
4
|
+
* for handling mixed-type sessions
|
|
5
|
+
*/
|
|
6
|
+
import { createStorageService } from './adapters/storage.adapter.js';
|
|
7
|
+
// Test Prompts
|
|
8
|
+
const PROMPTS = {
|
|
9
|
+
// Approach A: Current style but clearer
|
|
10
|
+
strictSingle: `You are a session classifier. Analyze this transcript and output ONLY valid JSON.
|
|
11
|
+
|
|
12
|
+
Session types:
|
|
13
|
+
- meeting: Conversations, interviews, discussions between people
|
|
14
|
+
- debugging: Fixing issues, troubleshooting, error analysis
|
|
15
|
+
- tutorial: Teaching, explaining, demonstrating how to do something
|
|
16
|
+
- learning: Researching, studying, exploring new concepts
|
|
17
|
+
|
|
18
|
+
Output exactly this JSON structure:
|
|
19
|
+
{ "type": "meeting|debugging|tutorial|learning", "confidence": 0.0-1.0 }
|
|
20
|
+
|
|
21
|
+
Transcript: {{TRANSCRIPT}}`,
|
|
22
|
+
// Approach B: Multi-label scoring
|
|
23
|
+
multiLabel: `Rate how much this transcript matches each session type (0-100):
|
|
24
|
+
|
|
25
|
+
Session types:
|
|
26
|
+
- meeting: Conversations, interviews, discussions between people
|
|
27
|
+
- debugging: Fixing issues, troubleshooting, error analysis
|
|
28
|
+
- tutorial: Teaching, explaining, demonstrating how to do something
|
|
29
|
+
- learning: Researching, studying, exploring new concepts
|
|
30
|
+
|
|
31
|
+
Output JSON with all types scored:
|
|
32
|
+
{ "classifications": { "meeting": 85, "debugging": 10, "tutorial": 20, "learning": 45 } }
|
|
33
|
+
|
|
34
|
+
Transcript: {{TRANSCRIPT}}`,
|
|
35
|
+
// Approach C: Primary + Secondary
|
|
36
|
+
primarySecondary: `Identify the PRIMARY type and any SECONDARY types that apply.
|
|
37
|
+
|
|
38
|
+
Session types:
|
|
39
|
+
- meeting: Conversations, interviews, discussions between people
|
|
40
|
+
- debugging: Fixing issues, troubleshooting, error analysis
|
|
41
|
+
- tutorial: Teaching, explaining, demonstrating how to do something
|
|
42
|
+
- learning: Researching, studying, exploring new concepts
|
|
43
|
+
|
|
44
|
+
Output JSON with structure shown:
|
|
45
|
+
{ "primary": { "type": "meeting|debugging|tutorial|learning", "confidence": 0.0-1.0 }, "secondary": ["type1", "type2"] }
|
|
46
|
+
|
|
47
|
+
Transcript: {{TRANSCRIPT}}`,
|
|
48
|
+
// Approach D: Array of applicable types
|
|
49
|
+
arrayTypes: `List ALL session types that apply with confidence scores.
|
|
50
|
+
|
|
51
|
+
Session types:
|
|
52
|
+
- meeting: Conversations, interviews, discussions between people
|
|
53
|
+
- debugging: Fixing issues, troubleshooting, error analysis
|
|
54
|
+
- tutorial: Teaching, explaining, demonstrating how to do something
|
|
55
|
+
- learning: Researching, studying, exploring new concepts
|
|
56
|
+
|
|
57
|
+
Output JSON with array of applicable types (include if confidence > 0.3):
|
|
58
|
+
{ "types": [ {"type": "meeting", "confidence": 0.85} ] }
|
|
59
|
+
|
|
60
|
+
Transcript: {{TRANSCRIPT}}`,
|
|
61
|
+
};
|
|
62
|
+
async function testPrompt(name, prompt, transcript, intelligenceConfig) {
|
|
63
|
+
console.log(`\n${'='.repeat(60)}`);
|
|
64
|
+
console.log(`Testing: ${name}`);
|
|
65
|
+
console.log(`${'='.repeat(60)}`);
|
|
66
|
+
const finalPrompt = prompt.replace('{{TRANSCRIPT}}', transcript.fullText);
|
|
67
|
+
try {
|
|
68
|
+
const controller = new AbortController();
|
|
69
|
+
const timeoutId = setTimeout(() => controller.abort(), 3000000);
|
|
70
|
+
const response = await fetch(intelligenceConfig.endpoint, {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: { 'Content-Type': 'application/json' },
|
|
73
|
+
body: JSON.stringify({
|
|
74
|
+
model: intelligenceConfig.model,
|
|
75
|
+
messages: [{ role: 'system', content: finalPrompt }],
|
|
76
|
+
stream: false,
|
|
77
|
+
format: 'json',
|
|
78
|
+
temperature: 0.3,
|
|
79
|
+
}),
|
|
80
|
+
signal: controller.signal,
|
|
81
|
+
});
|
|
82
|
+
clearTimeout(timeoutId);
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
|
85
|
+
}
|
|
86
|
+
const result = await response.json();
|
|
87
|
+
console.log('Result:');
|
|
88
|
+
console.log(JSON.stringify(result, null, 2));
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
93
|
+
console.error('Error: Request timed out after 30s');
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
console.error('Error:', error);
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function main() {
|
|
102
|
+
const sessionId = process.argv[2];
|
|
103
|
+
if (!sessionId) {
|
|
104
|
+
console.error('Usage: pnpm run test-prompts <session-id>');
|
|
105
|
+
console.error('Example: pnpm run test-prompts "Your Recording.cap"');
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
console.log(`\n🔍 Testing classification prompts for: ${sessionId}`);
|
|
109
|
+
const storage = createStorageService();
|
|
110
|
+
const session = await storage.loadSession(sessionId);
|
|
111
|
+
if (!session || !session.transcripts || session.transcripts.length === 0) {
|
|
112
|
+
console.error(`\n❌ Session not found or has no transcripts: ${sessionId}`);
|
|
113
|
+
console.error('\nTo find sessions, run:');
|
|
114
|
+
console.error(' pnpm run list');
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
const transcript = session.transcripts[0].transcript;
|
|
118
|
+
console.log(`✓ Transcript length: ${transcript.fullText.length} chars`);
|
|
119
|
+
console.log(`✓ Number of segments: ${transcript.segments.length}`);
|
|
120
|
+
console.log(`✓ Audio source: ${session.transcripts[0].source}`);
|
|
121
|
+
console.log(`\n⏱️ Running 4 classification tests...\n`);
|
|
122
|
+
// Intelligence config
|
|
123
|
+
const intelligenceConfig = {
|
|
124
|
+
provider: 'ollama',
|
|
125
|
+
endpoint: 'http://localhost:11434/v1/chat/completions',
|
|
126
|
+
model: 'qwen3:32b',
|
|
127
|
+
maxRetries: 1,
|
|
128
|
+
timeout: 3000000,
|
|
129
|
+
};
|
|
130
|
+
// Test each prompt
|
|
131
|
+
const results = {};
|
|
132
|
+
let testNum = 1;
|
|
133
|
+
for (const [name, prompt] of Object.entries(PROMPTS)) {
|
|
134
|
+
console.log(`\n[${testNum}/4] Running test...`);
|
|
135
|
+
results[name] = await testPrompt(name, prompt, transcript, intelligenceConfig);
|
|
136
|
+
testNum++;
|
|
137
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
138
|
+
}
|
|
139
|
+
// Summary
|
|
140
|
+
console.log(`\n${'='.repeat(60)}`);
|
|
141
|
+
console.log('📊 FINAL SUMMARY');
|
|
142
|
+
console.log(`${'='.repeat(60)}`);
|
|
143
|
+
for (const [name, result] of Object.entries(results)) {
|
|
144
|
+
console.log(`\n${name
|
|
145
|
+
.toUpperCase()
|
|
146
|
+
.replace(/([A-Z])/g, ' $1')
|
|
147
|
+
.trim()}:`);
|
|
148
|
+
if (result) {
|
|
149
|
+
// Try to extract key info for quick comparison
|
|
150
|
+
if (result.type) {
|
|
151
|
+
console.log(` Primary Type: ${result.type}`);
|
|
152
|
+
if (result.confidence)
|
|
153
|
+
console.log(` Confidence: ${(result.confidence * 100).toFixed(0)}%`);
|
|
154
|
+
}
|
|
155
|
+
else if (result.classifications) {
|
|
156
|
+
console.log(' Scores:', JSON.stringify(result.classifications));
|
|
157
|
+
}
|
|
158
|
+
else if (result.primary) {
|
|
159
|
+
console.log(` Primary: ${result.primary.type} (${(result.primary.confidence * 100).toFixed(0)}%)`);
|
|
160
|
+
if (result.secondary?.length)
|
|
161
|
+
console.log(` Secondary: [${result.secondary.join(', ')}]`);
|
|
162
|
+
}
|
|
163
|
+
else if (result.types) {
|
|
164
|
+
console.log(` Types: ${result.types.map((t) => `${t.type} (${(t.confidence * 100).toFixed(0)}%)`).join(', ')}`);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
console.log(` Raw:`, JSON.stringify(result));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
console.log(' ❌ Failed or timed out');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
console.log(`\n${'='.repeat(60)}`);
|
|
175
|
+
console.log('✅ All tests complete!');
|
|
176
|
+
console.log(`${'='.repeat(60)}\n`);
|
|
177
|
+
}
|
|
178
|
+
main().catch((error) => {
|
|
179
|
+
console.error('Fatal error:', error);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cap Adapter Tests
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { createCapSource } from '../adapters/cap.adapter';
|
|
6
|
+
describe('Cap Adapter', () => {
|
|
7
|
+
it('should create a CapSource', () => {
|
|
8
|
+
const capSource = createCapSource({
|
|
9
|
+
recordingsPath: '~/tmp/recordings',
|
|
10
|
+
});
|
|
11
|
+
expect(capSource).toBeDefined();
|
|
12
|
+
expect(capSource.getLatestRecording).toBeInstanceOf(Function);
|
|
13
|
+
});
|
|
14
|
+
it('should handle nonexistent directory gracefully', async () => {
|
|
15
|
+
const capSource = createCapSource({
|
|
16
|
+
recordingsPath: '/nonexistent/path',
|
|
17
|
+
});
|
|
18
|
+
const latest = await capSource.getLatestRecording();
|
|
19
|
+
expect(latest).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
it('should validate Cap recording metadata structure', async () => {
|
|
22
|
+
const mockMeta = {
|
|
23
|
+
platform: 'MacOS',
|
|
24
|
+
pretty_name: 'Cap 2026-01-08 at 16.46.37',
|
|
25
|
+
segments: [
|
|
26
|
+
{
|
|
27
|
+
display: {
|
|
28
|
+
path: 'content/segments/segment-0/display.mp4',
|
|
29
|
+
fps: 37,
|
|
30
|
+
},
|
|
31
|
+
mic: {
|
|
32
|
+
path: 'content/segments/segment-0/audio-input.ogg',
|
|
33
|
+
start_time: -0.032719958,
|
|
34
|
+
},
|
|
35
|
+
cursor: 'content/segments/segment-0/cursor.json',
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
expect(mockMeta.segments).toBeDefined();
|
|
40
|
+
expect(mockMeta.segments[0]).toBeDefined();
|
|
41
|
+
expect(mockMeta.segments[0].display?.path).toBe('content/segments/segment-0/display.mp4');
|
|
42
|
+
expect(mockMeta.segments[0].mic?.path).toBe('content/segments/segment-0/audio-input.ogg');
|
|
43
|
+
});
|
|
44
|
+
it('should identify recordings without mic/audio field', async () => {
|
|
45
|
+
const mockMeta = {
|
|
46
|
+
platform: 'MacOS',
|
|
47
|
+
pretty_name: 'Cap 2026-01-08 at 16.46.37',
|
|
48
|
+
segments: [
|
|
49
|
+
{
|
|
50
|
+
display: {
|
|
51
|
+
path: 'content/segments/segment-0/display.mp4',
|
|
52
|
+
fps: 37,
|
|
53
|
+
},
|
|
54
|
+
cursor: 'content/segments/segment-0/cursor.json',
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
expect(mockMeta.segments[0]?.mic).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
it('should identify missing segments array', async () => {
|
|
61
|
+
const invalidMeta = {
|
|
62
|
+
platform: 'MacOS',
|
|
63
|
+
pretty_name: 'Cap 2026-01-08 at 16.46.37',
|
|
64
|
+
};
|
|
65
|
+
expect(invalidMeta.segments).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
it('should identify empty segments array', async () => {
|
|
68
|
+
const invalidMeta = {
|
|
69
|
+
platform: 'MacOS',
|
|
70
|
+
pretty_name: 'Cap 2026-01-08 at 16.46.37',
|
|
71
|
+
segments: [],
|
|
72
|
+
};
|
|
73
|
+
expect(invalidMeta.segments?.length).toBe(0);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intelligence Adapter Tests
|
|
3
|
+
*/
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { createIntelligenceService } from '../adapters/intelligence.adapter.js';
|
|
6
|
+
const mockConfig = {
|
|
7
|
+
provider: 'ollama',
|
|
8
|
+
endpoint: 'http://localhost:11434/v1/chat/completions',
|
|
9
|
+
model: 'qwen3:32b',
|
|
10
|
+
maxRetries: 3,
|
|
11
|
+
timeout: 30000,
|
|
12
|
+
};
|
|
13
|
+
const mockTranscript = {
|
|
14
|
+
fullText: 'This is a debugging session about authentication errors.',
|
|
15
|
+
segments: [
|
|
16
|
+
{
|
|
17
|
+
id: 'seg-0',
|
|
18
|
+
start: 0,
|
|
19
|
+
end: 5,
|
|
20
|
+
text: 'I fixed the authentication bug.',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'seg-1',
|
|
24
|
+
start: 5,
|
|
25
|
+
end: 10,
|
|
26
|
+
text: 'Used JWT tokens for security.',
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
language: 'en',
|
|
30
|
+
duration: 10,
|
|
31
|
+
};
|
|
32
|
+
describe('IntelligenceService', () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.resetAllMocks();
|
|
35
|
+
});
|
|
36
|
+
it('should create an intelligence service', () => {
|
|
37
|
+
const service = createIntelligenceService(mockConfig);
|
|
38
|
+
expect(service).toBeDefined();
|
|
39
|
+
expect(service.classify).toBeInstanceOf(Function);
|
|
40
|
+
expect(service.generate).toBeInstanceOf(Function);
|
|
41
|
+
});
|
|
42
|
+
it('should classify a debugging session', async () => {
|
|
43
|
+
const mockResponse = JSON.stringify({
|
|
44
|
+
message: {
|
|
45
|
+
content: JSON.stringify({
|
|
46
|
+
meeting: 10,
|
|
47
|
+
debugging: 90,
|
|
48
|
+
tutorial: 15,
|
|
49
|
+
learning: 20,
|
|
50
|
+
working: 5,
|
|
51
|
+
}),
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
55
|
+
ok: true,
|
|
56
|
+
json: async () => JSON.parse(mockResponse),
|
|
57
|
+
});
|
|
58
|
+
const service = createIntelligenceService(mockConfig);
|
|
59
|
+
const result = await service.classify(mockTranscript);
|
|
60
|
+
expect(result.debugging).toBe(90);
|
|
61
|
+
expect(result.meeting).toBe(10);
|
|
62
|
+
expect(result.tutorial).toBe(15);
|
|
63
|
+
expect(result.learning).toBe(20);
|
|
64
|
+
expect(result.working).toBe(5);
|
|
65
|
+
});
|
|
66
|
+
it('should retry on API failures', async () => {
|
|
67
|
+
let attempts = 0;
|
|
68
|
+
global.fetch = vi.fn().mockImplementation(async () => {
|
|
69
|
+
attempts++;
|
|
70
|
+
if (attempts < 3) {
|
|
71
|
+
throw new Error('API timeout');
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
ok: true,
|
|
75
|
+
json: async () => ({
|
|
76
|
+
message: {
|
|
77
|
+
content: JSON.stringify({
|
|
78
|
+
meeting: 80,
|
|
79
|
+
debugging: 10,
|
|
80
|
+
tutorial: 5,
|
|
81
|
+
learning: 15,
|
|
82
|
+
working: 5,
|
|
83
|
+
}),
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
const service = createIntelligenceService(mockConfig);
|
|
89
|
+
const result = await service.classify(mockTranscript);
|
|
90
|
+
expect(attempts).toBe(3);
|
|
91
|
+
expect(result.meeting).toBe(80);
|
|
92
|
+
});
|
|
93
|
+
it('should handle fetch errors correctly', async () => {
|
|
94
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
95
|
+
ok: false,
|
|
96
|
+
status: 500,
|
|
97
|
+
statusText: 'Internal Server Error',
|
|
98
|
+
});
|
|
99
|
+
const service = createIntelligenceService(mockConfig);
|
|
100
|
+
await expect(service.classify(mockTranscript)).rejects.toThrow('Classification failed after 3 retries');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -22,7 +22,7 @@ vi.mock('node:child_process', () => ({
|
|
|
22
22
|
kill: vi.fn(),
|
|
23
23
|
})),
|
|
24
24
|
}));
|
|
25
|
-
import { existsSync } from 'node:fs';
|
|
25
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
26
26
|
import { resolvePythonPath } from '../adapters/intelligence.mlx.adapter.js';
|
|
27
27
|
import { getPythonPath } from '../python-utils.js';
|
|
28
28
|
const mockExistsSync = vi.mocked(existsSync);
|
|
@@ -178,8 +178,10 @@ describe('resolvePythonPath', () => {
|
|
|
178
178
|
});
|
|
179
179
|
it('installs mlx-vlm when the import probe fails', async () => {
|
|
180
180
|
const venvPython = resolve(homedir(), '.escribano', 'venv', 'bin', 'python3');
|
|
181
|
-
|
|
182
|
-
mockExistsSync.mockImplementation((p) => p ===
|
|
181
|
+
const escribanoHome = resolve(homedir(), '.escribano');
|
|
182
|
+
mockExistsSync.mockImplementation((p) => p === escribanoHome);
|
|
183
|
+
const mockMkdirSync = vi.mocked(mkdirSync);
|
|
184
|
+
mockMkdirSync.mockReturnValue(undefined);
|
|
183
185
|
const { spawn } = await import('node:child_process');
|
|
184
186
|
const mockSpawn = vi.mocked(spawn);
|
|
185
187
|
mockSpawn.mockClear();
|
|
@@ -189,9 +191,12 @@ describe('resolvePythonPath', () => {
|
|
|
189
191
|
const emitter = {
|
|
190
192
|
on: vi.fn((event, cb) => {
|
|
191
193
|
if (event === 'exit') {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
194
|
+
if (thisCall === 1) {
|
|
195
|
+
cb(1);
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
cb(0);
|
|
199
|
+
}
|
|
195
200
|
}
|
|
196
201
|
return emitter;
|
|
197
202
|
}),
|
|
@@ -202,8 +207,7 @@ describe('resolvePythonPath', () => {
|
|
|
202
207
|
return emitter;
|
|
203
208
|
});
|
|
204
209
|
await expect(resolvePythonPath()).resolves.toBe(venvPython);
|
|
205
|
-
expect(mockSpawn.mock.calls.length).toBeGreaterThanOrEqual(
|
|
206
|
-
// Find the pip install call regardless of its position (robust to ensurepip being inserted)
|
|
210
|
+
expect(mockSpawn.mock.calls.length).toBeGreaterThanOrEqual(3);
|
|
207
211
|
const installCall = mockSpawn.mock.calls.find(([_cmd, args]) => Array.isArray(args) &&
|
|
208
212
|
args.includes('-m') &&
|
|
209
213
|
args.includes('pip') &&
|
|
@@ -217,6 +221,7 @@ describe('resolvePythonPath', () => {
|
|
|
217
221
|
'mlx-vlm',
|
|
218
222
|
'torch',
|
|
219
223
|
'torchvision',
|
|
224
|
+
'mlx-lm',
|
|
220
225
|
]));
|
|
221
226
|
});
|
|
222
227
|
});
|