Files
template-app/convex/documentsActions.ts
T

157 lines
5.9 KiB
TypeScript

// 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<typeof serviceEnv>) =>
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 }),
})
}
},
})