124 lines
5.2 KiB
TypeScript
124 lines
5.2 KiB
TypeScript
// The dependencies every use case of the module takes, and how a proposal becomes a line a reviewer
|
|
// sees. A line is not a row of this module until it is approved: until then it is a proposal of the
|
|
// proposals module, and this file is the one place the two are read together.
|
|
|
|
import type { TenantScope } from "@conusai-kit/core"
|
|
import type { DataSource, Filter } from "@conusai-kit/data"
|
|
import { type FilesDeps, sha256Hex } from "@conusai-kit/files"
|
|
import { type ProposalRow, type ProposalsDeps, type ProposalView, proposals, viewOf } from "@conusai-kit/proposals"
|
|
import { LINE_TARGET_KEY, type LineView, lineChangeSchema } from "../contract/index.js"
|
|
|
|
/** A project scope: the only level a document can live at. */
|
|
export type DocumentScope = TenantScope & { workspaceId: string; projectId: string }
|
|
|
|
export interface DocumentsDeps {
|
|
readonly data: DataSource
|
|
/** The files module's ports: a document is made from one of its files. */
|
|
readonly files: FilesDeps
|
|
/** The proposals module's ports: an extracted line is one of its proposals. */
|
|
readonly proposals: ProposalsDeps
|
|
}
|
|
|
|
/** The page size used to read this module's proposals. */
|
|
const PAGE = 200
|
|
|
|
/**
|
|
* Every proposal of this module's target matching the given record filter, any status, oldest
|
|
* first.
|
|
*
|
|
* The proposals module's own `listProposals` filters by status alone, so the filter by target is
|
|
* done here on its table, through the entity it exports: the `where` runs against the table's
|
|
* `(targetKey, targetRecordId)` index instead of paging the project's whole queue into memory and
|
|
* filtering it in JavaScript (one canonical copy of a line's state still — this adds a query, not
|
|
* a table). When the kit's list grows a `targetRecordId` filter of its own, this becomes a call
|
|
* of it again.
|
|
*/
|
|
async function proposalsWhere(
|
|
deps: DocumentsDeps,
|
|
scope: DocumentScope,
|
|
target: { recordId: string } | { recordIds: readonly string[] } | null,
|
|
): Promise<ProposalView[]> {
|
|
const byRecord: Filter<ProposalRow>[] =
|
|
target === null
|
|
? []
|
|
: "recordId" in target
|
|
? [{ field: "targetRecordId", op: "eq", value: target.recordId }]
|
|
: [{ field: "targetRecordId", op: "in", values: target.recordIds }]
|
|
const found: ProposalView[] = []
|
|
let cursor: string | undefined
|
|
do {
|
|
const page = await deps.proposals.data
|
|
.forScope(scope)
|
|
.of(proposals)
|
|
.list({
|
|
where: [{ field: "targetKey", op: "eq", value: LINE_TARGET_KEY }, ...byRecord],
|
|
order: [{ field: "createdAt", direction: "asc" }],
|
|
limit: PAGE,
|
|
...(cursor === undefined ? {} : { cursor }),
|
|
})
|
|
found.push(...page.items.map(viewOf))
|
|
cursor = page.hasMore ? page.cursor : undefined
|
|
} while (cursor !== undefined)
|
|
return found
|
|
}
|
|
|
|
/** Every proposal of this module's target in the project — or only one document's, when given. */
|
|
export function lineProposals(deps: DocumentsDeps, scope: DocumentScope, documentId?: string): Promise<ProposalView[]> {
|
|
return proposalsWhere(deps, scope, documentId === undefined ? null : { recordId: documentId })
|
|
}
|
|
|
|
/**
|
|
* This module's proposals of several documents at once, keyed by document id: the document list's
|
|
* join, one indexed page chain instead of one pass over the queue per document.
|
|
*/
|
|
export async function lineProposalsByDocument(
|
|
deps: DocumentsDeps,
|
|
scope: DocumentScope,
|
|
documentIds: readonly string[],
|
|
): Promise<Map<string, ProposalView[]>> {
|
|
const byDocument = new Map<string, ProposalView[]>()
|
|
if (documentIds.length === 0) return byDocument
|
|
for (const proposal of await proposalsWhere(deps, scope, { recordIds: documentIds })) {
|
|
if (proposal.targetRecordId === null) continue
|
|
const own = byDocument.get(proposal.targetRecordId)
|
|
if (own) own.push(proposal)
|
|
else byDocument.set(proposal.targetRecordId, [proposal])
|
|
}
|
|
return byDocument
|
|
}
|
|
|
|
/** A stable key for the line at a position of a document: what makes an extraction safe to run twice. */
|
|
export const lineKeyOf = (documentId: string, page: number, position: number): Promise<string> =>
|
|
sha256Hex(new TextEncoder().encode(`documents.line:${documentId}:${page}:${position}`))
|
|
|
|
/** The review screen's view of a proposal. */
|
|
export function lineViewOf(proposal: ProposalView): LineView {
|
|
const change = lineChangeSchema.parse(proposal.change)
|
|
return {
|
|
lineId: proposal.proposalId,
|
|
expectedVersion: proposal.version,
|
|
status: proposal.status,
|
|
label: change.label,
|
|
value: change.value,
|
|
page: change.page,
|
|
quote: change.quote,
|
|
box: proposal.evidence[0]?.box ?? null,
|
|
confidence: change.confidence,
|
|
verification: change.verification,
|
|
note: change.note ?? null,
|
|
blockId: proposal.evidence[0]?.blockId ?? null,
|
|
edited: change.edited,
|
|
model: proposal.model,
|
|
promptVersion: proposal.promptVersion,
|
|
decidedAt: proposal.decidedAt,
|
|
}
|
|
}
|
|
|
|
/** The lines a reviewer should see, in reading order, not creation order: the lines of one extraction are
|
|
* created within a millisecond of each other and their ids do not order them. */
|
|
export function visibleLines(proposals: readonly ProposalView[]): LineView[] {
|
|
return [...proposals]
|
|
.sort((a, b) => lineChangeSchema.parse(a.change).position - lineChangeSchema.parse(b.change).position)
|
|
.map(lineViewOf)
|
|
}
|