// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 // Node action for the documents pipeline. OCR, Blob and model calls happen here; every repository // change below is a single internal mutation/query and proposal creation remains atomic with its // outbox event. "use node" import { env } from "node:process" import { AppError, notFound } from "@conusai-kit/core" import { systemActor } from "@conusai-kit/core/worker" import { DEFAULT_FILES_POLICY, type ProcessorJobPayload, platformObjectStore } from "@conusai-kit/files" import { createDocumentReader, createModelGateway, createObjectStore } from "@conusai-kit/platform" import { makeFunctionReference } from "convex/server" import { v } from "convex/values" import { excludedPageClassNamesOf } from "../config/dist/documents.js" import { pageClassesOf } from "../modules/dist/documents/contract/constants.js" import type { DocumentScope } from "../modules/dist/documents/core/lines.js" import { type DocumentProcessRecords, type ProcessDocumentRow, processDocumentWithRecords, } from "../modules/dist/documents/core/process.js" import { modelLineExtractor, sdkDocumentOcr } from "../modules/dist/documents/index.js" import { internalAction } from "./_generated/server" const required = (name: string): string => { const value = env[name]?.trim() if (!value) throw new Error(`Convex deployment is missing ${name}`) return value } const serviceEnv = () => { const project = Number(required("CONUSAI_PROJECT_ID")) if (!Number.isSafeInteger(project) || project < 1) throw new Error("Convex deployment has an invalid CONUSAI_PROJECT_ID") return { CONUSAI_API_URL: required("CONUSAI_API_URL"), CONUSAI_API_TOKEN: required("CONUSAI_API_TOKEN"), CONUSAI_PROJECT_ID: String(project), } } const platformBlobStore = (deployment: ReturnType) => platformObjectStore({ blob: createObjectStore(deployment) }) const queryRef = (name: string) => makeFunctionReference<"query">(name) const mutationRef = (name: string) => makeFunctionReference<"mutation">(name) const documentScope = (ids: { orgId: string; workspaceId: string; projectId: string }): DocumentScope => ({ level: "project", actor: systemActor("documents.ocr"), ...ids, }) export const process = internalAction({ args: { jobId: v.string(), attempt: v.number(), orgId: v.string(), workspaceId: v.string(), projectId: v.string(), payload: v.any(), }, handler: async (ctx, args) => { const ids = { orgId: args.orgId, workspaceId: args.workspaceId, projectId: args.projectId } const scope = documentScope(ids) const deployment = serviceEnv() const store = platformBlobStore(deployment) const reader = sdkDocumentOcr(createDocumentReader(deployment)) const model = env.AI_MODEL?.trim() || "anthropic/claude-sonnet-5" const excludedPageClassNames = excludedPageClassNamesOf() const extractor = modelLineExtractor({ gateway: createModelGateway(deployment, { model }), model, ...(excludedPageClassNames === undefined ? {} : { excludedPageClasses: pageClassesOf(excludedPageClassNames) }), }) const payload = args.payload as ProcessorJobPayload const records: DocumentProcessRecords = { async getFile(_scope, fileId) { const file = await ctx.runQuery(queryRef("documents:fileInfo"), { ...ids, fileId }) if (!file) throw notFound("no such file") return file as never }, ensureProcessing: async (_scope, _file, job) => (await ctx.runMutation(mutationRef("documents:ensureProcessing"), { ...ids, fileId: job.fileId, storageKey: job.storageKey, sha256: job.sha256, })) as ProcessDocumentRow | undefined, setOcrJob: async (_scope, document, jobId) => (await ctx.runMutation(mutationRef("documents:setOcrJob"), { ...ids, documentId: document.id, expectedVersion: document.version, jobId, })) as ProcessDocumentRow, markFailed: async (_scope, document, code, finishedAt) => { await ctx.runMutation(mutationRef("documents:markFailed"), { ...ids, documentId: document.id, expectedVersion: document.version, code, finishedAt, }) }, proposals: async (_scope, documentId) => { const lines = await ctx.runQuery(queryRef("documents:lineProposals"), { ...ids, documentId }) return lines.map(({ lineKey }: { lineKey?: string }) => ({ change: { lineKey } })) as never }, createProposal: async (_scope, input, idempotencyKey) => { await ctx.runMutation(mutationRef("documents:addLineProposal"), { ...ids, documentId: input.targetRecordId, input, idempotencyKey, }) }, finish: async (_scope, document, result) => { await ctx.runMutation(mutationRef("documents:finish"), { ...ids, documentId: document.id, expectedVersion: document.version, ...result, }) }, } try { await processDocumentWithRecords( { store, ocr: reader, extractor, maxBytes: DEFAULT_FILES_POLICY.maxBytes, }, scope, payload, records, ) await ctx.runMutation(mutationRef("conusaiJobs:settleAction"), { jobId: args.jobId, attempt: args.attempt, }) } catch (error) { const retryAfterSeconds = error instanceof AppError && error.problem.code === "rate_limited" ? error.problem.retryAfter : undefined const errorCode = error instanceof AppError ? error.problem.code : error instanceof Error ? error.name : "unknown" await ctx.runMutation(mutationRef("conusaiJobs:settleAction"), { jobId: args.jobId, attempt: args.attempt, error: `documents.ocr failed (${errorCode})`, ...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }), }) } }, })