Files
openmaic/OpenMAIC/lib/document/bundle.ts
2026-08-16 14:58:47 +08:00

257 lines
8.5 KiB
TypeScript

import { MAX_PDF_CONTENT_CHARS, MAX_VISION_IMAGES } from '@/lib/constants/generation';
import type { PdfImage, SessionDocumentSource } from '@/lib/types/generation';
export const MAX_DOCUMENT_BUNDLE_FILES = 5;
export const MAX_DOCUMENT_BUNDLE_TOTAL_SIZE_BYTES = 150 * 1024 * 1024;
const BASE_BUDGET_PER_DOCUMENT = 1500;
const RESERVED_BUDGET_RATIO = 0.4;
const SECTION_SEPARATOR = '\n\n---\n\n';
export interface ParsedDocumentImage extends Omit<PdfImage, 'storageId' | 'visionPriority'> {
src: string;
}
export interface ParsedDocumentPart {
source: Omit<SessionDocumentSource, 'storageKey'>;
text: string;
rawTextLength: number;
pageCount?: number;
images: ParsedDocumentImage[];
}
export interface DocumentBundleResult {
text: string;
images: Array<ParsedDocumentImage & { visionPriority: number }>;
textContentBudget: number;
totalRawTextLength: number;
totalImageCount: number;
visionImageCount: number;
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function replaceImageIds(text: string, idMap: ReadonlyMap<string, string>): string {
let nextText = text;
for (const [fromId, toId] of idMap.entries()) {
nextText = nextText.replace(
new RegExp(`(?<![\\w-])${escapeRegex(fromId)}(?![\\w-])`, 'g'),
toId,
);
}
return nextText;
}
function truncateTextAtBoundary(text: string, maxChars: number): string {
if (maxChars <= 0) return '';
if (text.length <= maxChars) return text;
const sliced = Array.from(text).slice(0, maxChars).join('');
let cut = sliced.length;
while (cut > 0 && /[\p{L}\p{N}_-]/u.test(sliced[cut - 1])) {
cut -= 1;
}
return cut > 0 ? sliced.slice(0, cut) : sliced;
}
function buildSectionHeader(part: ParsedDocumentPart, index: number): string {
const lines = [
`## Source Document ${index + 1}: ${part.source.name}`,
`- Order: ${part.source.order}`,
part.source.mimeType ? `- MIME type: ${part.source.mimeType}` : undefined,
typeof part.pageCount === 'number' ? `- Pages: ${part.pageCount}` : undefined,
'',
].filter((line): line is string => typeof line === 'string');
return `${lines.join('\n')}\n`;
}
export function allocateDocumentTextBudgets(lengths: number[], maxChars: number): number[] {
if (lengths.length === 0 || maxChars <= 0) return lengths.map(() => 0);
const reserved = Math.min(
lengths.length * BASE_BUDGET_PER_DOCUMENT,
Math.floor(maxChars * RESERVED_BUDGET_RATIO),
);
const basePerDocument = Math.floor(reserved / lengths.length);
const budgets = lengths.map((length) => Math.min(length, basePerDocument));
let remainingBudget = maxChars - budgets.reduce((sum, value) => sum + value, 0);
const unmet = lengths
.map((length, index) => ({ index, remaining: Math.max(0, length - budgets[index]) }))
.filter((entry) => entry.remaining > 0);
while (remainingBudget > 0 && unmet.length > 0) {
const totalRemaining = unmet.reduce((sum, entry) => sum + entry.remaining, 0);
if (totalRemaining === 0) break;
let distributed = 0;
for (const entry of unmet) {
if (remainingBudget === 0) break;
const share = Math.floor((remainingBudget * entry.remaining) / totalRemaining);
const allocation = Math.min(entry.remaining, share > 0 ? share : 1, remainingBudget);
budgets[entry.index] += allocation;
entry.remaining -= allocation;
remainingBudget -= allocation;
distributed += allocation;
}
if (distributed === 0) break;
for (let i = unmet.length - 1; i >= 0; i -= 1) {
if (unmet[i].remaining === 0) unmet.splice(i, 1);
}
}
return budgets;
}
function compareImagesForVision(a: ParsedDocumentImage, b: ParsedDocumentImage): number {
const aHasDescription = Number(Boolean(a.description));
const bHasDescription = Number(Boolean(b.description));
if (aHasDescription !== bHasDescription) return bHasDescription - aHasDescription;
const sourceDiff = (a.sourceDocumentOrder ?? 0) - (b.sourceDocumentOrder ?? 0);
if (sourceDiff !== 0) return sourceDiff;
const pageDiff = a.pageNumber - b.pageNumber;
if (pageDiff !== 0) return pageDiff;
const aArea = (a.width ?? 0) * (a.height ?? 0);
const bArea = (b.width ?? 0) * (b.height ?? 0);
return bArea - aArea;
}
function pickVisionImageIds(images: ParsedDocumentImage[], maxImages: number): string[] {
if (images.length === 0 || maxImages <= 0) return [];
const grouped = new Map<string, ParsedDocumentImage[]>();
for (const image of images) {
const key = image.sourceDocumentId || 'unknown';
const bucket = grouped.get(key) ?? [];
bucket.push(image);
grouped.set(key, bucket);
}
const groups = Array.from(grouped.entries())
.sort((a, b) => (a[1][0]?.sourceDocumentOrder ?? 0) - (b[1][0]?.sourceDocumentOrder ?? 0))
.map(([, group]) => [...group].sort(compareImagesForVision));
const selectedIds: string[] = [];
for (const group of groups) {
if (selectedIds.length >= maxImages) break;
const image = group.shift();
if (image) selectedIds.push(image.id);
}
while (selectedIds.length < maxImages) {
let added = false;
for (const group of groups) {
if (selectedIds.length >= maxImages) break;
const image = group.shift();
if (image) {
selectedIds.push(image.id);
added = true;
}
}
if (!added) break;
}
return selectedIds;
}
export function sortDocumentImagesForVision<
T extends Pick<PdfImage, 'visionPriority' | 'pageNumber' | 'id'>,
>(images: T[]): T[] {
return [...images].sort((a, b) => {
const priorityDiff = (b.visionPriority ?? 0) - (a.visionPriority ?? 0);
if (priorityDiff !== 0) return priorityDiff;
if (a.pageNumber !== b.pageNumber) return a.pageNumber - b.pageNumber;
const aNumericId = Number(a.id.match(/^img_(\d+)$/)?.[1] ?? Number.NaN);
const bNumericId = Number(b.id.match(/^img_(\d+)$/)?.[1] ?? Number.NaN);
if (Number.isFinite(aNumericId) && Number.isFinite(bNumericId)) {
return aNumericId - bNumericId;
}
return a.id.localeCompare(b.id);
});
}
export function buildDocumentBundle(
parts: ParsedDocumentPart[],
options?: { maxChars?: number; maxVisionImages?: number },
): DocumentBundleResult {
const maxChars = options?.maxChars ?? MAX_PDF_CONTENT_CHARS;
const maxVisionImages = options?.maxVisionImages ?? MAX_VISION_IMAGES;
const orderedParts = [...parts].sort((a, b) => a.source.order - b.source.order);
const stableParts = orderedParts.map((part) => {
const stableIdMap = new Map<string, string>();
const stableImages = part.images.map((image, index) => {
const stableId = `doc_${part.source.order}_img_${index + 1}`;
stableIdMap.set(image.id, stableId);
return {
...image,
id: stableId,
originalId: image.originalId ?? image.id,
sourceDocumentId: part.source.id,
sourceDocumentName: part.source.name,
sourceDocumentOrder: part.source.order,
};
});
return {
...part,
text: replaceImageIds(part.text, stableIdMap),
images: stableImages,
};
});
const headers = stableParts.map(buildSectionHeader);
const framingChars =
headers.reduce((sum, header) => sum + header.length, 0) +
Math.max(0, stableParts.length - 1) * SECTION_SEPARATOR.length;
const textContentBudget = Math.max(0, maxChars - framingChars);
const textBudgets = allocateDocumentTextBudgets(
stableParts.map((part) => part.text.length),
textContentBudget,
);
const flattenedImages = stableParts.flatMap((part) => part.images);
const finalIdMap = new Map<string, string>();
flattenedImages.forEach((image, index) => finalIdMap.set(image.id, `img_${index + 1}`));
const text = stableParts
.map((part, index) => {
const boundedText = replaceImageIds(
truncateTextAtBoundary(part.text, textBudgets[index]),
finalIdMap,
);
return `${headers[index]}${boundedText}`;
})
.join(SECTION_SEPARATOR);
const images = flattenedImages.map((image) => ({
...image,
id: finalIdMap.get(image.id) ?? image.id,
}));
const selectedVisionIds = pickVisionImageIds(images, maxVisionImages);
const visionPriority = new Map(
selectedVisionIds.map((id, index) => [id, selectedVisionIds.length - index]),
);
return {
text,
images: images.map((image) => ({
...image,
visionPriority: visionPriority.get(image.id) ?? 0,
})),
textContentBudget,
totalRawTextLength: stableParts.reduce((sum, part) => sum + part.rawTextLength, 0),
totalImageCount: images.length,
visionImageCount: selectedVisionIds.length,
};
}