Merge pull request #122 from richardr1126/refactor/compute-worker
refactor: modularize monorepo packages, decouple compute-worker, and isolate ML dependencies
This commit is contained in:
commit
9ee77408fe
262 changed files with 7549 additions and 3998 deletions
|
|
@ -1,5 +1,6 @@
|
|||
.env
|
||||
.env.*
|
||||
**/*.creds
|
||||
README.md
|
||||
.next
|
||||
node_modules
|
||||
|
|
|
|||
4
.github/workflows/docker-publish.yml
vendored
4
.github/workflows/docker-publish.yml
vendored
|
|
@ -62,13 +62,13 @@ jobs:
|
|||
platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
context: .
|
||||
dockerfile: ./compute/worker/Dockerfile
|
||||
dockerfile: ./packages/compute-worker/Dockerfile
|
||||
- image_target: compute-worker
|
||||
arch: arm64
|
||||
platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
context: .
|
||||
dockerfile: ./compute/worker/Dockerfile
|
||||
dockerfile: ./packages/compute-worker/Dockerfile
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
|
|
|||
2
.github/workflows/vitest.yml
vendored
2
.github/workflows/vitest.yml
vendored
|
|
@ -25,3 +25,5 @@ jobs:
|
|||
run: pnpm migrate
|
||||
- name: Run Vitest suites
|
||||
run: pnpm test:unit
|
||||
- name: Verify compute worker contract and boundary
|
||||
run: pnpm compute:openapi:check && pnpm check:compute-boundary
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -56,5 +56,7 @@ node_modules/
|
|||
# vscode
|
||||
.vscode
|
||||
|
||||
# .agents
|
||||
# Agents
|
||||
.agents
|
||||
.codex
|
||||
.claude
|
||||
|
|
|
|||
40
Dockerfile
40
Dockerfile
|
|
@ -20,9 +20,9 @@ WORKDIR /app
|
|||
|
||||
# Copy workspace manifests needed for dependency installation
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY compute/core/package.json ./compute/core/package.json
|
||||
COPY compute/worker/package.json ./compute/worker/package.json
|
||||
COPY docker/entrypoint-migration-tools/package.json ./docker/entrypoint-migration-tools/package.json
|
||||
COPY packages/bootstrap/package.json ./packages/bootstrap/package.json
|
||||
COPY packages/compute-worker/package.json ./packages/compute-worker/package.json
|
||||
COPY packages/database/package.json ./packages/database/package.json
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
|
@ -33,8 +33,9 @@ COPY . .
|
|||
# Build the Next.js application
|
||||
RUN pnpm exec next telemetry disable
|
||||
RUN AUTH_SECRET=build-placeholder-secret-value-32chars!! BASE_URL=http://localhost:3003 pnpm build
|
||||
RUN pnpm --config.inject-workspace-packages=true --filter @openreader/entrypoint-migration-tools deploy /opt/entrypoint-migration-tools
|
||||
RUN pnpm --config.inject-workspace-packages=true --filter @openreader/compute-worker deploy /opt/embedded-compute-worker
|
||||
RUN pnpm --config.inject-workspace-packages=true --filter @openreader/bootstrap deploy /opt/openreader/bootstrap
|
||||
RUN pnpm --dir /opt/openreader/bootstrap rebuild better-sqlite3 ffmpeg-static
|
||||
RUN pnpm --config.inject-workspace-packages=true --filter @openreader/compute-worker deploy /opt/openreader/embedded-compute-worker
|
||||
# Generate third-party dependency license report plus copied license files.
|
||||
RUN mkdir -p /app/THIRD_PARTY_LICENSES && \
|
||||
pnpm dlx license-checker-rseidelsohn@4.3.0 \
|
||||
|
|
@ -63,32 +64,15 @@ COPY --from=app-builder /app/.next/standalone ./
|
|||
COPY --from=app-builder /app/.next/static ./.next/static
|
||||
COPY --from=app-builder /app/public ./public
|
||||
|
||||
# Copy the entrypoint and migration/runtime helper files it invokes directly.
|
||||
COPY --from=app-builder /app/scripts/openreader-entrypoint.mjs ./scripts/openreader-entrypoint.mjs
|
||||
COPY --from=app-builder /app/scripts/migrate-fs-v2.mjs ./scripts/migrate-fs-v2.mjs
|
||||
COPY --from=app-builder /app/drizzle ./drizzle
|
||||
COPY --from=app-builder /app/drizzle.config.pg.ts ./drizzle.config.pg.ts
|
||||
COPY --from=app-builder /app/drizzle.config.sqlite.ts ./drizzle.config.sqlite.ts
|
||||
COPY --from=app-builder /app/src/db ./src/db
|
||||
|
||||
# Merge in the dependency subset needed by the entrypoint migration scripts.
|
||||
COPY --from=app-builder /opt/entrypoint-migration-tools/node_modules /tmp/runtime-tools-node_modules
|
||||
RUN mkdir -p /app/node_modules && \
|
||||
rm -rf /tmp/runtime-tools-node_modules/@aws-sdk \
|
||||
/tmp/runtime-tools-node_modules/better-sqlite3 \
|
||||
/tmp/runtime-tools-node_modules/ffmpeg-static \
|
||||
/tmp/runtime-tools-node_modules/pg && \
|
||||
cp -an /tmp/runtime-tools-node_modules/. /app/node_modules/ && \
|
||||
rm -rf /tmp/runtime-tools-node_modules
|
||||
|
||||
# Ship the embedded compute worker as a separate deployed bundle.
|
||||
COPY --from=app-builder /opt/embedded-compute-worker ./embedded-compute-worker
|
||||
# Ship startup orchestration and the embedded worker as independent deployed bundles.
|
||||
COPY --from=app-builder /opt/openreader/bootstrap /opt/openreader/bootstrap
|
||||
COPY --from=app-builder /opt/openreader/embedded-compute-worker /opt/openreader/embedded-compute-worker
|
||||
# Include third-party license report and copied license texts at a stable path in the image.
|
||||
COPY --from=app-builder /app/THIRD_PARTY_LICENSES /licenses
|
||||
# Include SeaweedFS license text for the copied weed binary.
|
||||
COPY --from=seaweedfs-builder /tmp/SeaweedFS-LICENSE.txt /licenses/SeaweedFS-LICENSE.txt
|
||||
# Include static model notices for runtime-downloaded assets.
|
||||
COPY --from=app-builder /app/compute/core/src/pdf/assets/LICENSE.txt /licenses/pp-doclayoutv3-LICENSE.txt
|
||||
COPY --from=app-builder /app/packages/compute-worker/src/inference/pdf/assets/LICENSE.txt /licenses/pp-doclayoutv3-LICENSE.txt
|
||||
|
||||
# Copy seaweedfs weed binary for optional embedded local S3.
|
||||
COPY --from=seaweedfs-builder /tmp/weed /usr/local/bin/weed
|
||||
|
|
@ -98,7 +82,7 @@ COPY --from=nats-builder /tmp/nats-server /usr/local/bin/nats-server
|
|||
RUN chmod +x /usr/local/bin/nats-server
|
||||
|
||||
# Include OpenAI Whisper license text for runtime-downloaded ONNX artifacts.
|
||||
COPY --from=app-builder /app/compute/core/src/whisper/assets/LICENSE.txt /licenses/openai-whisper-LICENSE.txt
|
||||
COPY --from=app-builder /app/packages/compute-worker/src/inference/whisper/assets/LICENSE.txt /licenses/openai-whisper-LICENSE.txt
|
||||
|
||||
# Match the app's historical container port now that standalone server.js
|
||||
# is started directly instead of `next start -p 3003`.
|
||||
|
|
@ -108,5 +92,5 @@ ENV PORT=3003
|
|||
EXPOSE 3003
|
||||
|
||||
# Start the application
|
||||
ENTRYPOINT ["node", "scripts/openreader-entrypoint.mjs", "--"]
|
||||
ENTRYPOINT ["node", "/opt/openreader/bootstrap/src/cli.mjs", "--"]
|
||||
CMD ["node", "server.js"]
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
{
|
||||
"name": "@openreader/compute-core",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@huggingface/tokenizers": "^0.1.3",
|
||||
"@napi-rs/canvas": "^0.1.100",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"jszip": "^3.10.1",
|
||||
"onnxruntime-node": "^1.26.0",
|
||||
"pdfjs-dist": "4.8.69"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./local-runtime": "./src/local-runtime.ts",
|
||||
"./api-contracts": "./src/api-contracts/index.ts",
|
||||
"./control-plane": "./src/control-plane/index.ts",
|
||||
"./types": "./src/types/index.ts"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import os from 'node:os';
|
||||
|
||||
function readPositiveInt(name: string, fallback: number): number {
|
||||
const raw = process.env[name]?.trim();
|
||||
if (!raw) return fallback;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
export function getComputeJobConcurrency(): number {
|
||||
return readPositiveInt('COMPUTE_JOB_CONCURRENCY', 1);
|
||||
}
|
||||
|
||||
export function getAvailableCpuCores(): number {
|
||||
if (typeof os.availableParallelism === 'function') {
|
||||
const value = os.availableParallelism();
|
||||
if (Number.isFinite(value) && value >= 1) return Math.floor(value);
|
||||
}
|
||||
const fallback = os.cpus().length;
|
||||
return Number.isFinite(fallback) && fallback >= 1 ? Math.floor(fallback) : 1;
|
||||
}
|
||||
|
||||
export function getOnnxThreadsPerJob(): number {
|
||||
const concurrency = getComputeJobConcurrency();
|
||||
const usableCores = Math.max(1, getAvailableCpuCores() - 1);
|
||||
return Math.max(1, Math.floor(usableCores / concurrency));
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
export * from './api-contracts';
|
||||
export {
|
||||
getComputeJobConcurrency,
|
||||
getAvailableCpuCores,
|
||||
getOnnxThreadsPerJob,
|
||||
} from './config/cpu-budget';
|
||||
export {
|
||||
getComputeTimeoutConfig,
|
||||
getComputeOpStaleMs,
|
||||
getWorkerClientWaitTimeoutMs,
|
||||
withTimeout,
|
||||
withIdleTimeoutAndHardCap,
|
||||
type ComputeTimeoutConfig,
|
||||
type ComputeOperationKind,
|
||||
type IdleTimeoutAndHardCapInput,
|
||||
} from './config/timeout';
|
||||
export { renderPage } from './pdf/render';
|
||||
export { mergeTextWithRegions } from './pdf/merge';
|
||||
export { PDF_PARSER_VERSION } from './pdf/parser-version';
|
||||
export { encodeParserVersion } from './pdf/parser-version-key';
|
||||
export { stitchCrossPageBlocks } from './pdf/stitch';
|
||||
export { normalizeTextItemsForLayout } from './pdf/normalize-text';
|
||||
export { mapWordsToSentenceOffsets, type WhisperWord } from './whisper/alignment-map';
|
||||
export { buildGoertzelCoefficients, goertzelPower } from './whisper/spectral';
|
||||
export { buildWordsFromTimestampedTokens, extractTokenStartTimestamps } from './whisper/token-timestamps';
|
||||
export * from './control-plane';
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { PDF_PARSER_VERSION } from './parser-version';
|
||||
|
||||
export function encodeParserVersion(
|
||||
parserVersion: string,
|
||||
defaultVersion = PDF_PARSER_VERSION,
|
||||
): string {
|
||||
const normalized = parserVersion.trim() || defaultVersion;
|
||||
return encodeURIComponent(normalized);
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export const PDF_PARSER_VERSION = 'pp-doclayoutv3-onnx@800+pdfjs@4.8.69';
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import type { ParsedPdfBlockKind } from '../types/parsed-pdf';
|
||||
|
||||
export interface PdfTextItem {
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface LayoutRegion {
|
||||
bbox: [number, number, number, number];
|
||||
label: ParsedPdfBlockKind;
|
||||
confidence?: number;
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
function findMonorepoRoot(startDir: string): string | null {
|
||||
let current = path.resolve(startDir);
|
||||
for (;;) {
|
||||
const marker = path.join(current, 'pnpm-workspace.yaml');
|
||||
if (fs.existsSync(marker)) return current;
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return null;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDocstoreDir(): string {
|
||||
const repoRoot = findMonorepoRoot(process.cwd());
|
||||
if (repoRoot) return path.join(repoRoot, 'docstore');
|
||||
return path.join(process.cwd(), 'docstore');
|
||||
}
|
||||
|
||||
export const DOCSTORE_DIR = resolveDocstoreDir();
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { existsSync } from 'fs';
|
||||
import ffmpegStatic from 'ffmpeg-static';
|
||||
|
||||
function normalizePath(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function resolveBinary(envValue: string | null, bundledValue: string | null, envVarName: string, packageName: string): string {
|
||||
if (envValue) {
|
||||
if ((envValue.includes('/') || envValue.includes('\\')) && !existsSync(envValue)) {
|
||||
throw new Error(`${envVarName} points to a missing binary: ${envValue}`);
|
||||
}
|
||||
return envValue;
|
||||
}
|
||||
|
||||
if (!bundledValue) {
|
||||
throw new Error(`${packageName} binary is unavailable on this platform. Set ${envVarName} to an installed binary path.`);
|
||||
}
|
||||
|
||||
if ((bundledValue.includes('/') || bundledValue.includes('\\')) && !existsSync(bundledValue)) {
|
||||
throw new Error(`${packageName} resolved to a missing binary path: ${bundledValue}`);
|
||||
}
|
||||
|
||||
return bundledValue;
|
||||
}
|
||||
|
||||
export function getFFmpegPath(): string {
|
||||
return resolveBinary(
|
||||
normalizePath(process.env.FFMPEG_BIN),
|
||||
normalizePath(ffmpegStatic),
|
||||
'FFMPEG_BIN',
|
||||
'ffmpeg-static',
|
||||
);
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
export type {
|
||||
TTSAudioBuffer,
|
||||
TTSAudioBytes,
|
||||
TTSSentenceAlignment,
|
||||
TTSSentenceWord,
|
||||
} from './tts';
|
||||
|
||||
export type {
|
||||
ParsedPdfBlock,
|
||||
ParsedPdfBlockFragment,
|
||||
ParsedPdfBlockKind,
|
||||
ParsedPdfDocument,
|
||||
ParsedPdfPage,
|
||||
PdfParsePhase,
|
||||
PdfParseProgress,
|
||||
PdfParseStatus,
|
||||
} from './parsed-pdf';
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
export type TTSAudioBuffer = ArrayBuffer;
|
||||
export type TTSAudioBytes = number[];
|
||||
|
||||
export interface TTSSentenceWord {
|
||||
text: string;
|
||||
startSec: number;
|
||||
endSec: number;
|
||||
charStart: number;
|
||||
charEnd: number;
|
||||
}
|
||||
|
||||
export interface TTSSentenceAlignment {
|
||||
sentence: string;
|
||||
sentenceIndex: number;
|
||||
words: TTSSentenceWord[];
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import type { TTSSentenceAlignment, TTSSentenceWord } from '../types/tts';
|
||||
|
||||
// Worker-side mirror of the app's canonical audio-text cleaning rules in
|
||||
// `src/lib/shared/audio-text.ts`. This is a separate build target and cannot
|
||||
// import from `@/lib`, so the rules are duplicated here on purpose. The word
|
||||
// `charStart`/`charEnd` offsets this module emits are consumed against text
|
||||
// normalized by that shared module, so any divergence shifts viewer highlights
|
||||
// off-word — keep this byte-for-byte in sync with `audio-text.ts`.
|
||||
const STRIPPED_GLYPHS = /[*•◦‣⁃∙▪▫■□●○◆◇★☆▶▸►▹➤➢❖]/g;
|
||||
|
||||
function preprocessSentenceForAudio(text: string): string {
|
||||
return text
|
||||
.replace(/\S*(?:https?:\/\/|www\.)([^\/\s]+)(?:\/\S*)?/gi, '- (link to $1) -')
|
||||
.replace(/([\p{L}\p{N}\p{M}]+)-\s+([\p{L}\p{N}\p{M}]+)/gu, '$1$2')
|
||||
.replace(STRIPPED_GLYPHS, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export interface WhisperWord {
|
||||
start: number;
|
||||
end: number;
|
||||
word: string;
|
||||
}
|
||||
|
||||
export function mapWordsToSentenceOffsets(sentence: string, words: WhisperWord[]): TTSSentenceAlignment {
|
||||
const normalizedSentence = preprocessSentenceForAudio(sentence);
|
||||
const lowerSentence = normalizedSentence.toLowerCase();
|
||||
let cursor = 0;
|
||||
|
||||
const alignedWords: TTSSentenceWord[] = words.map((w) => {
|
||||
const token = w.word.trim();
|
||||
if (!token) {
|
||||
return {
|
||||
text: '',
|
||||
startSec: w.start,
|
||||
endSec: w.end,
|
||||
charStart: cursor,
|
||||
charEnd: cursor,
|
||||
};
|
||||
}
|
||||
|
||||
const idx = lowerSentence.indexOf(token.toLowerCase(), cursor);
|
||||
const start = idx >= 0 ? idx : cursor;
|
||||
const end = Math.min(normalizedSentence.length, start + token.length);
|
||||
cursor = Math.max(cursor, end);
|
||||
|
||||
return {
|
||||
text: token,
|
||||
startSec: w.start,
|
||||
endSec: w.end,
|
||||
charStart: start,
|
||||
charEnd: end,
|
||||
};
|
||||
}).filter((word) => word.text.length > 0);
|
||||
|
||||
return {
|
||||
sentence,
|
||||
sentenceIndex: 0,
|
||||
words: alignedWords,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
export function buildGoertzelCoefficients(freqBins: number, fftSize: number): Float64Array {
|
||||
const coeffs = new Float64Array(freqBins);
|
||||
for (let k = 0; k < freqBins; k += 1) {
|
||||
coeffs[k] = 2 * Math.cos((2 * Math.PI * k) / fftSize);
|
||||
}
|
||||
return coeffs;
|
||||
}
|
||||
|
||||
export function goertzelPower(samples: Float32Array, coeff: number): number {
|
||||
let s1 = 0;
|
||||
let s2 = 0;
|
||||
for (let i = 0; i < samples.length; i += 1) {
|
||||
const s0 = samples[i] + (coeff * s1) - s2;
|
||||
s2 = s1;
|
||||
s1 = s0;
|
||||
}
|
||||
|
||||
const power = (s1 * s1) + (s2 * s2) - (coeff * s1 * s2);
|
||||
if (!Number.isFinite(power) || power < 0) return 0;
|
||||
return power;
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
services:
|
||||
nats:
|
||||
image: nats:2.14-alpine
|
||||
container_name: openreader-compute-nats
|
||||
command: ["-js", "-sd", "/data"]
|
||||
ports:
|
||||
- "4222:4222"
|
||||
- "8222:8222"
|
||||
volumes:
|
||||
- nats-data:/data
|
||||
|
||||
compute-worker:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: compute/worker/Dockerfile
|
||||
container_name: openreader-compute-worker
|
||||
depends_on:
|
||||
- nats
|
||||
env_file:
|
||||
- ./.env
|
||||
environment:
|
||||
NATS_URL: ${NATS_URL:-nats://nats:4222}
|
||||
COMPUTE_WORKER_HOST: ${COMPUTE_WORKER_HOST:-0.0.0.0}
|
||||
PORT: ${PORT:-8081}
|
||||
COMPUTE_WORKER_TOKEN: ${COMPUTE_WORKER_TOKEN:-local-compute-token}
|
||||
S3_PREFIX: ${S3_PREFIX:-openreader}
|
||||
COMPUTE_PREWARM_MODELS: ${COMPUTE_PREWARM_MODELS:-false}
|
||||
ports:
|
||||
- "8081:8081"
|
||||
develop:
|
||||
watch:
|
||||
- action: sync+restart
|
||||
path: .
|
||||
target: /workspace
|
||||
- action: rebuild
|
||||
path: ../../compute/core
|
||||
- action: rebuild
|
||||
path: ./package.json
|
||||
- action: rebuild
|
||||
path: ../../compute/core/package.json
|
||||
- action: rebuild
|
||||
path: ../../pnpm-lock.yaml
|
||||
- action: rebuild
|
||||
path: ../../pnpm-workspace.yaml
|
||||
|
||||
volumes:
|
||||
nats-data:
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"name": "@openreader/entrypoint-migration-tools",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1061.0",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"pg": "^8.21.0"
|
||||
}
|
||||
}
|
||||
124
docker/examples/compose.full.yml
Normal file
124
docker/examples/compose.full.yml
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
name: openreader-full
|
||||
|
||||
services:
|
||||
openreader:
|
||||
image: ghcr.io/richardr1126/openreader:latest
|
||||
# Keep localhost:8333 valid for both app requests and browser-facing presigned URLs.
|
||||
network_mode: service:seaweedfs
|
||||
depends_on:
|
||||
kokoro-tts:
|
||||
condition: service_started
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
seaweedfs:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
BASE_URL: ${BASE_URL:-http://localhost:3003}
|
||||
AUTH_SECRET: ${AUTH_SECRET:-local-openreader-auth-secret-change-me}
|
||||
POSTGRES_URL: postgres://openreader:openreader@postgres:5432/openreader
|
||||
COMPUTE_WORKER_URL: http://compute-worker:8081
|
||||
COMPUTE_WORKER_TOKEN: ${COMPUTE_WORKER_TOKEN:-local-compute-token}
|
||||
USE_EMBEDDED_WEED_MINI: "false"
|
||||
S3_ENDPOINT: ${S3_ENDPOINT:-http://localhost:8333} # Used for internal endpoint and public facing presigned URLs.
|
||||
S3_BUCKET: ${S3_BUCKET:-openreader-documents}
|
||||
S3_REGION: ${S3_REGION:-us-east-1}
|
||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-devkey}
|
||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-devsecret}
|
||||
S3_FORCE_PATH_STYLE: "true"
|
||||
S3_PREFIX: ${S3_PREFIX:-openreader}
|
||||
RUNTIME_SEED_JSON: |-
|
||||
{
|
||||
"version": 1,
|
||||
"runtimeConfig": {
|
||||
"defaultTtsProvider": "kokoro"
|
||||
},
|
||||
"providers": [
|
||||
{
|
||||
"slug": "kokoro",
|
||||
"displayName": "Kokoro",
|
||||
"providerType": "custom-openai",
|
||||
"baseUrl": "http://kokoro-tts:8880/v1",
|
||||
"defaultModel": "kokoro",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
volumes:
|
||||
- openreader-docstore:/app/docstore
|
||||
|
||||
kokoro-tts:
|
||||
image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4
|
||||
environment:
|
||||
ONNX_NUM_THREADS: 8
|
||||
ONNX_INTER_OP_THREADS: 4
|
||||
ONNX_EXECUTION_MODE: parallel
|
||||
ONNX_OPTIMIZATION_LEVEL: all
|
||||
ONNX_MEMORY_PATTERN: "true"
|
||||
ONNX_ARENA_EXTEND_STRATEGY: kNextPowerOfTwo
|
||||
API_LOG_LEVEL: DEBUG
|
||||
ports:
|
||||
- "8880:8880"
|
||||
|
||||
seaweedfs:
|
||||
image: chrislusf/seaweedfs:4.18
|
||||
command: ["mini", "-dir=/data"]
|
||||
environment:
|
||||
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-devkey}
|
||||
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-devsecret}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status >/dev/null"]
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 30
|
||||
ports:
|
||||
- "3003:3003"
|
||||
- "8333:8333"
|
||||
volumes:
|
||||
- seaweedfs-data:/data
|
||||
|
||||
nats:
|
||||
image: nats:2.14-alpine
|
||||
command: ["-js", "-sd", "/data"]
|
||||
volumes:
|
||||
- nats-data:/data
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
POSTGRES_USER: openreader
|
||||
POSTGRES_PASSWORD: openreader
|
||||
POSTGRES_DB: openreader
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U openreader -d openreader"]
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 30
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
|
||||
compute-worker:
|
||||
image: ghcr.io/richardr1126/openreader-compute-worker:latest
|
||||
depends_on:
|
||||
nats:
|
||||
condition: service_started
|
||||
seaweedfs:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NATS_URL: nats://nats:4222
|
||||
COMPUTE_WORKER_HOST: 0.0.0.0
|
||||
PORT: 8081
|
||||
COMPUTE_WORKER_TOKEN: ${COMPUTE_WORKER_TOKEN:-local-compute-token}
|
||||
S3_ENDPOINT: http://seaweedfs:8333
|
||||
S3_BUCKET: ${S3_BUCKET:-openreader-documents}
|
||||
S3_REGION: ${S3_REGION:-us-east-1}
|
||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-devkey}
|
||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-devsecret}
|
||||
S3_FORCE_PATH_STYLE: "true"
|
||||
S3_PREFIX: ${S3_PREFIX:-openreader}
|
||||
COMPUTE_PREWARM_MODELS: ${COMPUTE_PREWARM_MODELS:-false}
|
||||
|
||||
volumes:
|
||||
openreader-docstore:
|
||||
seaweedfs-data:
|
||||
nats-data:
|
||||
postgres-data:
|
||||
130
docker/examples/compose.local-full.yml
Normal file
130
docker/examples/compose.local-full.yml
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
name: openreader-local-full-build
|
||||
|
||||
services:
|
||||
openreader:
|
||||
image: openreader:local
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: Dockerfile
|
||||
# Keep localhost:8333 valid for both app requests and browser-facing presigned URLs.
|
||||
network_mode: service:seaweedfs
|
||||
depends_on:
|
||||
kokoro-tts:
|
||||
condition: service_started
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
seaweedfs:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
BASE_URL: ${BASE_URL:-http://localhost:3003}
|
||||
AUTH_SECRET: ${AUTH_SECRET:-local-openreader-auth-secret-change-me}
|
||||
POSTGRES_URL: postgres://openreader:openreader@postgres:5432/openreader
|
||||
COMPUTE_WORKER_URL: http://compute-worker:8081
|
||||
COMPUTE_WORKER_TOKEN: ${COMPUTE_WORKER_TOKEN:-local-compute-token}
|
||||
USE_EMBEDDED_WEED_MINI: "false"
|
||||
S3_ENDPOINT: ${S3_ENDPOINT:-http://localhost:8333} # Used for internal endpoint and public facing presigned URLs.
|
||||
S3_BUCKET: ${S3_BUCKET:-openreader-documents}
|
||||
S3_REGION: ${S3_REGION:-us-east-1}
|
||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-devkey}
|
||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-devsecret}
|
||||
S3_FORCE_PATH_STYLE: "true"
|
||||
S3_PREFIX: ${S3_PREFIX:-openreader}
|
||||
RUNTIME_SEED_JSON: |-
|
||||
{
|
||||
"version": 1,
|
||||
"runtimeConfig": {
|
||||
"defaultTtsProvider": "kokoro"
|
||||
},
|
||||
"providers": [
|
||||
{
|
||||
"slug": "kokoro",
|
||||
"displayName": "Kokoro",
|
||||
"providerType": "custom-openai",
|
||||
"baseUrl": "http://kokoro-tts:8880/v1",
|
||||
"defaultModel": "kokoro",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
volumes:
|
||||
- openreader-docstore:/app/docstore
|
||||
|
||||
kokoro-tts:
|
||||
image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4
|
||||
environment:
|
||||
ONNX_NUM_THREADS: 8
|
||||
ONNX_INTER_OP_THREADS: 4
|
||||
ONNX_EXECUTION_MODE: parallel
|
||||
ONNX_OPTIMIZATION_LEVEL: all
|
||||
ONNX_MEMORY_PATTERN: "true"
|
||||
ONNX_ARENA_EXTEND_STRATEGY: kNextPowerOfTwo
|
||||
API_LOG_LEVEL: DEBUG
|
||||
ports:
|
||||
- "8880:8880"
|
||||
|
||||
seaweedfs:
|
||||
image: chrislusf/seaweedfs:4.18
|
||||
command: ["mini", "-dir=/data"]
|
||||
environment:
|
||||
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-devkey}
|
||||
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-devsecret}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status >/dev/null"]
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 30
|
||||
ports:
|
||||
- "3003:3003"
|
||||
- "8333:8333"
|
||||
volumes:
|
||||
- seaweedfs-data:/data
|
||||
|
||||
nats:
|
||||
image: nats:2.14-alpine
|
||||
command: ["-js", "-sd", "/data"]
|
||||
volumes:
|
||||
- nats-data:/data
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
POSTGRES_USER: openreader
|
||||
POSTGRES_PASSWORD: openreader
|
||||
POSTGRES_DB: openreader
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U openreader -d openreader"]
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 30
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
|
||||
compute-worker:
|
||||
image: openreader-compute-worker:local
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: packages/compute-worker/Dockerfile
|
||||
depends_on:
|
||||
nats:
|
||||
condition: service_started
|
||||
seaweedfs:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NATS_URL: nats://nats:4222
|
||||
COMPUTE_WORKER_HOST: 0.0.0.0
|
||||
PORT: 8081
|
||||
COMPUTE_WORKER_TOKEN: ${COMPUTE_WORKER_TOKEN:-local-compute-token}
|
||||
S3_ENDPOINT: http://seaweedfs:8333
|
||||
S3_BUCKET: ${S3_BUCKET:-openreader-documents}
|
||||
S3_REGION: ${S3_REGION:-us-east-1}
|
||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-devkey}
|
||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-devsecret}
|
||||
S3_FORCE_PATH_STYLE: "true"
|
||||
S3_PREFIX: ${S3_PREFIX:-openreader}
|
||||
COMPUTE_PREWARM_MODELS: ${COMPUTE_PREWARM_MODELS:-false}
|
||||
|
||||
volumes:
|
||||
openreader-docstore:
|
||||
seaweedfs-data:
|
||||
nats-data:
|
||||
postgres-data:
|
||||
52
docker/examples/compose.local-slim.yml
Normal file
52
docker/examples/compose.local-slim.yml
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
name: openreader-local-slim-build
|
||||
|
||||
services:
|
||||
openreader:
|
||||
image: openreader:local
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: Dockerfile
|
||||
depends_on:
|
||||
kokoro-tts:
|
||||
condition: service_started
|
||||
environment:
|
||||
BASE_URL: ${BASE_URL:-http://localhost:3003}
|
||||
AUTH_SECRET: ${AUTH_SECRET:-local-openreader-auth-secret-change-me}
|
||||
RUNTIME_SEED_JSON: |-
|
||||
{
|
||||
"version": 1,
|
||||
"runtimeConfig": {
|
||||
"defaultTtsProvider": "kokoro"
|
||||
},
|
||||
"providers": [
|
||||
{
|
||||
"slug": "kokoro",
|
||||
"displayName": "Kokoro",
|
||||
"providerType": "custom-openai",
|
||||
"baseUrl": "http://kokoro-tts:8880/v1",
|
||||
"defaultModel": "kokoro",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
ports:
|
||||
- "3003:3003"
|
||||
- "8333:8333"
|
||||
volumes:
|
||||
- openreader-docstore:/app/docstore
|
||||
|
||||
kokoro-tts:
|
||||
image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4
|
||||
environment:
|
||||
ONNX_NUM_THREADS: 8
|
||||
ONNX_INTER_OP_THREADS: 4
|
||||
ONNX_EXECUTION_MODE: parallel
|
||||
ONNX_OPTIMIZATION_LEVEL: all
|
||||
ONNX_MEMORY_PATTERN: "true"
|
||||
ONNX_ARENA_EXTEND_STRATEGY: kNextPowerOfTwo
|
||||
API_LOG_LEVEL: DEBUG
|
||||
ports:
|
||||
- "8880:8880"
|
||||
|
||||
volumes:
|
||||
openreader-docstore:
|
||||
49
docker/examples/compose.yml
Normal file
49
docker/examples/compose.yml
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
name: openreader-slim
|
||||
|
||||
services:
|
||||
openreader:
|
||||
image: ghcr.io/richardr1126/openreader:latest
|
||||
depends_on:
|
||||
kokoro-tts:
|
||||
condition: service_started
|
||||
environment:
|
||||
BASE_URL: ${BASE_URL:-http://localhost:3003}
|
||||
AUTH_SECRET: ${AUTH_SECRET:-local-openreader-auth-secret-change-me}
|
||||
RUNTIME_SEED_JSON: |-
|
||||
{
|
||||
"version": 1,
|
||||
"runtimeConfig": {
|
||||
"defaultTtsProvider": "kokoro"
|
||||
},
|
||||
"providers": [
|
||||
{
|
||||
"slug": "kokoro",
|
||||
"displayName": "Kokoro",
|
||||
"providerType": "custom-openai",
|
||||
"baseUrl": "http://kokoro-tts:8880/v1",
|
||||
"defaultModel": "kokoro",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
ports:
|
||||
- "3003:3003"
|
||||
- "8333:8333"
|
||||
volumes:
|
||||
- openreader-docstore:/app/docstore
|
||||
|
||||
kokoro-tts:
|
||||
image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4
|
||||
environment:
|
||||
ONNX_NUM_THREADS: 8
|
||||
ONNX_INTER_OP_THREADS: 4
|
||||
ONNX_EXECUTION_MODE: parallel
|
||||
ONNX_OPTIMIZATION_LEVEL: all
|
||||
ONNX_MEMORY_PATTERN: "true"
|
||||
ONNX_ARENA_EXTEND_STRATEGY: kNextPowerOfTwo
|
||||
API_LOG_LEVEL: DEBUG
|
||||
ports:
|
||||
- "8880:8880"
|
||||
|
||||
volumes:
|
||||
openreader-docstore:
|
||||
|
|
@ -7,6 +7,18 @@ import TabItem from '@theme/TabItem';
|
|||
|
||||
This page covers migration behavior for both database schema and storage data in OpenReader.
|
||||
|
||||
## Runtime ownership
|
||||
|
||||
- `@openreader/database` owns database clients, schemas, SQL migration files, and programmatic
|
||||
migration execution for SQLite and PostgreSQL.
|
||||
- `@openreader/bootstrap` owns startup orchestration, storage migration, and optional embedded
|
||||
SeaweedFS, NATS, and compute-worker processes.
|
||||
- The Next.js app imports `@openreader/database` directly, but does not orchestrate migrations or
|
||||
child processes.
|
||||
|
||||
Docker deploys bootstrap as an isolated runtime bundle under `/opt/openreader/bootstrap`; it does
|
||||
not merge migration dependencies into the standalone Next.js app under `/app`.
|
||||
|
||||
## Startup migration behavior
|
||||
|
||||
By default, the shared entrypoint runs migrations automatically before app startup in:
|
||||
|
|
@ -53,11 +65,6 @@ In most cases, you do not need manual migration commands because startup runs mi
|
|||
- Postgres when `POSTGRES_URL` is set
|
||||
- SQLite when `POSTGRES_URL` is unset
|
||||
|
||||
You can always override the target explicitly with `--config`.
|
||||
|
||||
<Tabs groupId="apply-migration-commands">
|
||||
<TabItem value="project-scripts" label="Project Scripts" default>
|
||||
|
||||
```bash
|
||||
# Run pending migrations for one target:
|
||||
# - Postgres if POSTGRES_URL is set
|
||||
|
|
@ -71,26 +78,15 @@ pnpm migrate-fs
|
|||
pnpm migrate-fs:dry-run
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="drizzle-direct" label="Manual Drizzle Cmd">
|
||||
|
||||
```bash
|
||||
# Migrate SQLite
|
||||
pnpm exec drizzle-kit migrate --config drizzle.config.sqlite.ts
|
||||
|
||||
# Migrate Postgres
|
||||
pnpm exec drizzle-kit migrate --config drizzle.config.pg.ts
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
`pnpm migrate` uses the programmatic Drizzle migrator from `@openreader/database`. Drizzle Kit is
|
||||
not a production or startup dependency; it is used only to generate new migration files.
|
||||
|
||||
## Generate migrations
|
||||
|
||||
`pnpm generate` is a two-phase script for contributors and schema changes:
|
||||
|
||||
1. **Better Auth schema generation** — runs the Better Auth CLI twice (once for SQLite, once for Postgres) to produce auto-generated Drizzle schema files for auth tables (`user`, `session`, `account`, `verification`).
|
||||
2. **Drizzle migration generation** — runs `drizzle-kit generate` for both `drizzle.config.sqlite.ts` and `drizzle.config.pg.ts`, producing SQL migration files from all schema files (app + auth).
|
||||
2. **Drizzle migration generation** — runs `drizzle-kit generate` for both configs in `packages/database`, producing SQL migration files from all schema files (app + auth).
|
||||
|
||||
:::note
|
||||
Most users do not need to run `pnpm generate`. Use it when contributing or when you have changed Drizzle schema files and need new migration files.
|
||||
|
|
@ -100,22 +96,23 @@ Most users do not need to run `pnpm generate`. Use it when contributing or when
|
|||
|
||||
Auth tables are owned by Better Auth. Their Drizzle schema definitions are auto-generated and should **not** be hand-edited:
|
||||
|
||||
- `src/db/schema_auth_sqlite.ts`
|
||||
- `src/db/schema_auth_postgres.ts`
|
||||
- `packages/database/src/schema_auth_sqlite.ts`
|
||||
- `packages/database/src/schema_auth_postgres.ts`
|
||||
|
||||
App-specific tables are manually maintained in the standard Drizzle schema files:
|
||||
|
||||
- `src/db/schema_sqlite.ts`
|
||||
- `src/db/schema_postgres.ts`
|
||||
- `packages/database/src/schema_sqlite.ts`
|
||||
- `packages/database/src/schema_postgres.ts`
|
||||
|
||||
Both sets of schema files are included in the Drizzle configs, so `drizzle-kit generate` and `drizzle-kit migrate` handle all tables together.
|
||||
Both sets of schema files are included in the Drizzle generation configs. Runtime migration
|
||||
execution is owned by `@openreader/database`.
|
||||
|
||||
When app schema changes (for example `tts_segments`), keep these in sync:
|
||||
|
||||
- `src/db/schema_sqlite.ts`
|
||||
- `src/db/schema_postgres.ts`
|
||||
- `drizzle/sqlite/*.sql` + `drizzle/sqlite/meta/_journal.json`
|
||||
- `drizzle/postgres/*.sql` + `drizzle/postgres/meta/_journal.json`
|
||||
- `packages/database/src/schema_sqlite.ts`
|
||||
- `packages/database/src/schema_postgres.ts`
|
||||
- `packages/database/migrations/sqlite/*.sql` + `packages/database/migrations/sqlite/meta/_journal.json`
|
||||
- `packages/database/migrations/postgres/*.sql` + `packages/database/migrations/postgres/meta/_journal.json`
|
||||
|
||||
<Tabs groupId="generate-migration-commands">
|
||||
<TabItem value="project-script" label="Project Script" default>
|
||||
|
|
@ -130,10 +127,10 @@ pnpm generate
|
|||
|
||||
```bash
|
||||
# Generate SQLite migrations only (skips Better Auth CLI)
|
||||
pnpm exec drizzle-kit generate --config drizzle.config.sqlite.ts
|
||||
pnpm exec drizzle-kit generate --config packages/database/drizzle.config.sqlite.ts
|
||||
|
||||
# Generate Postgres migrations only (skips Better Auth CLI)
|
||||
pnpm exec drizzle-kit generate --config drizzle.config.pg.ts
|
||||
pnpm exec drizzle-kit generate --config packages/database/drizzle.config.pg.ts
|
||||
```
|
||||
|
||||
:::warning
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ Use this guide when OpenReader runs compute as a separate service. For the defau
|
|||
- Runs PDF layout parsing jobs
|
||||
- Stores durable job state in NATS JetStream and NATS KV
|
||||
|
||||
The app server submits work to `POST /ops` and listens for updates on `GET /ops/:opId/events`.
|
||||
The app server submits resource-specific operations under `/v1` and listens for updates on
|
||||
`GET /v1/operations/:opId/events`.
|
||||
|
||||
## When to use it
|
||||
|
||||
|
|
@ -37,7 +38,7 @@ S3_SECRET_ACCESS_KEY=...
|
|||
```
|
||||
|
||||
:::important
|
||||
`compute/worker/.env*` is only for standalone worker deployments.
|
||||
`compute-worker/.env*` is only for standalone worker deployments.
|
||||
|
||||
- Embedded/local mode: configure the root `.env` only.
|
||||
- External worker mode: set `COMPUTE_WORKER_URL` and `COMPUTE_WORKER_TOKEN` on the app, and worker runtime values on the worker service.
|
||||
|
|
|
|||
175
docs-site/docs/deploy/docker-compose.md
Normal file
175
docs-site/docs/deploy/docker-compose.md
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
---
|
||||
title: Docker Compose
|
||||
description: Run OpenReader with the slim, full, local-slim, or local-full Docker Compose examples.
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
Use these examples to run OpenReader with Kokoro-FastAPI and persistent storage. Choose the slim
|
||||
stack for the simplest deployment, or the full stack when you want PostgreSQL, SeaweedFS, NATS,
|
||||
and the compute worker as separate containers. Local build variants are also available for both slim
|
||||
and full stacks to build the application from your current checkout.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A recent Docker version with Docker Compose
|
||||
- A clone of the OpenReader repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/richardr1126/openreader.git
|
||||
cd openreader
|
||||
```
|
||||
|
||||
## Choose a stack
|
||||
|
||||
<Tabs groupId="docker-compose-stack">
|
||||
<TabItem value="slim" label="Slim" default>
|
||||
|
||||
The default slim example runs:
|
||||
|
||||
- OpenReader with embedded SeaweedFS, NATS, compute worker, and SQLite
|
||||
- Kokoro-FastAPI as a companion container
|
||||
|
||||
```bash
|
||||
docker compose -f docker/examples/compose.yml up
|
||||
# Repository convenience command: pnpm compose
|
||||
```
|
||||
|
||||
Compose file: [`docker/examples/compose.yml`](https://github.com/richardr1126/openreader/blob/main/docker/examples/compose.yml)
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="full" label="Full">
|
||||
|
||||
The full example runs OpenReader, Kokoro-FastAPI, PostgreSQL, SeaweedFS, NATS, and the compute
|
||||
worker as separate containers using published images.
|
||||
|
||||
```bash
|
||||
docker compose -f docker/examples/compose.full.yml up
|
||||
# Repository convenience command: pnpm compose:full
|
||||
```
|
||||
|
||||
Compose file: [`docker/examples/compose.full.yml`](https://github.com/richardr1126/openreader/blob/main/docker/examples/compose.full.yml)
|
||||
|
||||
For details about running the worker separately, see
|
||||
[Compute Worker](./compute-worker).
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="local-slim" label="Local Slim">
|
||||
|
||||
The local-slim example runs a slim setup (OpenReader and Kokoro-FastAPI), but builds the OpenReader app image from the current checkout.
|
||||
|
||||
```bash
|
||||
docker compose -f docker/examples/compose.local-slim.yml up --build
|
||||
# Repository convenience command: pnpm compose:local
|
||||
```
|
||||
|
||||
Compose file: [`docker/examples/compose.local-slim.yml`](https://github.com/richardr1126/openreader/blob/main/docker/examples/compose.local-slim.yml)
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="local-full" label="Local Full">
|
||||
|
||||
The local-full example uses the full multi-container layout, but builds the OpenReader app and compute-worker images from the current checkout.
|
||||
|
||||
```bash
|
||||
docker compose -f docker/examples/compose.local-full.yml up --build
|
||||
# Repository convenience command: pnpm compose:local:full
|
||||
```
|
||||
|
||||
Compose file: [`docker/examples/compose.local-full.yml`](https://github.com/richardr1126/openreader/blob/main/docker/examples/compose.local-full.yml)
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Included services
|
||||
|
||||
| Service | Slim | Full | Local Slim | Local Full |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| OpenReader | Published image | Published image | Local build | Local build |
|
||||
| Kokoro-FastAPI | Container | Container | Container | Container |
|
||||
| Database | Embedded SQLite | PostgreSQL container | Embedded SQLite | PostgreSQL container |
|
||||
| SeaweedFS | Embedded | Container | Embedded | Container |
|
||||
| NATS | Embedded | Container | Embedded | Container |
|
||||
| Compute worker | Embedded | Published image | Embedded | Local build |
|
||||
|
||||
On first boot, `RUNTIME_SEED_JSON` creates an enabled Kokoro shared provider and selects it as the
|
||||
default TTS provider.
|
||||
|
||||
## Endpoints
|
||||
|
||||
- OpenReader: `http://localhost:3003`
|
||||
- SeaweedFS S3: `http://localhost:8333`
|
||||
- Kokoro-FastAPI: `http://localhost:8880`
|
||||
|
||||
In the full examples, PostgreSQL, the compute worker, and NATS remain internal to the Compose
|
||||
network.
|
||||
|
||||
## LAN access
|
||||
|
||||
Set `BASE_URL` and `S3_ENDPOINT` to the Docker host's LAN IP so browser-facing app and presigned
|
||||
S3 URLs are reachable from other devices:
|
||||
|
||||
<Tabs groupId="docker-compose-lan-stack">
|
||||
<TabItem value="slim" label="Slim" default>
|
||||
|
||||
```bash
|
||||
BASE_URL=http://192.168.0.XXX:3003 \
|
||||
S3_ENDPOINT=http://192.168.0.XXX:8333 \
|
||||
docker compose -f docker/examples/compose.yml up
|
||||
# Repository convenience command: pnpm compose
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="full" label="Full">
|
||||
|
||||
```bash
|
||||
BASE_URL=http://192.168.0.XXX:3003 \
|
||||
S3_ENDPOINT=http://192.168.0.XXX:8333 \
|
||||
docker compose -f docker/examples/compose.full.yml up
|
||||
# Repository convenience command: pnpm compose:full
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="local-slim" label="Local Slim">
|
||||
|
||||
```bash
|
||||
BASE_URL=http://192.168.0.XXX:3003 \
|
||||
S3_ENDPOINT=http://192.168.0.XXX:8333 \
|
||||
docker compose -f docker/examples/compose.local-slim.yml up --build
|
||||
# Repository convenience command: pnpm compose:local
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="local-full" label="Local Full">
|
||||
|
||||
```bash
|
||||
BASE_URL=http://192.168.0.XXX:3003 \
|
||||
S3_ENDPOINT=http://192.168.0.XXX:8333 \
|
||||
docker compose -f docker/examples/compose.local-full.yml up --build
|
||||
# Repository convenience command: pnpm compose:local:full
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
Replace `192.168.0.XXX` with your Docker host's LAN IP and allow inbound TCP ports `3003` and
|
||||
`8333` through its firewall.
|
||||
|
||||
:::info Internal full-stack endpoint
|
||||
The full and local-full compute workers continue using `http://seaweedfs:8333` internally.
|
||||
`S3_ENDPOINT` configures the app endpoint and browser-facing presigned URLs.
|
||||
:::
|
||||
|
||||
## Configuration
|
||||
|
||||
The examples use local-only default credentials. Override existing `${VARIABLE}` values through
|
||||
your shell environment before using them beyond local development.
|
||||
|
||||
:::warning Protect public deployments
|
||||
Replace the default `AUTH_SECRET`, PostgreSQL credentials, S3 credentials, and compute-worker
|
||||
token before exposing a stack outside your trusted local network.
|
||||
:::
|
||||
|
||||
For the complete configuration reference, see
|
||||
[Environment Variables](../reference/environment-variables). See [Database](../configure/database)
|
||||
for PostgreSQL and SQLite behavior.
|
||||
|
|
@ -159,45 +159,10 @@ If you need mirrors or pinned artifact locations, set `WHISPER_MODEL_BASE_URL` i
|
|||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>External compute worker dev stack (optional)</strong></summary>
|
||||
|
||||
Use this only when you intentionally run compute-worker as a separate service.
|
||||
Default local flow does not need `compute/worker/.env`; embedded worker startup reads root `.env`.
|
||||
Full worker deployment details are in [Compute Worker (NATS JetStream)](./compute-worker).
|
||||
|
||||
Start only NATS + compute-worker via compose watch:
|
||||
|
||||
```bash
|
||||
docker compose --env-file compute/worker/.env -f compute/worker/docker-compose.yml up --watch
|
||||
# or: pnpm compute:dev:watch
|
||||
```
|
||||
|
||||
`compute/worker/.env.example` contains a starter config for standalone worker service deployments.
|
||||
|
||||
Run the main app separately on the host:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
For app -> external worker routing, set in root `.env`:
|
||||
|
||||
```env
|
||||
COMPUTE_WORKER_URL=http://localhost:8081
|
||||
COMPUTE_WORKER_TOKEN=<same-token-used-by-worker>
|
||||
```
|
||||
|
||||
Ownership in external worker mode:
|
||||
- root `.env`: app routing/auth (`COMPUTE_WORKER_URL`, `COMPUTE_WORKER_TOKEN`) plus optional shared timeout/stale/retry overrides such as `COMPUTE_PDF_JOB_ATTEMPTS`
|
||||
- `compute/worker/.env*` (or worker platform env): worker runtime variables (`NATS_*`, `S3_*`, model base URLs, worker tuning)
|
||||
|
||||
For embedded worker startup (`COMPUTE_WORKER_URL` unset), worker tuning values such as `COMPUTE_PDF_JOB_ATTEMPTS` must be set in the root `.env` because `compute/worker/.env*` is ignored in that mode.
|
||||
|
||||
Worker mode requires worker-reachable shared object storage (S3-compatible endpoint).
|
||||
For external worker mode, object storage must be shared/reachable by both app and worker services.
|
||||
|
||||
</details>
|
||||
:::tip Docker Compose
|
||||
To run OpenReader and Kokoro-FastAPI with Docker Compose, including slim, full, and local-build
|
||||
options, see [Docker Compose](./docker-compose).
|
||||
:::
|
||||
|
||||
## Steps
|
||||
|
||||
|
|
@ -240,7 +205,7 @@ COMPUTE_WORKER_TOKEN=<same-token-used-by-worker>
|
|||
|
||||
Use the same ownership split:
|
||||
- root `.env`: app routing/auth (`COMPUTE_WORKER_URL`, `COMPUTE_WORKER_TOKEN`) plus optional shared timeout/stale overrides
|
||||
- `compute/worker/.env*` (or worker platform env): worker runtime variables (`NATS_*`, `S3_*`, model base URLs, worker tuning)
|
||||
- `compute-worker/.env*` (or worker platform env): worker runtime variables (`NATS_*`, `S3_*`, model base URLs, worker tuning)
|
||||
|
||||
Use one of these `.env` mode templates:
|
||||
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ For all variables and defaults, see [Environment Variables](../reference/environ
|
|||
|
||||
## 4. Database and data migrations
|
||||
|
||||
Vercel deployments do not run `scripts/openreader-entrypoint.mjs`, so automatic startup migrations do not run there.
|
||||
Vercel deployments do not run the `@openreader/bootstrap` process, so automatic startup migrations do not run there.
|
||||
|
||||
- Run `pnpm migrate` in a controlled environment to apply Drizzle schema migrations to your Postgres DB.
|
||||
- Run `pnpm migrate-fs` only when migrating legacy local filesystem data (`docstore/documents_v1`, `docstore/audiobooks_v1`) into object storage + DB rows. Fresh Vercel deployments usually do not need this.
|
||||
|
|
|
|||
|
|
@ -45,21 +45,20 @@ title: Stack
|
|||
|
||||
## External compute worker (optional)
|
||||
|
||||
Monorepo packages under `compute/`:
|
||||
Standalone worker package:
|
||||
|
||||
- **`@openreader/compute-core`** — ONNX runtime lifecycle, model management, and inference logic shared by compute worker runtime + app/worker contracts
|
||||
- **`@openreader/compute-worker`** — standalone Node.js compute service containing its private inference and queue runtime
|
||||
- ONNX runtime: `onnxruntime-node` with `@huggingface/tokenizers`
|
||||
- Whisper alignment: `onnx-community/whisper-base_timestamped` (q4) for word-level timestamps
|
||||
- PDF layout: `Bei0001/PP-DocLayoutV3-ONNX` for document block detection and layout parsing
|
||||
- PDF rendering: `pdfjs-dist`, `@napi-rs/canvas` for server-side page rasterization
|
||||
- Utilities: `jszip`, `ffmpeg-static`
|
||||
- **`@openreader/compute-worker`** — standalone Node.js worker service
|
||||
- HTTP server: [Fastify](https://fastify.dev/) v5
|
||||
- HTTP server: [Fastify](https://fastify.dev/) v5 with a versioned OpenAPI contract
|
||||
- Job queue + state: [NATS](https://nats.io/) JetStream WorkQueue pull consumers + NATS KV (`jobs.whisper`, `jobs.layout`)
|
||||
- Storage: AWS SDK v3 S3 client for reading/writing blobs
|
||||
- Logging: [Pino](https://getpino.io/)
|
||||
- Validation: [Zod](https://zod.dev/)
|
||||
- Heavy compute is worker-backed via `COMPUTE_WORKER_URL` + `COMPUTE_WORKER_TOKEN` (remote queue via HTTP + NATS)
|
||||
- The Next.js app communicates with the worker only through the versioned HTTP API generated from OpenAPI.
|
||||
|
||||
## Tooling and testing
|
||||
|
||||
|
|
|
|||
|
|
@ -55,7 +55,12 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: '🚀 Deploy',
|
||||
items: ['deploy/local-development', 'deploy/compute-worker', 'deploy/vercel-deployment'],
|
||||
items: [
|
||||
'deploy/local-development',
|
||||
'deploy/docker-compose',
|
||||
'deploy/compute-worker',
|
||||
'deploy/vercel-deployment',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
function loadEnvFiles() {
|
||||
// Approximate Next.js behavior enough for server-side scripts.
|
||||
// Load .env first, then .env.local (local overrides).
|
||||
const cwd = process.cwd();
|
||||
const envPath = path.join(cwd, '.env');
|
||||
const envLocalPath = path.join(cwd, '.env.local');
|
||||
|
||||
if (fs.existsSync(envPath)) {
|
||||
dotenv.config({ path: envPath });
|
||||
}
|
||||
if (fs.existsSync(envLocalPath)) {
|
||||
dotenv.config({ path: envLocalPath, override: true });
|
||||
}
|
||||
}
|
||||
|
||||
loadEnvFiles();
|
||||
|
||||
const extraArgs = process.argv.slice(2);
|
||||
|
||||
const hasConfigArg = extraArgs.includes('--config');
|
||||
const configFile = process.env.POSTGRES_URL ? 'drizzle.config.pg.ts' : 'drizzle.config.sqlite.ts';
|
||||
const configArgs = hasConfigArg ? [] : ['--config', configFile];
|
||||
|
||||
// Ensure the docstore directory exists for SQLite migrations.
|
||||
// drizzle-kit opens the database file directly and will fail if the parent
|
||||
// directory is missing (e.g. in a fresh CI checkout).
|
||||
if (!process.env.POSTGRES_URL) {
|
||||
const dbDir = path.join(process.cwd(), 'docstore');
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDrizzleKitBin() {
|
||||
const binName = process.platform === 'win32' ? 'drizzle-kit.cmd' : 'drizzle-kit';
|
||||
const localBin = path.join(process.cwd(), 'node_modules', '.bin', binName);
|
||||
if (fs.existsSync(localBin)) return localBin;
|
||||
return 'drizzle-kit';
|
||||
}
|
||||
|
||||
const result = spawnSync(resolveDrizzleKitBin(), ['migrate', ...configArgs, ...extraArgs], {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(result.error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
|
|
@ -34,16 +34,14 @@ const UI_ARCHITECTURE_FILES = [
|
|||
"src/components/player/**/*.{ts,tsx}",
|
||||
"src/components/reader/**/*.{ts,tsx}",
|
||||
];
|
||||
const COMPUTE_CORE_IMPORT_PATTERNS = [
|
||||
const COMPUTE_WORKER_IMPORT_PATTERNS = [
|
||||
{
|
||||
group: [
|
||||
"@openreader/compute-core/*",
|
||||
"!@openreader/compute-core/local-runtime",
|
||||
"!@openreader/compute-core/types",
|
||||
"!@openreader/compute-core/api-contracts",
|
||||
"@openreader/compute-worker",
|
||||
"@openreader/compute-worker/*",
|
||||
],
|
||||
message:
|
||||
"Use '@openreader/compute-core' root imports for light APIs. Allowed subpaths are '@openreader/compute-core/local-runtime', '@openreader/compute-core/types', and '@openreader/compute-core/api-contracts'.",
|
||||
"The app must communicate with compute-worker through the app-owned HTTP client and generated protocol types.",
|
||||
},
|
||||
];
|
||||
const UI_ARCHITECTURE_IMPORT_PATHS = [
|
||||
|
|
@ -124,7 +122,7 @@ const eslintConfig = [
|
|||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
patterns: COMPUTE_CORE_IMPORT_PATTERNS,
|
||||
patterns: COMPUTE_WORKER_IMPORT_PATTERNS,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -142,7 +140,7 @@ const eslintConfig = [
|
|||
"error",
|
||||
{
|
||||
paths: UI_ARCHITECTURE_IMPORT_PATHS,
|
||||
patterns: COMPUTE_CORE_IMPORT_PATTERNS,
|
||||
patterns: COMPUTE_WORKER_IMPORT_PATTERNS,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ const securityHeaders = [
|
|||
},
|
||||
];
|
||||
|
||||
const bundleWorkerCompute = true;
|
||||
const pdfjsTraceFiles = [
|
||||
'./node_modules/pdfjs-dist/package.json',
|
||||
'./node_modules/pdfjs-dist/legacy/build/pdf.mjs',
|
||||
|
|
@ -29,7 +28,6 @@ const serverExternalPackages = [
|
|||
// Keep pdfjs-dist as a real package in node_modules. Server-side preview
|
||||
// rendering resolves pdf.js runtime assets from the filesystem at runtime.
|
||||
'pdfjs-dist',
|
||||
...(!bundleWorkerCompute ? ['onnxruntime-node', '@huggingface/tokenizers'] : []),
|
||||
];
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
|
|
@ -48,7 +46,7 @@ const nextConfig: NextConfig = {
|
|||
canvas: '@napi-rs/canvas',
|
||||
},
|
||||
},
|
||||
transpilePackages: [],
|
||||
transpilePackages: ['@openreader/database'],
|
||||
serverExternalPackages,
|
||||
outputFileTracingIncludes: {
|
||||
'/api/audiobook': [
|
||||
|
|
@ -88,14 +86,6 @@ const nextConfig: NextConfig = {
|
|||
},
|
||||
];
|
||||
}
|
||||
if (isServer && bundleWorkerCompute) {
|
||||
config.resolve.alias = {
|
||||
...(config.resolve.alias || {}),
|
||||
'@openreader/compute-core/local-runtime$': false,
|
||||
'onnxruntime-node$': false,
|
||||
'@huggingface/tokenizers$': false,
|
||||
};
|
||||
}
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
36
package.json
36
package.json
|
|
@ -4,54 +4,59 @@
|
|||
"private": true,
|
||||
"packageManager": "pnpm@10.33.4",
|
||||
"scripts": {
|
||||
"dev": "node scripts/openreader-entrypoint.mjs -- pnpm dev:raw",
|
||||
"dev": "pnpm --filter @openreader/bootstrap start pnpm dev:raw",
|
||||
"dev:raw": "next dev --turbopack -p 3003",
|
||||
"build": "next build",
|
||||
"build:bundle-guard": "node scripts/check-next-server-bundle.mjs",
|
||||
"start": "node scripts/openreader-entrypoint.mjs -- pnpm start:raw",
|
||||
"check:compute-boundary": "node scripts/check-compute-boundary.mjs",
|
||||
"start": "pnpm --filter @openreader/bootstrap start pnpm start:raw",
|
||||
"start:raw": "next start -p 3003",
|
||||
"lint": "next lint",
|
||||
"test": "playwright test",
|
||||
"test:e2e": "playwright test",
|
||||
"test:unit": "vitest run",
|
||||
"test:compute": "vitest run --project compute-core --project compute-worker",
|
||||
"migrate": "node drizzle/scripts/migrate.mjs",
|
||||
"migrate-fs": "node scripts/migrate-fs-v2.mjs",
|
||||
"migrate-fs:dry-run": "node scripts/migrate-fs-v2.mjs --dry-run true",
|
||||
"generate": "node drizzle/scripts/generate.mjs",
|
||||
"test:compute": "vitest run --project compute-worker",
|
||||
"compose": "docker compose -f docker/examples/compose.yml up",
|
||||
"compose:full": "docker compose -f docker/examples/compose.full.yml up",
|
||||
"compose:local": "docker compose -f docker/examples/compose.local-slim.yml up --build",
|
||||
"compose:local:slim": "docker compose -f docker/examples/compose.local-slim.yml up --build",
|
||||
"compose:local:full": "docker compose -f docker/examples/compose.local-full.yml up --build",
|
||||
"migrate": "pnpm --filter @openreader/database migrate",
|
||||
"migrate-fs": "pnpm --filter @openreader/bootstrap migrate-storage",
|
||||
"migrate-fs:dry-run": "pnpm --filter @openreader/bootstrap migrate-storage --dry-run true",
|
||||
"generate": "node packages/database/scripts/generate.mjs",
|
||||
"docs:init": "pnpm --dir docs-site install",
|
||||
"docs:dev": "pnpm --dir docs-site start",
|
||||
"docs:build": "pnpm --dir docs-site build",
|
||||
"docs:serve": "pnpm --dir docs-site serve",
|
||||
"docs:version": "pnpm --dir docs-site docusaurus docs:version",
|
||||
"docs:clear": "pnpm --dir docs-site clear",
|
||||
"compute:worker:dev": "pnpm --filter @openreader/compute-worker dev",
|
||||
"compute:worker:start": "pnpm --filter @openreader/compute-worker start",
|
||||
"compute:dev:compose": "docker compose --env-file compute/worker/.env -f compute/worker/docker-compose.yml up --build",
|
||||
"compute:dev": "pnpm --filter @openreader/compute-worker dev",
|
||||
"compute:start": "pnpm --filter @openreader/compute-worker start",
|
||||
"compute:openapi:generate": "pnpm --dir packages/compute-worker openapi:generate && openapi-typescript packages/compute-worker/openapi.json -o src/lib/server/compute-worker/generated.ts",
|
||||
"compute:openapi:check": "pnpm compute:openapi:generate && git diff --exit-code -- packages/compute-worker/openapi.json src/lib/server/compute-worker/generated.ts",
|
||||
"lint:route-errors": "node scripts/check-route-error-responses.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1061.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1061.0",
|
||||
"@headlessui/react": "^2.2.10",
|
||||
"@huggingface/tokenizers": "^0.1.3",
|
||||
"@microsoft/antissrf": "^1.0.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@napi-rs/canvas": "^0.1.100",
|
||||
"@openreader/database": "workspace:*",
|
||||
"@speech-sdk/core": "^0.15.0",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"archiver": "^7.0.1",
|
||||
"better-auth": "^1.6.14",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"cmpstr": "^3.3.0",
|
||||
"compromise": "^14.15.1",
|
||||
"core-js": "^3.49.0",
|
||||
"dexie": "^4.4.3",
|
||||
"dexie-react-hooks": "^4.4.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"epubjs": "^0.3.93",
|
||||
"fast-xml-parser": "^5.8.0",
|
||||
|
|
@ -61,10 +66,8 @@
|
|||
"linkedom": "^0.18.12",
|
||||
"lru-cache": "^11.5.1",
|
||||
"next": "^15.5.19",
|
||||
"onnxruntime-node": "^1.26.0",
|
||||
"openai": "^6.41.0",
|
||||
"pdfjs-dist": "4.8.69",
|
||||
"pg": "^8.21.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"react": "^19.2.7",
|
||||
|
|
@ -95,9 +98,12 @@
|
|||
"@types/react-dom": "^19.2.3",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "^15.5.19",
|
||||
"postcss": "^8.5.15",
|
||||
"openapi-typescript": "^7.10.1",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.8"
|
||||
|
|
|
|||
25
packages/bootstrap/package.json
Normal file
25
packages/bootstrap/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "@openreader/bootstrap",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"bin": {
|
||||
"openreader": "./src/cli.mjs",
|
||||
"openreader-migrate-storage": "./src/storage-migration.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node src/cli.mjs --",
|
||||
"migrate-storage": "node src/storage-migration.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1061.0",
|
||||
"@openreader/database": "workspace:*",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"pg": "^8.21.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -3,16 +3,44 @@ import { spawn, spawnSync } from 'node:child_process';
|
|||
import { randomBytes } from 'node:crypto';
|
||||
import { once } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as dotenv from 'dotenv';
|
||||
import { runMigrations } from '@openreader/database/migrate';
|
||||
import { hasNatsBinary } from './embedded-nats.mjs';
|
||||
import {
|
||||
detectHostForDefaultEndpoint,
|
||||
hasWeedBinary,
|
||||
isRunningInDocker,
|
||||
loopbackS3Endpoint,
|
||||
parseS3Endpoint,
|
||||
parseUrlHost,
|
||||
waitForEndpoint,
|
||||
} from './embedded-seaweedfs.mjs';
|
||||
import { resolveEmbeddedWorkerLaunch } from './embedded-worker.mjs';
|
||||
|
||||
function findWorkspaceRoot(startDir = process.cwd()) {
|
||||
let dir = startDir;
|
||||
while (true) {
|
||||
if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) {
|
||||
break;
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
return startDir;
|
||||
}
|
||||
|
||||
const workspaceRoot = findWorkspaceRoot(process.cwd());
|
||||
|
||||
function loadEnvFiles() {
|
||||
const cwd = process.cwd();
|
||||
const envPath = path.join(cwd, '.env');
|
||||
const envLocalPath = path.join(cwd, '.env.local');
|
||||
const envPath = path.join(workspaceRoot, '.env');
|
||||
const envLocalPath = path.join(workspaceRoot, '.env.local');
|
||||
|
||||
if (fs.existsSync(envPath)) {
|
||||
dotenv.config({ path: envPath });
|
||||
|
|
@ -49,83 +77,6 @@ function requireAuthEnv(env) {
|
|||
}
|
||||
}
|
||||
|
||||
function isPrivateIPv4(address) {
|
||||
if (!address) return false;
|
||||
if (address.startsWith('10.')) return true;
|
||||
if (address.startsWith('192.168.')) return true;
|
||||
const m = /^172\.(\d+)\./.exec(address);
|
||||
if (m) {
|
||||
const second = Number.parseInt(m[1], 10);
|
||||
if (second >= 16 && second <= 31) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function detectHostForDefaultEndpoint() {
|
||||
const interfaces = os.networkInterfaces();
|
||||
const ipv4 = [];
|
||||
|
||||
for (const entries of Object.values(interfaces)) {
|
||||
for (const entry of entries || []) {
|
||||
if (!entry) continue;
|
||||
const family = typeof entry.family === 'string' ? entry.family : String(entry.family);
|
||||
if (family !== 'IPv4') continue;
|
||||
if (entry.internal) continue;
|
||||
ipv4.push(entry.address);
|
||||
}
|
||||
}
|
||||
|
||||
const privateAddr = ipv4.find(isPrivateIPv4);
|
||||
if (privateAddr) return privateAddr;
|
||||
if (ipv4[0]) return ipv4[0];
|
||||
return '127.0.0.1';
|
||||
}
|
||||
|
||||
function parseS3Endpoint(endpoint) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(endpoint);
|
||||
} catch {
|
||||
throw new Error(`Invalid S3_ENDPOINT: ${endpoint}`);
|
||||
}
|
||||
|
||||
if (!url.hostname) {
|
||||
throw new Error(`Invalid S3_ENDPOINT host: ${endpoint}`);
|
||||
}
|
||||
|
||||
const port = Number.parseInt(url.port || '8333', 10);
|
||||
if (!Number.isFinite(port) || port < 1 || port > 65535) {
|
||||
throw new Error(`Invalid S3_ENDPOINT port: ${endpoint}`);
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: url.hostname,
|
||||
port,
|
||||
normalized: `${url.protocol}//${url.hostname}:${port}`,
|
||||
};
|
||||
}
|
||||
|
||||
function loopbackS3Endpoint(endpoint) {
|
||||
const parsed = parseS3Endpoint(endpoint);
|
||||
const url = new URL(parsed.normalized);
|
||||
return `${url.protocol}//127.0.0.1:${parsed.port}`;
|
||||
}
|
||||
|
||||
function parseUrlHost(urlValue, fieldName) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(urlValue);
|
||||
} catch {
|
||||
throw new Error(`Invalid ${fieldName}: ${urlValue}`);
|
||||
}
|
||||
|
||||
if (!url.hostname) {
|
||||
throw new Error(`Invalid ${fieldName} host: ${urlValue}`);
|
||||
}
|
||||
|
||||
return url.hostname;
|
||||
}
|
||||
|
||||
function parseCommandFromArgs(argv) {
|
||||
const marker = argv.indexOf('--');
|
||||
if (marker >= 0) return argv.slice(marker + 1);
|
||||
|
|
@ -143,54 +94,10 @@ function forwardChildStream(stream, target) {
|
|||
};
|
||||
}
|
||||
|
||||
function hasWeedBinary() {
|
||||
const probe = spawnSync('weed', ['version'], { stdio: 'ignore' });
|
||||
if (probe.error) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasNatsBinary() {
|
||||
const probe = spawnSync('nats-server', ['-v'], { stdio: 'ignore' });
|
||||
if (probe.error) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function waitForEndpoint(url, timeoutSeconds) {
|
||||
const waitMs = Math.max(1, timeoutSeconds) * 1000;
|
||||
const deadline = Date.now() + waitMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 2000);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { method: 'GET', signal: controller.signal });
|
||||
if (res) return;
|
||||
} catch {
|
||||
// retry
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
await delay(1000);
|
||||
}
|
||||
|
||||
throw new Error(`Embedded weed mini did not become ready at ${url} within ${timeoutSeconds}s.`);
|
||||
}
|
||||
|
||||
function isRunningInDocker() {
|
||||
if (process.platform !== 'linux') return false;
|
||||
if (fs.existsSync('/.dockerenv')) return true;
|
||||
try {
|
||||
const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf8');
|
||||
return /(docker|containerd|kubepods|podman)/i.test(cgroup);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function spawnMainCommand(command, env) {
|
||||
const [cmd, ...args] = command;
|
||||
const child = spawn(cmd, args, {
|
||||
cwd: workspaceRoot,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
|
|
@ -219,53 +126,13 @@ function spawnMainCommand(command, env) {
|
|||
return { child, exitPromise };
|
||||
}
|
||||
|
||||
function resolveEmbeddedWorkerLaunch() {
|
||||
const candidateDirs = [
|
||||
path.join(process.cwd(), 'embedded-compute-worker'),
|
||||
path.join(process.cwd(), 'compute', 'worker'),
|
||||
];
|
||||
|
||||
for (const workerDir of candidateDirs) {
|
||||
const serverEntry = path.join(workerDir, 'src', 'server.ts');
|
||||
if (!fs.existsSync(serverEntry)) continue;
|
||||
return {
|
||||
cmd: process.execPath,
|
||||
args: ['--import', 'tsx', 'src/server.ts'],
|
||||
cwd: workerDir,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Could not find an embedded compute worker runtime. '
|
||||
+ 'Include embedded-compute-worker/src/server.ts in the runtime image or keep compute/worker available locally.',
|
||||
);
|
||||
}
|
||||
|
||||
function runDbMigrations(env) {
|
||||
const migrateScript = path.join(process.cwd(), 'drizzle', 'scripts', 'migrate.mjs');
|
||||
if (!fs.existsSync(migrateScript)) {
|
||||
throw new Error(`Could not find migration script at ${migrateScript}`);
|
||||
}
|
||||
|
||||
async function runDbMigrations(env) {
|
||||
console.log('Running database migrations...');
|
||||
const migration = spawnSync(process.execPath, [migrateScript], {
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
if (migration.error) {
|
||||
throw migration.error;
|
||||
}
|
||||
if (typeof migration.status === 'number' && migration.status !== 0) {
|
||||
throw new Error(`Database migrations failed with exit code ${migration.status}.`);
|
||||
}
|
||||
await runMigrations({ cwd: workspaceRoot, env });
|
||||
}
|
||||
|
||||
function runStorageMigrations(env) {
|
||||
const migrateScript = path.join(process.cwd(), 'scripts', 'migrate-fs-v2.mjs');
|
||||
if (!fs.existsSync(migrateScript)) {
|
||||
throw new Error(`Could not find storage migration script at ${migrateScript}`);
|
||||
}
|
||||
const migrateScript = fileURLToPath(new URL('./storage-migration.mjs', import.meta.url));
|
||||
|
||||
console.log('Running storage migrations (v2)...');
|
||||
const migration = spawnSync(process.execPath, [migrateScript, '--dry-run', 'false', '--delete-local', 'false'], {
|
||||
|
|
@ -335,7 +202,7 @@ async function main() {
|
|||
|
||||
const command = parseCommandFromArgs(process.argv.slice(2));
|
||||
if (command.length === 0) {
|
||||
console.error('Usage: node scripts/openreader-entrypoint.mjs -- <command> [args]');
|
||||
console.error('Usage: openreader -- <command> [args]');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
|
|
@ -424,11 +291,11 @@ async function main() {
|
|||
try {
|
||||
const shouldRunDbMigrations = resolveBooleanEnv(runtimeEnv, 'RUN_DRIZZLE_MIGRATIONS', true);
|
||||
if (shouldRunDbMigrations) {
|
||||
runDbMigrations(runtimeEnv);
|
||||
await runDbMigrations(runtimeEnv);
|
||||
}
|
||||
|
||||
if (useEmbeddedWeed) {
|
||||
runtimeEnv.WEED_MINI_DIR = withDefault(runtimeEnv.WEED_MINI_DIR, 'docstore/seaweedfs');
|
||||
runtimeEnv.WEED_MINI_DIR = withDefault(runtimeEnv.WEED_MINI_DIR, path.join(workspaceRoot, 'docstore/seaweedfs'));
|
||||
runtimeEnv.WEED_MINI_WAIT_SEC = withDefault(runtimeEnv.WEED_MINI_WAIT_SEC, '20');
|
||||
runtimeEnv.S3_BUCKET = withDefault(runtimeEnv.S3_BUCKET, 'openreader-documents');
|
||||
runtimeEnv.S3_REGION = withDefault(runtimeEnv.S3_REGION, 'us-east-1');
|
||||
|
|
@ -482,7 +349,7 @@ async function main() {
|
|||
console.log('Starting embedded SeaweedFS weed mini...');
|
||||
launchWeed(runtimeEnv.S3_ENDPOINT);
|
||||
const startupEndpoint = parseS3Endpoint(runtimeEnv.S3_ENDPOINT);
|
||||
await waitForEndpoint(`http://127.0.0.1:${startupEndpoint.port}`, waitTimeout);
|
||||
await waitForEndpoint(`http://127.0.0.1:${startupEndpoint.port}`, waitTimeout, 'Embedded SeaweedFS');
|
||||
console.log(`Embedded SeaweedFS is ready at ${runtimeEnv.S3_ENDPOINT}`);
|
||||
}
|
||||
|
||||
|
|
@ -521,7 +388,7 @@ async function main() {
|
|||
runtimeEnv.COMPUTE_WORKER_HOST = withDefault(runtimeEnv.COMPUTE_WORKER_HOST, '127.0.0.1');
|
||||
runtimeEnv.COMPUTE_NATS_REPLICAS = withDefault(runtimeEnv.COMPUTE_NATS_REPLICAS, '1');
|
||||
|
||||
const natsStoreDir = withDefault(runtimeEnv.EMBEDDED_NATS_STORE_DIR, 'docstore/nats/jetstream');
|
||||
const natsStoreDir = withDefault(runtimeEnv.EMBEDDED_NATS_STORE_DIR, path.join(workspaceRoot, 'docstore/nats/jetstream'));
|
||||
fs.mkdirSync(natsStoreDir, { recursive: true });
|
||||
|
||||
console.log(`Starting embedded nats-server on 127.0.0.1:${embeddedNatsPort}...`);
|
||||
|
|
@ -555,7 +422,7 @@ async function main() {
|
|||
console.error(`Embedded nats-server failed to start: ${error instanceof Error ? error.message : String(error)}`);
|
||||
scheduleFatalShutdown('nats-server', null, null, 'failed to start');
|
||||
});
|
||||
await waitForEndpoint(`http://127.0.0.1:${embeddedNatsMonitorPort}/healthz`, 20);
|
||||
await waitForEndpoint(`http://127.0.0.1:${embeddedNatsMonitorPort}/healthz`, 20, 'Embedded nats-server');
|
||||
console.log(`Embedded nats-server is ready at nats://127.0.0.1:${embeddedNatsPort}`);
|
||||
|
||||
console.log(`Starting embedded compute-worker on 127.0.0.1:${embeddedWorkerPort}...`);
|
||||
|
|
@ -589,7 +456,7 @@ async function main() {
|
|||
console.error(`Embedded compute-worker failed to start: ${error instanceof Error ? error.message : String(error)}`);
|
||||
scheduleFatalShutdown('compute-worker', null, null, 'failed to start');
|
||||
});
|
||||
await waitForEndpoint(`http://127.0.0.1:${embeddedWorkerPort}/health/ready`, 30);
|
||||
await waitForEndpoint(`http://127.0.0.1:${embeddedWorkerPort}/health/ready`, 30, 'Embedded compute-worker');
|
||||
console.log(`Embedded compute-worker is ready at http://127.0.0.1:${embeddedWorkerPort}`);
|
||||
} else if (!runtimeEnv.COMPUTE_WORKER_URL?.trim() || !runtimeEnv.COMPUTE_WORKER_TOKEN?.trim()) {
|
||||
throw new Error('COMPUTE_WORKER_URL and COMPUTE_WORKER_TOKEN are required when embedded compute worker startup is disabled.');
|
||||
5
packages/bootstrap/src/embedded-nats.mjs
Normal file
5
packages/bootstrap/src/embedded-nats.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
export function hasNatsBinary() {
|
||||
return !spawnSync('nats-server', ['-v'], { stdio: 'ignore' }).error;
|
||||
}
|
||||
95
packages/bootstrap/src/embedded-seaweedfs.mjs
Normal file
95
packages/bootstrap/src/embedded-seaweedfs.mjs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import process from 'node:process';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
function isPrivateIPv4(address) {
|
||||
if (!address) return false;
|
||||
if (address.startsWith('10.')) return true;
|
||||
if (address.startsWith('192.168.')) return true;
|
||||
const match = /^172\.(\d+)\./.exec(address);
|
||||
if (!match) return false;
|
||||
const second = Number.parseInt(match[1], 10);
|
||||
return second >= 16 && second <= 31;
|
||||
}
|
||||
|
||||
export function hasWeedBinary() {
|
||||
return !spawnSync('weed', ['version'], { stdio: 'ignore' }).error;
|
||||
}
|
||||
|
||||
export function detectHostForDefaultEndpoint() {
|
||||
const ipv4 = Object.values(os.networkInterfaces())
|
||||
.flatMap((entries) => entries || [])
|
||||
.filter((entry) => entry && !entry.internal && String(entry.family) === 'IPv4')
|
||||
.map((entry) => entry.address);
|
||||
|
||||
return ipv4.find(isPrivateIPv4) || ipv4[0] || '127.0.0.1';
|
||||
}
|
||||
|
||||
export function parseS3Endpoint(endpoint) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(endpoint);
|
||||
} catch {
|
||||
throw new Error(`Invalid S3_ENDPOINT: ${endpoint}`);
|
||||
}
|
||||
|
||||
if (!url.hostname) throw new Error(`Invalid S3_ENDPOINT host: ${endpoint}`);
|
||||
const port = Number.parseInt(url.port || '8333', 10);
|
||||
if (!Number.isFinite(port) || port < 1 || port > 65535) {
|
||||
throw new Error(`Invalid S3_ENDPOINT port: ${endpoint}`);
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: url.hostname,
|
||||
port,
|
||||
normalized: `${url.protocol}//${url.hostname}:${port}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function loopbackS3Endpoint(endpoint) {
|
||||
const parsed = parseS3Endpoint(endpoint);
|
||||
const url = new URL(parsed.normalized);
|
||||
return `${url.protocol}//127.0.0.1:${parsed.port}`;
|
||||
}
|
||||
|
||||
export function parseUrlHost(urlValue, fieldName) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(urlValue);
|
||||
} catch {
|
||||
throw new Error(`Invalid ${fieldName}: ${urlValue}`);
|
||||
}
|
||||
|
||||
if (!url.hostname) throw new Error(`Invalid ${fieldName} host: ${urlValue}`);
|
||||
return url.hostname;
|
||||
}
|
||||
|
||||
export async function waitForEndpoint(url, timeoutSeconds, serviceName = 'service') {
|
||||
const deadline = Date.now() + Math.max(1, timeoutSeconds) * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 2000);
|
||||
try {
|
||||
const response = await fetch(url, { method: 'GET', signal: controller.signal });
|
||||
if (response) return;
|
||||
} catch {
|
||||
// Retry until the startup deadline.
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
await delay(1000);
|
||||
}
|
||||
throw new Error(`${serviceName} did not become ready at ${url} within ${timeoutSeconds}s.`);
|
||||
}
|
||||
|
||||
export function isRunningInDocker() {
|
||||
if (process.platform !== 'linux') return false;
|
||||
if (fs.existsSync('/.dockerenv')) return true;
|
||||
try {
|
||||
return /(docker|containerd|kubepods|podman)/i.test(fs.readFileSync('/proc/1/cgroup', 'utf8'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
30
packages/bootstrap/src/embedded-worker.mjs
Normal file
30
packages/bootstrap/src/embedded-worker.mjs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const bootstrapDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
export function resolveEmbeddedWorkerLaunch() {
|
||||
const candidateDirs = [
|
||||
path.resolve(bootstrapDir, '..', 'embedded-compute-worker'),
|
||||
path.resolve(bootstrapDir, '..', 'compute-worker'),
|
||||
path.join(process.cwd(), 'embedded-compute-worker'),
|
||||
path.join(process.cwd(), 'packages', 'compute-worker'),
|
||||
path.join(process.cwd(), 'compute-worker'),
|
||||
];
|
||||
|
||||
for (const workerDir of candidateDirs) {
|
||||
if (!fs.existsSync(path.join(workerDir, 'src', 'server.ts'))) continue;
|
||||
return {
|
||||
cmd: process.execPath,
|
||||
args: ['--import', 'tsx', 'src/server.ts'],
|
||||
cwd: workerDir,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Could not find an embedded compute worker runtime. '
|
||||
+ 'Include embedded-compute-worker/src/server.ts in the runtime image or keep packages/compute-worker available locally.',
|
||||
);
|
||||
}
|
||||
|
|
@ -17,7 +17,23 @@ const { Pool } = require('pg');
|
|||
const BetterSqlite3 = require('better-sqlite3');
|
||||
const ffmpegStatic = require('ffmpeg-static');
|
||||
|
||||
const DOCSTORE_DIR = path.join(process.cwd(), 'docstore');
|
||||
function findWorkspaceRoot(startDir = process.cwd()) {
|
||||
let dir = startDir;
|
||||
while (true) {
|
||||
if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) {
|
||||
break;
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
return startDir;
|
||||
}
|
||||
|
||||
const workspaceRoot = findWorkspaceRoot(process.cwd());
|
||||
const DOCSTORE_DIR = path.join(workspaceRoot, 'docstore');
|
||||
const DOCUMENTS_V1_DIR = path.join(DOCSTORE_DIR, 'documents_v1');
|
||||
const AUDIOBOOKS_V1_DIR = path.join(DOCSTORE_DIR, 'audiobooks_v1');
|
||||
const UNCLAIMED_USER_ID = 'unclaimed';
|
||||
|
|
@ -27,10 +43,15 @@ const SAFE_AUDIOBOOK_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
|
|||
const DOCUMENT_ID_REGEX = /^[a-f0-9]{64}$/i;
|
||||
|
||||
function loadEnvFiles() {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
const envLocalPath = path.join(process.cwd(), '.env.local');
|
||||
if (fs.existsSync(envPath)) dotenv.config({ path: envPath });
|
||||
if (fs.existsSync(envLocalPath)) dotenv.config({ path: envLocalPath, override: true });
|
||||
const envPath = path.join(workspaceRoot, '.env');
|
||||
const envLocalPath = path.join(workspaceRoot, '.env.local');
|
||||
|
||||
if (fs.existsSync(envPath)) {
|
||||
dotenv.config({ path: envPath });
|
||||
}
|
||||
if (fs.existsSync(envLocalPath)) {
|
||||
dotenv.config({ path: envLocalPath, override: true });
|
||||
}
|
||||
}
|
||||
|
||||
function parseBool(value, fallback = false) {
|
||||
|
|
@ -688,7 +709,7 @@ function openDatabase() {
|
|||
};
|
||||
}
|
||||
|
||||
const dbPath = path.join(process.cwd(), 'docstore', 'sqlite3.db');
|
||||
const dbPath = path.join(DOCSTORE_DIR, 'sqlite3.db');
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
const sqlite = new BetterSqlite3(dbPath);
|
||||
return {
|
||||
|
|
@ -1,22 +1,17 @@
|
|||
FROM node:lts AS deploy-stage
|
||||
|
||||
RUN npm install -g pnpm@11.1.2
|
||||
RUN npm install -g pnpm@10.33.4
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY .npmrc package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY compute/worker/package.json compute/worker/package.json
|
||||
COPY compute/core/package.json compute/core/package.json
|
||||
|
||||
COPY compute/worker compute/worker
|
||||
COPY compute/core compute/core
|
||||
COPY packages/compute-worker/package.json packages/compute-worker/package.json
|
||||
COPY packages/compute-worker packages/compute-worker
|
||||
|
||||
RUN pnpm --config.inject-workspace-packages=true --filter @openreader/compute-worker deploy /opt/compute-worker
|
||||
|
||||
FROM node:lts
|
||||
|
||||
RUN npm install -g pnpm@11.1.2
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY --from=deploy-stage /opt/compute-worker ./
|
||||
1766
packages/compute-worker/openapi.json
Normal file
1766
packages/compute-worker/openapi.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -5,15 +5,22 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"start": "tsx src/server.ts"
|
||||
"start": "tsx src/server.ts",
|
||||
"openapi:generate": "tsx scripts/generate-openapi.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1061.0",
|
||||
"@fastify/swagger": "^9.0.0",
|
||||
"@huggingface/tokenizers": "^0.1.3",
|
||||
"@napi-rs/canvas": "^0.1.100",
|
||||
"@nats-io/jetstream": "^3.4.0",
|
||||
"@nats-io/kv": "^3.4.0",
|
||||
"@nats-io/transport-node": "^3.4.0",
|
||||
"@openreader/compute-core": "workspace:*",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"fastify": "^5.8.5",
|
||||
"jszip": "^3.10.1",
|
||||
"onnxruntime-node": "^1.26.0",
|
||||
"pdfjs-dist": "4.8.69",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"tsx": "^4.22.4",
|
||||
20
packages/compute-worker/scripts/generate-openapi.ts
Normal file
20
packages/compute-worker/scripts/generate-openapi.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { createComputeWorkerApp } from '../src/api/app';
|
||||
|
||||
process.env.COMPUTE_WORKER_TOKEN ||= 'openapi-generation-token';
|
||||
process.env.NATS_URL ||= 'nats://127.0.0.1:4222';
|
||||
|
||||
const outputPath = resolve(process.cwd(), 'openapi.json');
|
||||
const runtime = await createComputeWorkerApp({
|
||||
workerToken: process.env.COMPUTE_WORKER_TOKEN,
|
||||
disableWorkers: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await runtime.app.ready();
|
||||
await mkdir(dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, `${JSON.stringify(runtime.app.swagger(), null, 2)}\n`, 'utf8');
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
372
packages/compute-worker/src/api/app.ts
Normal file
372
packages/compute-worker/src/api/app.ts
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import swagger from '@fastify/swagger';
|
||||
import {
|
||||
credsAuthenticator,
|
||||
type ConnectionOptions,
|
||||
} from '@nats-io/transport-node';
|
||||
import {
|
||||
ensureComputeModels,
|
||||
} from '../inference/runtime';
|
||||
import {
|
||||
getComputeTimeoutConfig,
|
||||
getComputeOpStaleMs,
|
||||
getAvailableCpuCores,
|
||||
getOnnxThreadsPerJob,
|
||||
buildLoggerConfig,
|
||||
normalizeNatsReplicas,
|
||||
readBoolEnv,
|
||||
readPositiveIntEnv,
|
||||
requireEnv,
|
||||
} from '../infrastructure/config';
|
||||
import { OperationOrchestrator } from '../operations';
|
||||
import type {
|
||||
PdfLayoutJobRequest,
|
||||
PdfLayoutJobResult,
|
||||
WhisperAlignJobRequest,
|
||||
WhisperAlignJobResult,
|
||||
} from '../operations/contracts';
|
||||
import {
|
||||
JetStreamOperationEventStream,
|
||||
JetStreamOperationQueue,
|
||||
JetStreamOperationStateStore,
|
||||
} from '../infrastructure/nats-adapters';
|
||||
import { createJsonCodec } from '../infrastructure/json-codec';
|
||||
import { createOperationReconciler } from '../operations/reconciliation';
|
||||
import {
|
||||
createArtifactStorage,
|
||||
createS3ClientFromEnv,
|
||||
normalizeS3Prefix,
|
||||
type ArtifactStorage,
|
||||
} from '../infrastructure/storage';
|
||||
import { createJobHandlers } from '../jobs/handlers';
|
||||
import { createWorkerLoopController, type QueuedJob } from '../jobs/worker-loop';
|
||||
import { createNatsSessionManager } from '../infrastructure/nats-session';
|
||||
import {
|
||||
EVENTS_STREAM_NAME,
|
||||
LAYOUT_JOBS_SUBJECT,
|
||||
NATS_API_TIMEOUT_MS,
|
||||
WHISPER_JOBS_SUBJECT,
|
||||
} from '../infrastructure/nats';
|
||||
import { registerHttpHooks } from './http-hooks';
|
||||
import {
|
||||
registerComputeWorkerRoutes,
|
||||
type ComputeWorkerRouteDeps,
|
||||
} from './routes';
|
||||
import {
|
||||
apiErrorResponseSchema,
|
||||
artifactReferenceSchema,
|
||||
jsonSchema,
|
||||
operationErrorSchema,
|
||||
parsedPdfDocumentSchema,
|
||||
pdfLayoutProgressSchema,
|
||||
pdfLayoutResolutionSchema,
|
||||
computeOperationEventSchema,
|
||||
computeOperationSchema,
|
||||
ttsSentenceAlignmentSchema,
|
||||
} from './schemas';
|
||||
|
||||
// Disconnect from NATS after this much continuous idle so the worker stops
|
||||
// generating outbound traffic (pull polling + keepalive PINGs) and Railway can
|
||||
// put it to sleep. Reconnect happens lazily on the next inbound request.
|
||||
const IDLE_DISCONNECT_MS = 120_000;
|
||||
const IDLE_CHECK_INTERVAL_MS = 5_000;
|
||||
const IDLE_STATUS_LOG_INTERVAL_MS = 60_000;
|
||||
const ORPHAN_SWEEP_INTERVAL_MS = 15_000;
|
||||
// Bounded pull window so consumer loops yield periodically and can be stopped
|
||||
// cleanly when going idle, instead of blocking on a long-lived pull.
|
||||
const WHISPER_MAX_DELIVER = 1;
|
||||
|
||||
export type { ComputeWorkerRouteDeps } from './routes';
|
||||
|
||||
export interface CreateComputeWorkerAppOptions {
|
||||
host?: string;
|
||||
port?: number;
|
||||
workerToken?: string;
|
||||
routeDeps?: ComputeWorkerRouteDeps;
|
||||
disableWorkers?: boolean;
|
||||
}
|
||||
|
||||
export interface ComputeWorkerApp {
|
||||
app: FastifyInstance;
|
||||
host: string;
|
||||
port: number;
|
||||
start(options?: { registerSignalHandlers?: boolean }): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function createComputeWorkerApp(options: CreateComputeWorkerAppOptions = {}): Promise<ComputeWorkerApp> {
|
||||
const port = options.port ?? readPositiveIntEnv('PORT', 8081);
|
||||
const host = options.host ?? (process.env.COMPUTE_WORKER_HOST?.trim() || '0.0.0.0');
|
||||
const workerToken = options.workerToken ?? requireEnv('COMPUTE_WORKER_TOKEN');
|
||||
const disableWorkers = options.disableWorkers ?? false;
|
||||
const natsUrl = requireEnv('NATS_URL');
|
||||
const timeoutConfig = getComputeTimeoutConfig();
|
||||
|
||||
const jobConcurrency = readPositiveIntEnv('COMPUTE_JOB_CONCURRENCY', 1);
|
||||
const whisperTimeoutMs = timeoutConfig.whisperTimeoutMs;
|
||||
const pdfTimeoutMs = timeoutConfig.pdfTimeoutMs;
|
||||
const pdfHardCapMs = timeoutConfig.pdfHardCapMs;
|
||||
const pdfAttempts = readPositiveIntEnv('COMPUTE_PDF_JOB_ATTEMPTS', 1);
|
||||
const prewarmModels = readBoolEnv('COMPUTE_PREWARM_MODELS', false);
|
||||
const jobsStreamMaxBytes = readPositiveIntEnv('COMPUTE_JOBS_STREAM_MAX_BYTES', 256 * 1024 * 1024);
|
||||
const eventsStreamMaxBytes = readPositiveIntEnv('COMPUTE_EVENTS_STREAM_MAX_BYTES', 128 * 1024 * 1024);
|
||||
const jobStatesMaxBytes = readPositiveIntEnv('COMPUTE_JOB_STATES_MAX_BYTES', 64 * 1024 * 1024);
|
||||
const natsReplicas = normalizeNatsReplicas(readPositiveIntEnv('COMPUTE_NATS_REPLICAS', 1));
|
||||
const opStaleMs = getComputeOpStaleMs();
|
||||
|
||||
const connectOpts: ConnectionOptions = { servers: natsUrl };
|
||||
const natsCreds = process.env.NATS_CREDS?.trim();
|
||||
const natsCredsFile = process.env.NATS_CREDS_FILE?.trim();
|
||||
|
||||
if (natsCreds) {
|
||||
console.log('[compute-worker] Connecting to NATS using credentials string from NATS_CREDS');
|
||||
connectOpts.authenticator = credsAuthenticator(new TextEncoder().encode(natsCreds));
|
||||
} else if (natsCredsFile) {
|
||||
console.log(`[compute-worker] Connecting to NATS using credentials file: ${natsCredsFile}`);
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const credsData = readFileSync(natsCredsFile);
|
||||
connectOpts.authenticator = credsAuthenticator(credsData);
|
||||
}
|
||||
|
||||
let stopping = false;
|
||||
let inFlightHttp = 0;
|
||||
let activeSse = 0;
|
||||
let inFlightJobs = 0;
|
||||
let lastActivityAt = Date.now();
|
||||
let lastActivityReason = "startup";
|
||||
const markActivity = (reason: string): void => {
|
||||
lastActivityAt = Date.now();
|
||||
lastActivityReason = reason;
|
||||
};
|
||||
let sessionManager!: ReturnType<typeof createNatsSessionManager>;
|
||||
const ensureConnected = () => sessionManager.ensureConnected();
|
||||
|
||||
const s3Prefix = normalizeS3Prefix(process.env.S3_PREFIX);
|
||||
const storageDisabled = async (): Promise<never> => {
|
||||
throw new Error('S3 access is disabled for this worker app instance');
|
||||
};
|
||||
const storage: ArtifactStorage = disableWorkers
|
||||
? {
|
||||
readObject: storageDisabled,
|
||||
objectExists: storageDisabled,
|
||||
deleteObject: storageDisabled,
|
||||
putParsedPdf: storageDisabled,
|
||||
}
|
||||
: createArtifactStorage({
|
||||
bucket: requireEnv('S3_BUCKET'),
|
||||
prefix: s3Prefix,
|
||||
client: createS3ClientFromEnv(requireEnv),
|
||||
});
|
||||
|
||||
if (prewarmModels && !disableWorkers) {
|
||||
await ensureComputeModels();
|
||||
}
|
||||
|
||||
const app = Fastify({
|
||||
logger: buildLoggerConfig(),
|
||||
disableRequestLogging: true,
|
||||
});
|
||||
await app.register(swagger, {
|
||||
openapi: {
|
||||
openapi: '3.0.3',
|
||||
info: {
|
||||
title: 'OpenReader Compute Worker API',
|
||||
version: '1.0.0',
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
ParsedPdfDocument: jsonSchema(parsedPdfDocumentSchema),
|
||||
TTSSentenceAlignment: jsonSchema(ttsSentenceAlignmentSchema),
|
||||
ArtifactReference: jsonSchema(artifactReferenceSchema),
|
||||
ErrorResponse: jsonSchema(apiErrorResponseSchema),
|
||||
OperationError: jsonSchema(operationErrorSchema),
|
||||
PdfLayoutProgress: jsonSchema(pdfLayoutProgressSchema),
|
||||
ComputeOperation: jsonSchema(computeOperationSchema),
|
||||
ComputeOperationEvent: jsonSchema(computeOperationEventSchema),
|
||||
PdfLayoutResolution: jsonSchema(pdfLayoutResolutionSchema),
|
||||
},
|
||||
},
|
||||
security: [{ bearerAuth: [] }],
|
||||
},
|
||||
});
|
||||
app.log.info({
|
||||
jobConcurrency,
|
||||
whisperTimeoutMs,
|
||||
pdfTimeoutMs,
|
||||
pdfAttempts,
|
||||
opStaleMs,
|
||||
availableCpuCores: getAvailableCpuCores(),
|
||||
onnxThreadsPerJob: getOnnxThreadsPerJob(),
|
||||
natsApiTimeoutMs: NATS_API_TIMEOUT_MS,
|
||||
natsReplicas,
|
||||
eventsStreamMaxBytes,
|
||||
pdfLayoutHardCapMs: pdfHardCapMs,
|
||||
}, 'compute runtime config');
|
||||
|
||||
const whisperJobCodec = createJsonCodec<QueuedJob<WhisperAlignJobRequest>>();
|
||||
const layoutJobCodec = createJsonCodec<QueuedJob<PdfLayoutJobRequest>>();
|
||||
|
||||
const defaultOperationStateStore = new JetStreamOperationStateStore<WhisperAlignJobResult | PdfLayoutJobResult>({
|
||||
getKv: async () => (await ensureConnected()).kv,
|
||||
});
|
||||
|
||||
const defaultOperationEventStream = new JetStreamOperationEventStream<WhisperAlignJobResult | PdfLayoutJobResult>({
|
||||
getJs: async () => (await ensureConnected()).js,
|
||||
getJsm: async () => (await ensureConnected()).jsm,
|
||||
eventsStreamName: EVENTS_STREAM_NAME,
|
||||
});
|
||||
|
||||
const operationQueue = new JetStreamOperationQueue({
|
||||
getJs: async () => (await ensureConnected()).js,
|
||||
whisperSubject: WHISPER_JOBS_SUBJECT,
|
||||
layoutSubject: LAYOUT_JOBS_SUBJECT,
|
||||
});
|
||||
|
||||
const defaultOrchestrator = new OperationOrchestrator({
|
||||
queue: operationQueue,
|
||||
stateStore: defaultOperationStateStore,
|
||||
eventStream: defaultOperationEventStream,
|
||||
config: {
|
||||
opStaleMs,
|
||||
maxCasRetries: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const operationStateStore = options.routeDeps?.operationStateStore ?? defaultOperationStateStore;
|
||||
const operationEventStream = options.routeDeps?.operationEventStream ?? defaultOperationEventStream;
|
||||
const orchestrator = options.routeDeps?.orchestrator ?? defaultOrchestrator;
|
||||
const reconciler = createOperationReconciler({
|
||||
stateStore: operationStateStore,
|
||||
orchestrator,
|
||||
whisperTimeoutMs,
|
||||
pdfTimeoutMs,
|
||||
opStaleMs,
|
||||
getGeneration: () => options.routeDeps ? 0 : sessionManager.getGeneration(),
|
||||
logger: app.log,
|
||||
});
|
||||
const ensureOrphanedOpRecovery = () => reconciler.run();
|
||||
const getOpState = (opId: string) => reconciler.getOpState(opId);
|
||||
|
||||
const { releaseHttp } = registerHttpHooks({
|
||||
app,
|
||||
workerToken,
|
||||
markActivity,
|
||||
onInFlightHttpChanged: (delta) => {
|
||||
inFlightHttp = Math.max(0, inFlightHttp + delta);
|
||||
},
|
||||
});
|
||||
|
||||
registerComputeWorkerRoutes({
|
||||
app,
|
||||
deps: {
|
||||
orchestrator,
|
||||
operationStateStore,
|
||||
operationEventStream,
|
||||
artifactExists: options.routeDeps?.artifactExists ?? storage.objectExists,
|
||||
},
|
||||
s3Prefix,
|
||||
ensureOrphanedOpRecovery,
|
||||
getOpState,
|
||||
getNatsConnected: () => sessionManager.isConnected(),
|
||||
releaseHttp,
|
||||
markActivity,
|
||||
onActiveSseChanged: (delta) => {
|
||||
activeSse = Math.max(0, activeSse + delta);
|
||||
},
|
||||
});
|
||||
|
||||
const jobHandlers = createJobHandlers({
|
||||
storage,
|
||||
whisperTimeoutMs,
|
||||
pdfTimeoutMs,
|
||||
pdfHardCapMs,
|
||||
});
|
||||
|
||||
const workerLoops = createWorkerLoopController({
|
||||
orchestrator,
|
||||
handlers: jobHandlers,
|
||||
logger: app.log,
|
||||
jobConcurrency,
|
||||
pdfAttempts,
|
||||
whisperCodec: whisperJobCodec,
|
||||
pdfCodec: layoutJobCodec,
|
||||
isOwnerActive: (owner) => sessionManager.isOwnerActive(owner),
|
||||
isStopping: () => stopping,
|
||||
markActivity,
|
||||
onInFlightJobsChanged: (delta) => {
|
||||
inFlightJobs = Math.max(0, inFlightJobs + delta);
|
||||
},
|
||||
});
|
||||
|
||||
sessionManager = createNatsSessionManager({
|
||||
connectOptions: connectOpts,
|
||||
logger: app.log,
|
||||
whisperTimeoutMs,
|
||||
pdfTimeoutMs,
|
||||
pdfAttempts,
|
||||
jobsStreamMaxBytes,
|
||||
eventsStreamMaxBytes,
|
||||
jobStatesMaxBytes,
|
||||
natsReplicas,
|
||||
isStopping: () => stopping,
|
||||
getActivity: () => ({
|
||||
activeSse,
|
||||
inFlightHttp,
|
||||
inFlightJobs,
|
||||
lastActivityAt,
|
||||
lastActivityReason,
|
||||
}),
|
||||
markActivity,
|
||||
startWorkers: (session) => {
|
||||
if (disableWorkers) return;
|
||||
workerLoops.start(session, {
|
||||
whisper: session.whisperConsumer,
|
||||
pdfLayout: session.layoutConsumer,
|
||||
});
|
||||
},
|
||||
stopWorkers: () => workerLoops.stop(),
|
||||
runReconciliation: () => reconciler.run({ force: true }),
|
||||
});
|
||||
|
||||
const close = async (): Promise<void> => {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
await app.close();
|
||||
await sessionManager.close();
|
||||
};
|
||||
|
||||
const registerSignalHandlers = (): void => {
|
||||
process.once('SIGINT', () => {
|
||||
void close().finally(() => process.exit(0));
|
||||
});
|
||||
|
||||
process.once('SIGTERM', () => {
|
||||
void close().finally(() => process.exit(0));
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
app,
|
||||
host,
|
||||
port,
|
||||
async start(startOptions) {
|
||||
if (startOptions?.registerSignalHandlers) {
|
||||
registerSignalHandlers();
|
||||
}
|
||||
await app.listen({ host, port });
|
||||
app.log.info({ host, port }, 'compute worker listening');
|
||||
},
|
||||
close,
|
||||
};
|
||||
}
|
||||
|
||||
export async function startComputeWorkerFromEnv(): Promise<void> {
|
||||
const runtime = await createComputeWorkerApp();
|
||||
await runtime.start({ registerSignalHandlers: true });
|
||||
}
|
||||
44
packages/compute-worker/src/api/compute-operation.ts
Normal file
44
packages/compute-worker/src/api/compute-operation.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { WorkerOperationState } from '../operations/contracts';
|
||||
import { pdfSubjectFromOperationKey } from '../operations/keys';
|
||||
|
||||
export type ComputeOperationSubject =
|
||||
| { kind: 'whisper_align' }
|
||||
| { kind: 'pdf_layout'; documentId: string; namespace: string | null };
|
||||
|
||||
export interface ComputeOperation<Result = unknown> {
|
||||
opId: string;
|
||||
subject: ComputeOperationSubject;
|
||||
status: WorkerOperationState['status'];
|
||||
queuedAt: number;
|
||||
updatedAt: number;
|
||||
startedAt?: number;
|
||||
result?: Result;
|
||||
error?: WorkerOperationState['error'];
|
||||
timing?: WorkerOperationState['timing'];
|
||||
progress?: WorkerOperationState['progress'];
|
||||
}
|
||||
|
||||
export interface ComputeOperationEvent<Result = unknown> {
|
||||
eventId: number;
|
||||
snapshot: ComputeOperation<Result>;
|
||||
}
|
||||
|
||||
export function toComputeOperation<Result>(
|
||||
state: WorkerOperationState<Result>,
|
||||
): ComputeOperation<Result> {
|
||||
const subject = state.kind === 'pdf_layout'
|
||||
? (pdfSubjectFromOperationKey(state.opKey) ?? { kind: 'pdf_layout', documentId: '', namespace: null })
|
||||
: { kind: 'whisper_align' as const };
|
||||
return {
|
||||
opId: state.opId,
|
||||
subject,
|
||||
status: state.status,
|
||||
queuedAt: state.queuedAt,
|
||||
updatedAt: state.updatedAt,
|
||||
...(state.startedAt === undefined ? {} : { startedAt: state.startedAt }),
|
||||
...(state.result === undefined ? {} : { result: state.result }),
|
||||
...(state.error === undefined ? {} : { error: state.error }),
|
||||
...(state.timing === undefined ? {} : { timing: state.timing }),
|
||||
...(state.progress === undefined ? {} : { progress: state.progress }),
|
||||
};
|
||||
}
|
||||
80
packages/compute-worker/src/api/http-hooks.ts
Normal file
80
packages/compute-worker/src/api/http-hooks.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
|
||||
const REQUEST_STARTED_AT_MS_KEY = Symbol('request-started-at-ms');
|
||||
const REQUEST_COUNTED_KEY = Symbol('request-activity-counted');
|
||||
|
||||
function requestPath(request: FastifyRequest): string {
|
||||
return request.url.split('?')[0] ?? request.url;
|
||||
}
|
||||
|
||||
function isHealthPath(path: string): boolean {
|
||||
return path === '/health/live' || path === '/health/ready';
|
||||
}
|
||||
|
||||
function isAuthed(request: FastifyRequest, expectedToken: string): boolean {
|
||||
const auth = request.headers.authorization;
|
||||
return auth?.startsWith('Bearer ') === true
|
||||
&& auth.slice('Bearer '.length).trim() === expectedToken;
|
||||
}
|
||||
|
||||
function extractTraceId(request: FastifyRequest): string | null {
|
||||
const header = request.headers['x-openreader-trace-id'];
|
||||
if (Array.isArray(header)) return header[0] ?? null;
|
||||
return typeof header === 'string' ? header : null;
|
||||
}
|
||||
|
||||
function extractOpId(request: FastifyRequest, path: string): string | null {
|
||||
const params = request.params as { opId?: unknown } | undefined;
|
||||
if (typeof params?.opId === 'string' && params.opId.trim()) return params.opId.trim();
|
||||
const match = path.match(/^\/v1\/operations\/([^/]+)/);
|
||||
if (!match?.[1]) return null;
|
||||
try {
|
||||
return decodeURIComponent(match[1]);
|
||||
} catch {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
|
||||
export function registerHttpHooks(input: {
|
||||
app: FastifyInstance;
|
||||
workerToken: string;
|
||||
markActivity: (reason: string) => void;
|
||||
onInFlightHttpChanged: (delta: number) => void;
|
||||
}) {
|
||||
const releaseHttp = (request: FastifyRequest): void => {
|
||||
const counted = request as FastifyRequest & { [REQUEST_COUNTED_KEY]?: boolean };
|
||||
if (!counted[REQUEST_COUNTED_KEY]) return;
|
||||
counted[REQUEST_COUNTED_KEY] = false;
|
||||
input.onInFlightHttpChanged(-1);
|
||||
input.markActivity('http_completed');
|
||||
};
|
||||
|
||||
input.app.addHook('onRequest', async (request, reply) => {
|
||||
const path = requestPath(request);
|
||||
(request as FastifyRequest & { [REQUEST_STARTED_AT_MS_KEY]?: number })[REQUEST_STARTED_AT_MS_KEY] = Date.now();
|
||||
(request as FastifyRequest & { [REQUEST_COUNTED_KEY]?: boolean })[REQUEST_COUNTED_KEY] = true;
|
||||
input.onInFlightHttpChanged(1);
|
||||
input.markActivity(`http_started:${path}`);
|
||||
if (!isHealthPath(path) && !isAuthed(request, input.workerToken)) {
|
||||
return reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
});
|
||||
|
||||
input.app.addHook('onResponse', async (request, reply) => {
|
||||
releaseHttp(request);
|
||||
const path = requestPath(request);
|
||||
if (isHealthPath(path) || reply.statusCode < 500) return;
|
||||
const startedAt = (request as FastifyRequest & { [REQUEST_STARTED_AT_MS_KEY]?: number })[REQUEST_STARTED_AT_MS_KEY];
|
||||
input.app.log.error({
|
||||
reqId: request.id,
|
||||
method: request.method,
|
||||
path,
|
||||
statusCode: reply.statusCode,
|
||||
durationMs: Number.isFinite(startedAt) ? Math.max(0, Date.now() - (startedAt as number)) : -1,
|
||||
traceId: extractTraceId(request),
|
||||
opId: extractOpId(request, path),
|
||||
}, 'http.error');
|
||||
});
|
||||
|
||||
return { releaseHttp };
|
||||
}
|
||||
356
packages/compute-worker/src/api/routes.ts
Normal file
356
packages/compute-worker/src/api/routes.ts
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { encodeSseFrame } from '../operations';
|
||||
import type {
|
||||
PdfLayoutJobResult,
|
||||
WorkerJobTiming,
|
||||
WorkerOperationEvent,
|
||||
WorkerOperationRequest,
|
||||
WorkerOperationState,
|
||||
WhisperAlignJobResult,
|
||||
} from '../operations/contracts';
|
||||
import { hashOpKey } from '../infrastructure/nats-adapters';
|
||||
import type { StreamedOperationState } from '../operations/recovery';
|
||||
import type { ReconciliationStateStore } from '../operations/reconciliation';
|
||||
import { parsedPdfArtifactKey } from '../storage/artifact-addressing';
|
||||
import { buildPdfOperationKey, buildWhisperOperationKey } from '../operations/keys';
|
||||
import { toComputeOperation, type ComputeOperationEvent } from './compute-operation';
|
||||
import {
|
||||
apiErrorResponseSchema,
|
||||
jsonSchema,
|
||||
operationEventsQuerySchema,
|
||||
operationParamsSchema,
|
||||
pdfLayoutResolutionSchema,
|
||||
pdfOperationCreateSchema,
|
||||
pdfResolveSchema,
|
||||
computeOperationSchema,
|
||||
whisperOperationCreateSchema,
|
||||
} from './schemas';
|
||||
|
||||
const OP_EVENTS_KEEPALIVE_MS = 15_000;
|
||||
const OP_EVENTS_RECONNECT_HINT_MS = 120_000;
|
||||
const errorResponseSchema = jsonSchema(apiErrorResponseSchema);
|
||||
|
||||
interface OperationEventStreamLike {
|
||||
subscribe(input: {
|
||||
opId: string;
|
||||
sinceEventId?: number;
|
||||
onEvent: (event: WorkerOperationEvent<WhisperAlignJobResult | PdfLayoutJobResult>) => void | Promise<void>;
|
||||
onError?: (error: unknown) => void;
|
||||
}): Promise<() => void>;
|
||||
}
|
||||
|
||||
interface OperationStateStoreLike extends ReconciliationStateStore {}
|
||||
|
||||
interface OrchestratorLike {
|
||||
enqueueOrReuse(request: WorkerOperationRequest): Promise<WorkerOperationState>;
|
||||
markRunning(input: {
|
||||
opId: string;
|
||||
startedAt?: number;
|
||||
updatedAt?: number;
|
||||
timing?: WorkerJobTiming;
|
||||
}): Promise<unknown>;
|
||||
markProgress(input: {
|
||||
opId: string;
|
||||
progress: WorkerOperationState['progress'];
|
||||
updatedAt?: number;
|
||||
timing?: WorkerJobTiming;
|
||||
}): Promise<unknown>;
|
||||
markSucceeded(input: {
|
||||
opId: string;
|
||||
result: unknown;
|
||||
updatedAt?: number;
|
||||
timing?: WorkerJobTiming;
|
||||
}): Promise<unknown>;
|
||||
markFailed(input: {
|
||||
opId: string;
|
||||
error: { message: string; code?: string } | string;
|
||||
updatedAt?: number;
|
||||
timing?: WorkerJobTiming;
|
||||
}): Promise<unknown>;
|
||||
markFailedIfUnchanged?(input: {
|
||||
current: StreamedOperationState;
|
||||
expectedRevision: number;
|
||||
error: { message: string; code?: string } | string;
|
||||
updatedAt?: number;
|
||||
timing?: WorkerJobTiming;
|
||||
}): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface ComputeWorkerRouteDeps {
|
||||
orchestrator: OrchestratorLike;
|
||||
operationStateStore: OperationStateStoreLike;
|
||||
operationEventStream: OperationEventStreamLike;
|
||||
artifactExists?: (key: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function isTerminalStatus(status: import('../operations/contracts').WorkerJobState): boolean {
|
||||
return status === 'succeeded' || status === 'failed';
|
||||
}
|
||||
|
||||
export function registerComputeWorkerRoutes(input: {
|
||||
app: FastifyInstance;
|
||||
deps: ComputeWorkerRouteDeps;
|
||||
s3Prefix: string;
|
||||
ensureOrphanedOpRecovery: () => Promise<void>;
|
||||
getOpState: (opId: string) => Promise<StreamedOperationState | null>;
|
||||
getNatsConnected: () => boolean;
|
||||
releaseHttp: (request: FastifyRequest) => void;
|
||||
markActivity: (reason: string) => void;
|
||||
onActiveSseChanged: (delta: number) => void;
|
||||
}) {
|
||||
const {
|
||||
app,
|
||||
deps,
|
||||
s3Prefix,
|
||||
ensureOrphanedOpRecovery,
|
||||
getOpState,
|
||||
getNatsConnected,
|
||||
releaseHttp,
|
||||
markActivity,
|
||||
onActiveSseChanged,
|
||||
} = input;
|
||||
|
||||
app.get('/health/live', {
|
||||
schema: {
|
||||
security: [],
|
||||
response: { 200: { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'] } },
|
||||
},
|
||||
}, async () => ({ ok: true }));
|
||||
|
||||
app.get('/health/ready', {
|
||||
schema: {
|
||||
security: [],
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: { ok: { type: 'boolean' }, natsConnected: { type: 'boolean' } },
|
||||
required: ['ok', 'natsConnected'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async () => ({ ok: true, natsConnected: getNatsConnected() }));
|
||||
|
||||
app.post('/v1/whisper-align/operations', {
|
||||
schema: {
|
||||
body: jsonSchema(whisperOperationCreateSchema),
|
||||
response: { 202: jsonSchema(computeOperationSchema), 400: errorResponseSchema },
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const parsed = whisperOperationCreateSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'Invalid request body', issues: parsed.error.issues };
|
||||
}
|
||||
|
||||
const requestOp: WorkerOperationRequest = {
|
||||
kind: 'whisper_align',
|
||||
opKey: buildWhisperOperationKey(parsed.data),
|
||||
payload: parsed.data,
|
||||
};
|
||||
await ensureOrphanedOpRecovery();
|
||||
const op = await deps.orchestrator.enqueueOrReuse(requestOp);
|
||||
app.log.info({
|
||||
kind: requestOp.kind,
|
||||
opId: op.opId,
|
||||
jobId: op.jobId,
|
||||
status: op.status,
|
||||
opKeyHash: hashOpKey(requestOp.opKey.trim()).slice(0, 16),
|
||||
}, 'op.accepted');
|
||||
reply.code(202);
|
||||
return toComputeOperation(op);
|
||||
});
|
||||
|
||||
app.post('/v1/pdf-layout/operations', {
|
||||
schema: {
|
||||
body: jsonSchema(pdfOperationCreateSchema),
|
||||
response: { 202: jsonSchema(computeOperationSchema), 400: errorResponseSchema },
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const parsed = pdfOperationCreateSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'Invalid request body', issues: parsed.error.issues };
|
||||
}
|
||||
|
||||
const requestOp: WorkerOperationRequest = {
|
||||
kind: 'pdf_layout',
|
||||
opKey: buildPdfOperationKey(parsed.data),
|
||||
payload: {
|
||||
documentId: parsed.data.documentId,
|
||||
namespace: parsed.data.namespace,
|
||||
documentObjectKey: parsed.data.documentObjectKey,
|
||||
},
|
||||
};
|
||||
await ensureOrphanedOpRecovery();
|
||||
const op = await deps.orchestrator.enqueueOrReuse(requestOp);
|
||||
reply.code(202);
|
||||
return toComputeOperation(op);
|
||||
});
|
||||
|
||||
app.post('/v1/pdf-layout/resolve', {
|
||||
schema: {
|
||||
body: jsonSchema(pdfResolveSchema),
|
||||
response: { 200: jsonSchema(pdfLayoutResolutionSchema), 400: errorResponseSchema },
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const parsed = pdfResolveSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'Invalid request body', issues: parsed.error.issues };
|
||||
}
|
||||
await ensureOrphanedOpRecovery();
|
||||
const artifactKey = parsedPdfArtifactKey({
|
||||
documentId: parsed.data.documentId,
|
||||
namespace: parsed.data.namespace,
|
||||
prefix: s3Prefix,
|
||||
});
|
||||
const hasArtifact = await deps.artifactExists?.(artifactKey) ?? false;
|
||||
const opKey = buildPdfOperationKey(parsed.data);
|
||||
const index = await deps.operationStateStore.getOpIndex?.(opKey);
|
||||
const operation = index?.opId ? await deps.operationStateStore.getOpState(index.opId) : null;
|
||||
return {
|
||||
artifact: hasArtifact ? { objectKey: artifactKey } : null,
|
||||
operation: operation ? toComputeOperation(operation) : null,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/v1/operations/:opId', {
|
||||
schema: {
|
||||
params: jsonSchema(operationParamsSchema),
|
||||
response: { 200: jsonSchema(computeOperationSchema), 400: errorResponseSchema, 404: errorResponseSchema },
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const params = operationParamsSchema.safeParse(request.params);
|
||||
if (!params.success) {
|
||||
reply.code(400);
|
||||
return { error: 'Invalid op id' };
|
||||
}
|
||||
|
||||
const state = await getOpState(params.data.opId);
|
||||
if (!state) {
|
||||
reply.code(404);
|
||||
return { error: 'Operation not found' };
|
||||
}
|
||||
return toComputeOperation(state);
|
||||
});
|
||||
|
||||
app.get('/v1/operations/:opId/events', {
|
||||
schema: {
|
||||
params: jsonSchema(operationParamsSchema),
|
||||
querystring: jsonSchema(operationEventsQuerySchema),
|
||||
response: {
|
||||
200: { type: 'string', description: 'Server-sent ComputeOperationEvent stream' },
|
||||
400: errorResponseSchema,
|
||||
404: errorResponseSchema,
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const params = operationParamsSchema.safeParse(request.params);
|
||||
if (!params.success) {
|
||||
reply.code(400);
|
||||
return { error: 'Invalid op id' };
|
||||
}
|
||||
|
||||
const initial = await getOpState(params.data.opId);
|
||||
if (!initial) {
|
||||
reply.code(404);
|
||||
return { error: 'Operation not found' };
|
||||
}
|
||||
|
||||
const cursorQueryRaw = request.query as { sinceEventId?: string | number | null } | undefined;
|
||||
const cursorFromQuery = Number(cursorQueryRaw?.sinceEventId ?? 0);
|
||||
const lastEventIdHeader = request.headers['last-event-id'];
|
||||
const cursorFromHeader = Number(
|
||||
Array.isArray(lastEventIdHeader) ? (lastEventIdHeader[0] ?? 0) : (lastEventIdHeader ?? 0),
|
||||
);
|
||||
const sinceEventId = Math.max(
|
||||
0,
|
||||
Number.isFinite(cursorFromQuery) ? Math.floor(cursorFromQuery) : 0,
|
||||
Number.isFinite(cursorFromHeader) ? Math.floor(cursorFromHeader) : 0,
|
||||
);
|
||||
|
||||
reply.hijack();
|
||||
releaseHttp(request);
|
||||
onActiveSseChanged(1);
|
||||
markActivity('sse_started');
|
||||
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
reply.raw.setHeader('Connection', 'keep-alive');
|
||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||
reply.raw.write(encodeSseFrame({ retry: OP_EVENTS_RECONNECT_HINT_MS }));
|
||||
|
||||
let closed = false;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let keepalive: NodeJS.Timeout | null = null;
|
||||
|
||||
const writeSnapshot = (snapshot: StreamedOperationState, eventId: number): void => {
|
||||
if (closed || reply.raw.writableEnded) return;
|
||||
const frameEvent: ComputeOperationEvent<WhisperAlignJobResult | PdfLayoutJobResult> = {
|
||||
eventId,
|
||||
snapshot: toComputeOperation(snapshot),
|
||||
};
|
||||
reply.raw.write(encodeSseFrame({ id: eventId, event: 'snapshot', data: frameEvent }));
|
||||
};
|
||||
|
||||
const closeStream = (): void => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
unsubscribe = null;
|
||||
}
|
||||
if (keepalive) {
|
||||
clearInterval(keepalive);
|
||||
keepalive = null;
|
||||
}
|
||||
onActiveSseChanged(-1);
|
||||
markActivity('sse_closed');
|
||||
if (!reply.raw.writableEnded) reply.raw.end();
|
||||
};
|
||||
|
||||
request.raw.on('close', closeStream);
|
||||
|
||||
try {
|
||||
let current = initial;
|
||||
let signature = JSON.stringify(current);
|
||||
writeSnapshot(current, sinceEventId > 0 ? sinceEventId : 0);
|
||||
if (isTerminalStatus(current.status)) return reply;
|
||||
|
||||
keepalive = setInterval(() => {
|
||||
if (!closed && !reply.raw.writableEnded) reply.raw.write(': keepalive\n\n');
|
||||
}, OP_EVENTS_KEEPALIVE_MS);
|
||||
|
||||
unsubscribe = await deps.operationEventStream.subscribe({
|
||||
opId: params.data.opId,
|
||||
sinceEventId,
|
||||
onEvent: (event) => {
|
||||
if (closed || event.snapshot.opId !== params.data.opId) return;
|
||||
const nextSignature = JSON.stringify(event.snapshot);
|
||||
if (nextSignature !== signature) {
|
||||
current = event.snapshot;
|
||||
signature = nextSignature;
|
||||
markActivity('sse_event');
|
||||
writeSnapshot(current, event.eventId);
|
||||
}
|
||||
if (isTerminalStatus(event.snapshot.status)) closeStream();
|
||||
},
|
||||
onError: (error) => {
|
||||
app.log.warn({ opId: params.data.opId, error: toErrorMessage(error) }, 'op events stream loop error');
|
||||
closeStream();
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => request.raw.once('close', resolve));
|
||||
} catch (error) {
|
||||
app.log.warn({ opId: params.data.opId, error: toErrorMessage(error) }, 'op events stream loop error');
|
||||
} finally {
|
||||
closeStream();
|
||||
}
|
||||
return reply;
|
||||
});
|
||||
}
|
||||
134
packages/compute-worker/src/api/schemas.ts
Normal file
134
packages/compute-worker/src/api/schemas.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
const bboxSchema = z.tuple([z.number(), z.number(), z.number(), z.number()]);
|
||||
const parsedPdfBlockKindSchema = z.enum([
|
||||
'abstract', 'algorithm', 'aside_text', 'chart', 'content', 'formula',
|
||||
'doc_title', 'figure_title', 'footer', 'footnote', 'formula_number',
|
||||
'header', 'image', 'number', 'paragraph_title', 'reference',
|
||||
'reference_content', 'seal', 'table', 'text', 'vision_footnote',
|
||||
]);
|
||||
const documentIdSchema = z.string().trim().regex(/^[a-f0-9]{64}$/i);
|
||||
const namespaceSchema = z.string().trim().regex(/^[a-zA-Z0-9._-]{1,128}$/).nullable();
|
||||
|
||||
export const parsedPdfDocumentSchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
documentId: z.string(),
|
||||
parserVersion: z.string(),
|
||||
parsedAt: z.number(),
|
||||
pages: z.array(z.object({
|
||||
pageNumber: z.number(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
blocks: z.array(z.object({
|
||||
id: z.string(),
|
||||
kind: parsedPdfBlockKindSchema,
|
||||
fragments: z.array(z.object({
|
||||
page: z.number(),
|
||||
bbox: bboxSchema,
|
||||
text: z.string(),
|
||||
readingOrder: z.number(),
|
||||
modelConfidence: z.number().optional(),
|
||||
})),
|
||||
text: z.string(),
|
||||
parentSectionId: z.string().optional(),
|
||||
})),
|
||||
})),
|
||||
});
|
||||
|
||||
export const ttsSentenceAlignmentSchema = z.object({
|
||||
sentence: z.string(),
|
||||
sentenceIndex: z.number(),
|
||||
words: z.array(z.object({
|
||||
text: z.string(),
|
||||
startSec: z.number(),
|
||||
endSec: z.number(),
|
||||
charStart: z.number(),
|
||||
charEnd: z.number(),
|
||||
})),
|
||||
});
|
||||
|
||||
export const whisperOperationCreateSchema = z.object({
|
||||
text: z.string().trim().min(1),
|
||||
lang: z.string().trim().min(1).max(16).optional(),
|
||||
cacheKey: z.string().trim().min(1).max(256).optional(),
|
||||
audioObjectKey: z.string().trim().min(1).max(2048),
|
||||
});
|
||||
|
||||
export const pdfOperationCreateSchema = z.object({
|
||||
documentId: documentIdSchema,
|
||||
namespace: namespaceSchema,
|
||||
documentObjectKey: z.string().trim().min(1).max(2048),
|
||||
replaceToken: z.string().trim().min(1).max(256).optional(),
|
||||
});
|
||||
|
||||
export const pdfResolveSchema = z.object({
|
||||
documentId: documentIdSchema,
|
||||
namespace: namespaceSchema,
|
||||
documentObjectKey: z.string().trim().min(1).max(2048),
|
||||
});
|
||||
|
||||
export const operationParamsSchema = z.object({
|
||||
opId: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const operationEventsQuerySchema = z.object({
|
||||
sinceEventId: z.union([z.string(), z.number()]).optional(),
|
||||
});
|
||||
|
||||
export const apiErrorResponseSchema = z.object({
|
||||
error: z.string(),
|
||||
}).passthrough();
|
||||
|
||||
export const operationErrorSchema = z.object({
|
||||
message: z.string(),
|
||||
code: z.string().optional(),
|
||||
});
|
||||
|
||||
export const artifactReferenceSchema = z.object({
|
||||
objectKey: z.string(),
|
||||
});
|
||||
|
||||
export const pdfLayoutProgressSchema = z.object({
|
||||
totalPages: z.number(),
|
||||
pagesParsed: z.number(),
|
||||
currentPage: z.number().optional(),
|
||||
phase: z.enum(['infer', 'merge']),
|
||||
});
|
||||
|
||||
export const computeOperationSchema = z.object({
|
||||
opId: z.string(),
|
||||
subject: z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('whisper_align') }),
|
||||
z.object({
|
||||
kind: z.literal('pdf_layout'),
|
||||
documentId: z.string(),
|
||||
namespace: z.string().nullable(),
|
||||
}),
|
||||
]),
|
||||
status: z.enum(['queued', 'running', 'succeeded', 'failed']),
|
||||
queuedAt: z.number(),
|
||||
updatedAt: z.number(),
|
||||
startedAt: z.number().optional(),
|
||||
result: z.unknown().optional(),
|
||||
error: operationErrorSchema.optional(),
|
||||
timing: z.object({
|
||||
queueWaitMs: z.number().optional(),
|
||||
s3FetchMs: z.number().optional(),
|
||||
computeMs: z.number().optional(),
|
||||
}).optional(),
|
||||
progress: pdfLayoutProgressSchema.optional(),
|
||||
});
|
||||
|
||||
export const computeOperationEventSchema = z.object({
|
||||
eventId: z.number(),
|
||||
snapshot: computeOperationSchema,
|
||||
});
|
||||
|
||||
export const pdfLayoutResolutionSchema = z.object({
|
||||
artifact: artifactReferenceSchema.nullable(),
|
||||
operation: computeOperationSchema.nullable(),
|
||||
});
|
||||
|
||||
export function jsonSchema(schema: z.ZodType): Record<string, unknown> {
|
||||
return z.toJSONSchema(schema, { target: 'draft-7' }) as Record<string, unknown>;
|
||||
}
|
||||
|
|
@ -61,3 +61,20 @@ export interface PdfParseProgress {
|
|||
currentPage?: number;
|
||||
phase: PdfParsePhase;
|
||||
}
|
||||
|
||||
export type TTSAudioBuffer = ArrayBuffer;
|
||||
export type TTSAudioBytes = number[];
|
||||
|
||||
export interface TTSSentenceWord {
|
||||
text: string;
|
||||
startSec: number;
|
||||
endSec: number;
|
||||
charStart: number;
|
||||
charEnd: number;
|
||||
}
|
||||
|
||||
export interface TTSSentenceAlignment {
|
||||
sentence: string;
|
||||
sentenceIndex: number;
|
||||
words: TTSSentenceWord[];
|
||||
}
|
||||
|
|
@ -1,4 +1,18 @@
|
|||
import type { LayoutRegion, PdfTextItem } from './types';
|
||||
import type { ParsedPdfBlockKind } from '../../api/types';
|
||||
|
||||
export interface PdfTextItem {
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface LayoutRegion {
|
||||
bbox: [number, number, number, number];
|
||||
label: ParsedPdfBlockKind;
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
const NON_TEXT_REGION_LABELS = new Set<LayoutRegion['label']>(['chart', 'image', 'table', 'seal']);
|
||||
const TEXT_ASSIGNABLE_LABELS = new Set<LayoutRegion['label']>([
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import * as ort from 'onnxruntime-node';
|
||||
import { readFile } from 'fs/promises';
|
||||
import type { LayoutRegion, PdfTextItem } from './types';
|
||||
import type { LayoutRegion, PdfTextItem } from './document-layout';
|
||||
import { ensureModel, MODEL_CONFIG_PATH, MODEL_PREPROCESSOR_PATH } from './model';
|
||||
import { getOnnxThreadsPerJob } from '../config/cpu-budget';
|
||||
import { getOnnxThreadsPerJob } from '../../infrastructure/config';
|
||||
|
||||
interface RunLayoutInput {
|
||||
pageWidth: number;
|
||||
|
|
@ -3,7 +3,7 @@ import { fileURLToPath } from 'url';
|
|||
import { createHash } from 'crypto';
|
||||
import { readFileSync } from 'fs';
|
||||
import { access, mkdir, rename, writeFile, readFile, unlink, copyFile } from 'fs/promises';
|
||||
import { DOCSTORE_DIR } from '../platform/docstore';
|
||||
import { DOCSTORE_DIR } from '../../infrastructure/platform';
|
||||
|
||||
const DEFAULT_MODEL_BASE_URL = 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main';
|
||||
const PDF_LAYOUT_MODEL_BASE_URL_ENV = 'PDF_LAYOUT_MODEL_BASE_URL';
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
|
||||
import type { PdfTextItem } from './types';
|
||||
import type { PdfTextItem } from './document-layout';
|
||||
|
||||
interface ViewportLike {
|
||||
height: number;
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
|
||||
import type { ParsedPdfDocument, ParsedPdfPage } from '../types/parsed-pdf';
|
||||
import type { ParsedPdfDocument, ParsedPdfPage } from '../../api/types';
|
||||
import { ensureModel } from './model';
|
||||
import { runLayoutModel } from './runLayoutModel';
|
||||
import { mergeTextWithRegions } from './merge';
|
||||
import { configurePdfjsNodeRuntime, resolvePdfjsStandardFontDataUrl } from './pdfjs-runtime';
|
||||
import { PDF_PARSER_VERSION } from './parser-version';
|
||||
import { runLayoutModel } from './layout-model';
|
||||
import { mergeTextWithRegions } from './document-layout';
|
||||
import { configurePdfjsNodeRuntime, resolvePdfjsStandardFontDataUrl } from './pdfjs';
|
||||
import { PDF_PARSER_VERSION } from '../../operations/contracts';
|
||||
import { stitchCrossPageBlocks } from './stitch';
|
||||
import { renderPage } from './render';
|
||||
import { normalizeTextItemsForLayout } from './normalize-text';
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Canvas } from '@napi-rs/canvas';
|
||||
import { configurePdfjsNodeRuntime, resolvePdfjsStandardFontDataUrl } from './pdfjs-runtime';
|
||||
import { configurePdfjsNodeRuntime, resolvePdfjsStandardFontDataUrl } from './pdfjs';
|
||||
|
||||
type CanvasRuntime = {
|
||||
DOMMatrixCtor: unknown;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ParsedPdfDocument, ParsedPdfBlock } from '../types/parsed-pdf';
|
||||
import type { ParsedPdfDocument, ParsedPdfBlock } from '../../api/types';
|
||||
|
||||
const STITCHABLE_KINDS = new Set<ParsedPdfBlock['kind']>([
|
||||
'text',
|
||||
|
|
@ -7,19 +7,18 @@ import { fileURLToPath } from 'url';
|
|||
import * as ort from 'onnxruntime-node';
|
||||
import { Tokenizer } from '@huggingface/tokenizers';
|
||||
import JSZip from 'jszip';
|
||||
import type { TTSAudioBuffer, TTSAudioBytes, TTSSentenceAlignment } from '../types/tts';
|
||||
import { getFFmpegPath } from '../platform/ffmpeg';
|
||||
import { getOnnxThreadsPerJob } from '../config/cpu-budget';
|
||||
import { getComputeTimeoutConfig } from '../config/timeout';
|
||||
import type { TTSAudioBuffer, TTSAudioBytes, TTSSentenceAlignment } from '../../api/types';
|
||||
import { getFFmpegPath } from '../../infrastructure/platform';
|
||||
import { getOnnxThreadsPerJob } from '../../infrastructure/config';
|
||||
import { getComputeTimeoutConfig } from '../../infrastructure/config';
|
||||
import {
|
||||
mapWordsToSentenceOffsets,
|
||||
type WhisperWord,
|
||||
} from './alignment-map';
|
||||
import { buildGoertzelCoefficients, goertzelPower } from './spectral';
|
||||
} from './timestamps';
|
||||
import {
|
||||
buildWordsFromTimestampedTokens,
|
||||
extractTokenStartTimestamps,
|
||||
} from './token-timestamps';
|
||||
} from './timestamps';
|
||||
import {
|
||||
ensureWhisperModel,
|
||||
WHISPER_CONFIG_PATH,
|
||||
|
|
@ -30,6 +29,31 @@ import {
|
|||
WHISPER_DECODER_MERGED_MODEL_PATH,
|
||||
WHISPER_DECODER_WITH_PAST_MODEL_PATH,
|
||||
} from './model';
|
||||
import {
|
||||
applyTokenSuppression,
|
||||
applyWhisperTimestampLogitsRules,
|
||||
argmax,
|
||||
} from './decoder';
|
||||
|
||||
export function buildGoertzelCoefficients(freqBins: number, fftSize: number): Float64Array {
|
||||
const coeffs = new Float64Array(freqBins);
|
||||
for (let k = 0; k < freqBins; k += 1) {
|
||||
coeffs[k] = 2 * Math.cos((2 * Math.PI * k) / fftSize);
|
||||
}
|
||||
return coeffs;
|
||||
}
|
||||
|
||||
export function goertzelPower(samples: Float32Array, coeff: number): number {
|
||||
let s1 = 0;
|
||||
let s2 = 0;
|
||||
for (let i = 0; i < samples.length; i += 1) {
|
||||
const s0 = samples[i] + (coeff * s1) - s2;
|
||||
s2 = s1;
|
||||
s1 = s0;
|
||||
}
|
||||
const power = (s1 * s1) + (s2 * s2) - (coeff * s1 * s2);
|
||||
return !Number.isFinite(power) || power < 0 ? 0 : power;
|
||||
}
|
||||
|
||||
interface WhisperAlignmentOptions {
|
||||
lang?: string;
|
||||
|
|
@ -429,118 +453,6 @@ function buildEmptyPastFeeds() {
|
|||
return state.emptyPastFeedsTemplate;
|
||||
}
|
||||
|
||||
function argmax(values: Float32Array): number | null {
|
||||
let bestIdx = 0;
|
||||
let bestScore = Number.NEGATIVE_INFINITY;
|
||||
|
||||
for (let i = 0; i < values.length; i += 1) {
|
||||
const score = values[i];
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
return Number.isFinite(bestScore) ? bestIdx : null;
|
||||
}
|
||||
|
||||
function applyTokenSuppression(logits: Float32Array, tokens: Set<number>) {
|
||||
for (const tokenId of tokens) {
|
||||
if (tokenId >= 0 && tokenId < logits.length) {
|
||||
logits[tokenId] = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function logSoftmax(input: Float32Array): Float32Array {
|
||||
let max = Number.NEGATIVE_INFINITY;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
if (input[i] > max) max = input[i];
|
||||
}
|
||||
if (!Number.isFinite(max)) {
|
||||
return new Float32Array(input.length).fill(Number.NEGATIVE_INFINITY);
|
||||
}
|
||||
|
||||
let sum = 0;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
sum += Math.exp(input[i] - max);
|
||||
}
|
||||
const logSum = Math.log(sum);
|
||||
|
||||
const out = new Float32Array(input.length);
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
out[i] = input[i] - max - logSum;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function applyWhisperTimestampLogitsRules(input: {
|
||||
logits: Float32Array;
|
||||
generated: number[];
|
||||
beginIndex: number;
|
||||
eosTokenId: number;
|
||||
noTimestampsTokenId: number;
|
||||
timestampBeginTokenId: number;
|
||||
maxInitialTimestampIndex: number;
|
||||
}) {
|
||||
const {
|
||||
logits,
|
||||
generated,
|
||||
beginIndex,
|
||||
eosTokenId,
|
||||
noTimestampsTokenId,
|
||||
timestampBeginTokenId,
|
||||
maxInitialTimestampIndex,
|
||||
} = input;
|
||||
|
||||
if (noTimestampsTokenId >= 0 && noTimestampsTokenId < logits.length) {
|
||||
logits[noTimestampsTokenId] = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
if (generated.length === beginIndex) {
|
||||
const upper = Math.min(timestampBeginTokenId, logits.length);
|
||||
for (let i = 0; i < upper; i += 1) logits[i] = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
const seq = generated.slice(beginIndex);
|
||||
const lastWasTimestamp = seq.length >= 1 && seq[seq.length - 1] >= timestampBeginTokenId;
|
||||
const penultimateWasTimestamp = seq.length < 2 || seq[seq.length - 2] >= timestampBeginTokenId;
|
||||
|
||||
if (lastWasTimestamp) {
|
||||
if (penultimateWasTimestamp) {
|
||||
for (let i = timestampBeginTokenId; i < logits.length; i += 1) logits[i] = Number.NEGATIVE_INFINITY;
|
||||
} else {
|
||||
const upper = Math.min(eosTokenId, logits.length);
|
||||
for (let i = 0; i < upper; i += 1) logits[i] = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
}
|
||||
|
||||
if (generated.length === beginIndex && Number.isFinite(maxInitialTimestampIndex)) {
|
||||
const lastAllowed = timestampBeginTokenId + maxInitialTimestampIndex;
|
||||
for (let i = lastAllowed + 1; i < logits.length; i += 1) logits[i] = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
const textUpper = Math.min(timestampBeginTokenId, logits.length);
|
||||
if (textUpper <= 0 || textUpper >= logits.length) return;
|
||||
|
||||
const logprobs = logSoftmax(logits);
|
||||
|
||||
let maxTextTokenLogprob = Number.NEGATIVE_INFINITY;
|
||||
for (let i = 0; i < textUpper; i += 1) {
|
||||
if (logprobs[i] > maxTextTokenLogprob) maxTextTokenLogprob = logprobs[i];
|
||||
}
|
||||
|
||||
let timestampProbMass = 0;
|
||||
for (let i = textUpper; i < logprobs.length; i += 1) {
|
||||
timestampProbMass += Math.exp(logprobs[i]);
|
||||
}
|
||||
const timestampLogprob = timestampProbMass > 0 ? Math.log(timestampProbMass) : Number.NEGATIVE_INFINITY;
|
||||
|
||||
if (timestampLogprob > maxTextTokenLogprob) {
|
||||
for (let i = 0; i < textUpper; i += 1) logits[i] = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
}
|
||||
|
||||
async function getRuntime(): Promise<WhisperRuntime> {
|
||||
if (state.runtimePromise) return state.runtimePromise;
|
||||
|
||||
111
packages/compute-worker/src/inference/whisper/decoder.ts
Normal file
111
packages/compute-worker/src/inference/whisper/decoder.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
export function argmax(values: Float32Array): number | null {
|
||||
let bestIdx = 0;
|
||||
let bestScore = Number.NEGATIVE_INFINITY;
|
||||
|
||||
for (let i = 0; i < values.length; i += 1) {
|
||||
const score = values[i];
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
return Number.isFinite(bestScore) ? bestIdx : null;
|
||||
}
|
||||
|
||||
export function applyTokenSuppression(logits: Float32Array, tokens: Set<number>) {
|
||||
for (const tokenId of tokens) {
|
||||
if (tokenId >= 0 && tokenId < logits.length) {
|
||||
logits[tokenId] = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function logSoftmax(input: Float32Array): Float32Array {
|
||||
let max = Number.NEGATIVE_INFINITY;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
if (input[i] > max) max = input[i];
|
||||
}
|
||||
if (!Number.isFinite(max)) {
|
||||
return new Float32Array(input.length).fill(Number.NEGATIVE_INFINITY);
|
||||
}
|
||||
|
||||
let sum = 0;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
sum += Math.exp(input[i] - max);
|
||||
}
|
||||
const logSum = Math.log(sum);
|
||||
|
||||
const out = new Float32Array(input.length);
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
out[i] = input[i] - max - logSum;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function applyWhisperTimestampLogitsRules(input: {
|
||||
logits: Float32Array;
|
||||
generated: number[];
|
||||
beginIndex: number;
|
||||
eosTokenId: number;
|
||||
noTimestampsTokenId: number;
|
||||
timestampBeginTokenId: number;
|
||||
maxInitialTimestampIndex: number;
|
||||
}) {
|
||||
const {
|
||||
logits,
|
||||
generated,
|
||||
beginIndex,
|
||||
eosTokenId,
|
||||
noTimestampsTokenId,
|
||||
timestampBeginTokenId,
|
||||
maxInitialTimestampIndex,
|
||||
} = input;
|
||||
|
||||
if (noTimestampsTokenId >= 0 && noTimestampsTokenId < logits.length) {
|
||||
logits[noTimestampsTokenId] = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
if (generated.length === beginIndex) {
|
||||
const upper = Math.min(timestampBeginTokenId, logits.length);
|
||||
for (let i = 0; i < upper; i += 1) logits[i] = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
const seq = generated.slice(beginIndex);
|
||||
const lastWasTimestamp = seq.length >= 1 && seq[seq.length - 1] >= timestampBeginTokenId;
|
||||
const penultimateWasTimestamp = seq.length < 2 || seq[seq.length - 2] >= timestampBeginTokenId;
|
||||
|
||||
if (lastWasTimestamp) {
|
||||
if (penultimateWasTimestamp) {
|
||||
for (let i = timestampBeginTokenId; i < logits.length; i += 1) logits[i] = Number.NEGATIVE_INFINITY;
|
||||
} else {
|
||||
const upper = Math.min(eosTokenId, logits.length);
|
||||
for (let i = 0; i < upper; i += 1) logits[i] = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
}
|
||||
|
||||
if (generated.length === beginIndex && Number.isFinite(maxInitialTimestampIndex)) {
|
||||
const lastAllowed = timestampBeginTokenId + maxInitialTimestampIndex;
|
||||
for (let i = lastAllowed + 1; i < logits.length; i += 1) logits[i] = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
const textUpper = Math.min(timestampBeginTokenId, logits.length);
|
||||
if (textUpper <= 0 || textUpper >= logits.length) return;
|
||||
|
||||
const logprobs = logSoftmax(logits);
|
||||
|
||||
let maxTextTokenLogprob = Number.NEGATIVE_INFINITY;
|
||||
for (let i = 0; i < textUpper; i += 1) {
|
||||
if (logprobs[i] > maxTextTokenLogprob) maxTextTokenLogprob = logprobs[i];
|
||||
}
|
||||
|
||||
let timestampProbMass = 0;
|
||||
for (let i = textUpper; i < logprobs.length; i += 1) {
|
||||
timestampProbMass += Math.exp(logprobs[i]);
|
||||
}
|
||||
const timestampLogprob = timestampProbMass > 0 ? Math.log(timestampProbMass) : Number.NEGATIVE_INFINITY;
|
||||
|
||||
if (timestampLogprob > maxTextTokenLogprob) {
|
||||
for (let i = 0; i < textUpper; i += 1) logits[i] = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import { fileURLToPath } from 'url';
|
|||
import { createHash } from 'crypto';
|
||||
import { readFileSync } from 'fs';
|
||||
import { access, copyFile, mkdir, readFile, rename, unlink, writeFile } from 'fs/promises';
|
||||
import { DOCSTORE_DIR } from '../platform/docstore';
|
||||
import { DOCSTORE_DIR } from '../../infrastructure/platform';
|
||||
|
||||
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const MODEL_DIR = path.join(DOCSTORE_DIR, 'model', 'whisper-base_timestamped');
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Tokenizer } from '@huggingface/tokenizers';
|
||||
import type * as ort from 'onnxruntime-node';
|
||||
import type { TTSSentenceAlignment, TTSSentenceWord } from '../../api/types';
|
||||
|
||||
const PUNCTUATION_REGEX = '\\p{P}\\u0021-\\u002F\\u003A-\\u0040\\u005B-\\u0060\\u007B-\\u007E';
|
||||
const PUNCTUATION_ONLY_REGEX = new RegExp(`^[${PUNCTUATION_REGEX}]+$`, 'gu');
|
||||
|
|
@ -12,6 +13,29 @@ export interface WhisperWordTiming {
|
|||
endSec: number;
|
||||
}
|
||||
|
||||
export interface WhisperWord {
|
||||
start: number;
|
||||
end: number;
|
||||
word: string;
|
||||
}
|
||||
|
||||
export function mapWordsToSentenceOffsets(sentence: string, words: WhisperWord[]): TTSSentenceAlignment {
|
||||
const lowerSentence = sentence.toLowerCase();
|
||||
let cursor = 0;
|
||||
const alignedWords: TTSSentenceWord[] = words.map((word) => {
|
||||
const token = word.word.trim();
|
||||
if (!token) {
|
||||
return { text: '', startSec: word.start, endSec: word.end, charStart: cursor, charEnd: cursor };
|
||||
}
|
||||
const index = lowerSentence.indexOf(token.toLowerCase(), cursor);
|
||||
const start = index >= 0 ? index : cursor;
|
||||
const end = Math.min(sentence.length, start + token.length);
|
||||
cursor = Math.max(cursor, end);
|
||||
return { text: token, startSec: word.start, endSec: word.end, charStart: start, charEnd: end };
|
||||
}).filter((word) => word.text.length > 0);
|
||||
return { sentence, sentenceIndex: 0, words: alignedWords };
|
||||
}
|
||||
|
||||
function medianFilter(data: Float32Array, windowSize: number): Float32Array {
|
||||
if (windowSize % 2 === 0 || windowSize <= 0) {
|
||||
throw new Error('Window size must be a positive odd number');
|
||||
|
|
@ -19,7 +19,13 @@ export type IdleTimeoutAndHardCapInput<T> = {
|
|||
label: string;
|
||||
};
|
||||
|
||||
function readPositiveIntEnv(name: string, fallback: number): number {
|
||||
export function requireEnv(name: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) throw new Error(`${name} is required`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function readPositiveIntEnv(name: string, fallback: number): number {
|
||||
const raw = process.env[name]?.trim();
|
||||
if (!raw) return fallback;
|
||||
const parsed = Number(raw);
|
||||
|
|
@ -27,6 +33,34 @@ function readPositiveIntEnv(name: string, fallback: number): number {
|
|||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
export function readBoolEnv(name: string, fallback: boolean): boolean {
|
||||
const raw = process.env[name]?.trim();
|
||||
if (!raw) return fallback;
|
||||
return ['1', 'true', 'yes', 'on'].includes(raw.toLowerCase());
|
||||
}
|
||||
|
||||
export function normalizeNatsReplicas(value: number): number {
|
||||
return value === 3 || value === 5 ? value : 1;
|
||||
}
|
||||
|
||||
export function buildLoggerConfig(): boolean | Record<string, unknown> {
|
||||
const format = process.env.LOG_FORMAT?.trim().toLowerCase() || 'pretty';
|
||||
const level = process.env.COMPUTE_LOG_LEVEL?.trim() || 'info';
|
||||
if (format === 'json') return { level, base: null };
|
||||
return {
|
||||
level,
|
||||
base: null,
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: 'SYS:standard',
|
||||
ignore: 'pid,hostname',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let timeoutConfigCache: ComputeTimeoutConfig | null = null;
|
||||
let opStaleMsCache: number | null = null;
|
||||
|
||||
|
|
@ -117,3 +151,23 @@ export async function withIdleTimeoutAndHardCap<T>(input: IdleTimeoutAndHardCapI
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function getComputeJobConcurrency(): number {
|
||||
return readPositiveIntEnv('COMPUTE_JOB_CONCURRENCY', 1);
|
||||
}
|
||||
|
||||
export function getAvailableCpuCores(): number {
|
||||
if (typeof os.availableParallelism === 'function') {
|
||||
const value = os.availableParallelism();
|
||||
if (Number.isFinite(value) && value >= 1) return Math.floor(value);
|
||||
}
|
||||
const fallback = os.cpus().length;
|
||||
return Number.isFinite(fallback) && fallback >= 1 ? Math.floor(fallback) : 1;
|
||||
}
|
||||
|
||||
export function getOnnxThreadsPerJob(): number {
|
||||
const concurrency = getComputeJobConcurrency();
|
||||
const usableCores = Math.max(1, getAvailableCpuCores() - 1);
|
||||
return Math.max(1, Math.floor(usableCores / concurrency));
|
||||
}
|
||||
import os from 'node:os';
|
||||
|
|
@ -8,12 +8,12 @@ import type {
|
|||
OperationState,
|
||||
OperationStateStore,
|
||||
QueuedOperation,
|
||||
} from '@openreader/compute-core/control-plane';
|
||||
} from '../operations';
|
||||
import type {
|
||||
PdfLayoutJobRequest,
|
||||
WhisperAlignJobRequest,
|
||||
WorkerOperationKind,
|
||||
} from '@openreader/compute-core/api-contracts';
|
||||
} from '../operations/contracts';
|
||||
import { createJsonCodec } from './json-codec';
|
||||
|
||||
export interface KvEntryLike {
|
||||
204
packages/compute-worker/src/infrastructure/nats-session.ts
Normal file
204
packages/compute-worker/src/infrastructure/nats-session.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import type { Consumer, JetStreamClient, JetStreamManager } from '@nats-io/jetstream';
|
||||
import { jetstream, jetstreamManager } from '@nats-io/jetstream';
|
||||
import { Kvm } from '@nats-io/kv';
|
||||
import { connect, type ConnectionOptions, type NatsConnection } from '@nats-io/transport-node';
|
||||
import {
|
||||
COMPUTE_STATE_BUCKET,
|
||||
COMPUTE_STATE_TTL_MS,
|
||||
JOBS_STREAM_NAME,
|
||||
LAYOUT_CONSUMER_NAME,
|
||||
NATS_API_TIMEOUT_MS,
|
||||
WHISPER_CONSUMER_NAME,
|
||||
ensureJetStreamResources,
|
||||
} from './nats';
|
||||
|
||||
const IDLE_DISCONNECT_MS = 120_000;
|
||||
const IDLE_CHECK_INTERVAL_MS = 5_000;
|
||||
const IDLE_STATUS_LOG_INTERVAL_MS = 60_000;
|
||||
const ORPHAN_SWEEP_INTERVAL_MS = 15_000;
|
||||
|
||||
export interface NatsSession {
|
||||
nc: NatsConnection;
|
||||
js: JetStreamClient;
|
||||
jsm: JetStreamManager;
|
||||
kv: Awaited<ReturnType<Kvm['create']>>;
|
||||
whisperConsumer: Consumer;
|
||||
layoutConsumer: Consumer;
|
||||
}
|
||||
|
||||
interface NatsSessionLogger {
|
||||
info(data: unknown, message?: string): void;
|
||||
info(message: string): void;
|
||||
error(data: unknown, message?: string): void;
|
||||
}
|
||||
|
||||
export interface NatsActivitySnapshot {
|
||||
activeSse: number;
|
||||
inFlightHttp: number;
|
||||
inFlightJobs: number;
|
||||
lastActivityAt: number;
|
||||
lastActivityReason: string;
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function createNatsSessionManager(input: {
|
||||
connectOptions: ConnectionOptions;
|
||||
logger: NatsSessionLogger;
|
||||
whisperTimeoutMs: number;
|
||||
pdfTimeoutMs: number;
|
||||
pdfAttempts: number;
|
||||
jobsStreamMaxBytes: number;
|
||||
eventsStreamMaxBytes: number;
|
||||
jobStatesMaxBytes: number;
|
||||
natsReplicas: number;
|
||||
isStopping: () => boolean;
|
||||
getActivity: () => NatsActivitySnapshot;
|
||||
markActivity: (reason: string) => void;
|
||||
startWorkers: (session: NatsSession) => void;
|
||||
stopWorkers: () => Promise<void>;
|
||||
runReconciliation: () => Promise<void>;
|
||||
}) {
|
||||
let session: NatsSession | null = null;
|
||||
let connecting: Promise<NatsSession> | null = null;
|
||||
let idleTimer: NodeJS.Timeout | null = null;
|
||||
let orphanSweepTimer: NodeJS.Timeout | null = null;
|
||||
let lastIdleStatusLogAt = 0;
|
||||
let generation = -1;
|
||||
|
||||
const clearTimers = () => {
|
||||
if (idleTimer) clearInterval(idleTimer);
|
||||
if (orphanSweepTimer) clearInterval(orphanSweepTimer);
|
||||
idleTimer = null;
|
||||
orphanSweepTimer = null;
|
||||
};
|
||||
|
||||
const disconnect = async (reason: string): Promise<void> => {
|
||||
const current = session;
|
||||
if (!current) return;
|
||||
const activity = input.getActivity();
|
||||
input.logger.info({
|
||||
reason,
|
||||
activeSse: activity.activeSse,
|
||||
inFlightHttp: activity.inFlightHttp,
|
||||
inFlightJobs: activity.inFlightJobs,
|
||||
idleForMs: Date.now() - activity.lastActivityAt,
|
||||
}, 'nats dropping connection');
|
||||
session = null;
|
||||
clearTimers();
|
||||
try {
|
||||
await current.nc.close();
|
||||
} catch {
|
||||
// Closing an already-closed session is harmless.
|
||||
}
|
||||
await input.stopWorkers();
|
||||
input.logger.info({ reason }, 'nats disconnected');
|
||||
};
|
||||
|
||||
const startTimers = () => {
|
||||
if (!idleTimer) {
|
||||
idleTimer = setInterval(() => {
|
||||
if (!session || input.isStopping()) return;
|
||||
const activity = input.getActivity();
|
||||
const now = Date.now();
|
||||
const idleForMs = now - activity.lastActivityAt;
|
||||
if (now - lastIdleStatusLogAt >= IDLE_STATUS_LOG_INTERVAL_MS) {
|
||||
lastIdleStatusLogAt = now;
|
||||
input.logger.info({
|
||||
...activity,
|
||||
idleForMs,
|
||||
disconnectEligible: activity.inFlightHttp === 0
|
||||
&& activity.inFlightJobs === 0
|
||||
&& idleForMs >= IDLE_DISCONNECT_MS,
|
||||
}, 'nats idle status');
|
||||
}
|
||||
if (activity.inFlightHttp > 0 || activity.inFlightJobs > 0 || idleForMs < IDLE_DISCONNECT_MS) return;
|
||||
void disconnect('idle');
|
||||
}, IDLE_CHECK_INTERVAL_MS);
|
||||
idleTimer.unref?.();
|
||||
}
|
||||
if (!orphanSweepTimer) {
|
||||
orphanSweepTimer = setInterval(() => {
|
||||
if (!session || input.isStopping()) return;
|
||||
void input.runReconciliation().catch((error) => {
|
||||
input.logger.error({ error: toErrorMessage(error) }, 'periodic orphaned operation recovery failed');
|
||||
});
|
||||
}, ORPHAN_SWEEP_INTERVAL_MS);
|
||||
orphanSweepTimer.unref?.();
|
||||
}
|
||||
};
|
||||
|
||||
const ensureConnected = async (): Promise<NatsSession> => {
|
||||
if (session) return session;
|
||||
if (connecting) return connecting;
|
||||
connecting = (async () => {
|
||||
const nc = await connect(input.connectOptions);
|
||||
const js = jetstream(nc, { timeout: NATS_API_TIMEOUT_MS });
|
||||
const jsm = await jetstreamManager(nc, { timeout: NATS_API_TIMEOUT_MS });
|
||||
await ensureJetStreamResources({
|
||||
jsm,
|
||||
whisperTimeoutMs: input.whisperTimeoutMs,
|
||||
pdfTimeoutMs: input.pdfTimeoutMs,
|
||||
pdfAttempts: input.pdfAttempts,
|
||||
jobsMaxBytes: input.jobsStreamMaxBytes,
|
||||
eventsMaxBytes: input.eventsStreamMaxBytes,
|
||||
natsReplicas: input.natsReplicas,
|
||||
});
|
||||
const kv = await new Kvm(js).create(COMPUTE_STATE_BUCKET, {
|
||||
replicas: input.natsReplicas,
|
||||
history: 1,
|
||||
ttl: COMPUTE_STATE_TTL_MS,
|
||||
max_bytes: input.jobStatesMaxBytes,
|
||||
});
|
||||
const next: NatsSession = {
|
||||
nc,
|
||||
js,
|
||||
jsm,
|
||||
kv,
|
||||
whisperConsumer: await js.consumers.get(JOBS_STREAM_NAME, WHISPER_CONSUMER_NAME),
|
||||
layoutConsumer: await js.consumers.get(JOBS_STREAM_NAME, LAYOUT_CONSUMER_NAME),
|
||||
};
|
||||
session = next;
|
||||
generation += 1;
|
||||
input.markActivity('nats_connected');
|
||||
input.startWorkers(next);
|
||||
startTimers();
|
||||
void nc.closed().then(() => {
|
||||
if (session?.nc === nc) session = null;
|
||||
});
|
||||
input.logger.info('nats connected');
|
||||
return next;
|
||||
})();
|
||||
try {
|
||||
return await connecting;
|
||||
} finally {
|
||||
connecting = null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
ensureConnected,
|
||||
disconnect,
|
||||
isConnected: () => session !== null,
|
||||
isOwnerActive: (owner: object) => session === owner,
|
||||
getGeneration: () => generation,
|
||||
async close(): Promise<void> {
|
||||
clearTimers();
|
||||
await input.stopWorkers();
|
||||
const current = session;
|
||||
session = null;
|
||||
if (!current) return;
|
||||
try {
|
||||
await current.nc.drain();
|
||||
} catch {
|
||||
try {
|
||||
await current.nc.close();
|
||||
} catch {
|
||||
// Closing an already-closed session is harmless.
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
104
packages/compute-worker/src/infrastructure/nats.ts
Normal file
104
packages/compute-worker/src/infrastructure/nats.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import {
|
||||
AckPolicy,
|
||||
DeliverPolicy,
|
||||
ReplayPolicy,
|
||||
RetentionPolicy,
|
||||
StorageType,
|
||||
type JetStreamManager,
|
||||
} from '@nats-io/jetstream';
|
||||
import { nanos } from '@nats-io/transport-node';
|
||||
import { OP_EVENTS_SUBJECT_WILDCARD } from './nats-adapters';
|
||||
|
||||
export const JOBS_STREAM_NAME = 'compute_jobs';
|
||||
export const WHISPER_JOBS_SUBJECT = 'jobs.whisper';
|
||||
export const LAYOUT_JOBS_SUBJECT = 'jobs.layout';
|
||||
export const WHISPER_CONSUMER_NAME = 'compute_whisper';
|
||||
export const LAYOUT_CONSUMER_NAME = 'compute_layout';
|
||||
export const EVENTS_STREAM_NAME = 'compute_events';
|
||||
export const COMPUTE_STATE_BUCKET = 'compute_state';
|
||||
export const COMPUTE_STATE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
export const NATS_API_TIMEOUT_MS = 60_000;
|
||||
|
||||
const WHISPER_MAX_DELIVER = 1;
|
||||
|
||||
function isAlreadyExistsError(error: unknown): boolean {
|
||||
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
||||
return message.includes('already in use') || message.includes('already exists');
|
||||
}
|
||||
|
||||
export async function ensureJetStreamResources(input: {
|
||||
jsm: JetStreamManager;
|
||||
whisperTimeoutMs: number;
|
||||
pdfTimeoutMs: number;
|
||||
pdfAttempts: number;
|
||||
jobsMaxBytes: number;
|
||||
eventsMaxBytes: number;
|
||||
natsReplicas: number;
|
||||
}): Promise<void> {
|
||||
const streamConfig = {
|
||||
name: JOBS_STREAM_NAME,
|
||||
subjects: [WHISPER_JOBS_SUBJECT, LAYOUT_JOBS_SUBJECT],
|
||||
retention: RetentionPolicy.Workqueue,
|
||||
storage: StorageType.File,
|
||||
max_bytes: input.jobsMaxBytes,
|
||||
num_replicas: input.natsReplicas,
|
||||
};
|
||||
try {
|
||||
await input.jsm.streams.add(streamConfig);
|
||||
} catch (error) {
|
||||
if (!isAlreadyExistsError(error)) throw error;
|
||||
await input.jsm.streams.update(JOBS_STREAM_NAME, {
|
||||
subjects: streamConfig.subjects,
|
||||
max_bytes: input.jobsMaxBytes,
|
||||
num_replicas: input.natsReplicas,
|
||||
});
|
||||
}
|
||||
|
||||
const eventsStreamConfig = {
|
||||
name: EVENTS_STREAM_NAME,
|
||||
subjects: [OP_EVENTS_SUBJECT_WILDCARD],
|
||||
retention: RetentionPolicy.Limits,
|
||||
storage: StorageType.File,
|
||||
max_bytes: input.eventsMaxBytes,
|
||||
max_age: nanos(COMPUTE_STATE_TTL_MS),
|
||||
num_replicas: input.natsReplicas,
|
||||
};
|
||||
try {
|
||||
await input.jsm.streams.add(eventsStreamConfig);
|
||||
} catch (error) {
|
||||
if (!isAlreadyExistsError(error)) throw error;
|
||||
await input.jsm.streams.update(EVENTS_STREAM_NAME, {
|
||||
subjects: eventsStreamConfig.subjects,
|
||||
max_bytes: input.eventsMaxBytes,
|
||||
max_age: eventsStreamConfig.max_age,
|
||||
num_replicas: input.natsReplicas,
|
||||
});
|
||||
}
|
||||
|
||||
const ensureConsumer = async (name: string, subject: string, ackWaitMs: number, maxDeliver: number) => {
|
||||
const config = {
|
||||
durable_name: name,
|
||||
ack_policy: AckPolicy.Explicit,
|
||||
deliver_policy: DeliverPolicy.All,
|
||||
replay_policy: ReplayPolicy.Instant,
|
||||
filter_subject: subject,
|
||||
ack_wait: nanos(Math.max(ackWaitMs, 1_000)),
|
||||
max_deliver: maxDeliver,
|
||||
};
|
||||
try {
|
||||
await input.jsm.consumers.add(JOBS_STREAM_NAME, config);
|
||||
} catch (error) {
|
||||
if (!isAlreadyExistsError(error)) throw error;
|
||||
await input.jsm.consumers.update(JOBS_STREAM_NAME, name, {
|
||||
filter_subject: subject,
|
||||
ack_wait: config.ack_wait,
|
||||
max_deliver: maxDeliver,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
ensureConsumer(WHISPER_CONSUMER_NAME, WHISPER_JOBS_SUBJECT, input.whisperTimeoutMs + 15_000, WHISPER_MAX_DELIVER),
|
||||
ensureConsumer(LAYOUT_CONSUMER_NAME, LAYOUT_JOBS_SUBJECT, input.pdfTimeoutMs + 15_000, input.pdfAttempts),
|
||||
]);
|
||||
}
|
||||
68
packages/compute-worker/src/infrastructure/platform.ts
Normal file
68
packages/compute-worker/src/infrastructure/platform.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import ffmpegStatic from 'ffmpeg-static';
|
||||
|
||||
function findMonorepoRoot(startDir: string): string | null {
|
||||
let current = path.resolve(startDir);
|
||||
for (;;) {
|
||||
const marker = path.join(current, 'pnpm-workspace.yaml');
|
||||
if (fs.existsSync(marker)) return current;
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return null;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDocstoreDir(): string {
|
||||
// 1. Try to find the monorepo root (works in local development)
|
||||
const repoRoot = findMonorepoRoot(process.cwd());
|
||||
if (repoRoot) return path.join(repoRoot, 'docstore');
|
||||
|
||||
// 2. In a containerized environment, the parent bootstrap process runs in /app
|
||||
// and the child process inherits process.env.PWD = '/app'.
|
||||
if (process.env.PWD) {
|
||||
const pwdDocstore = path.join(process.env.PWD, 'docstore');
|
||||
if (fs.existsSync(pwdDocstore)) return pwdDocstore;
|
||||
}
|
||||
|
||||
// 3. Fallback to the standard container app docstore path if it exists
|
||||
if (fs.existsSync('/app/docstore')) {
|
||||
return '/app/docstore';
|
||||
}
|
||||
|
||||
// 4. Fallback to the process cwd docstore
|
||||
return path.join(process.cwd(), 'docstore');
|
||||
}
|
||||
|
||||
export const DOCSTORE_DIR = resolveDocstoreDir();
|
||||
|
||||
function normalizePath(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function resolveBinary(envValue: string | null, bundledValue: string | null, envVarName: string, packageName: string): string {
|
||||
if (envValue) {
|
||||
if ((envValue.includes('/') || envValue.includes('\\')) && !fs.existsSync(envValue)) {
|
||||
throw new Error(`${envVarName} points to a missing binary: ${envValue}`);
|
||||
}
|
||||
return envValue;
|
||||
}
|
||||
if (!bundledValue) {
|
||||
throw new Error(`${packageName} binary is unavailable on this platform. Set ${envVarName} to an installed binary path.`);
|
||||
}
|
||||
if ((bundledValue.includes('/') || bundledValue.includes('\\')) && !fs.existsSync(bundledValue)) {
|
||||
throw new Error(`${packageName} resolved to a missing binary path: ${bundledValue}`);
|
||||
}
|
||||
return bundledValue;
|
||||
}
|
||||
|
||||
export function getFFmpegPath(): string {
|
||||
return resolveBinary(
|
||||
normalizePath(process.env.FFMPEG_BIN),
|
||||
normalizePath(ffmpegStatic),
|
||||
'FFMPEG_BIN',
|
||||
'ffmpeg-static',
|
||||
);
|
||||
}
|
||||
127
packages/compute-worker/src/infrastructure/storage.ts
Normal file
127
packages/compute-worker/src/infrastructure/storage.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { parsedPdfArtifactKey } from '../storage/artifact-addressing';
|
||||
|
||||
export interface ArtifactStorage {
|
||||
readObject(key: string): Promise<ArrayBuffer>;
|
||||
objectExists(key: string): Promise<boolean>;
|
||||
deleteObject(key: string): Promise<void>;
|
||||
putParsedPdf(documentId: string, namespace: string | null, parsed: unknown): Promise<string>;
|
||||
}
|
||||
|
||||
export interface ArtifactStorageConfig {
|
||||
bucket: string;
|
||||
prefix: string;
|
||||
client: S3Client;
|
||||
}
|
||||
|
||||
function bodyToBuffer(body: unknown): Promise<Buffer> | Buffer {
|
||||
if (!body) return Buffer.alloc(0);
|
||||
if (body instanceof Uint8Array) return Buffer.from(body);
|
||||
if (ArrayBuffer.isView(body)) return Buffer.from(body.buffer, body.byteOffset, body.byteLength);
|
||||
if (body instanceof ArrayBuffer) return Buffer.from(body);
|
||||
if (typeof body === 'object' && body !== null && 'transformToByteArray' in body) {
|
||||
const maybe = body as { transformToByteArray?: () => Promise<Uint8Array> };
|
||||
if (typeof maybe.transformToByteArray === 'function') {
|
||||
return maybe.transformToByteArray().then((bytes) => Buffer.from(bytes));
|
||||
}
|
||||
}
|
||||
if (typeof body === 'object' && body !== null && 'on' in body) {
|
||||
return (async () => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of body as NodeJS.ReadableStream) {
|
||||
if (Buffer.isBuffer(chunk)) chunks.push(chunk);
|
||||
else if (typeof chunk === 'string') chunks.push(Buffer.from(chunk));
|
||||
else chunks.push(Buffer.from(chunk as Uint8Array));
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
})();
|
||||
}
|
||||
throw new Error('Unsupported S3 response body type');
|
||||
}
|
||||
|
||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
return copy.buffer;
|
||||
}
|
||||
|
||||
function isNotFound(error: unknown): boolean {
|
||||
const maybe = error as { name?: string; Code?: string; $metadata?: { httpStatusCode?: number } };
|
||||
return maybe.$metadata?.httpStatusCode === 404
|
||||
|| maybe.name === 'NotFound'
|
||||
|| maybe.name === 'NoSuchKey'
|
||||
|| maybe.Code === 'NotFound'
|
||||
|| maybe.Code === 'NoSuchKey';
|
||||
}
|
||||
|
||||
export function normalizeS3Prefix(prefix: string | undefined): string {
|
||||
const value = (prefix || 'openreader').trim();
|
||||
return value ? value.replace(/^\/+|\/+$/g, '') : 'openreader';
|
||||
}
|
||||
|
||||
export function createS3ClientFromEnv(requireEnv: (name: string) => string): S3Client {
|
||||
return new S3Client({
|
||||
region: requireEnv('S3_REGION'),
|
||||
endpoint: process.env.S3_ENDPOINT?.trim() || undefined,
|
||||
forcePathStyle: ['1', 'true', 'yes', 'on'].includes(process.env.S3_FORCE_PATH_STYLE?.trim().toLowerCase() ?? ''),
|
||||
requestChecksumCalculation: 'WHEN_REQUIRED',
|
||||
responseChecksumValidation: 'WHEN_REQUIRED',
|
||||
credentials: {
|
||||
accessKeyId: requireEnv('S3_ACCESS_KEY_ID'),
|
||||
secretAccessKey: requireEnv('S3_SECRET_ACCESS_KEY'),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createArtifactStorage(config: ArtifactStorageConfig): ArtifactStorage {
|
||||
const safeKey = (key: string): string => {
|
||||
const trimmed = key.trim();
|
||||
if (!trimmed.startsWith(`${config.prefix}/`)) throw new Error('Object key prefix mismatch');
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
return {
|
||||
async readObject(key) {
|
||||
const response = await config.client.send(new GetObjectCommand({
|
||||
Bucket: config.bucket,
|
||||
Key: safeKey(key),
|
||||
}));
|
||||
return toArrayBuffer(new Uint8Array(await bodyToBuffer(response.Body)));
|
||||
},
|
||||
async objectExists(key) {
|
||||
try {
|
||||
await config.client.send(new HeadObjectCommand({
|
||||
Bucket: config.bucket,
|
||||
Key: safeKey(key),
|
||||
}));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) return false;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async deleteObject(key) {
|
||||
await config.client.send(new DeleteObjectCommand({
|
||||
Bucket: config.bucket,
|
||||
Key: safeKey(key),
|
||||
}));
|
||||
},
|
||||
async putParsedPdf(documentId, namespace, parsed) {
|
||||
const key = parsedPdfArtifactKey({ documentId, namespace, prefix: config.prefix });
|
||||
await config.client.send(new PutObjectCommand({
|
||||
Bucket: config.bucket,
|
||||
Key: key,
|
||||
Body: Buffer.from(JSON.stringify(parsed)),
|
||||
ContentType: 'application/json',
|
||||
ServerSideEncryption: 'AES256',
|
||||
}));
|
||||
return key;
|
||||
},
|
||||
};
|
||||
}
|
||||
126
packages/compute-worker/src/jobs/handlers.ts
Normal file
126
packages/compute-worker/src/jobs/handlers.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { z } from 'zod';
|
||||
import {
|
||||
runPdfLayoutFromPdfBuffer,
|
||||
runWhisperAlignmentFromAudioBuffer,
|
||||
} from '../inference/runtime';
|
||||
import { withIdleTimeoutAndHardCap, withTimeout } from '../infrastructure/config';
|
||||
import type {
|
||||
PdfLayoutJobRequest,
|
||||
PdfLayoutJobResult,
|
||||
PdfLayoutProgress,
|
||||
WhisperAlignJobRequest,
|
||||
WhisperAlignJobResult,
|
||||
} from '../operations/contracts';
|
||||
import type { ArtifactStorage } from '../infrastructure/storage';
|
||||
import { persistParsedPdfWhileSourceExists } from './pdf-artifact-persistence';
|
||||
import { buildInferProgressForPageParsed, buildInferProgressForPageStart } from './pdf-progress';
|
||||
|
||||
const whisperRequestSchema = z.object({
|
||||
text: z.string().trim().min(1),
|
||||
lang: z.string().trim().min(1).max(16).optional(),
|
||||
cacheKey: z.string().trim().min(1).max(256).optional(),
|
||||
audioObjectKey: z.string().trim().min(1).max(2048),
|
||||
});
|
||||
|
||||
const pdfRequestSchema = z.object({
|
||||
documentId: z.string().trim().min(1),
|
||||
namespace: z.string().trim().min(1).max(128).nullable(),
|
||||
documentObjectKey: z.string().trim().min(1).max(2048),
|
||||
});
|
||||
|
||||
export interface JobHandlers {
|
||||
runWhisper(payload: WhisperAlignJobRequest, queueWaitMs: number): Promise<WhisperAlignJobResult>;
|
||||
runPdfLayout(
|
||||
payload: PdfLayoutJobRequest,
|
||||
queueWaitMs: number,
|
||||
hooks?: { onProgress?: (progress: PdfLayoutProgress) => Promise<void> },
|
||||
): Promise<PdfLayoutJobResult>;
|
||||
}
|
||||
|
||||
export function createJobHandlers(input: {
|
||||
storage: ArtifactStorage;
|
||||
whisperTimeoutMs: number;
|
||||
pdfTimeoutMs: number;
|
||||
pdfHardCapMs: number;
|
||||
}): JobHandlers {
|
||||
return {
|
||||
async runWhisper(payload, queueWaitMs) {
|
||||
const parsed = whisperRequestSchema.parse(payload);
|
||||
const s3FetchStartedAt = Date.now();
|
||||
const audioBuffer = await withTimeout(
|
||||
input.storage.readObject(parsed.audioObjectKey),
|
||||
input.whisperTimeoutMs,
|
||||
'whisper s3 fetch',
|
||||
);
|
||||
const s3FetchMs = Date.now() - s3FetchStartedAt;
|
||||
const computeStartedAt = Date.now();
|
||||
const result = await withTimeout(
|
||||
runWhisperAlignmentFromAudioBuffer({
|
||||
audioBuffer,
|
||||
text: parsed.text,
|
||||
cacheKey: parsed.cacheKey,
|
||||
lang: parsed.lang,
|
||||
}),
|
||||
input.whisperTimeoutMs,
|
||||
'whisper alignment job',
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
timing: { queueWaitMs, s3FetchMs, computeMs: Date.now() - computeStartedAt },
|
||||
};
|
||||
},
|
||||
|
||||
async runPdfLayout(payload, queueWaitMs, hooks) {
|
||||
const parsed = pdfRequestSchema.parse(payload);
|
||||
const s3FetchStartedAt = Date.now();
|
||||
const pdfBytes = await withTimeout(
|
||||
input.storage.readObject(parsed.documentObjectKey),
|
||||
Math.max(input.pdfTimeoutMs, 1_000),
|
||||
'pdf s3 fetch',
|
||||
);
|
||||
const s3FetchMs = Date.now() - s3FetchStartedAt;
|
||||
let lastTotalPages = 0;
|
||||
let lastPagesParsed = 0;
|
||||
const computeStartedAt = Date.now();
|
||||
const result = await withIdleTimeoutAndHardCap({
|
||||
idleTimeoutMs: Math.max(input.pdfTimeoutMs, 1_000),
|
||||
hardCapMs: input.pdfHardCapMs,
|
||||
label: 'pdf layout job',
|
||||
run: async (touchProgress) => runPdfLayoutFromPdfBuffer({
|
||||
documentId: parsed.documentId,
|
||||
pdfBytes,
|
||||
onPageStarted: async ({ pageNumber, totalPages }) => {
|
||||
touchProgress();
|
||||
lastTotalPages = totalPages;
|
||||
await hooks?.onProgress?.(buildInferProgressForPageStart({ pageNumber, totalPages }));
|
||||
},
|
||||
onPageParsed: async ({ pageNumber, totalPages }) => {
|
||||
touchProgress();
|
||||
lastTotalPages = totalPages;
|
||||
lastPagesParsed = pageNumber;
|
||||
await hooks?.onProgress?.(buildInferProgressForPageParsed({ pageNumber, totalPages }));
|
||||
},
|
||||
}),
|
||||
});
|
||||
const computeMs = Date.now() - computeStartedAt;
|
||||
if (hooks?.onProgress && lastTotalPages > 0) {
|
||||
await hooks.onProgress({
|
||||
totalPages: lastTotalPages,
|
||||
pagesParsed: lastPagesParsed,
|
||||
currentPage: lastPagesParsed || undefined,
|
||||
phase: 'merge',
|
||||
});
|
||||
}
|
||||
const parsedObjectKey = await persistParsedPdfWhileSourceExists({
|
||||
sourceObjectKey: parsed.documentObjectKey,
|
||||
sourceExists: input.storage.objectExists,
|
||||
putParsedObject: () => input.storage.putParsedPdf(parsed.documentId, parsed.namespace, result.parsed),
|
||||
deleteParsedObject: input.storage.deleteObject,
|
||||
});
|
||||
return {
|
||||
parsedObjectKey,
|
||||
timing: { queueWaitMs, s3FetchMs, computeMs },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { PdfLayoutProgress } from '@openreader/compute-core/api-contracts';
|
||||
import type { PdfLayoutProgress } from '../operations/contracts';
|
||||
|
||||
export function buildInferProgressForPageStart(input: {
|
||||
pageNumber: number;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { WorkerOperationKind } from '@openreader/compute-core/api-contracts';
|
||||
import type { WorkerOperationKind } from '../operations/contracts';
|
||||
|
||||
export type RetryAction = 'nak_retry' | 'term_fail';
|
||||
|
||||
327
packages/compute-worker/src/jobs/worker-loop.ts
Normal file
327
packages/compute-worker/src/jobs/worker-loop.ts
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
import type { Consumer, JsMsg } from '@nats-io/jetstream';
|
||||
import type {
|
||||
PdfLayoutJobRequest,
|
||||
PdfLayoutJobResult,
|
||||
PdfLayoutProgress,
|
||||
WhisperAlignJobRequest,
|
||||
WhisperAlignJobResult,
|
||||
WorkerJobTiming,
|
||||
WorkerOperationKind,
|
||||
} from '../operations/contracts';
|
||||
import type { JsonCodec } from '../infrastructure/json-codec';
|
||||
import type { JobHandlers } from './handlers';
|
||||
import { buildQueueWaitTiming, decideRetryAction } from './worker-loop-policy';
|
||||
|
||||
const LOOP_ERROR_BACKOFF_MS = 500;
|
||||
const RUNNING_HEARTBEAT_MS = 5000;
|
||||
const PULL_EXPIRES_MS = 5_000;
|
||||
const WHISPER_MAX_DELIVER = 1;
|
||||
const SLOW_JOB_LOG_THRESHOLD_MS_BY_KIND: Record<WorkerOperationKind, number> = {
|
||||
whisper_align: 15_000,
|
||||
pdf_layout: 120_000,
|
||||
};
|
||||
|
||||
export interface QueuedJob<TPayload> {
|
||||
jobId: string;
|
||||
opId: string;
|
||||
opKey: string;
|
||||
kind: WorkerOperationKind;
|
||||
queuedAt: number;
|
||||
payload: TPayload;
|
||||
}
|
||||
|
||||
export interface WorkerLoopOrchestrator {
|
||||
markRunning(input: { opId: string; startedAt?: number; updatedAt?: number; timing?: WorkerJobTiming }): Promise<unknown>;
|
||||
markProgress(input: {
|
||||
opId: string;
|
||||
progress: PdfLayoutProgress;
|
||||
updatedAt?: number;
|
||||
timing?: WorkerJobTiming;
|
||||
}): Promise<unknown>;
|
||||
markSucceeded(input: { opId: string; result: unknown; updatedAt?: number; timing?: WorkerJobTiming }): Promise<unknown>;
|
||||
markFailed(input: {
|
||||
opId: string;
|
||||
error: { message: string; code?: string } | string;
|
||||
updatedAt?: number;
|
||||
timing?: WorkerJobTiming;
|
||||
}): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface WorkerLogger {
|
||||
info(data: unknown, message?: string): void;
|
||||
warn(data: unknown, message?: string): void;
|
||||
error(data: unknown, message?: string): void;
|
||||
}
|
||||
|
||||
class ConcurrencyGate {
|
||||
private inFlight = 0;
|
||||
private readonly queue: Array<() => void> = [];
|
||||
|
||||
constructor(private readonly limit: number) {}
|
||||
|
||||
async acquire(): Promise<void> {
|
||||
if (this.inFlight < this.limit) {
|
||||
this.inFlight += 1;
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
this.queue.push(() => {
|
||||
this.inFlight += 1;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
release(): void {
|
||||
this.inFlight = Math.max(0, this.inFlight - 1);
|
||||
this.queue.shift()?.();
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message ? error.message : String(error);
|
||||
}
|
||||
|
||||
function safeDurationMs(start: number, end: number): number {
|
||||
return Math.max(0, Math.floor(end - start));
|
||||
}
|
||||
|
||||
function extractTiming(result: unknown): WorkerJobTiming | undefined {
|
||||
if (!result || typeof result !== 'object' || !('timing' in result)) return undefined;
|
||||
return (result as { timing?: WorkerJobTiming }).timing;
|
||||
}
|
||||
|
||||
function extractResultRef(kind: WorkerOperationKind, result: unknown): string | undefined {
|
||||
if (kind !== 'pdf_layout' || !result || typeof result !== 'object') return undefined;
|
||||
const maybe = result as { parsedObjectKey?: unknown };
|
||||
return typeof maybe.parsedObjectKey === 'string' ? maybe.parsedObjectKey : undefined;
|
||||
}
|
||||
|
||||
export function createWorkerLoopController(input: {
|
||||
orchestrator: WorkerLoopOrchestrator;
|
||||
handlers: JobHandlers;
|
||||
logger: WorkerLogger;
|
||||
jobConcurrency: number;
|
||||
pdfAttempts: number;
|
||||
whisperCodec: JsonCodec<QueuedJob<WhisperAlignJobRequest>>;
|
||||
pdfCodec: JsonCodec<QueuedJob<PdfLayoutJobRequest>>;
|
||||
isOwnerActive: (owner: object) => boolean;
|
||||
isStopping: () => boolean;
|
||||
markActivity: (reason: string) => void;
|
||||
onInFlightJobsChanged: (delta: number) => void;
|
||||
}) {
|
||||
const gate = new ConcurrencyGate(Math.max(1, Math.floor(input.jobConcurrency)));
|
||||
let loops: Promise<void>[] = [];
|
||||
let stopRequested = false;
|
||||
|
||||
type Context<TPayload> = {
|
||||
decoded: QueuedJob<TPayload>;
|
||||
workerLabel: string;
|
||||
startedAt: number;
|
||||
queueWaitTiming?: { queueWaitMs: number };
|
||||
latestProgress?: PdfLayoutProgress;
|
||||
};
|
||||
|
||||
type JobRunner<TPayload, TResult> = (
|
||||
payload: TPayload,
|
||||
queueWaitMs: number,
|
||||
hooks?: { onProgress?: (progress: PdfLayoutProgress) => Promise<void> },
|
||||
) => Promise<TResult>;
|
||||
|
||||
type WorkDefinition<TPayload, TResult> = {
|
||||
codec: JsonCodec<QueuedJob<TPayload>>;
|
||||
run: JobRunner<TPayload, TResult>;
|
||||
};
|
||||
|
||||
const markRunning = async <TPayload>(context: Context<TPayload>, updatedAt: number): Promise<void> => {
|
||||
if (context.latestProgress) {
|
||||
await input.orchestrator.markProgress({
|
||||
opId: context.decoded.opId,
|
||||
progress: context.latestProgress,
|
||||
updatedAt,
|
||||
...(context.queueWaitTiming ? { timing: context.queueWaitTiming } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await input.orchestrator.markRunning({
|
||||
opId: context.decoded.opId,
|
||||
startedAt: context.startedAt,
|
||||
updatedAt,
|
||||
...(context.queueWaitTiming ? { timing: context.queueWaitTiming } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
const processMessage = async <TPayload, TResult>(work: WorkDefinition<TPayload, TResult> & {
|
||||
msg: JsMsg;
|
||||
workerLabel: string;
|
||||
}): Promise<void> => {
|
||||
let context: Context<TPayload> | null = null;
|
||||
let heartbeat: NodeJS.Timeout | null = null;
|
||||
try {
|
||||
const decoded = work.codec.decode(work.msg.data);
|
||||
const startedAt = Date.now();
|
||||
context = {
|
||||
decoded,
|
||||
workerLabel: work.workerLabel,
|
||||
startedAt,
|
||||
queueWaitTiming: buildQueueWaitTiming(decoded.queuedAt, startedAt),
|
||||
};
|
||||
await markRunning(context, startedAt);
|
||||
input.logger.info({
|
||||
worker: work.workerLabel,
|
||||
kind: decoded.kind,
|
||||
opId: decoded.opId,
|
||||
jobId: decoded.jobId,
|
||||
queueWaitMs: context.queueWaitTiming?.queueWaitMs ?? null,
|
||||
deliveryCount: work.msg.info.deliveryCount,
|
||||
}, 'job.started');
|
||||
heartbeat = setInterval(() => {
|
||||
void markRunning(context!, Date.now()).catch((error) => {
|
||||
input.logger.error({
|
||||
worker: work.workerLabel,
|
||||
opId: context?.decoded.opId,
|
||||
jobId: context?.decoded.jobId,
|
||||
error: toErrorMessage(error),
|
||||
}, 'failed to persist operation heartbeat state');
|
||||
});
|
||||
}, RUNNING_HEARTBEAT_MS);
|
||||
const result = await work.run(decoded.payload, context.queueWaitTiming?.queueWaitMs ?? 0, {
|
||||
onProgress: async (progress) => {
|
||||
try {
|
||||
work.msg.working();
|
||||
} catch (error) {
|
||||
input.logger.warn({
|
||||
worker: work.workerLabel,
|
||||
kind: context?.decoded.kind,
|
||||
opId: context?.decoded.opId,
|
||||
jobId: context?.decoded.jobId,
|
||||
error: toErrorMessage(error),
|
||||
}, 'failed to extend JetStream ack wait on progress');
|
||||
}
|
||||
context!.latestProgress = progress;
|
||||
await markRunning(context!, Date.now());
|
||||
},
|
||||
});
|
||||
const timing = extractTiming(result);
|
||||
const now = Date.now();
|
||||
await input.orchestrator.markSucceeded({
|
||||
opId: decoded.opId,
|
||||
result,
|
||||
updatedAt: now,
|
||||
...(timing ? { timing } : {}),
|
||||
});
|
||||
work.msg.ack();
|
||||
const durationMs = safeDurationMs(startedAt, now);
|
||||
if (durationMs >= SLOW_JOB_LOG_THRESHOLD_MS_BY_KIND[decoded.kind]) {
|
||||
input.logger.info({ worker: work.workerLabel, kind: decoded.kind, opId: decoded.opId, jobId: decoded.jobId, durationMs, timing: timing ?? null }, 'job.stage');
|
||||
}
|
||||
input.logger.info({
|
||||
worker: work.workerLabel,
|
||||
kind: decoded.kind,
|
||||
opId: decoded.opId,
|
||||
jobId: decoded.jobId,
|
||||
status: 'succeeded',
|
||||
durationMs,
|
||||
resultRef: extractResultRef(decoded.kind, result),
|
||||
timing: timing ?? null,
|
||||
}, 'job.terminal');
|
||||
} catch (error) {
|
||||
const errorMessage = toErrorMessage(error);
|
||||
const deliveryCount = work.msg.info.deliveryCount;
|
||||
const kind = context?.decoded.kind ?? 'pdf_layout';
|
||||
const action = decideRetryAction({ kind, deliveryCount, pdfAttempts: input.pdfAttempts, whisperMaxDeliver: WHISPER_MAX_DELIVER });
|
||||
const timing = context ? buildQueueWaitTiming(context.decoded.queuedAt, Date.now()) : undefined;
|
||||
if (context) {
|
||||
const update = action === 'nak_retry'
|
||||
? markRunning(context, Date.now())
|
||||
: input.orchestrator.markFailed({
|
||||
opId: context.decoded.opId,
|
||||
error: { message: errorMessage },
|
||||
updatedAt: Date.now(),
|
||||
...(timing ? { timing } : {}),
|
||||
});
|
||||
await update.catch((stateError) => input.logger.error({
|
||||
worker: context?.workerLabel,
|
||||
opId: context?.decoded.opId,
|
||||
jobId: context?.decoded.jobId,
|
||||
error: toErrorMessage(stateError),
|
||||
}, 'failed to persist operation state'));
|
||||
}
|
||||
if (action === 'nak_retry') work.msg.nak();
|
||||
else work.msg.term(errorMessage);
|
||||
input.logger.error({
|
||||
worker: context?.workerLabel,
|
||||
kind: context?.decoded.kind,
|
||||
opId: context?.decoded.opId,
|
||||
jobId: context?.decoded.jobId,
|
||||
status: action === 'nak_retry' ? 'running' : 'failed',
|
||||
error: errorMessage,
|
||||
deliveryCount,
|
||||
retryAction: action === 'nak_retry' ? 'nack_retry' : 'term',
|
||||
}, 'job.terminal');
|
||||
} finally {
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
}
|
||||
};
|
||||
|
||||
const runLoop = async <TPayload, TResult>(work: WorkDefinition<TPayload, TResult> & {
|
||||
owner: object;
|
||||
consumer: Consumer;
|
||||
workerLabel: string;
|
||||
}): Promise<void> => {
|
||||
const detached = () => input.isStopping() || stopRequested || !input.isOwnerActive(work.owner);
|
||||
while (!detached()) {
|
||||
let msg: JsMsg | null = null;
|
||||
try {
|
||||
try {
|
||||
msg = await work.consumer.next({ expires: PULL_EXPIRES_MS });
|
||||
} catch (error) {
|
||||
if (detached()) return;
|
||||
input.logger.error({ error: toErrorMessage(error), worker: work.workerLabel }, 'worker pull failed');
|
||||
await sleep(LOOP_ERROR_BACKOFF_MS);
|
||||
continue;
|
||||
}
|
||||
if (!msg) continue;
|
||||
input.markActivity(`job_received:${work.workerLabel}`);
|
||||
input.onInFlightJobsChanged(1);
|
||||
await gate.acquire();
|
||||
if (detached()) return;
|
||||
await processMessage({ ...work, msg });
|
||||
} finally {
|
||||
if (msg) {
|
||||
gate.release();
|
||||
input.onInFlightJobsChanged(-1);
|
||||
input.markActivity(`job_completed:${work.workerLabel}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
start(owner: object, consumers: { whisper: Consumer; pdfLayout: Consumer }): void {
|
||||
stopRequested = false;
|
||||
loops = [];
|
||||
const whisperWork: WorkDefinition<WhisperAlignJobRequest, WhisperAlignJobResult> = {
|
||||
codec: input.whisperCodec,
|
||||
run: input.handlers.runWhisper,
|
||||
};
|
||||
const pdfWork: WorkDefinition<PdfLayoutJobRequest, PdfLayoutJobResult> = {
|
||||
codec: input.pdfCodec,
|
||||
run: input.handlers.runPdfLayout,
|
||||
};
|
||||
for (let i = 0; i < input.jobConcurrency; i += 1) {
|
||||
loops.push(runLoop({ owner, consumer: consumers.whisper, ...whisperWork, workerLabel: `whisper-${i + 1}` }));
|
||||
loops.push(runLoop({ owner, consumer: consumers.pdfLayout, ...pdfWork, workerLabel: `layout-${i + 1}` }));
|
||||
}
|
||||
},
|
||||
async stop(): Promise<void> {
|
||||
stopRequested = true;
|
||||
await Promise.allSettled(loops);
|
||||
loops = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,24 +1,26 @@
|
|||
import type { TTSSentenceAlignment } from '../types/tts';
|
||||
import type { ParsedPdfDocument } from '../types/parsed-pdf';
|
||||
import type { TTSSentenceAlignment, ParsedPdfDocument } from '../api/types';
|
||||
|
||||
export type {
|
||||
TTSAudioBuffer,
|
||||
TTSAudioBytes,
|
||||
TTSSentenceAlignment,
|
||||
TTSSentenceWord,
|
||||
} from '../types/tts';
|
||||
} from '../api/types';
|
||||
export type {
|
||||
ParsedPdfBlockKind,
|
||||
ParsedPdfBlockFragment,
|
||||
ParsedPdfBlock,
|
||||
ParsedPdfPage,
|
||||
ParsedPdfDocument,
|
||||
} from '../types/parsed-pdf';
|
||||
} from '../api/types';
|
||||
|
||||
export const ALIGN_QUEUE_NAME = 'whisper-align';
|
||||
export const PDF_LAYOUT_QUEUE_NAME = 'pdf-layout';
|
||||
export { PDF_PARSER_VERSION } from '../pdf/parser-version';
|
||||
export { encodeParserVersion } from '../pdf/parser-version-key';
|
||||
export const PDF_PARSER_VERSION = 'pp-doclayoutv3-onnx@800+pdfjs@4.8.69';
|
||||
|
||||
export function encodeParserVersion(parserVersion: string, defaultVersion = PDF_PARSER_VERSION): string {
|
||||
return encodeURIComponent(parserVersion.trim() || defaultVersion);
|
||||
}
|
||||
|
||||
export interface WhisperAlignJobBase {
|
||||
text: string;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
export * from './types';
|
||||
export * from './state-machine';
|
||||
export * from './orchestrator';
|
||||
export * from './service';
|
||||
export * from './sse';
|
||||
56
packages/compute-worker/src/operations/keys.ts
Normal file
56
packages/compute-worker/src/operations/keys.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { PDF_PARSER_VERSION } from './contracts';
|
||||
|
||||
function sha256Hex(input: string): string {
|
||||
return createHash('sha256').update(input).digest('hex');
|
||||
}
|
||||
|
||||
export function buildWhisperOperationKey(input: {
|
||||
audioObjectKey: string;
|
||||
text: string;
|
||||
lang?: string;
|
||||
cacheKey?: string;
|
||||
}): string {
|
||||
const cacheKey = input.cacheKey?.trim();
|
||||
if (cacheKey) {
|
||||
return `whisper_align|v1|cache|${cacheKey}|${input.audioObjectKey}`;
|
||||
}
|
||||
return [
|
||||
'whisper_align',
|
||||
'v1',
|
||||
input.audioObjectKey,
|
||||
input.lang ?? '',
|
||||
sha256Hex(input.text),
|
||||
].join('|');
|
||||
}
|
||||
|
||||
export function buildPdfOperationKey(input: {
|
||||
documentId: string;
|
||||
namespace: string | null;
|
||||
documentObjectKey: string;
|
||||
replaceToken?: string;
|
||||
}, parserVersion = PDF_PARSER_VERSION): string {
|
||||
return [
|
||||
'pdf_layout',
|
||||
'v1',
|
||||
parserVersion,
|
||||
input.documentId,
|
||||
input.namespace ?? '',
|
||||
input.documentObjectKey,
|
||||
input.replaceToken?.trim() || '',
|
||||
].join('|');
|
||||
}
|
||||
|
||||
export function pdfSubjectFromOperationKey(opKey: string): {
|
||||
kind: 'pdf_layout';
|
||||
documentId: string;
|
||||
namespace: string | null;
|
||||
} | null {
|
||||
const [kind, version, , documentId, namespace] = opKey.split('|');
|
||||
if (kind !== 'pdf_layout' || version !== 'v1' || !documentId) return null;
|
||||
return {
|
||||
kind: 'pdf_layout',
|
||||
documentId,
|
||||
namespace: namespace || null,
|
||||
};
|
||||
}
|
||||
95
packages/compute-worker/src/operations/reconciliation.ts
Normal file
95
packages/compute-worker/src/operations/reconciliation.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import type { WorkerJobTiming } from './contracts';
|
||||
import {
|
||||
recoverOrphanedOperations,
|
||||
type StreamedOperationState,
|
||||
} from './recovery';
|
||||
|
||||
export interface ReconciliationStateStore {
|
||||
getOpState(opId: string): Promise<StreamedOperationState | null>;
|
||||
getOpStateRecord?(opId: string): Promise<{ state: StreamedOperationState; revision: number } | null>;
|
||||
getOpIndex?(opKey: string): Promise<{ opId: string } | null>;
|
||||
listOpStates?(): Promise<StreamedOperationState[]>;
|
||||
}
|
||||
|
||||
export interface ReconciliationOrchestrator {
|
||||
markFailedIfUnchanged?(input: {
|
||||
current: StreamedOperationState;
|
||||
expectedRevision: number;
|
||||
error: { message: string; code?: string } | string;
|
||||
updatedAt?: number;
|
||||
timing?: WorkerJobTiming;
|
||||
}): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface ReconciliationLogger {
|
||||
warn(data: unknown, message?: string): void;
|
||||
}
|
||||
|
||||
function supportsRecovery(
|
||||
stateStore: ReconciliationStateStore,
|
||||
orchestrator: ReconciliationOrchestrator,
|
||||
): boolean {
|
||||
return typeof stateStore.listOpStates === 'function'
|
||||
&& typeof stateStore.getOpStateRecord === 'function'
|
||||
&& typeof orchestrator.markFailedIfUnchanged === 'function';
|
||||
}
|
||||
|
||||
export function createOperationReconciler(input: {
|
||||
stateStore: ReconciliationStateStore;
|
||||
orchestrator: ReconciliationOrchestrator;
|
||||
whisperTimeoutMs: number;
|
||||
pdfTimeoutMs: number;
|
||||
opStaleMs: number;
|
||||
getGeneration: () => number;
|
||||
logger: ReconciliationLogger;
|
||||
}) {
|
||||
let recoveryPromise: Promise<void> | null = null;
|
||||
let recoveredGeneration = -1;
|
||||
|
||||
const run = async (options?: { force?: boolean }): Promise<void> => {
|
||||
if (!supportsRecovery(input.stateStore, input.orchestrator)) return;
|
||||
const generation = input.getGeneration();
|
||||
if (!options?.force && recoveredGeneration === generation) return;
|
||||
if (recoveryPromise) return await recoveryPromise;
|
||||
|
||||
recoveryPromise = (async () => {
|
||||
const recoveredStates = await recoverOrphanedOperations({
|
||||
operationStateStore: {
|
||||
getOpStateRecord: (opId) => input.stateStore.getOpStateRecord!(opId),
|
||||
listOpStates: () => input.stateStore.listOpStates!(),
|
||||
},
|
||||
orchestrator: {
|
||||
markFailedIfUnchanged: async (request) => {
|
||||
const result = await input.orchestrator.markFailedIfUnchanged!(request);
|
||||
return result as StreamedOperationState | null;
|
||||
},
|
||||
},
|
||||
whisperTimeoutMs: input.whisperTimeoutMs,
|
||||
pdfTimeoutMs: input.pdfTimeoutMs,
|
||||
opStaleMs: input.opStaleMs,
|
||||
});
|
||||
if (recoveredStates.length > 0) {
|
||||
input.logger.warn({
|
||||
recoveredCount: recoveredStates.length,
|
||||
ops: recoveredStates.map((state) => ({
|
||||
opId: state.opId,
|
||||
kind: state.kind,
|
||||
status: state.status,
|
||||
})),
|
||||
}, 'recovered stale in-flight operations during reconciliation');
|
||||
}
|
||||
recoveredGeneration = generation;
|
||||
})().finally(() => {
|
||||
recoveryPromise = null;
|
||||
});
|
||||
await recoveryPromise;
|
||||
};
|
||||
|
||||
return {
|
||||
run,
|
||||
async getOpState(opId: string): Promise<StreamedOperationState | null> {
|
||||
await run();
|
||||
return await input.stateStore.getOpState(opId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import type {
|
|||
WorkerJobTiming,
|
||||
WorkerJobState,
|
||||
WorkerOperationState,
|
||||
} from '@openreader/compute-core/api-contracts';
|
||||
} from '../operations/contracts';
|
||||
|
||||
export type StreamedOperationState = WorkerOperationState<WhisperAlignJobResult | PdfLayoutJobResult>;
|
||||
|
||||
|
|
@ -5,7 +5,7 @@ import type {
|
|||
WorkerOperationKind,
|
||||
WorkerOperationRequest,
|
||||
WorkerOperationState,
|
||||
} from '../api-contracts';
|
||||
} from '../operations/contracts';
|
||||
import {
|
||||
buildQueuedState,
|
||||
createErrorShape,
|
||||
|
|
@ -4,7 +4,7 @@ import type {
|
|||
WorkerOperationKind,
|
||||
WorkerOperationRequest,
|
||||
WorkerOperationState,
|
||||
} from '../api-contracts';
|
||||
} from '../operations/contracts';
|
||||
|
||||
export function isTerminalStatus(status: WorkerJobState): boolean {
|
||||
return status === 'succeeded' || status === 'failed';
|
||||
|
|
@ -2,7 +2,7 @@ import type {
|
|||
WorkerOperationEvent,
|
||||
WorkerOperationKind,
|
||||
WorkerOperationState,
|
||||
} from '../api-contracts';
|
||||
} from '../operations/contracts';
|
||||
|
||||
export type OperationState<Result = unknown> = WorkerOperationState<Result>;
|
||||
export type OperationEvent<Result = unknown> = WorkerOperationEvent<Result>;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { startComputeWorkerFromEnv } from './runtime';
|
||||
import { startComputeWorkerFromEnv } from './api/app';
|
||||
|
||||
void startComputeWorkerFromEnv().catch((error) => {
|
||||
console.error('[compute-worker] fatal startup error', error);
|
||||
21
packages/compute-worker/src/storage/artifact-addressing.ts
Normal file
21
packages/compute-worker/src/storage/artifact-addressing.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { PDF_PARSER_VERSION } from '../operations/contracts';
|
||||
import { encodeParserVersion } from '../operations/contracts';
|
||||
|
||||
const DOCUMENT_ID_REGEX = /^[a-f0-9]{64}$/i;
|
||||
const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
|
||||
|
||||
export function parsedPdfArtifactKey(input: {
|
||||
documentId: string;
|
||||
namespace: string | null;
|
||||
prefix: string;
|
||||
parserVersion?: string;
|
||||
}): string {
|
||||
if (!DOCUMENT_ID_REGEX.test(input.documentId)) {
|
||||
throw new Error(`Invalid document id: ${input.documentId}`);
|
||||
}
|
||||
const namespace = input.namespace && SAFE_NAMESPACE_REGEX.test(input.namespace)
|
||||
? input.namespace
|
||||
: null;
|
||||
const namespaceSegment = namespace ? `ns/${namespace}/` : '';
|
||||
return `${input.prefix}/documents_v1/parsed_v2/${namespaceSegment}${input.documentId}/${encodeParserVersion(input.parserVersion ?? PDF_PARSER_VERSION)}.json`;
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
import { createComputeWorkerApp } from '../../src/runtime';
|
||||
import { createComputeWorkerApp } from '../../src/api/app';
|
||||
import { FakeControlPlane } from '../fixtures/fake-control-plane';
|
||||
|
||||
const AUTH = { authorization: 'Bearer test-token' };
|
||||
|
|
@ -25,53 +25,51 @@ describe('compute worker API routes', () => {
|
|||
const live = await runtime.app.inject({ method: 'GET', url: '/health/live' });
|
||||
expect(live.statusCode).toBe(200);
|
||||
|
||||
const protectedRoute = await runtime.app.inject({ method: 'GET', url: '/ops/op-1' });
|
||||
const protectedRoute = await runtime.app.inject({ method: 'GET', url: '/v1/operations/op-1' });
|
||||
expect(protectedRoute.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
test('validates operation creation body and returns 400 for invalid payload', async () => {
|
||||
const response = await runtime.app.inject({
|
||||
method: 'POST',
|
||||
url: '/ops',
|
||||
url: '/v1/pdf-layout/operations',
|
||||
headers: AUTH,
|
||||
payload: {
|
||||
kind: 'pdf_layout',
|
||||
opKey: '',
|
||||
payload: {
|
||||
documentId: 'd1',
|
||||
namespace: null,
|
||||
documentObjectKey: 's3://bucket/doc.pdf',
|
||||
},
|
||||
documentId: '',
|
||||
namespace: null,
|
||||
documentObjectKey: 'openreader/doc.pdf',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({ error: 'Invalid request body' });
|
||||
expect(response.json()).toMatchObject({ error: 'Bad Request' });
|
||||
});
|
||||
|
||||
test('creates operation and fetches state by op id', async () => {
|
||||
const documentId = 'c'.repeat(64);
|
||||
const create = await runtime.app.inject({
|
||||
method: 'POST',
|
||||
url: '/ops',
|
||||
url: '/v1/pdf-layout/operations',
|
||||
headers: AUTH,
|
||||
payload: {
|
||||
kind: 'pdf_layout',
|
||||
opKey: 'doc-1:layout',
|
||||
payload: {
|
||||
documentId: 'doc-1',
|
||||
namespace: null,
|
||||
documentObjectKey: 'openreader/doc-1.pdf',
|
||||
},
|
||||
documentId,
|
||||
namespace: null,
|
||||
documentObjectKey: `openreader/${documentId}.pdf`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(create.statusCode).toBe(202);
|
||||
const created = create.json();
|
||||
expect(created).toMatchObject({ kind: 'pdf_layout', status: 'queued' });
|
||||
expect(created).toMatchObject({
|
||||
subject: { kind: 'pdf_layout', documentId, namespace: null },
|
||||
status: 'queued',
|
||||
});
|
||||
expect(created).not.toHaveProperty('opKey');
|
||||
expect(created).not.toHaveProperty('jobId');
|
||||
|
||||
const fetch = await runtime.app.inject({
|
||||
method: 'GET',
|
||||
url: `/ops/${created.opId}`,
|
||||
url: `/v1/operations/${created.opId}`,
|
||||
headers: AUTH,
|
||||
});
|
||||
|
||||
|
|
@ -79,17 +77,120 @@ describe('compute worker API routes', () => {
|
|||
expect(fetch.json()).toMatchObject({ opId: created.opId, status: 'queued' });
|
||||
});
|
||||
|
||||
test('reuses idempotent PDF requests and replaces them only with an explicit token', async () => {
|
||||
const documentId = 'e'.repeat(64);
|
||||
const payload = {
|
||||
documentId,
|
||||
namespace: null,
|
||||
documentObjectKey: `openreader/${documentId}.pdf`,
|
||||
};
|
||||
const create = (body: typeof payload & { replaceToken?: string }) => runtime.app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/pdf-layout/operations',
|
||||
headers: AUTH,
|
||||
payload: body,
|
||||
});
|
||||
|
||||
const initial = (await create(payload)).json();
|
||||
const reused = (await create(payload)).json();
|
||||
const replacement = (await create({ ...payload, replaceToken: 'replace-1' })).json();
|
||||
|
||||
expect(reused.opId).toBe(initial.opId);
|
||||
expect(replacement.opId).not.toBe(initial.opId);
|
||||
expect(replacement.subject).toEqual(initial.subject);
|
||||
});
|
||||
|
||||
test('creates whisper alignment operations without exposing internal keys', async () => {
|
||||
const response = await runtime.app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/whisper-align/operations',
|
||||
headers: AUTH,
|
||||
payload: {
|
||||
text: 'Canonical worker text',
|
||||
audioObjectKey: 'openreader/audio.mp3',
|
||||
lang: 'en',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(202);
|
||||
expect(response.json()).toMatchObject({
|
||||
subject: { kind: 'whisper_align' },
|
||||
status: 'queued',
|
||||
});
|
||||
expect(response.json()).not.toHaveProperty('opKey');
|
||||
});
|
||||
|
||||
test('resolves the current PDF artifact and operation without exposing parser identity', async () => {
|
||||
const documentId = 'a'.repeat(64);
|
||||
const create = await runtime.app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/pdf-layout/operations',
|
||||
headers: AUTH,
|
||||
payload: {
|
||||
documentId,
|
||||
namespace: null,
|
||||
documentObjectKey: `openreader/${documentId}.pdf`,
|
||||
},
|
||||
});
|
||||
expect(create.statusCode).toBe(202);
|
||||
|
||||
const artifactKey = `openreader/documents_v1/parsed_v2/${documentId}/pp-doclayoutv3-onnx%40800%2Bpdfjs%404.8.69.json`;
|
||||
fake.seedArtifact(artifactKey);
|
||||
const resolve = await runtime.app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/pdf-layout/resolve',
|
||||
headers: AUTH,
|
||||
payload: {
|
||||
documentId,
|
||||
namespace: null,
|
||||
documentObjectKey: `openreader/${documentId}.pdf`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolve.statusCode).toBe(200);
|
||||
expect(resolve.json()).toMatchObject({
|
||||
artifact: { objectKey: artifactKey },
|
||||
operation: {
|
||||
subject: { kind: 'pdf_layout', documentId, namespace: null },
|
||||
},
|
||||
});
|
||||
expect(resolve.json().operation).not.toHaveProperty('opKey');
|
||||
});
|
||||
|
||||
test('resolves a current PDF artifact after operation state has expired', async () => {
|
||||
const documentId = 'd'.repeat(64);
|
||||
const artifactKey = `openreader/documents_v1/parsed_v2/${documentId}/pp-doclayoutv3-onnx%40800%2Bpdfjs%404.8.69.json`;
|
||||
fake.seedArtifact(artifactKey);
|
||||
|
||||
const resolve = await runtime.app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/pdf-layout/resolve',
|
||||
headers: AUTH,
|
||||
payload: {
|
||||
documentId,
|
||||
namespace: null,
|
||||
documentObjectKey: `openreader/${documentId}.pdf`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolve.statusCode).toBe(200);
|
||||
expect(resolve.json()).toEqual({
|
||||
artifact: { objectKey: artifactKey },
|
||||
operation: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('returns not found for unknown operation and event stream lookups', async () => {
|
||||
const opResponse = await runtime.app.inject({
|
||||
method: 'GET',
|
||||
url: '/ops/missing',
|
||||
url: '/v1/operations/missing',
|
||||
headers: AUTH,
|
||||
});
|
||||
expect(opResponse.statusCode).toBe(404);
|
||||
|
||||
const eventsResponse = await runtime.app.inject({
|
||||
method: 'GET',
|
||||
url: '/ops/missing/events',
|
||||
url: '/v1/operations/missing/events',
|
||||
headers: AUTH,
|
||||
});
|
||||
expect(eventsResponse.statusCode).toBe(404);
|
||||
|
|
@ -109,7 +210,7 @@ describe('compute worker API routes', () => {
|
|||
|
||||
const stream = await runtime.app.inject({
|
||||
method: 'GET',
|
||||
url: '/ops/op-terminal/events?sinceEventId=7',
|
||||
url: '/v1/operations/op-terminal/events?sinceEventId=7',
|
||||
headers: AUTH,
|
||||
});
|
||||
|
||||
|
|
@ -159,11 +260,11 @@ describe('compute worker API routes', () => {
|
|||
updatedAt: now - 310_000,
|
||||
});
|
||||
|
||||
// GET /ops/:opId resolves via getOpState(), which first awaits the shared
|
||||
// GET /v1/operations/:opId resolves via getOpState(), which first awaits the shared
|
||||
// orphanRecoveryPromise path through ensureOrphanedOpRecovery().
|
||||
const fetch = await runtime.app.inject({
|
||||
method: 'GET',
|
||||
url: '/ops/op-stale-whisper-running',
|
||||
url: '/v1/operations/op-stale-whisper-running',
|
||||
headers: AUTH,
|
||||
});
|
||||
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { mergeTextWithRegions } from '@openreader/compute-core';
|
||||
import { mergeTextWithRegions } from '../../../src/inference/pdf/document-layout';
|
||||
|
||||
describe('mergeTextWithRegions', () => {
|
||||
test('assigns text items to containing regions by centroid and joins in reading order', () => {
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { normalizeTextItemsForLayout } from '@openreader/compute-core';
|
||||
import { normalizeTextItemsForLayout } from '../../../src/inference/pdf/normalize-text';
|
||||
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
|
||||
|
||||
function makeTextItem(
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { stitchCrossPageBlocks } from '@openreader/compute-core';
|
||||
import type { ParsedPdfBlock, ParsedPdfDocument, ParsedPdfBlockKind } from '../../src/types/parsed-pdf';
|
||||
import { stitchCrossPageBlocks } from '../../../src/inference/pdf/stitch';
|
||||
import type { ParsedPdfBlock, ParsedPdfDocument, ParsedPdfBlockKind } from '../../../src/api/types';
|
||||
import { makeParsedPdfDocument, makeParsedPdfPage } from './support/document-fixtures';
|
||||
|
||||
function makeBlock(
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import type { BaseDocument } from '../../../../../../src/types/documents';
|
||||
import type { ParsedPdfBlock, ParsedPdfBlockKind, ParsedPdfDocument, ParsedPdfPage } from '../../../../src/api/types';
|
||||
|
||||
export function makeBaseDocument(overrides: Partial<BaseDocument> = {}): BaseDocument {
|
||||
return {
|
||||
id: 'doc-1',
|
||||
name: 'document.pdf',
|
||||
size: 1_024,
|
||||
lastModified: 1_700_000_000_000,
|
||||
type: 'pdf',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeParsedPdfBlock(input: {
|
||||
id: string;
|
||||
kind: ParsedPdfBlockKind;
|
||||
text: string;
|
||||
page: number;
|
||||
readingOrder: number;
|
||||
}): ParsedPdfBlock {
|
||||
return {
|
||||
id: input.id,
|
||||
kind: input.kind,
|
||||
text: input.text,
|
||||
fragments: [
|
||||
{
|
||||
page: input.page,
|
||||
bbox: [0, 0, 100, 10],
|
||||
text: input.text,
|
||||
readingOrder: input.readingOrder,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function makeParsedPdfPage(pageNumber: number, blocks: ParsedPdfBlock[]): ParsedPdfPage {
|
||||
return {
|
||||
pageNumber,
|
||||
width: 800,
|
||||
height: 1_200,
|
||||
blocks,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeParsedPdfDocument(pages: ParsedPdfPage[]): ParsedPdfDocument {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
documentId: 'doc-fixture',
|
||||
parserVersion: 'test',
|
||||
parsedAt: 1_700_000_000_000,
|
||||
pages,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
mapWordsToSentenceOffsets,
|
||||
} from '@openreader/compute-core';
|
||||
} from '../../../src/inference/whisper/timestamps';
|
||||
|
||||
describe('whisper alignment mapping', () => {
|
||||
test('maps words to sentence offsets with punctuation and repeated spaces', () => {
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue