unrag 0.2.1 → 0.2.3
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 +2 -2
- package/dist/cli/index.js +251 -42
- package/package.json +2 -1
- package/registry/config/unrag.config.ts +140 -7
- package/registry/connectors/notion/render.ts +78 -0
- package/registry/connectors/notion/sync.ts +12 -3
- package/registry/connectors/notion/types.ts +3 -1
- package/registry/core/assets.ts +54 -0
- package/registry/core/config.ts +150 -0
- package/registry/core/context-engine.ts +69 -1
- package/registry/core/index.ts +15 -2
- package/registry/core/ingest.ts +743 -17
- package/registry/core/types.ts +606 -0
- package/registry/docs/unrag.md +6 -0
- package/registry/embedding/ai.ts +89 -8
- package/registry/extractors/_shared/fetch.ts +113 -0
- package/registry/extractors/_shared/media.ts +14 -0
- package/registry/extractors/_shared/text.ts +11 -0
- package/registry/extractors/audio-transcribe/index.ts +75 -0
- package/registry/extractors/file-docx/index.ts +53 -0
- package/registry/extractors/file-pptx/index.ts +92 -0
- package/registry/extractors/file-text/index.ts +85 -0
- package/registry/extractors/file-xlsx/index.ts +58 -0
- package/registry/extractors/image-caption-llm/index.ts +60 -0
- package/registry/extractors/image-ocr/index.ts +60 -0
- package/registry/extractors/pdf-llm/index.ts +84 -0
- package/registry/extractors/pdf-ocr/index.ts +125 -0
- package/registry/extractors/pdf-text-layer/index.ts +76 -0
- package/registry/extractors/video-frames/index.ts +126 -0
- package/registry/extractors/video-transcribe/index.ts +78 -0
- package/registry/store/drizzle-postgres-pgvector/store.ts +1 -1
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { generateText } from "ai";
|
|
2
|
+
import type { AssetExtractor } from "../../core/types";
|
|
3
|
+
import { getAssetBytes } from "../_shared/fetch";
|
|
4
|
+
import { normalizeMediaType } from "../_shared/media";
|
|
5
|
+
import { capText } from "../_shared/text";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Image OCR via a vision-capable LLM.
|
|
9
|
+
*
|
|
10
|
+
* This extractor is intended for screenshots, charts, diagrams, and any image with embedded text.
|
|
11
|
+
*/
|
|
12
|
+
export function createImageOcrExtractor(): AssetExtractor {
|
|
13
|
+
return {
|
|
14
|
+
name: "image:ocr",
|
|
15
|
+
supports: ({ asset, ctx }) =>
|
|
16
|
+
asset.kind === "image" && ctx.assetProcessing.image.ocr.enabled,
|
|
17
|
+
extract: async ({ asset, ctx }) => {
|
|
18
|
+
const cfg = ctx.assetProcessing.image.ocr;
|
|
19
|
+
const fetchConfig = ctx.assetProcessing.fetch;
|
|
20
|
+
|
|
21
|
+
const maxBytes = Math.min(cfg.maxBytes, fetchConfig.maxBytes);
|
|
22
|
+
const { bytes, mediaType } = await getAssetBytes({
|
|
23
|
+
data: asset.data,
|
|
24
|
+
fetchConfig,
|
|
25
|
+
maxBytes,
|
|
26
|
+
defaultMediaType: "image/jpeg",
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const abortSignal = AbortSignal.timeout(cfg.timeoutMs);
|
|
30
|
+
|
|
31
|
+
const result = await generateText({
|
|
32
|
+
model: cfg.model as any,
|
|
33
|
+
abortSignal,
|
|
34
|
+
messages: [
|
|
35
|
+
{
|
|
36
|
+
role: "user",
|
|
37
|
+
content: [
|
|
38
|
+
{ type: "text", text: cfg.prompt },
|
|
39
|
+
{
|
|
40
|
+
type: "image",
|
|
41
|
+
image: bytes,
|
|
42
|
+
mediaType: normalizeMediaType(mediaType),
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const text = String((result as any)?.text ?? "").trim();
|
|
50
|
+
if (!text) return { texts: [], diagnostics: { model: cfg.model } };
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
texts: [{ label: "ocr", content: capText(text, cfg.maxOutputChars) }],
|
|
54
|
+
diagnostics: { model: cfg.model },
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { generateText } from "ai";
|
|
2
|
+
import type { AssetData, AssetExtractor, AssetFetchConfig } from "../../core/types";
|
|
3
|
+
import { getAssetBytes } from "../_shared/fetch";
|
|
4
|
+
import { normalizeMediaType } from "../_shared/media";
|
|
5
|
+
import { capText } from "../_shared/text";
|
|
6
|
+
|
|
7
|
+
async function getPdfBytes(args: {
|
|
8
|
+
data: AssetData;
|
|
9
|
+
fetchConfig: AssetFetchConfig;
|
|
10
|
+
maxBytes: number;
|
|
11
|
+
}): Promise<{ bytes: Uint8Array; mediaType: string; filename?: string }> {
|
|
12
|
+
return await getAssetBytes({
|
|
13
|
+
data: args.data,
|
|
14
|
+
fetchConfig: args.fetchConfig,
|
|
15
|
+
maxBytes: args.maxBytes,
|
|
16
|
+
defaultMediaType: "application/pdf",
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* PDF text extraction via LLM (default model: Gemini via AI Gateway).
|
|
22
|
+
*
|
|
23
|
+
* This extractor reads its configuration from `assetProcessing.pdf.llmExtraction`.
|
|
24
|
+
*/
|
|
25
|
+
export function createPdfLlmExtractor(): AssetExtractor {
|
|
26
|
+
return {
|
|
27
|
+
name: "pdf:llm",
|
|
28
|
+
supports: ({ asset, ctx }) =>
|
|
29
|
+
asset.kind === "pdf" && ctx.assetProcessing.pdf.llmExtraction.enabled,
|
|
30
|
+
extract: async ({ asset, ctx }) => {
|
|
31
|
+
const llm = ctx.assetProcessing.pdf.llmExtraction;
|
|
32
|
+
const fetchConfig = ctx.assetProcessing.fetch;
|
|
33
|
+
|
|
34
|
+
if (!llm.enabled) {
|
|
35
|
+
return { texts: [] };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const maxBytes = Math.min(llm.maxBytes, fetchConfig.maxBytes);
|
|
39
|
+
const { bytes, mediaType, filename } = await getPdfBytes({
|
|
40
|
+
data: asset.data,
|
|
41
|
+
fetchConfig,
|
|
42
|
+
maxBytes,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
if (bytes.byteLength > maxBytes) {
|
|
46
|
+
throw new Error(`PDF too large (${bytes.byteLength} > ${maxBytes})`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const abortSignal = AbortSignal.timeout(llm.timeoutMs);
|
|
50
|
+
|
|
51
|
+
const result = await generateText({
|
|
52
|
+
// Intentionally allow string model ids for AI Gateway usage.
|
|
53
|
+
model: llm.model as any,
|
|
54
|
+
abortSignal,
|
|
55
|
+
messages: [
|
|
56
|
+
{
|
|
57
|
+
role: "user",
|
|
58
|
+
content: [
|
|
59
|
+
{ type: "text", text: llm.prompt },
|
|
60
|
+
{
|
|
61
|
+
type: "file",
|
|
62
|
+
data: bytes,
|
|
63
|
+
mediaType: normalizeMediaType(mediaType) ?? "application/pdf",
|
|
64
|
+
...(filename ? { filename } : {}),
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const text = String((result as any)?.text ?? "").trim();
|
|
72
|
+
if (!text) return { texts: [], diagnostics: { model: llm.model } };
|
|
73
|
+
|
|
74
|
+
const capped = capText(text, llm.maxOutputChars);
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
texts: [{ label: "fulltext", content: capped }],
|
|
78
|
+
diagnostics: { model: llm.model },
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type { AssetExtractor } from "../../core/types";
|
|
6
|
+
import { getAssetBytes } from "../_shared/fetch";
|
|
7
|
+
import { capText } from "../_shared/text";
|
|
8
|
+
|
|
9
|
+
const run = async (cmd: string, args: string[], opts: { cwd: string }) => {
|
|
10
|
+
return await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
|
11
|
+
const child = spawn(cmd, args, { cwd: opts.cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
12
|
+
let stdout = "";
|
|
13
|
+
let stderr = "";
|
|
14
|
+
child.stdout.on("data", (d) => (stdout += d.toString()));
|
|
15
|
+
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
16
|
+
child.on("error", reject);
|
|
17
|
+
child.on("close", (code) => {
|
|
18
|
+
if (code === 0) return resolve({ stdout, stderr });
|
|
19
|
+
reject(new Error(`${cmd} exited with code ${code}\n${stderr}`.trim()));
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Worker-only PDF OCR extractor.
|
|
26
|
+
*
|
|
27
|
+
* This extractor expects external binaries to be available:
|
|
28
|
+
* - `pdftoppm` (Poppler) to rasterize pages
|
|
29
|
+
* - `tesseract` to OCR rasterized images
|
|
30
|
+
*
|
|
31
|
+
* It is intentionally not serverless-friendly.
|
|
32
|
+
*/
|
|
33
|
+
export function createPdfOcrExtractor(): AssetExtractor {
|
|
34
|
+
return {
|
|
35
|
+
name: "pdf:ocr",
|
|
36
|
+
supports: ({ asset, ctx }) =>
|
|
37
|
+
asset.kind === "pdf" && ctx.assetProcessing.pdf.ocr.enabled,
|
|
38
|
+
extract: async ({ asset, ctx }) => {
|
|
39
|
+
const cfg = ctx.assetProcessing.pdf.ocr;
|
|
40
|
+
const fetchConfig = ctx.assetProcessing.fetch;
|
|
41
|
+
|
|
42
|
+
const maxBytes = Math.min(cfg.maxBytes, fetchConfig.maxBytes);
|
|
43
|
+
const { bytes } = await getAssetBytes({
|
|
44
|
+
data: asset.data,
|
|
45
|
+
fetchConfig,
|
|
46
|
+
maxBytes,
|
|
47
|
+
defaultMediaType: "application/pdf",
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const tmpDir = path.join(os.tmpdir(), `unrag-pdf-ocr-${crypto.randomUUID()}`);
|
|
51
|
+
await mkdir(tmpDir, { recursive: true });
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const pdfPath = path.join(tmpDir, "input.pdf");
|
|
55
|
+
await writeFile(pdfPath, bytes);
|
|
56
|
+
|
|
57
|
+
const prefix = path.join(tmpDir, "page");
|
|
58
|
+
const pdftoppm = cfg.pdftoppmPath ?? "pdftoppm";
|
|
59
|
+
const dpi = cfg.dpi ?? 200;
|
|
60
|
+
|
|
61
|
+
const pdftoppmArgs = [
|
|
62
|
+
"-png",
|
|
63
|
+
"-r",
|
|
64
|
+
String(dpi),
|
|
65
|
+
"-f",
|
|
66
|
+
"1",
|
|
67
|
+
...(cfg.maxPages ? ["-l", String(cfg.maxPages)] : []),
|
|
68
|
+
pdfPath,
|
|
69
|
+
prefix,
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
await run(pdftoppm, pdftoppmArgs, { cwd: tmpDir });
|
|
73
|
+
|
|
74
|
+
const files = (await readdir(tmpDir)).filter((f) =>
|
|
75
|
+
/^page-\d+\.png$/.test(f)
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Sort by page number: page-1.png, page-2.png, ...
|
|
79
|
+
files.sort((a, b) => {
|
|
80
|
+
const na = Number(a.match(/^page-(\d+)\.png$/)?.[1] ?? 0);
|
|
81
|
+
const nb = Number(b.match(/^page-(\d+)\.png$/)?.[1] ?? 0);
|
|
82
|
+
return na - nb;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const tesseract = cfg.tesseractPath ?? "tesseract";
|
|
86
|
+
const lang = cfg.lang ?? "eng";
|
|
87
|
+
|
|
88
|
+
let out = "";
|
|
89
|
+
for (const f of files) {
|
|
90
|
+
const imgPath = path.join(tmpDir, f);
|
|
91
|
+
const { stdout } = await run(
|
|
92
|
+
tesseract,
|
|
93
|
+
[imgPath, "stdout", "-l", lang],
|
|
94
|
+
{ cwd: tmpDir }
|
|
95
|
+
);
|
|
96
|
+
const text = String(stdout ?? "").trim();
|
|
97
|
+
if (text) {
|
|
98
|
+
out += (out ? "\n\n" : "") + text;
|
|
99
|
+
}
|
|
100
|
+
if (out.length >= cfg.maxOutputChars) {
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
out = capText(out.trim(), cfg.maxOutputChars);
|
|
106
|
+
if (out.length < cfg.minChars) {
|
|
107
|
+
return { texts: [] };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
texts: [
|
|
112
|
+
{
|
|
113
|
+
label: "ocr",
|
|
114
|
+
content: out,
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
} finally {
|
|
119
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { AssetExtractor } from "../../core/types";
|
|
2
|
+
import { getAssetBytes } from "../_shared/fetch";
|
|
3
|
+
import { capText } from "../_shared/text";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fast/cheap PDF extraction using the PDF's built-in text layer.
|
|
7
|
+
*
|
|
8
|
+
* This extractor is best-effort: if the PDF has little/no embedded text (scanned PDFs),
|
|
9
|
+
* it returns empty output so the pipeline can fall back to another extractor (e.g. `pdf:llm`).
|
|
10
|
+
*
|
|
11
|
+
* Dependencies (installed by CLI):
|
|
12
|
+
* - `pdfjs-dist`
|
|
13
|
+
*/
|
|
14
|
+
export function createPdfTextLayerExtractor(): AssetExtractor {
|
|
15
|
+
return {
|
|
16
|
+
name: "pdf:text-layer",
|
|
17
|
+
supports: ({ asset, ctx }) =>
|
|
18
|
+
asset.kind === "pdf" && ctx.assetProcessing.pdf.textLayer.enabled,
|
|
19
|
+
extract: async ({ asset, ctx }) => {
|
|
20
|
+
const cfg = ctx.assetProcessing.pdf.textLayer;
|
|
21
|
+
const fetchConfig = ctx.assetProcessing.fetch;
|
|
22
|
+
|
|
23
|
+
const maxBytes = Math.min(cfg.maxBytes, fetchConfig.maxBytes);
|
|
24
|
+
const { bytes } = await getAssetBytes({
|
|
25
|
+
data: asset.data,
|
|
26
|
+
fetchConfig,
|
|
27
|
+
maxBytes,
|
|
28
|
+
defaultMediaType: "application/pdf",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Dynamic import so the core package can be used without pdfjs unless this extractor is installed.
|
|
32
|
+
const pdfjs: any = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
|
33
|
+
|
|
34
|
+
const doc = await pdfjs.getDocument({ data: bytes }).promise;
|
|
35
|
+
const totalPages: number = Number(doc?.numPages ?? 0);
|
|
36
|
+
const maxPages = Math.max(
|
|
37
|
+
0,
|
|
38
|
+
Math.min(totalPages, cfg.maxPages ?? totalPages)
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
let out = "";
|
|
42
|
+
for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
|
|
43
|
+
const page = await doc.getPage(pageNum);
|
|
44
|
+
const textContent = await page.getTextContent();
|
|
45
|
+
const items: any[] = Array.isArray(textContent?.items)
|
|
46
|
+
? textContent.items
|
|
47
|
+
: [];
|
|
48
|
+
const pageText = items
|
|
49
|
+
.map((it) => (typeof it?.str === "string" ? it.str : ""))
|
|
50
|
+
.join(" ")
|
|
51
|
+
.replace(/\s+/g, " ")
|
|
52
|
+
.trim();
|
|
53
|
+
if (pageText) {
|
|
54
|
+
out += (out ? "\n\n" : "") + pageText;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
out = out.trim();
|
|
59
|
+
if (out.length < cfg.minChars) {
|
|
60
|
+
return { texts: [] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
texts: [
|
|
65
|
+
{
|
|
66
|
+
label: "text-layer",
|
|
67
|
+
content: capText(out, cfg.maxOutputChars),
|
|
68
|
+
pageRange: totalPages ? [1, maxPages || totalPages] : undefined,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { generateText } from "ai";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import type { AssetExtractor } from "../../core/types";
|
|
7
|
+
import { getAssetBytes } from "../_shared/fetch";
|
|
8
|
+
import { capText } from "../_shared/text";
|
|
9
|
+
|
|
10
|
+
const run = async (cmd: string, args: string[], opts: { cwd: string }) => {
|
|
11
|
+
return await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
|
12
|
+
const child = spawn(cmd, args, { cwd: opts.cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
13
|
+
let stdout = "";
|
|
14
|
+
let stderr = "";
|
|
15
|
+
child.stdout.on("data", (d) => (stdout += d.toString()));
|
|
16
|
+
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
17
|
+
child.on("error", reject);
|
|
18
|
+
child.on("close", (code) => {
|
|
19
|
+
if (code === 0) return resolve({ stdout, stderr });
|
|
20
|
+
reject(new Error(`${cmd} exited with code ${code}\n${stderr}`.trim()));
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Worker-only frame sampling + per-frame vision extraction.
|
|
27
|
+
*
|
|
28
|
+
* This extractor requires `ffmpeg` and is not suitable for serverless runtimes.
|
|
29
|
+
*/
|
|
30
|
+
export function createVideoFramesExtractor(): AssetExtractor {
|
|
31
|
+
return {
|
|
32
|
+
name: "video:frames",
|
|
33
|
+
supports: ({ asset, ctx }) =>
|
|
34
|
+
asset.kind === "video" && ctx.assetProcessing.video.frames.enabled,
|
|
35
|
+
extract: async ({ asset, ctx }) => {
|
|
36
|
+
const cfg = ctx.assetProcessing.video.frames;
|
|
37
|
+
const fetchConfig = ctx.assetProcessing.fetch;
|
|
38
|
+
|
|
39
|
+
const maxBytes = Math.min(cfg.maxBytes, fetchConfig.maxBytes);
|
|
40
|
+
const { bytes } = await getAssetBytes({
|
|
41
|
+
data: asset.data,
|
|
42
|
+
fetchConfig,
|
|
43
|
+
maxBytes,
|
|
44
|
+
defaultMediaType: "video/mp4",
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const tmpDir = path.join(os.tmpdir(), `unrag-video-frames-${crypto.randomUUID()}`);
|
|
48
|
+
await mkdir(tmpDir, { recursive: true });
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const videoPath = path.join(tmpDir, "input.mp4");
|
|
52
|
+
await writeFile(videoPath, bytes);
|
|
53
|
+
|
|
54
|
+
const ffmpeg = cfg.ffmpegPath ?? "ffmpeg";
|
|
55
|
+
const outPattern = path.join(tmpDir, "frame-%03d.jpg");
|
|
56
|
+
const fps = Math.max(0.001, cfg.sampleFps);
|
|
57
|
+
const maxFrames = Math.max(1, Math.floor(cfg.maxFrames));
|
|
58
|
+
|
|
59
|
+
await run(
|
|
60
|
+
ffmpeg,
|
|
61
|
+
[
|
|
62
|
+
"-hide_banner",
|
|
63
|
+
"-loglevel",
|
|
64
|
+
"error",
|
|
65
|
+
"-i",
|
|
66
|
+
videoPath,
|
|
67
|
+
"-vf",
|
|
68
|
+
`fps=${fps}`,
|
|
69
|
+
"-vframes",
|
|
70
|
+
String(maxFrames),
|
|
71
|
+
outPattern,
|
|
72
|
+
],
|
|
73
|
+
{ cwd: tmpDir }
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const frames = (await readdir(tmpDir))
|
|
77
|
+
.filter((f) => /^frame-\d+\.jpg$/.test(f))
|
|
78
|
+
.sort();
|
|
79
|
+
|
|
80
|
+
const abortPerFrame = (ms: number) => AbortSignal.timeout(ms);
|
|
81
|
+
const texts: Array<{ label: string; content: string }> = [];
|
|
82
|
+
let totalChars = 0;
|
|
83
|
+
|
|
84
|
+
for (const f of frames) {
|
|
85
|
+
if (texts.length >= maxFrames) break;
|
|
86
|
+
if (totalChars >= cfg.maxOutputChars) break;
|
|
87
|
+
|
|
88
|
+
const imgBytes = await readFile(path.join(tmpDir, f));
|
|
89
|
+
const result = await generateText({
|
|
90
|
+
model: cfg.model as any,
|
|
91
|
+
abortSignal: abortPerFrame(cfg.timeoutMs),
|
|
92
|
+
messages: [
|
|
93
|
+
{
|
|
94
|
+
role: "user",
|
|
95
|
+
content: [
|
|
96
|
+
{ type: "text", text: cfg.prompt },
|
|
97
|
+
{ type: "image", image: new Uint8Array(imgBytes), mediaType: "image/jpeg" },
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const t = String((result as any)?.text ?? "").trim();
|
|
104
|
+
if (!t) continue;
|
|
105
|
+
|
|
106
|
+
const capped = capText(t, cfg.maxOutputChars - totalChars);
|
|
107
|
+
if (!capped) continue;
|
|
108
|
+
|
|
109
|
+
texts.push({ label: f, content: capped });
|
|
110
|
+
totalChars += capped.length;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (texts.length === 0) return { texts: [] };
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
texts: texts.map((t) => ({ label: t.label, content: t.content })),
|
|
117
|
+
diagnostics: { model: cfg.model },
|
|
118
|
+
};
|
|
119
|
+
} finally {
|
|
120
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { experimental_transcribe as transcribe } from "ai";
|
|
2
|
+
import type { AssetExtractor } from "../../core/types";
|
|
3
|
+
import { getAssetBytes } from "../_shared/fetch";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Video transcription by sending the video file to the AI SDK transcription API.
|
|
7
|
+
*
|
|
8
|
+
* Note: provider support varies; many transcription providers accept audio formats only.
|
|
9
|
+
* If your provider does not accept video files, use a worker pipeline to extract audio first.
|
|
10
|
+
*/
|
|
11
|
+
export function createVideoTranscribeExtractor(): AssetExtractor {
|
|
12
|
+
return {
|
|
13
|
+
name: "video:transcribe",
|
|
14
|
+
supports: ({ asset, ctx }) =>
|
|
15
|
+
asset.kind === "video" && ctx.assetProcessing.video.transcription.enabled,
|
|
16
|
+
extract: async ({ asset, ctx }) => {
|
|
17
|
+
const cfg = ctx.assetProcessing.video.transcription;
|
|
18
|
+
const fetchConfig = ctx.assetProcessing.fetch;
|
|
19
|
+
|
|
20
|
+
const maxBytes = Math.min(cfg.maxBytes, fetchConfig.maxBytes);
|
|
21
|
+
const { bytes } = await getAssetBytes({
|
|
22
|
+
data: asset.data,
|
|
23
|
+
fetchConfig,
|
|
24
|
+
maxBytes,
|
|
25
|
+
defaultMediaType: "video/mp4",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const abortSignal = AbortSignal.timeout(cfg.timeoutMs);
|
|
29
|
+
|
|
30
|
+
const result = await transcribe({
|
|
31
|
+
model: cfg.model as any,
|
|
32
|
+
audio: bytes as any,
|
|
33
|
+
abortSignal,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const segments: any[] = Array.isArray((result as any)?.segments)
|
|
37
|
+
? (result as any).segments
|
|
38
|
+
: [];
|
|
39
|
+
|
|
40
|
+
if (segments.length > 0) {
|
|
41
|
+
return {
|
|
42
|
+
texts: segments
|
|
43
|
+
.map((s, i) => {
|
|
44
|
+
const t = String(s?.text ?? "").trim();
|
|
45
|
+
if (!t) return null;
|
|
46
|
+
const start = Number(s?.startSecond ?? NaN);
|
|
47
|
+
const end = Number(s?.endSecond ?? NaN);
|
|
48
|
+
return {
|
|
49
|
+
label: `segment-${i + 1}`,
|
|
50
|
+
content: t,
|
|
51
|
+
...(Number.isFinite(start) && Number.isFinite(end)
|
|
52
|
+
? { timeRangeSec: [start, end] as [number, number] }
|
|
53
|
+
: {}),
|
|
54
|
+
};
|
|
55
|
+
})
|
|
56
|
+
.filter(Boolean) as any,
|
|
57
|
+
diagnostics: {
|
|
58
|
+
model: cfg.model,
|
|
59
|
+
seconds:
|
|
60
|
+
typeof (result as any)?.durationInSeconds === "number"
|
|
61
|
+
? (result as any).durationInSeconds
|
|
62
|
+
: undefined,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const text = String((result as any)?.text ?? "").trim();
|
|
68
|
+
if (!text) return { texts: [], diagnostics: { model: cfg.model } };
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
texts: [{ label: "transcript", content: text }],
|
|
72
|
+
diagnostics: { model: cfg.model },
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
@@ -146,7 +146,7 @@ export const createDrizzleVectorStore = (db: DrizzleDb): VectorStore => ({
|
|
|
146
146
|
},
|
|
147
147
|
|
|
148
148
|
delete: async (input) => {
|
|
149
|
-
if (
|
|
149
|
+
if (input.sourceId !== undefined) {
|
|
150
150
|
await db.delete(documents).where(eq(documents.sourceId, input.sourceId));
|
|
151
151
|
return;
|
|
152
152
|
}
|