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/README.md
CHANGED
|
@@ -95,19 +95,49 @@ Good for retrospectives or blog drafts.
|
|
|
95
95
|
|
|
96
96
|
## Benchmarks
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
### Architecture Benefits (MLX Migration)
|
|
99
|
+
|
|
100
|
+
| Improvement | Impact |
|
|
101
|
+
|-------------|--------|
|
|
102
|
+
| **Zero dependencies** | No external daemons required |
|
|
103
|
+
| **Unified backend** | VLM + LLM use same MLX infrastructure |
|
|
104
|
+
| **Native Metal** | Optimized for Apple Silicon |
|
|
105
|
+
| **Memory efficient** | Sequential model loading (no OOM) |
|
|
106
|
+
| **Auto-detection** | RAM-based model selection |
|
|
107
|
+
|
|
108
|
+
### Production Run (March 2026)
|
|
109
|
+
|
|
110
|
+
Processed **17 real screen recordings** with MLX backend:
|
|
99
111
|
|
|
100
112
|
| Metric | Result |
|
|
101
113
|
|--------|--------|
|
|
102
|
-
| Videos processed |
|
|
103
|
-
|
|
|
104
|
-
|
|
|
105
|
-
|
|
|
106
|
-
|
|
|
114
|
+
| Videos processed | 17 |
|
|
115
|
+
| Successful | 15 (88%) |
|
|
116
|
+
| Total video duration | 25.6 hours |
|
|
117
|
+
| Artifacts generated | 45 (3 formats × 15 videos) |
|
|
118
|
+
| **LLM generation** | **~2.2 min per video** |
|
|
119
|
+
| Subject grouping | 78.7s avg |
|
|
120
|
+
| Artifact generation | 53.6s avg |
|
|
121
|
+
| LLM success rate | 100% (92 calls) |
|
|
107
122
|
| Hardware | MacBook Pro M4 Max, 128GB |
|
|
123
|
+
| Backend | MLX (Qwen3-VL-2B + Qwen3.5-27B) |
|
|
108
124
|
|
|
109
125
|
Everything runs locally. No API keys. Nothing leaves your machine.
|
|
110
126
|
|
|
127
|
+
### Hardware Tiers (March 2026)
|
|
128
|
+
|
|
129
|
+
Performance varies by hardware:
|
|
130
|
+
|
|
131
|
+
| Hardware | RAM | VLM Speed | LLM Model | LLM Speed | Total (1min video) |
|
|
132
|
+
|----------|-----|-----------|-----------|-----------|-------------------|
|
|
133
|
+
| **M4 Max** | 128GB | 0.7s/frame | Qwen3.5-27B | 53s avg | **~2.2 min** |
|
|
134
|
+
| **M1/M2/M3 Pro** | 16-32GB | 1.5-3s/frame | Qwen3.5-9B | 80-120s | ~5-8 min |
|
|
135
|
+
| **M1/M2 Air** | 16GB | 7-9s/frame | Qwen3.5-9B | 150-250s | ~12-15 min |
|
|
136
|
+
|
|
137
|
+
**Minimum viable**: 16GB unified memory (slower but functional)
|
|
138
|
+
|
|
139
|
+
**Recommended**: 32GB+ for comfortable use, 64GB+ for best quality
|
|
140
|
+
|
|
111
141
|
---
|
|
112
142
|
|
|
113
143
|
## Why this exists
|
|
@@ -141,7 +171,7 @@ Screen recording
|
|
|
141
171
|
Activity segmentation → temporal audio alignment → TopicBlocks
|
|
142
172
|
│
|
|
143
173
|
▼
|
|
144
|
-
LLM summary (
|
|
174
|
+
LLM summary (MLX-LM, auto-detected) → Markdown artifact
|
|
145
175
|
```
|
|
146
176
|
|
|
147
177
|
Uses VLM-first visual understanding, not OCR + text clustering. OCR fails for developer work because all code screens produce similar tokens. VLMs understand the *activity*, not just the text.
|
|
@@ -154,32 +184,22 @@ Uses VLM-first visual understanding, not OCR + text clustering. OCR fails for de
|
|
|
154
184
|
|
|
155
185
|
```bash
|
|
156
186
|
# macOS (Homebrew)
|
|
157
|
-
brew install
|
|
187
|
+
brew install whisper-cpp ffmpeg
|
|
158
188
|
|
|
159
|
-
# MLX
|
|
160
|
-
#
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
# Or using pip
|
|
164
|
-
pip install mlx-vlm
|
|
189
|
+
# MLX for inference (Apple Silicon) - auto-installed on first run
|
|
190
|
+
# Or pre-install with:
|
|
191
|
+
pip install mlx-vlm mlx-lm
|
|
165
192
|
```
|
|
166
193
|
|
|
167
|
-
|
|
194
|
+
That's it. No external daemons required. MLX-VLM and MLX-LM run in-process.
|
|
168
195
|
|
|
169
|
-
|
|
196
|
+
### (Optional) Ollama Backend
|
|
170
197
|
|
|
171
|
-
|
|
172
|
-
|----------|---------------|-----------------|
|
|
173
|
-
| 16GB | `qwen3:8b` | `ollama pull qwen3:8b` |
|
|
174
|
-
| 32GB | `qwen3:14b` | `ollama pull qwen3:14b` |
|
|
175
|
-
| 64GB+ | `qwen3.5:27b` | `ollama pull qwen3.5:27b` |
|
|
198
|
+
If you prefer Ollama, set `ESCRIBANO_LLM_BACKEND=ollama`:
|
|
176
199
|
|
|
177
200
|
```bash
|
|
178
|
-
|
|
179
|
-
ollama pull qwen3:8b
|
|
180
|
-
|
|
181
|
-
# Or best quality (64GB+)
|
|
182
|
-
ollama pull qwen3.5:27b
|
|
201
|
+
brew install ollama
|
|
202
|
+
ollama pull qwen3:8b # or qwen3.5:27b for 64GB+ RAM
|
|
183
203
|
```
|
|
184
204
|
|
|
185
205
|
### Run
|
|
@@ -228,9 +228,11 @@ async function generateLlmArtifact(subjects, groupingResult, format, recording,
|
|
|
228
228
|
.replace('{{SUBJECT_COUNT}}', String(subjects.length))
|
|
229
229
|
.replace('{{SUBJECTS_DATA}}', subjectsData)
|
|
230
230
|
.replace('{{WORK_SUBJECTS}}', subjectsData);
|
|
231
|
-
return
|
|
232
|
-
|
|
233
|
-
|
|
231
|
+
return step('llm_artifact_generation', async () => {
|
|
232
|
+
return intelligence.generateText(prompt, {
|
|
233
|
+
expectJson: false,
|
|
234
|
+
think: ARTIFACT_THINK,
|
|
235
|
+
});
|
|
234
236
|
});
|
|
235
237
|
}
|
|
236
238
|
function buildSubjectsDataForPrompt(subjects, allTopicBlocks) {
|
|
@@ -8,7 +8,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
|
8
8
|
import { homedir } from 'node:os';
|
|
9
9
|
import path, { dirname, resolve } from 'node:path';
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
|
-
import { log } from '../pipeline/context.js';
|
|
11
|
+
import { log, step } from '../pipeline/context.js';
|
|
12
12
|
import { groupTopicBlocksIntoSubjects, saveSubjectsToDatabase, } from '../services/subject-grouping.js';
|
|
13
13
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
14
|
/**
|
|
@@ -225,10 +225,35 @@ ${section.transcript ? `**Audio Transcript:**\n${section.transcript}` : '*No aud
|
|
|
225
225
|
.replace('{{APPS_LIST}}', appsList)
|
|
226
226
|
.replace('{{URLS_LIST}}', urlsList);
|
|
227
227
|
// Call LLM
|
|
228
|
-
const result = await
|
|
229
|
-
|
|
228
|
+
const result = await step('llm_artifact_generation', async () => {
|
|
229
|
+
return intelligence.generateText(prompt, {
|
|
230
|
+
expectJson: false,
|
|
231
|
+
debugContext: {
|
|
232
|
+
recordingId: recording.id,
|
|
233
|
+
callType: 'artifact_generation',
|
|
234
|
+
},
|
|
235
|
+
});
|
|
230
236
|
});
|
|
231
|
-
|
|
237
|
+
// Strip thinking leakage if present
|
|
238
|
+
let cleaned = result.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
|
239
|
+
if (cleaned.includes('</think>')) {
|
|
240
|
+
// Handle orphan </think> tag (Qwen3.5 behavior)
|
|
241
|
+
cleaned = cleaned.split('</think>')[1].trim();
|
|
242
|
+
}
|
|
243
|
+
// Strip "Thinking Process:" prose (Qwen3.5-OptiQ format)
|
|
244
|
+
const tpMatch = cleaned.match(/(?:^|\n)Thinking Process:/);
|
|
245
|
+
if (tpMatch !== null) {
|
|
246
|
+
const after = cleaned.slice((tpMatch.index ?? 0) + tpMatch[0].length);
|
|
247
|
+
const heading = after.match(/\n(#\s|\*\*)/);
|
|
248
|
+
cleaned =
|
|
249
|
+
heading?.index !== undefined ? after.slice(heading.index).trim() : '';
|
|
250
|
+
}
|
|
251
|
+
// If cleaning leaves nothing usable, fall back to template
|
|
252
|
+
if (cleaned.length > 50) {
|
|
253
|
+
return cleaned;
|
|
254
|
+
}
|
|
255
|
+
console.warn('[artifact-generation] Thinking leakage detected or response too short — falling back to template');
|
|
256
|
+
return formatSummary(sections, recording.duration, recording.id);
|
|
232
257
|
}
|
|
233
258
|
/**
|
|
234
259
|
* Format sections into a readable markdown summary (template fallback).
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { capConfigSchema } from '../0_types.js';
|
|
5
|
+
function expandPath(path) {
|
|
6
|
+
if (path.startsWith('~/')) {
|
|
7
|
+
return join(homedir(), path.slice(2));
|
|
8
|
+
}
|
|
9
|
+
return path;
|
|
10
|
+
}
|
|
11
|
+
async function parseCapRecording(capDirPath) {
|
|
12
|
+
try {
|
|
13
|
+
const metaPath = join(capDirPath, 'recording-meta.json');
|
|
14
|
+
const metaContent = await readFile(metaPath, 'utf-8');
|
|
15
|
+
const meta = JSON.parse(metaContent);
|
|
16
|
+
if (!meta.segments ||
|
|
17
|
+
!Array.isArray(meta.segments) ||
|
|
18
|
+
meta.segments.length === 0) {
|
|
19
|
+
throw new Error(`Invalid metadata in ${capDirPath}: missing or empty segments array`);
|
|
20
|
+
}
|
|
21
|
+
const firstSegment = meta.segments[0];
|
|
22
|
+
const videoPath = firstSegment.display?.path
|
|
23
|
+
? join(capDirPath, firstSegment.display.path)
|
|
24
|
+
: null;
|
|
25
|
+
// we fked up cuz we have mic but also system_audio.ogg
|
|
26
|
+
const micAudio = firstSegment.mic?.path
|
|
27
|
+
? join(capDirPath, firstSegment.mic.path)
|
|
28
|
+
: null;
|
|
29
|
+
const systemAudio = firstSegment.system_audio?.path
|
|
30
|
+
? join(capDirPath, firstSegment.system_audio.path)
|
|
31
|
+
: null;
|
|
32
|
+
const audioToStat = micAudio || systemAudio;
|
|
33
|
+
if (!audioToStat) {
|
|
34
|
+
console.log(`Skipping ${capDirPath}: none audio track found`);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const stats = await stat(audioToStat);
|
|
38
|
+
const capturedAt = stats.mtime;
|
|
39
|
+
const recordingId = capDirPath.split('/').pop() || 'unknown';
|
|
40
|
+
return {
|
|
41
|
+
id: recordingId,
|
|
42
|
+
source: {
|
|
43
|
+
type: 'cap',
|
|
44
|
+
originalPath: capDirPath,
|
|
45
|
+
metadata: meta,
|
|
46
|
+
},
|
|
47
|
+
videoPath,
|
|
48
|
+
audioMicPath: micAudio ? micAudio : null,
|
|
49
|
+
audioSystemPath: systemAudio ? systemAudio : null,
|
|
50
|
+
duration: 0,
|
|
51
|
+
capturedAt,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error.code === 'ENOENT') {
|
|
56
|
+
throw new Error(`Recording directory or files not found: ${capDirPath}`);
|
|
57
|
+
}
|
|
58
|
+
if (error.name === 'SyntaxError') {
|
|
59
|
+
throw new Error(`Invalid JSON in recording-meta.json at ${capDirPath}`);
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`Failed to parse recording at ${capDirPath}: ${error.message}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function createCapSource(config = {}) {
|
|
65
|
+
const parsedConfig = capConfigSchema.parse(config);
|
|
66
|
+
const recordingsPath = expandPath(parsedConfig.recordingsPath);
|
|
67
|
+
const innerList = async (limit = 10) => {
|
|
68
|
+
try {
|
|
69
|
+
//
|
|
70
|
+
// 7 directories, 5 files
|
|
71
|
+
const entries = await readdir(recordingsPath, { withFileTypes: true });
|
|
72
|
+
const capDirs = entries.filter((entry) => entry.isDirectory() && entry.name.endsWith('.cap'));
|
|
73
|
+
const recordings = await Promise.allSettled(capDirs.map(async (dir) => parseCapRecording(join(recordingsPath, dir.name))));
|
|
74
|
+
// logging errors
|
|
75
|
+
console.log(recordings
|
|
76
|
+
.filter((p) => p.status === 'rejected')
|
|
77
|
+
.map((p) => p.reason + '\n'));
|
|
78
|
+
return recordings
|
|
79
|
+
.filter((p) => p.status === 'fulfilled')
|
|
80
|
+
.map((x) => x.value)
|
|
81
|
+
.filter((r) => r !== null)
|
|
82
|
+
.sort((a, b) => b.capturedAt.getTime() - a.capturedAt.getTime())
|
|
83
|
+
.slice(0, limit);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
console.error('Failed to list Cap recordings:', error);
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
getLatestRecording: () => innerList(1).then((recordings) => recordings[0] ?? null),
|
|
92
|
+
listRecordings: innerList,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escribano - Intelligence Adapter (Ollama)
|
|
3
|
+
*
|
|
4
|
+
* Implements IntelligenceService using Ollama REST API
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
export function createIntelligenceService(config) {
|
|
9
|
+
return {
|
|
10
|
+
classify: (transcript) => classifyWithOllama(transcript, config),
|
|
11
|
+
extractMetadata: (transcript, classification) => extractMetadata(transcript, classification, config),
|
|
12
|
+
generate: (artifactType, context) => generateArtifact(artifactType, context, config),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
async function checkOllamaHealth() {
|
|
16
|
+
try {
|
|
17
|
+
const response = await fetch('http://localhost:11434/api/tags');
|
|
18
|
+
if (!response.ok) {
|
|
19
|
+
throw new Error('Ollama API not accessible');
|
|
20
|
+
}
|
|
21
|
+
const data = await response.json();
|
|
22
|
+
console.log('✓ Ollama is running and accessible');
|
|
23
|
+
console.log(` Available models: ${data.models?.length || 0}`);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
console.error('✗ Ollama is not running or not accessible');
|
|
27
|
+
console.error(' Error:', error.message);
|
|
28
|
+
console.error('');
|
|
29
|
+
console.error('Please start Ollama:');
|
|
30
|
+
console.error(' brew install ollama');
|
|
31
|
+
console.error(' ollama pull qwen3:32b');
|
|
32
|
+
console.error(' ollama serve');
|
|
33
|
+
console.error('');
|
|
34
|
+
throw new Error('Ollama service required for classification');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async function classifyWithOllama(transcript, config) {
|
|
38
|
+
console.log('Classifying transcript with Ollama...');
|
|
39
|
+
const tick = setInterval(() => {
|
|
40
|
+
process.stdout.write('.');
|
|
41
|
+
}, 1000);
|
|
42
|
+
await checkOllamaHealth();
|
|
43
|
+
const prompt = loadClassifyPrompt(transcript);
|
|
44
|
+
const raw = await callOllama(prompt, config, { expectJson: true });
|
|
45
|
+
clearInterval(tick);
|
|
46
|
+
console.log('\nClassification completed.');
|
|
47
|
+
const classification = {
|
|
48
|
+
meeting: raw.meeting * (raw.meeting <= 1 ? 100 : 1) || 0,
|
|
49
|
+
debugging: raw.debugging * (raw.debugging <= 1 ? 100 : 1) || 0,
|
|
50
|
+
tutorial: raw.tutorial * (raw.tutorial <= 1 ? 100 : 1) || 0,
|
|
51
|
+
learning: raw.learning * (raw.learning <= 1 ? 100 : 1) || 0,
|
|
52
|
+
working: raw.working * (raw.working <= 1 ? 100 : 1) || 0,
|
|
53
|
+
};
|
|
54
|
+
return classification;
|
|
55
|
+
}
|
|
56
|
+
function loadClassifyPrompt(transcript) {
|
|
57
|
+
const promptPath = join(process.cwd(), 'prompts', 'classify.md');
|
|
58
|
+
let prompt = readFileSync(promptPath, 'utf-8');
|
|
59
|
+
const segmentsText = transcript.segments
|
|
60
|
+
.map((seg) => `[seg-${seg.id}] [${seg.start}s - ${seg.end}s] ${seg.text}`)
|
|
61
|
+
.join('\n');
|
|
62
|
+
prompt = prompt.replace('{{TRANSCRIPT_ALL}}', transcript.fullText);
|
|
63
|
+
prompt = prompt.replace('{{TRANSCRIPT_SEGMENTS}}', segmentsText);
|
|
64
|
+
return prompt;
|
|
65
|
+
}
|
|
66
|
+
async function callOllama(prompt, config, options = { expectJson: true }) {
|
|
67
|
+
const { endpoint, model, maxRetries, timeout } = config;
|
|
68
|
+
let lastError = null;
|
|
69
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
70
|
+
try {
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
73
|
+
const response = await fetch(endpoint, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: {
|
|
76
|
+
'Content-Type': 'application/json',
|
|
77
|
+
},
|
|
78
|
+
body: JSON.stringify({
|
|
79
|
+
model,
|
|
80
|
+
messages: [
|
|
81
|
+
{
|
|
82
|
+
role: 'system',
|
|
83
|
+
content: options.expectJson
|
|
84
|
+
? 'You are a JSON-only output system. Output ONLY valid JSON, no other text.'
|
|
85
|
+
: 'You are a helpful assistant that generates high-quality markdown documentation.',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
role: 'user',
|
|
89
|
+
content: prompt,
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
stream: false,
|
|
93
|
+
...(options.expectJson && { format: 'json' }),
|
|
94
|
+
}),
|
|
95
|
+
signal: controller.signal,
|
|
96
|
+
});
|
|
97
|
+
clearTimeout(timeoutId);
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
|
|
100
|
+
}
|
|
101
|
+
const data = await response.json();
|
|
102
|
+
if (!data.done || data.done_reason !== 'stop') {
|
|
103
|
+
throw new Error(`Incomplete response: done=${data.done}, reason=${data.done_reason}`);
|
|
104
|
+
}
|
|
105
|
+
const content = data.message.content;
|
|
106
|
+
if (options.expectJson) {
|
|
107
|
+
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
|
108
|
+
if (!jsonMatch)
|
|
109
|
+
throw new Error('No JSON found in response');
|
|
110
|
+
return JSON.parse(jsonMatch[0]);
|
|
111
|
+
}
|
|
112
|
+
return content;
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
lastError = error;
|
|
116
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
117
|
+
console.log(`Attempt ${attempt}/${maxRetries}: Request timed out, retrying...`);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
console.log(`Attempt ${attempt}/${maxRetries}: Request failed, retrying...`);
|
|
121
|
+
}
|
|
122
|
+
if (attempt < maxRetries) {
|
|
123
|
+
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
throw new Error(`Request failed after ${maxRetries} retries: ${lastError?.message}`);
|
|
128
|
+
}
|
|
129
|
+
async function extractMetadata(transcript, classification, config) {
|
|
130
|
+
const prompt = loadMetadataPrompt(transcript, classification);
|
|
131
|
+
const raw = await callOllama(prompt, config, { expectJson: true });
|
|
132
|
+
return {
|
|
133
|
+
speakers: raw.speakers || [],
|
|
134
|
+
keyMoments: raw.keyMoments || [],
|
|
135
|
+
actionItems: raw.actionItems || [],
|
|
136
|
+
technicalTerms: raw.technicalTerms || [],
|
|
137
|
+
codeSnippets: raw.codeSnippets || [],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function loadMetadataPrompt(transcript, classification) {
|
|
141
|
+
const promptPath = join(process.cwd(), 'prompts', 'extract-metadata.md');
|
|
142
|
+
let prompt = readFileSync(promptPath, 'utf-8');
|
|
143
|
+
const classificationSummary = Object.entries(classification)
|
|
144
|
+
.filter(([_, score]) => score >= 25)
|
|
145
|
+
.map(([type, score]) => `${type}: ${score}%`)
|
|
146
|
+
.join(', ');
|
|
147
|
+
const segmentsText = transcript.segments
|
|
148
|
+
.map((seg) => `[${seg.start}s - ${seg.end}s] ${seg.text}`)
|
|
149
|
+
.join('\n');
|
|
150
|
+
prompt = prompt.replace('{{CLASSIFICATION_SUMMARY}}', classificationSummary);
|
|
151
|
+
prompt = prompt.replace('{{TRANSCRIPT_SEGMENTS}}', segmentsText);
|
|
152
|
+
prompt = prompt.replace('{{TRANSCRIPT_ALL}}', transcript.fullText);
|
|
153
|
+
return prompt;
|
|
154
|
+
}
|
|
155
|
+
function parseMetadataJson(content) {
|
|
156
|
+
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
|
157
|
+
if (!jsonMatch) {
|
|
158
|
+
throw new Error('No JSON object found in metadata extraction response');
|
|
159
|
+
}
|
|
160
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
161
|
+
return {
|
|
162
|
+
speakers: parsed.speakers || [],
|
|
163
|
+
keyMoments: parsed.keyMoments || [],
|
|
164
|
+
actionItems: parsed.actionItems || [],
|
|
165
|
+
technicalTerms: parsed.technicalTerms || [],
|
|
166
|
+
codeSnippets: parsed.codeSnippets || [],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
async function generateArtifact(artifactType, context, config) {
|
|
170
|
+
const prompt = loadArtifactPrompt(artifactType, context);
|
|
171
|
+
const response = await callOllama(prompt, config, { expectJson: false });
|
|
172
|
+
return response;
|
|
173
|
+
}
|
|
174
|
+
function loadArtifactPrompt(artifactType, context) {
|
|
175
|
+
const promptPath = join(process.cwd(), 'prompts', `${artifactType}.md`);
|
|
176
|
+
let prompt = readFileSync(promptPath, 'utf-8');
|
|
177
|
+
prompt = prompt.replace('{{TRANSCRIPT_ALL}}', context.transcript.fullText);
|
|
178
|
+
const segmentsText = context.transcript.segments
|
|
179
|
+
.map((seg) => `[${seg.start}s - ${seg.end}s] ${seg.text}`)
|
|
180
|
+
.join('\n');
|
|
181
|
+
prompt = prompt.replace('{{TRANSCRIPT_SEGMENTS}}', segmentsText);
|
|
182
|
+
const classificationSummary = Object.entries(context.classification)
|
|
183
|
+
.filter(([_, score]) => score >= 25)
|
|
184
|
+
.map(([type, score]) => `${type}: ${score}%`)
|
|
185
|
+
.join(', ');
|
|
186
|
+
prompt = prompt.replace('{{CLASSIFICATION_SUMMARY}}', classificationSummary);
|
|
187
|
+
if (context.metadata) {
|
|
188
|
+
prompt = prompt.replace('{{SPEAKERS}}', JSON.stringify(context.metadata.speakers || [], null, 2));
|
|
189
|
+
prompt = prompt.replace('{{KEY_MOMENTS}}', JSON.stringify(context.metadata.keyMoments || [], null, 2));
|
|
190
|
+
prompt = prompt.replace('{{ACTION_ITEMS}}', JSON.stringify(context.metadata.actionItems || [], null, 2));
|
|
191
|
+
prompt = prompt.replace('{{TECHNICAL_TERMS}}', JSON.stringify(context.metadata.technicalTerms || [], null, 2));
|
|
192
|
+
prompt = prompt.replace('{{CODE_SNIPPETS}}', JSON.stringify(context.metadata.codeSnippets || [], null, 2));
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
prompt = prompt.replace('{{SPEAKERS}}', 'N/A');
|
|
196
|
+
prompt = prompt.replace('{{KEY_MOMENTS}}', 'N/A');
|
|
197
|
+
prompt = prompt.replace('{{ACTION_ITEMS}}', 'N/A');
|
|
198
|
+
prompt = prompt.replace('{{TECHNICAL_TERMS}}', 'N/A');
|
|
199
|
+
prompt = prompt.replace('{{CODE_SNIPPETS}}', 'N/A');
|
|
200
|
+
}
|
|
201
|
+
return prompt;
|
|
202
|
+
}
|