samuraizer 0.0.1
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 +206 -0
- package/dist/checks/ffmpeg.js +9 -0
- package/dist/checks/ffprobe.js +9 -0
- package/dist/checks/ollama.js +12 -0
- package/dist/checks/whisper.js +9 -0
- package/dist/cli/commands/process.js +138 -0
- package/dist/cli/index.js +175 -0
- package/dist/cli/process-command.js +21 -0
- package/dist/config/defaults.js +7 -0
- package/dist/config/init.js +21 -0
- package/dist/config/load.js +57 -0
- package/dist/config/paths.js +20 -0
- package/dist/config/schema.js +12 -0
- package/dist/config/template.js +9 -0
- package/dist/config/types.js +1 -0
- package/dist/dev/run-tool.js +38 -0
- package/dist/dev/runProcessMeeting.js +21 -0
- package/dist/infra/ffmpeg/run-ffmpeg.js +29 -0
- package/dist/infra/ollama/ollama-client.js +42 -0
- package/dist/infra/whisper/run-whisper-cli.js +40 -0
- package/dist/lib/ollama.js +51 -0
- package/dist/lib/run-command.js +8 -0
- package/dist/orchestrators/process-meeting.js +196 -0
- package/dist/pipeline/analysis/action-items/generate.js +117 -0
- package/dist/pipeline/analysis/action-items/types.js +1 -0
- package/dist/pipeline/analysis/decisions/generate.js +101 -0
- package/dist/pipeline/analysis/decisions/types.js +1 -0
- package/dist/pipeline/analysis/summary/generate.js +24 -0
- package/dist/pipeline/analysis/summary/types.js +1 -0
- package/dist/pipeline/audio/normalize.js +11 -0
- package/dist/pipeline/audio/probe.js +37 -0
- package/dist/pipeline/audio/validate-input.js +46 -0
- package/dist/pipeline/output/paths.js +19 -0
- package/dist/pipeline/output/prepare.js +22 -0
- package/dist/pipeline/output/save.js +16 -0
- package/dist/pipeline/report/generate.js +54 -0
- package/dist/pipeline/transcription/transcribe.js +36 -0
- package/dist/pipeline/transcription/types.js +1 -0
- package/dist/shared/tool-definition.js +5 -0
- package/dist/shared/tool-registry.js +12 -0
- package/dist/tools/analysis/action-item-types.js +1 -0
- package/dist/tools/analysis/extract-action-items-tool.js +94 -0
- package/dist/tools/analysis/extract-decisions-tool.js +84 -0
- package/dist/tools/analysis/summarize-transcription-tool.js +44 -0
- package/dist/tools/input/normalize-audio-tool.js +24 -0
- package/dist/tools/transcription/transcribe-audio-tool.js +43 -0
- package/package.json +42 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const APP_DIR_NAME = "samuraizer";
|
|
4
|
+
const CONFIG_FILE_NAME = "config.json";
|
|
5
|
+
function getUserConfigDir() {
|
|
6
|
+
const home = os.homedir();
|
|
7
|
+
if (process.platform === "win32") {
|
|
8
|
+
return process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
|
|
9
|
+
}
|
|
10
|
+
if (process.platform === "darwin") {
|
|
11
|
+
return path.join(home, "Library", "Application Support");
|
|
12
|
+
}
|
|
13
|
+
return process.env.XDG_CONFIG_HOME ?? path.join(home, ".config");
|
|
14
|
+
}
|
|
15
|
+
export function getConfigDir() {
|
|
16
|
+
return path.join(getUserConfigDir(), APP_DIR_NAME);
|
|
17
|
+
}
|
|
18
|
+
export function getConfigFilePath() {
|
|
19
|
+
return path.join(getConfigDir(), CONFIG_FILE_NAME);
|
|
20
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const configSchema = z.object({
|
|
3
|
+
model: z.string().min(1),
|
|
4
|
+
ollamaBaseUrl: z.string().url(),
|
|
5
|
+
whisperCommand: z.string().min(1),
|
|
6
|
+
whisperModelPath: z.string().min(1),
|
|
7
|
+
language: z.string().min(1),
|
|
8
|
+
ffmpegCommand: z.string().min(1),
|
|
9
|
+
ffprobeCommand: z.string().min(1),
|
|
10
|
+
outputDir: z.string().min(1).optional(),
|
|
11
|
+
});
|
|
12
|
+
export const partialConfigSchema = configSchema.partial();
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const configTemplate = {
|
|
2
|
+
model: "qwen2.5:14b",
|
|
3
|
+
ollamaBaseUrl: "http://127.0.0.1:11434",
|
|
4
|
+
whisperCommand: "whisper-cli",
|
|
5
|
+
whisperModelPath: "/absolute/path/to/ggml-model.bin",
|
|
6
|
+
language: "auto",
|
|
7
|
+
ffmpegCommand: "ffmpeg",
|
|
8
|
+
ffprobeCommand: "ffprobe",
|
|
9
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { runTool } from "../shared/tool-definition.js";
|
|
2
|
+
import { tools } from "../shared/tool-registry.js";
|
|
3
|
+
async function main() {
|
|
4
|
+
const toolName = process.argv[2];
|
|
5
|
+
if (!toolName) {
|
|
6
|
+
throw new Error("Usage: tsx runTool.ts <tool-name>");
|
|
7
|
+
}
|
|
8
|
+
switch (toolName) {
|
|
9
|
+
case "normalize": {
|
|
10
|
+
const result = await runTool(tools.normalize_audio, {
|
|
11
|
+
inputPath: process.argv[3],
|
|
12
|
+
outputPath: process.argv[4] || "./normalized.wav",
|
|
13
|
+
});
|
|
14
|
+
console.log("normalize result:");
|
|
15
|
+
console.log(result);
|
|
16
|
+
break;
|
|
17
|
+
}
|
|
18
|
+
case "summarize": {
|
|
19
|
+
const text = process.argv[3];
|
|
20
|
+
if (!text) {
|
|
21
|
+
throw new Error("Provide transcript text");
|
|
22
|
+
}
|
|
23
|
+
const result = await runTool(tools.summarize_transcript, {
|
|
24
|
+
transcriptText: text,
|
|
25
|
+
model: process.env.SUMMARY_MODEL || "qwen2.5:14b",
|
|
26
|
+
});
|
|
27
|
+
console.log("summary:");
|
|
28
|
+
console.log(result.summary);
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
default:
|
|
32
|
+
throw new Error(`Unknown tool: ${toolName}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
main().catch((e) => {
|
|
36
|
+
console.error(e);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { processMeeting } from "../orchestrators/process-meeting.js";
|
|
2
|
+
async function main() {
|
|
3
|
+
const inputPath = process.argv[2];
|
|
4
|
+
if (!inputPath) {
|
|
5
|
+
throw new Error("Input path is required. Usage: tsx src/dev/runProcessMeeting.ts <audio-file>");
|
|
6
|
+
}
|
|
7
|
+
const result = await processMeeting({
|
|
8
|
+
inputPath,
|
|
9
|
+
outputRootDir: "./output",
|
|
10
|
+
whisperModelPath: process.env.WHISPER_MODEL_PATH || "",
|
|
11
|
+
summaryModel: process.env.SUMMARY_MODEL ?? "qwen2.5:14b",
|
|
12
|
+
language: process.env.TRANSCRIPT_LANGUAGE || "auto",
|
|
13
|
+
});
|
|
14
|
+
console.log("Process completed");
|
|
15
|
+
console.log(JSON.stringify(result, null, 2));
|
|
16
|
+
}
|
|
17
|
+
main().catch((error) => {
|
|
18
|
+
console.error("Process failed");
|
|
19
|
+
console.error(error);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
export async function runFfmpeg(args) {
|
|
3
|
+
await runCommand("ffmpeg", args);
|
|
4
|
+
}
|
|
5
|
+
async function runCommand(command, args) {
|
|
6
|
+
await new Promise((resolve, reject) => {
|
|
7
|
+
const child = spawn(command, args, {
|
|
8
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
9
|
+
});
|
|
10
|
+
let stderr = "";
|
|
11
|
+
let stdout = "";
|
|
12
|
+
child.stdout.on("data", (chunk) => {
|
|
13
|
+
stdout += String(chunk);
|
|
14
|
+
});
|
|
15
|
+
child.stderr.on("data", (chunk) => {
|
|
16
|
+
stderr += String(chunk);
|
|
17
|
+
});
|
|
18
|
+
child.on("error", (error) => {
|
|
19
|
+
reject(new Error(`Failed to start ${command}: ${error instanceof Error ? error.message : String(error)}`));
|
|
20
|
+
});
|
|
21
|
+
child.on("close", (code) => {
|
|
22
|
+
if (code === 0) {
|
|
23
|
+
resolve();
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
reject(new Error(`${command} exited with code ${code}\n stdout: ${stdout}\n stderr: ${stderr}`));
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export async function generateWithOllama({ model, prompt, format, stream = false, baseUrl = process.env.OLLAMA_BASE_URL || "http://localhost:11434" }) {
|
|
2
|
+
const body = {
|
|
3
|
+
model,
|
|
4
|
+
prompt,
|
|
5
|
+
stream,
|
|
6
|
+
};
|
|
7
|
+
if (format)
|
|
8
|
+
body.format = format;
|
|
9
|
+
let response;
|
|
10
|
+
try {
|
|
11
|
+
response = await fetch(`${baseUrl}/api/generate`, {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: {
|
|
14
|
+
"Content-Type": "application/json",
|
|
15
|
+
},
|
|
16
|
+
body: JSON.stringify(body),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
const message = error instanceof Error ? error.message : "Unknown network error";
|
|
21
|
+
throw new Error(`Failed to connect to Ollama API:\n${message}`);
|
|
22
|
+
}
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
const body = await response.text();
|
|
25
|
+
throw new Error(`Ollama API request failed with status ${response.status}: ${body}`);
|
|
26
|
+
}
|
|
27
|
+
let data;
|
|
28
|
+
try {
|
|
29
|
+
data = (await response.json());
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
throw new Error(`Failed to parse Ollama API response.`);
|
|
33
|
+
}
|
|
34
|
+
if (data.error) {
|
|
35
|
+
throw new Error(`Ollama API error: ${data.error}`);
|
|
36
|
+
}
|
|
37
|
+
const text = data.response?.trim();
|
|
38
|
+
if (!text) {
|
|
39
|
+
throw new Error(`Ollama API returned an empty response.`);
|
|
40
|
+
}
|
|
41
|
+
return text;
|
|
42
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
export async function runWhisperCli({ inputPath, outputTxtPath, modelPath, language }) {
|
|
4
|
+
const args = [
|
|
5
|
+
"-m",
|
|
6
|
+
modelPath,
|
|
7
|
+
"-f",
|
|
8
|
+
inputPath,
|
|
9
|
+
"-otxt",
|
|
10
|
+
"-of",
|
|
11
|
+
outputTxtPath.replace(/\.txt$/, ""),
|
|
12
|
+
];
|
|
13
|
+
if (language && language !== "auto") {
|
|
14
|
+
args.push("-l", language);
|
|
15
|
+
}
|
|
16
|
+
await new Promise((resolve, reject) => {
|
|
17
|
+
const child = spawn("whisper-cli", args, {
|
|
18
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
19
|
+
});
|
|
20
|
+
let stderr = "";
|
|
21
|
+
let stdout = "";
|
|
22
|
+
child.stdout.on("data", (chunk) => {
|
|
23
|
+
stdout += String(chunk);
|
|
24
|
+
});
|
|
25
|
+
child.stderr.on("data", (chunk) => {
|
|
26
|
+
stderr += String(chunk);
|
|
27
|
+
});
|
|
28
|
+
child.on("error", (error) => {
|
|
29
|
+
reject(new Error(`Failed to start whisper-cli: ${error instanceof Error ? error.message : String(error)}`));
|
|
30
|
+
});
|
|
31
|
+
child.on("close", (code) => {
|
|
32
|
+
if (code === 0) {
|
|
33
|
+
resolve();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
reject(new Error(`whisper-cli exited with code ${code}\nstdout:\n${stdout}\nstderr:\n${stderr}`));
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
return await readFile(outputTxtPath, "utf8");
|
|
40
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export async function callOllama({ baseUrl, model, format, temperature, messages, }) {
|
|
2
|
+
const body = {
|
|
3
|
+
model,
|
|
4
|
+
messages,
|
|
5
|
+
stream: false,
|
|
6
|
+
options: {
|
|
7
|
+
num_ctx: 16384,
|
|
8
|
+
...(temperature !== undefined && { options: { temperature } }),
|
|
9
|
+
},
|
|
10
|
+
...(format && { format }),
|
|
11
|
+
};
|
|
12
|
+
let response;
|
|
13
|
+
try {
|
|
14
|
+
response = await fetch(`${baseUrl}/api/chat`, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: { "Content-Type": "application/json" },
|
|
17
|
+
body: JSON.stringify(body),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
const message = error instanceof Error ? error.message : "Unknown network error";
|
|
22
|
+
throw new Error(`Failed to connect to Ollama API:\n${message}`);
|
|
23
|
+
}
|
|
24
|
+
if (!response.ok) {
|
|
25
|
+
const text = await safeReadText(response);
|
|
26
|
+
throw new Error(`Ollama API returned ${response.status} ${response.statusText}.\n${text}`);
|
|
27
|
+
}
|
|
28
|
+
let data;
|
|
29
|
+
try {
|
|
30
|
+
data = (await response.json());
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new Error("Failed to parse Ollama API JSON response.");
|
|
34
|
+
}
|
|
35
|
+
if (data.error) {
|
|
36
|
+
throw new Error(`Ollama API error: ${data.error}`);
|
|
37
|
+
}
|
|
38
|
+
const text = data.message?.content?.trim();
|
|
39
|
+
if (!text) {
|
|
40
|
+
throw new Error("Ollama API returned an empty response.");
|
|
41
|
+
}
|
|
42
|
+
return text;
|
|
43
|
+
}
|
|
44
|
+
async function safeReadText(response) {
|
|
45
|
+
try {
|
|
46
|
+
return await response.text();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { access, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ensureFfmpeg } from "../checks/ffmpeg.js";
|
|
4
|
+
import { ensureFfprobe } from "../checks/ffprobe.js";
|
|
5
|
+
import { ensureOllama } from "../checks/ollama.js";
|
|
6
|
+
import { ensureWhisperCli } from "../checks/whisper.js";
|
|
7
|
+
import { probeAudio } from "../pipeline/audio/probe.js";
|
|
8
|
+
import { validateInputFile } from "../pipeline/audio/validate-input.js";
|
|
9
|
+
import { prepareOutput } from "../pipeline/output/prepare.js";
|
|
10
|
+
import { saveMeta } from "../pipeline/output/save.js";
|
|
11
|
+
import { generateReport } from "../pipeline/report/generate.js";
|
|
12
|
+
import { runTool } from "../shared/tool-definition.js";
|
|
13
|
+
import { tools } from "../shared/tool-registry.js";
|
|
14
|
+
export async function processMeeting(input) {
|
|
15
|
+
const validatedFile = await validateInputFile(input.inputPath);
|
|
16
|
+
const outputRootDir = input.outputRootDir ?? path.dirname(validatedFile.resolvedPath);
|
|
17
|
+
const { paths, meta } = await prepareOutput(validatedFile, outputRootDir);
|
|
18
|
+
await ensureFfmpeg(input.ffmpegCommand);
|
|
19
|
+
await ensureFfprobe(input.ffprobeCommand);
|
|
20
|
+
await ensureWhisperCli(input.whisperCommand);
|
|
21
|
+
await ensureOllama(input.ollamaBaseUrl);
|
|
22
|
+
// Probe source audio
|
|
23
|
+
meta.input.audioMetadata = await probeAudio(validatedFile.resolvedPath, input.ffprobeCommand);
|
|
24
|
+
// Normalize audio
|
|
25
|
+
let normalized;
|
|
26
|
+
if (!input.force && await fileExists(paths.normalizedAudioPath)) {
|
|
27
|
+
console.log("Skipping normalization (cached).");
|
|
28
|
+
normalized = { normalizedAudioPath: paths.normalizedAudioPath };
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
console.log("Normalizing audio...");
|
|
32
|
+
normalized = await runTool(tools.normalize_audio, {
|
|
33
|
+
inputPath: validatedFile.resolvedPath,
|
|
34
|
+
outputPath: paths.normalizedAudioPath,
|
|
35
|
+
ffmpegCommand: input.ffmpegCommand,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
meta.output.normalizedAudioPath = normalized.normalizedAudioPath;
|
|
39
|
+
meta.output.normalizedAudioMetadata = await probeAudio(normalized.normalizedAudioPath, input.ffprobeCommand);
|
|
40
|
+
meta.status = "audio_normalized";
|
|
41
|
+
await saveMeta(paths, meta);
|
|
42
|
+
// Transcribe
|
|
43
|
+
let transcription;
|
|
44
|
+
if (!input.force && await fileExists(paths.transcriptJsonPath)) {
|
|
45
|
+
console.log("Skipping transcription (cached).");
|
|
46
|
+
const cached = await readJson(paths.transcriptJsonPath);
|
|
47
|
+
transcription = { ...cached, transcriptPath: paths.transcriptTextPath };
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
console.log("Transcribing audio...");
|
|
51
|
+
transcription = await runTool(tools.transcribe_audio, {
|
|
52
|
+
audioPath: normalized.normalizedAudioPath,
|
|
53
|
+
outputDir: paths.runDir,
|
|
54
|
+
modelPath: input.whisperModelPath,
|
|
55
|
+
language: input.language,
|
|
56
|
+
whisperCommand: input.whisperCommand,
|
|
57
|
+
});
|
|
58
|
+
await writeFile(paths.transcriptJsonPath, JSON.stringify({
|
|
59
|
+
text: transcription.text,
|
|
60
|
+
segments: transcription.segments,
|
|
61
|
+
sourceAudioPath: transcription.sourceAudioPath,
|
|
62
|
+
}, null, 2), "utf-8");
|
|
63
|
+
}
|
|
64
|
+
meta.output.transcriptTextPath = transcription.transcriptPath;
|
|
65
|
+
meta.output.transcriptJsonPath = paths.transcriptJsonPath;
|
|
66
|
+
meta.transcription = {
|
|
67
|
+
engine: "whisper.cpp",
|
|
68
|
+
modelPath: input.whisperModelPath,
|
|
69
|
+
textLength: transcription.text.length,
|
|
70
|
+
};
|
|
71
|
+
meta.status = "transcribed";
|
|
72
|
+
await saveMeta(paths, meta);
|
|
73
|
+
// Summarize
|
|
74
|
+
let summaryResult;
|
|
75
|
+
if (!input.force && await fileExists(paths.summaryJsonPath)) {
|
|
76
|
+
console.log("Skipping summary (cached).");
|
|
77
|
+
summaryResult = await readJson(paths.summaryJsonPath);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
console.log("Generating summary...");
|
|
81
|
+
const { summary } = await runTool(tools.summarize_transcript, {
|
|
82
|
+
transcriptText: transcription.text,
|
|
83
|
+
model: input.model,
|
|
84
|
+
ollamaBaseUrl: input.ollamaBaseUrl,
|
|
85
|
+
});
|
|
86
|
+
const createdAt = new Date().toISOString();
|
|
87
|
+
summaryResult = { summary, model: input.model, sourceTranscriptPath: transcription.transcriptPath, createdAt };
|
|
88
|
+
await writeFile(paths.summaryTextPath, summary, "utf-8");
|
|
89
|
+
await writeFile(paths.summaryJsonPath, JSON.stringify(summaryResult, null, 2), "utf-8");
|
|
90
|
+
}
|
|
91
|
+
meta.output.summaryTextPath = paths.summaryTextPath;
|
|
92
|
+
meta.output.summaryJsonPath = paths.summaryJsonPath;
|
|
93
|
+
meta.summary = { model: input.model, textLength: summaryResult.summary.length };
|
|
94
|
+
meta.status = "summarized";
|
|
95
|
+
await saveMeta(paths, meta);
|
|
96
|
+
// Extract action items
|
|
97
|
+
let actionItemsResult;
|
|
98
|
+
if (!input.force && await fileExists(paths.actionItemsJsonPath)) {
|
|
99
|
+
console.log("Skipping action items (cached).");
|
|
100
|
+
actionItemsResult = await readJson(paths.actionItemsJsonPath);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
console.log("Extracting action items...");
|
|
104
|
+
const { items: actionItems } = await runTool(tools.extract_action_items, {
|
|
105
|
+
transcriptText: transcription.text,
|
|
106
|
+
model: input.model,
|
|
107
|
+
ollamaBaseUrl: input.ollamaBaseUrl,
|
|
108
|
+
});
|
|
109
|
+
actionItemsResult = { items: actionItems, model: input.model, sourceTranscriptPath: transcription.transcriptPath, createdAt: summaryResult.createdAt };
|
|
110
|
+
await writeFile(paths.actionItemsTextPath, formatActionItems(actionItems), "utf-8");
|
|
111
|
+
await writeFile(paths.actionItemsJsonPath, JSON.stringify(actionItemsResult, null, 2), "utf-8");
|
|
112
|
+
}
|
|
113
|
+
meta.output.actionItemsTextPath = paths.actionItemsTextPath;
|
|
114
|
+
meta.output.actionItemsJsonPath = paths.actionItemsJsonPath;
|
|
115
|
+
meta.actionItems = { model: input.model, count: actionItemsResult.items.length };
|
|
116
|
+
meta.status = "action_items_extracted";
|
|
117
|
+
await saveMeta(paths, meta);
|
|
118
|
+
// Extract decisions
|
|
119
|
+
let decisionsResult;
|
|
120
|
+
if (!input.force && await fileExists(paths.decisionsJsonPath)) {
|
|
121
|
+
console.log("Skipping decisions (cached).");
|
|
122
|
+
decisionsResult = await readJson(paths.decisionsJsonPath);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
console.log("Extracting decisions...");
|
|
126
|
+
const { items: decisions } = await runTool(tools.extract_decisions, {
|
|
127
|
+
transcriptText: transcription.text,
|
|
128
|
+
model: input.model,
|
|
129
|
+
ollamaBaseUrl: input.ollamaBaseUrl,
|
|
130
|
+
});
|
|
131
|
+
decisionsResult = { items: decisions, model: input.model, sourceTranscriptPath: transcription.transcriptPath, createdAt: summaryResult.createdAt };
|
|
132
|
+
await writeFile(paths.decisionsTextPath, formatDecisions(decisions), "utf-8");
|
|
133
|
+
await writeFile(paths.decisionsJsonPath, JSON.stringify(decisionsResult, null, 2), "utf-8");
|
|
134
|
+
}
|
|
135
|
+
meta.output.decisionsTextPath = paths.decisionsTextPath;
|
|
136
|
+
meta.output.decisionsJsonPath = paths.decisionsJsonPath;
|
|
137
|
+
meta.decisions = { model: input.model, count: decisionsResult.items.length };
|
|
138
|
+
meta.status = "decisions_extracted";
|
|
139
|
+
await saveMeta(paths, meta);
|
|
140
|
+
// Generate report
|
|
141
|
+
if (!input.force && await fileExists(paths.reportMarkdownPath)) {
|
|
142
|
+
console.log("Skipping report (cached).");
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
console.log("Generating report...");
|
|
146
|
+
await generateReport({
|
|
147
|
+
reportPath: paths.reportMarkdownPath,
|
|
148
|
+
meetingTitle: validatedFile.baseName,
|
|
149
|
+
sourceFileName: validatedFile.fileName,
|
|
150
|
+
transcript: {
|
|
151
|
+
text: transcription.text,
|
|
152
|
+
segments: transcription.segments,
|
|
153
|
+
sourceAudioPath: transcription.sourceAudioPath,
|
|
154
|
+
},
|
|
155
|
+
summary: summaryResult,
|
|
156
|
+
actionItems: actionItemsResult,
|
|
157
|
+
decisions: decisionsResult,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
meta.output.reportMarkdownPath = paths.reportMarkdownPath;
|
|
161
|
+
meta.report = { generated: true };
|
|
162
|
+
meta.status = "report_generated";
|
|
163
|
+
await saveMeta(paths, meta);
|
|
164
|
+
return { paths, meta };
|
|
165
|
+
}
|
|
166
|
+
async function fileExists(filePath) {
|
|
167
|
+
try {
|
|
168
|
+
await access(filePath);
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function readJson(filePath) {
|
|
176
|
+
const raw = await readFile(filePath, "utf-8");
|
|
177
|
+
return JSON.parse(raw);
|
|
178
|
+
}
|
|
179
|
+
function formatActionItems(items) {
|
|
180
|
+
if (items.length === 0)
|
|
181
|
+
return "No action items found.";
|
|
182
|
+
return items
|
|
183
|
+
.map((item) => {
|
|
184
|
+
const details = [
|
|
185
|
+
item.owner ? `owner: ${item.owner}` : null,
|
|
186
|
+
item.dueDate ? `due: ${item.dueDate}` : null,
|
|
187
|
+
].filter(Boolean);
|
|
188
|
+
return details.length > 0 ? `- ${item.text} — ${details.join(", ")}` : `- ${item.text}`;
|
|
189
|
+
})
|
|
190
|
+
.join("\n");
|
|
191
|
+
}
|
|
192
|
+
function formatDecisions(items) {
|
|
193
|
+
if (items.length === 0)
|
|
194
|
+
return "No decisions found.";
|
|
195
|
+
return items.map((item) => `- ${item.text}`).join("\n");
|
|
196
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import z from "zod";
|
|
3
|
+
import { callOllama } from "../../../lib/ollama.js";
|
|
4
|
+
const actionItemSchema = z.object({
|
|
5
|
+
text: z.string().min(1),
|
|
6
|
+
owner: z.string().nullable(),
|
|
7
|
+
dueDate: z.string().nullable(),
|
|
8
|
+
});
|
|
9
|
+
const actionItemsResponseSchema = z.object({
|
|
10
|
+
items: z.array(actionItemSchema),
|
|
11
|
+
});
|
|
12
|
+
const actionItemsJsonSchema = {
|
|
13
|
+
type: "object",
|
|
14
|
+
properties: {
|
|
15
|
+
items: {
|
|
16
|
+
type: "array",
|
|
17
|
+
items: {
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: {
|
|
20
|
+
text: { type: "string" },
|
|
21
|
+
owner: { type: ["string", "null"] },
|
|
22
|
+
dueDate: { type: ["string", "null"] },
|
|
23
|
+
},
|
|
24
|
+
required: ["text", "owner", "dueDate"],
|
|
25
|
+
additionalProperties: false,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
required: ["items"],
|
|
30
|
+
additionalProperties: false,
|
|
31
|
+
};
|
|
32
|
+
export async function generateActionItemsWithOllama({ transcript, model, actionItemsTextPath, sourceTranscriptPath, }) {
|
|
33
|
+
const prompt = buildActionItemsPrompt(transcript);
|
|
34
|
+
const rawText = await callOllama({ model, prompt, format: actionItemsJsonSchema, temperature: 0 });
|
|
35
|
+
let parsedJson;
|
|
36
|
+
try {
|
|
37
|
+
parsedJson = JSON.parse(rawText);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new Error(`Failed to parse action items JSON.\nRaw response:\n${rawText}`);
|
|
41
|
+
}
|
|
42
|
+
parsedJson = normalizeActionItemsJson(parsedJson);
|
|
43
|
+
const validated = actionItemsResponseSchema.safeParse(parsedJson);
|
|
44
|
+
if (!validated.success) {
|
|
45
|
+
throw new Error(`Action items JSON does not match schema.\n${validated.error.message}\nRaw response:\n${rawText}`);
|
|
46
|
+
}
|
|
47
|
+
const items = validated.data.items.filter((item) => item.text.trim().length > 0);
|
|
48
|
+
await writeFile(actionItemsTextPath, toActionItemsText(items), "utf-8");
|
|
49
|
+
return { items, model, sourceTranscriptPath, createdAt: new Date().toISOString() };
|
|
50
|
+
}
|
|
51
|
+
export function buildActionItemsPrompt(transcript) {
|
|
52
|
+
return [
|
|
53
|
+
"Extract action items from the meeting transcript.",
|
|
54
|
+
"Return ONLY valid JSON.",
|
|
55
|
+
"Do not return markdown.",
|
|
56
|
+
"Do not return explanations.",
|
|
57
|
+
"Do not return any text before or after JSON.",
|
|
58
|
+
"",
|
|
59
|
+
"Required JSON schema:",
|
|
60
|
+
`{
|
|
61
|
+
"items": [
|
|
62
|
+
{
|
|
63
|
+
"text": "string",
|
|
64
|
+
"owner": "string or null",
|
|
65
|
+
"dueDate": "string or null"
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
}`,
|
|
69
|
+
"",
|
|
70
|
+
"Rules:",
|
|
71
|
+
"- Include only concrete tasks or follow-up actions.",
|
|
72
|
+
"- If no clear owner is mentioned, use null.",
|
|
73
|
+
"- If no due date is mentioned, use null.",
|
|
74
|
+
"- If there are no action items, return {'items':[]}.",
|
|
75
|
+
"",
|
|
76
|
+
"Transcript:",
|
|
77
|
+
transcript,
|
|
78
|
+
].join("\n");
|
|
79
|
+
}
|
|
80
|
+
function toActionItemsText(items) {
|
|
81
|
+
if (items.length === 0)
|
|
82
|
+
return "No action items found.";
|
|
83
|
+
return items
|
|
84
|
+
.map((item) => {
|
|
85
|
+
const details = [
|
|
86
|
+
item.owner ? `owner: ${item.owner}` : null,
|
|
87
|
+
item.dueDate ? `due: ${item.dueDate}` : null,
|
|
88
|
+
].filter(Boolean);
|
|
89
|
+
return details.length > 0 ? `- ${item.text} - ${details.join(", ")}` : `- ${item.text}`;
|
|
90
|
+
})
|
|
91
|
+
.join("\n");
|
|
92
|
+
}
|
|
93
|
+
function normalizeActionItemsJson(input) {
|
|
94
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
95
|
+
throw new Error(`Action items response is not an object:\n${JSON.stringify(input, null, 2)}`);
|
|
96
|
+
}
|
|
97
|
+
const obj = input;
|
|
98
|
+
if (!("items" in obj)) {
|
|
99
|
+
throw new Error(`Action items response does not contain "items":\n${JSON.stringify(input, null, 2)}`);
|
|
100
|
+
}
|
|
101
|
+
if (!Array.isArray(obj.items)) {
|
|
102
|
+
throw new Error(`Action items field "items" is not an array:\n${JSON.stringify(input, null, 2)}`);
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
items: obj.items.map((item) => {
|
|
106
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
107
|
+
return { text: "", owner: null, dueDate: null };
|
|
108
|
+
}
|
|
109
|
+
const i = item;
|
|
110
|
+
return {
|
|
111
|
+
text: typeof i.text === "string" ? i.text : "",
|
|
112
|
+
owner: typeof i.owner === "string" ? i.owner : null,
|
|
113
|
+
dueDate: typeof i.dueDate === "string" ? i.dueDate : null,
|
|
114
|
+
};
|
|
115
|
+
}),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|