feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
@@ -28,128 +28,130 @@ function countGifFrameBlocks(buffer) {
|
||||
return count
|
||||
}
|
||||
|
||||
function readAssetFrames(assetPath, options = {}) {
|
||||
const metadata = execFileSync('identify', ['-format', '%w %h %n\n', assetPath], {
|
||||
encoding: 'utf8'
|
||||
}).trim().split('\n')[0].split(/\s+/).map(Number)
|
||||
const sourceWidth = metadata[0]
|
||||
const sourceHeight = metadata[1]
|
||||
const frameCount = metadata[2] || 1
|
||||
const width = options.width || sourceWidth
|
||||
const height = options.height || sourceHeight
|
||||
const convertArgs = [assetPath, '-coalesce']
|
||||
if (options.width || options.height) {
|
||||
convertArgs.push('-resize', `${width}x${height}!`)
|
||||
}
|
||||
convertArgs.push('-alpha', 'off', '-depth', '8', 'rgb:-')
|
||||
const pixels = execFileSync('convert', convertArgs, {
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
})
|
||||
const frameSize = width * height * 3
|
||||
return {
|
||||
frameCount: Math.min(frameCount, Math.floor(pixels.length / frameSize)),
|
||||
frameSize,
|
||||
height,
|
||||
pixels,
|
||||
width
|
||||
}
|
||||
}
|
||||
|
||||
function measureGifMotion(assetPath) {
|
||||
const script = `
|
||||
from PIL import Image, ImageSequence
|
||||
import json
|
||||
import sys
|
||||
|
||||
image = Image.open(sys.argv[1])
|
||||
frames = [frame.convert("RGB").resize((64, 64)) for frame in ImageSequence.Iterator(image)]
|
||||
|
||||
def delta(left, right):
|
||||
left_pixels = left.load()
|
||||
right_pixels = right.load()
|
||||
total = 0
|
||||
for y in range(64):
|
||||
for x in range(64):
|
||||
a = left_pixels[x, y]
|
||||
b = right_pixels[x, y]
|
||||
total += abs(a[0] - b[0]) + abs(a[1] - b[1]) + abs(a[2] - b[2])
|
||||
return total / (64 * 64 * 3)
|
||||
|
||||
adjacent = [delta(frames[index], frames[index + 1]) for index in range(len(frames) - 1)]
|
||||
adjacent_sorted = sorted(adjacent)
|
||||
median = adjacent_sorted[len(adjacent_sorted) // 2]
|
||||
print(json.dumps({
|
||||
"medianAdjacentDelta": median,
|
||||
"seamDelta": delta(frames[-1], frames[0])
|
||||
}))
|
||||
`
|
||||
return JSON.parse(execFileSync('python3', ['-', assetPath], {
|
||||
encoding: 'utf8',
|
||||
input: script
|
||||
}).trim())
|
||||
const { frameCount, frameSize, pixels } = readAssetFrames(assetPath, { width: 64, height: 64 })
|
||||
const delta = (leftFrame, rightFrame) => {
|
||||
const leftOffset = leftFrame * frameSize
|
||||
const rightOffset = rightFrame * frameSize
|
||||
let total = 0
|
||||
for (let index = 0; index < frameSize; index += 1) {
|
||||
total += Math.abs(pixels[leftOffset + index] - pixels[rightOffset + index])
|
||||
}
|
||||
return total / frameSize
|
||||
}
|
||||
const adjacent = Array.from(
|
||||
{ length: Math.max(0, frameCount - 1) },
|
||||
(_, index) => delta(index, index + 1)
|
||||
).sort((left, right) => left - right)
|
||||
return {
|
||||
medianAdjacentDelta: adjacent[Math.floor(adjacent.length / 2)] || 0,
|
||||
seamDelta: delta(frameCount - 1, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function measureGifDuration(assetPath) {
|
||||
const script = `
|
||||
from PIL import Image
|
||||
import sys
|
||||
|
||||
image = Image.open(sys.argv[1])
|
||||
total = 0
|
||||
for index in range(getattr(image, "n_frames", 1)):
|
||||
image.seek(index)
|
||||
total += image.info.get("duration", 0)
|
||||
print(total)
|
||||
`
|
||||
return Number(execFileSync('python3', ['-', assetPath], {
|
||||
encoding: 'utf8',
|
||||
input: script
|
||||
}).trim())
|
||||
const delays = execFileSync('identify', ['-format', '%T\n', assetPath], {
|
||||
encoding: 'utf8'
|
||||
}).trim().split('\n').map(Number)
|
||||
return delays.reduce((total, delay) => total + delay * 10, 0)
|
||||
}
|
||||
|
||||
function measureOrbAssetPresentation(assetPath) {
|
||||
const script = `
|
||||
from PIL import Image
|
||||
import json
|
||||
import sys
|
||||
const { frameCount, frameSize, height, pixels, width } = readAssetFrames(assetPath)
|
||||
let minimumCornerLuma = 255
|
||||
let maximumCornerLuma = 0
|
||||
let minimumBackgroundSimilarityRatio = 1
|
||||
let minimumForegroundWidthRatio = 1
|
||||
let minimumForegroundHeightRatio = 1
|
||||
|
||||
image = Image.open(sys.argv[1])
|
||||
frame_count = getattr(image, "n_frames", 1)
|
||||
width, height = image.size
|
||||
minimum_corner_luma = 255
|
||||
maximum_corner_luma = 0
|
||||
minimum_background_similarity_ratio = 1
|
||||
minimum_foreground_width_ratio = 1
|
||||
minimum_foreground_height_ratio = 1
|
||||
|
||||
for index in range(frame_count):
|
||||
if frame_count > 1:
|
||||
image.seek(index)
|
||||
rgb = image.convert("RGB")
|
||||
corners = [
|
||||
rgb.getpixel((0, 0)),
|
||||
rgb.getpixel((width - 1, 0)),
|
||||
rgb.getpixel((0, height - 1)),
|
||||
rgb.getpixel((width - 1, height - 1)),
|
||||
for (let frame = 0; frame < frameCount; frame += 1) {
|
||||
const frameOffset = frame * frameSize
|
||||
const pixelAt = (x, y) => {
|
||||
const offset = frameOffset + (y * width + x) * 3
|
||||
return [pixels[offset], pixels[offset + 1], pixels[offset + 2]]
|
||||
}
|
||||
const corners = [
|
||||
pixelAt(0, 0),
|
||||
pixelAt(width - 1, 0),
|
||||
pixelAt(0, height - 1),
|
||||
pixelAt(width - 1, height - 1)
|
||||
]
|
||||
corner_lumas = [sum(pixel) / 3 for pixel in corners]
|
||||
minimum_corner_luma = min(minimum_corner_luma, min(corner_lumas))
|
||||
maximum_corner_luma = max(maximum_corner_luma, max(corner_lumas))
|
||||
background = tuple(round(sum(pixel[channel] for pixel in corners) / len(corners)) for channel in range(3))
|
||||
foreground_mask = Image.new("L", (width, height), 0)
|
||||
foreground_pixels = foreground_mask.load()
|
||||
background_similarity = 0
|
||||
rgb_pixels = rgb.load()
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
pixel = rgb_pixels[x, y]
|
||||
diff = sum(abs(pixel[channel] - background[channel]) for channel in range(3))
|
||||
if diff > 22:
|
||||
foreground_pixels[x, y] = 255
|
||||
if diff <= 12:
|
||||
background_similarity += 1
|
||||
foreground_box = foreground_mask.getbbox()
|
||||
if foreground_box:
|
||||
minimum_foreground_width_ratio = min(
|
||||
minimum_foreground_width_ratio,
|
||||
(foreground_box[2] - foreground_box[0]) / width
|
||||
)
|
||||
minimum_foreground_height_ratio = min(
|
||||
minimum_foreground_height_ratio,
|
||||
(foreground_box[3] - foreground_box[1]) / height
|
||||
)
|
||||
minimum_background_similarity_ratio = min(
|
||||
minimum_background_similarity_ratio,
|
||||
background_similarity / (width * height)
|
||||
)
|
||||
const cornerLumas = corners.map((pixel) => (pixel[0] + pixel[1] + pixel[2]) / 3)
|
||||
minimumCornerLuma = Math.min(minimumCornerLuma, ...cornerLumas)
|
||||
maximumCornerLuma = Math.max(maximumCornerLuma, ...cornerLumas)
|
||||
const background = [0, 1, 2].map((channel) => Math.round(
|
||||
corners.reduce((total, pixel) => total + pixel[channel], 0) / corners.length
|
||||
))
|
||||
let backgroundSimilarity = 0
|
||||
let minX = width
|
||||
let minY = height
|
||||
let maxX = -1
|
||||
let maxY = -1
|
||||
|
||||
print(json.dumps({
|
||||
"minimumCornerLuma": minimum_corner_luma,
|
||||
"maximumCornerLuma": maximum_corner_luma,
|
||||
"minimumBackgroundSimilarityRatio": minimum_background_similarity_ratio,
|
||||
"minimumForegroundWidthRatio": minimum_foreground_width_ratio,
|
||||
"minimumForegroundHeightRatio": minimum_foreground_height_ratio,
|
||||
"width": width,
|
||||
"height": height
|
||||
}))
|
||||
`
|
||||
return JSON.parse(execFileSync('python3', ['-', assetPath], {
|
||||
encoding: 'utf8',
|
||||
input: script
|
||||
}).trim())
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const pixel = pixelAt(x, y)
|
||||
const diff = pixel.reduce(
|
||||
(total, value, channel) => total + Math.abs(value - background[channel]),
|
||||
0
|
||||
)
|
||||
if (diff > 22) {
|
||||
minX = Math.min(minX, x)
|
||||
minY = Math.min(minY, y)
|
||||
maxX = Math.max(maxX, x)
|
||||
maxY = Math.max(maxY, y)
|
||||
}
|
||||
if (diff <= 12) {
|
||||
backgroundSimilarity += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxX >= minX && maxY >= minY) {
|
||||
minimumForegroundWidthRatio = Math.min(minimumForegroundWidthRatio, (maxX - minX + 1) / width)
|
||||
minimumForegroundHeightRatio = Math.min(minimumForegroundHeightRatio, (maxY - minY + 1) / height)
|
||||
}
|
||||
minimumBackgroundSimilarityRatio = Math.min(
|
||||
minimumBackgroundSimilarityRatio,
|
||||
backgroundSimilarity / (width * height)
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
minimumCornerLuma,
|
||||
maximumCornerLuma,
|
||||
minimumBackgroundSimilarityRatio,
|
||||
minimumForegroundWidthRatio,
|
||||
minimumForegroundHeightRatio,
|
||||
width,
|
||||
height
|
||||
}
|
||||
}
|
||||
|
||||
const appShell = readSource('../src/views/AppShellRouteView.vue')
|
||||
@@ -230,7 +232,7 @@ test('AI mode screen follows the approved reference structure', () => {
|
||||
assert.match(aiModeSurface, /费用测算中,请稍等/)
|
||||
assert.match(aiModeSurface, /rows="3"/)
|
||||
assert.match(aiModeSurface, /workbench-ai-composer-toolbar/)
|
||||
assert.match(aiModeSurface, /<article v-for="file in runtime\.selectedFileCards"[\s\S]*class="workbench-ai-file-card"/)
|
||||
assert.match(aiModeSurface, /<article[\s\S]{0,140}v-for="file in runtime\.selectedFileCards"[\s\S]{0,140}class="workbench-ai-file-card"/)
|
||||
assert.match(aiModeSurface, /class="workbench-ai-file-card__ocr"/)
|
||||
assert.match(aiModeSurface, /file\.ocrState\?\.label/)
|
||||
assert.match(aiModeSurface, /mdi mdi-text-recognition/)
|
||||
@@ -239,7 +241,7 @@ test('AI mode screen follows the approved reference structure', () => {
|
||||
assert.match(aiModeSurface, /:aria-label="`移除附件 \$\{file\.name\}`"/)
|
||||
assert.match(aiModeSurface, /function removeAiModeFile\(fileKey\)/)
|
||||
assert.match(aiModeSurface, /const selectedFileCards = computed/)
|
||||
assert.match(aiModeSurface, /resolveAiComposerFileType\(file\)/)
|
||||
assert.match(aiModeSurface, /resolveAiComposerFileType\(file, previewAsset\)/)
|
||||
assert.match(aiModeSurface, /AI_COMPOSER_FILE_TYPE_META = \{[\s\S]*pdf:\s*\{ label:\s*'PDF'/)
|
||||
assert.match(aiModeSurface, /buildFileIdentity,[\s\S]*collectReceiptFiles[\s\S]*travelReimbursementAttachmentModel\.js/)
|
||||
assert.match(aiModeSurface, /MAX_ATTACHMENTS,[\s\S]*mergeFilesWithLimit[\s\S]*travelReimbursementAttachmentModel\.js/)
|
||||
@@ -345,7 +347,8 @@ test('AI mode screen follows the approved reference structure', () => {
|
||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-query-summary\)/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-query-summary__scope\)/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card-list\) \{[\s\S]*gap:\s*16px;/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\) \{[\s\S]*url\("\.\.\/\.\.\/ai-document-card-bg\.png"\);/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\) \{[\s\S]*background-color:\s*#ffffff;/)
|
||||
assert.doesNotMatch(aiModeStyles, /ai-document-card-bg\.png/)
|
||||
assert.doesNotMatch(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\)::before/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__head\) \{[\s\S]*background: var\(--ai-document-card-head-bg\);/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\.is-success \.ai-document-card__head\)/)
|
||||
@@ -422,7 +425,8 @@ test('AI mode screen follows the approved reference structure', () => {
|
||||
assert.match(aiModeStyles, /\.workbench-ai-mode\s*\{[\s\S]*min-height:\s*100%;[\s\S]*background:/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-mode\.has-conversation\s*\{[\s\S]*place-items:\s*stretch;[\s\S]*padding:\s*0;/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-composer\s*\{[\s\S]*border-radius:\s*20px;[\s\S]*box-shadow:/)
|
||||
assert.match(fileStripRule, /flex-wrap:\s*wrap;/)
|
||||
assert.match(fileStripRule, /flex-wrap:\s*nowrap;/)
|
||||
assert.match(fileStripRule, /overflow-x:\s*auto;/)
|
||||
assert.match(fileStripRule, /justify-content:\s*flex-start;/)
|
||||
assert.match(fileCardRule, /grid-template-columns:\s*48px minmax\(0,\s*1fr\) 30px;/)
|
||||
assert.match(fileCardRule, /border-radius:\s*16px;/)
|
||||
@@ -579,7 +583,7 @@ test('AI mode normal assistant requests include OCR context for uploaded receipt
|
||||
assert.match(aiModeSurface, /function buildAiModeReceiptContextCacheKey\(ocrFiles = \[\]\)/)
|
||||
assert.match(aiModeSurface, /applyAiModeReceiptRecognitionResult\(ocrFiles, context\)/)
|
||||
assert.match(aiModeSurface, /buildFileIdentity\(file\)/)
|
||||
assert.match(aiModeSurface, /watch\(selectedFiles, \(files\) => \{[\s\S]*attachmentFlow\.primeAiModeReceiptContext\(files\)/)
|
||||
assert.match(aiModeSurface, /watch\(selectedFiles, \(files(?:, previousFiles = \[\])?\) => \{[\s\S]*attachmentFlow\.primeAiModeReceiptContext\(files\)/)
|
||||
assert.match(aiModeSurface, /async function collectAiModeReceiptContext\(files = \[\]\)/)
|
||||
assert.match(aiModeSurface, /cached\?\.status === 'pending'[\s\S]*await cached\.promise/)
|
||||
assert.match(aiModeSurface, /collectReceiptFiles\(\{[\s\S]*files:\s*ocrFiles,[\s\S]*recognizeOcrFiles[\s\S]*\}\)/)
|
||||
|
||||
Reference in New Issue
Block a user