157 lines
5.9 KiB
TypeScript
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 }),
|
|
})
|
|
}
|
|
},
|
|
})
|