216 lines
7.8 KiB
TypeScript
216 lines
7.8 KiB
TypeScript
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
|
// Convex transactions for the documents OCR action. External OCR/model/Blob work runs in the Node
|
|
// action; these functions perform one scoped row transition each, and proposal creation itself is
|
|
// still one mutation containing the proposal plus its outbox record.
|
|
|
|
import type { TenantScope } from "@conusai-kit/core"
|
|
import { systemActor } from "@conusai-kit/core/worker"
|
|
import { createConvexDataSource } from "@conusai-kit/data-convex"
|
|
import { executorFor } from "@conusai-kit/data-convex/runtime"
|
|
import { files } from "@conusai-kit/files"
|
|
import { createProposal, DEFAULT_PROPOSALS_POLICY, type ProposalView, proposals } from "@conusai-kit/proposals"
|
|
import { createTenancyHooks } from "@conusai-kit/tenancy"
|
|
import { ConvexError, type Value, v } from "convex/values"
|
|
import { documents } from "../modules/dist/documents/contract/entities.js"
|
|
import { internalMutation, internalQuery } from "./_generated/server"
|
|
import { convexEntities, registry } from "./lib/runtime"
|
|
|
|
const scopeArgs = {
|
|
orgId: v.string(),
|
|
workspaceId: v.string(),
|
|
projectId: v.string(),
|
|
}
|
|
const withFile = { ...scopeArgs, fileId: v.string() }
|
|
|
|
type ProjectScope = TenantScope & { workspaceId: string; projectId: string }
|
|
|
|
const systemScope = (ids: { orgId: string; workspaceId: string; projectId: string }): ProjectScope => ({
|
|
level: "project",
|
|
actor: systemActor("documents.ocr"),
|
|
...ids,
|
|
})
|
|
|
|
const dataFor = (ctx: { db: unknown }) =>
|
|
createConvexDataSource({
|
|
entities: convexEntities,
|
|
executor: executorFor(ctx as unknown as Parameters<typeof executorFor>[0]),
|
|
})
|
|
|
|
const problem = (message: string): never => {
|
|
throw new ConvexError({ code: "internal", message } as unknown as Value)
|
|
}
|
|
|
|
export const fileInfo = internalQuery({
|
|
args: withFile,
|
|
handler: async (ctx, { fileId, ...ids }) => {
|
|
const row = await dataFor(ctx).forScope(systemScope(ids)).of(files).get(fileId)
|
|
if (row?.status !== "stored" || row.size === null || row.sha256 === null || row.storedAt === null) {
|
|
return null
|
|
}
|
|
return {
|
|
fileId: row.id,
|
|
filename: row.filename,
|
|
contentType: row.contentType,
|
|
size: row.size,
|
|
sha256: row.sha256,
|
|
uploadedBy: row.uploadedBy,
|
|
createdAt: row.createdAt,
|
|
storedAt: row.storedAt,
|
|
}
|
|
},
|
|
})
|
|
|
|
export const ensureProcessing = internalMutation({
|
|
args: { ...withFile, storageKey: v.string(), sha256: v.string() },
|
|
handler: async (ctx, { fileId, storageKey, sha256, ...ids }) => {
|
|
const scope = systemScope(ids)
|
|
const data = dataFor(ctx)
|
|
const file = await data.forScope(scope).of(files).get(fileId)
|
|
if (
|
|
file?.status !== "stored" ||
|
|
file.storageKey !== storageKey ||
|
|
file.sha256 !== sha256 ||
|
|
file.size === null ||
|
|
file.storedAt === null
|
|
) {
|
|
return null
|
|
}
|
|
const repo = data.forScope(scope).of(documents)
|
|
const existing = (await repo.list({ where: [{ field: "fileId", op: "eq", value: fileId }], limit: 1 })).items[0]
|
|
if (existing) return existing.status === "processing" ? existing : null
|
|
try {
|
|
return await repo.insert({
|
|
fileId,
|
|
filename: file.filename,
|
|
status: "processing",
|
|
ocrJobId: null,
|
|
pageCount: null,
|
|
proposedCount: null,
|
|
failureCode: null,
|
|
skippedPages: null,
|
|
finishedAt: null,
|
|
} as never)
|
|
} catch (error) {
|
|
const raced = (await repo.list({ where: [{ field: "fileId", op: "eq", value: fileId }], limit: 1 })).items[0]
|
|
if (raced) return raced.status === "processing" ? raced : null
|
|
throw error
|
|
}
|
|
},
|
|
})
|
|
|
|
export const setOcrJob = internalMutation({
|
|
args: { ...scopeArgs, documentId: v.string(), expectedVersion: v.number(), jobId: v.number() },
|
|
handler: async (ctx, { documentId, expectedVersion, jobId, ...ids }) => {
|
|
const repo = dataFor(ctx).forScope(systemScope(ids)).of(documents)
|
|
const current = await repo.get(documentId)
|
|
if (current?.status !== "processing") return problem("document processing row is no longer active")
|
|
if (current.ocrJobId !== null) return current
|
|
return repo.update(documentId, { ocrJobId: jobId } as never, expectedVersion)
|
|
},
|
|
})
|
|
|
|
export const markFailed = internalMutation({
|
|
args: {
|
|
...scopeArgs,
|
|
documentId: v.string(),
|
|
expectedVersion: v.number(),
|
|
code: v.union(
|
|
v.literal("file_missing"),
|
|
v.literal("file_changed"),
|
|
v.literal("ocr_failed"),
|
|
v.literal("extraction_refused"),
|
|
v.literal("extraction_truncated"),
|
|
v.literal("extraction_invalid"),
|
|
),
|
|
finishedAt: v.string(),
|
|
},
|
|
handler: async (ctx, { documentId, expectedVersion, code, finishedAt, ...ids }) => {
|
|
const repo = dataFor(ctx).forScope(systemScope(ids)).of(documents)
|
|
const current = await repo.get(documentId)
|
|
if (current?.status !== "processing") return null
|
|
return repo.update(documentId, { status: "failed", failureCode: code, finishedAt } as never, expectedVersion)
|
|
},
|
|
})
|
|
|
|
export const lineProposals = internalQuery({
|
|
args: { ...scopeArgs, documentId: v.string() },
|
|
handler: async (ctx, { documentId, ...ids }) => {
|
|
const repo = dataFor(ctx).forScope(systemScope(ids)).of(proposals)
|
|
const proposalsForDocument: ProposalView[] = []
|
|
let cursor: string | undefined
|
|
do {
|
|
const page = await repo.list({
|
|
where: [
|
|
{ field: "targetKey", op: "eq", value: "documents.line" },
|
|
{ field: "targetRecordId", op: "eq", value: documentId },
|
|
],
|
|
limit: 200,
|
|
...(cursor === undefined ? {} : { cursor }),
|
|
})
|
|
proposalsForDocument.push(...(page.items as unknown as ProposalView[]))
|
|
cursor = page.hasMore ? page.cursor : undefined
|
|
} while (cursor !== undefined)
|
|
return proposalsForDocument.map((proposal) => ({ lineKey: (proposal.change as { lineKey?: string }).lineKey }))
|
|
},
|
|
})
|
|
|
|
export const addLineProposal = internalMutation({
|
|
args: {
|
|
...scopeArgs,
|
|
documentId: v.string(),
|
|
input: v.any(),
|
|
idempotencyKey: v.string(),
|
|
},
|
|
handler: async (ctx, { documentId, input, idempotencyKey, ...ids }) => {
|
|
const scope = systemScope(ids)
|
|
const data = dataFor(ctx)
|
|
const key = (input.change as { lineKey?: string }).lineKey
|
|
let cursor: string | undefined
|
|
do {
|
|
const page = await data
|
|
.forScope(scope)
|
|
.of(proposals)
|
|
.list({
|
|
where: [
|
|
{ field: "targetKey", op: "eq", value: "documents.line" },
|
|
{ field: "targetRecordId", op: "eq", value: documentId },
|
|
],
|
|
limit: 200,
|
|
...(cursor === undefined ? {} : { cursor }),
|
|
})
|
|
if (page.items.some((proposal) => (proposal.change as { lineKey?: string }).lineKey === key)) return null
|
|
cursor = page.hasMore ? page.cursor : undefined
|
|
} while (cursor !== undefined)
|
|
const tenancy = createTenancyHooks({ data, registry })
|
|
return createProposal(
|
|
{ data, registry, tenancy, policy: DEFAULT_PROPOSALS_POLICY },
|
|
scope,
|
|
{
|
|
targetKey: "documents.line",
|
|
targetRecordId: documentId,
|
|
...(input as Record<string, unknown>),
|
|
} as never,
|
|
idempotencyKey,
|
|
)
|
|
},
|
|
})
|
|
|
|
export const finish = internalMutation({
|
|
args: {
|
|
...scopeArgs,
|
|
documentId: v.string(),
|
|
expectedVersion: v.number(),
|
|
pageCount: v.number(),
|
|
proposedCount: v.number(),
|
|
skippedPages: v.array(v.any()),
|
|
finishedAt: v.string(),
|
|
},
|
|
handler: async (ctx, { documentId, expectedVersion, ...result }) => {
|
|
const { orgId, workspaceId, projectId, ...fields } = result
|
|
const repo = dataFor(ctx).forScope(systemScope({ orgId, workspaceId, projectId })).of(documents)
|
|
const current = await repo.get(documentId)
|
|
if (current?.status !== "processing") return null
|
|
return repo.update(documentId, { ...fields, status: "extracted", failureCode: null } as never, expectedVersion)
|
|
},
|
|
})
|