Files
zyt/server/tests/DifyOcrBudgetTest.php
T
2026-10-23 13:57:11 +08:00

184 lines
12 KiB
PHP

<?php
declare(strict_types=1);
// Real uploader + real model orchestration, fake OCR/cURL/clock. No live I/O.
namespace think\facade {
class Log { public static function warning(string $message): void {} }
}
namespace app\common\service {
require __DIR__ . '/fixtures/CancellableCurlMultiStub.php';
function config(string $name): array { return $GLOBALS['ocrBudgetConfig']; }
function microtime(bool $asFloat = false): float { return $GLOBALS['ocrBudgetClock']; }
class ClinicalKnowledgeReference {
public static function ancientBookSources(string $query): array { return []; }
public static function augmentQueryWithAncientBookSources(string $query, array $sources): string { return $query; }
}
class ClinicalImageTextService {
public static function read(array $images, string $root, float $deadline, ?callable $abort, array $settings): array {
foreach ($images as $image) if (!is_file($image['path'])) throw new \RuntimeException('expected authorized temporary image');
$GLOBALS['ocrBudgetReads'][] = ['start' => microtime(true), 'deadline' => $deadline,
'indices' => array_column($images, 'index')];
$scenario = $GLOBALS['ocrBudgetScenario'];
if ($scenario === 'exhaust') $GLOBALS['ocrBudgetClock'] = $deadline + 0.8;
elseif ($scenario === 'cancel') { $GLOBALS['ocrBudgetClock'] += 0.1; $GLOBALS['ocrBudgetCancelled'] = true; }
else $GLOBALS['ocrBudgetClock'] += min(1.25, max(0.0, $deadline - microtime(true)));
if ($scenario === 'throw') throw new \RuntimeException('PRIVATE_SYNTHETIC_OCR_FAILURE');
return ['status' => 'unavailable', 'images' => []];
}
}
function curl_init(): \stdClass { return (object) ['errno' => 0, 'code' => 200]; }
function curl_setopt_array(\stdClass $handle, array $options): bool { $handle->options = $options; return true; }
function curl_exec(\stdClass $handle): string|bool {
$options = $handle->options;
if (is_array($options[CURLOPT_POSTFIELDS])) {
if (!str_ends_with($options[CURLOPT_URL], '/files/upload')) throw new \RuntimeException('only synthetic upload endpoint');
$GLOBALS['ocrBudgetUploads']++;
$GLOBALS['ocrBudgetClock'] += 0.2;
$body = json_encode(['id' => 'synthetic-upload-' . $GLOBALS['ocrBudgetUploads']]);
($options[CURLOPT_WRITEFUNCTION])($handle, $body);
return $body;
}
$payload = json_decode($options[CURLOPT_POSTFIELDS], true);
$GLOBALS['ocrBudgetModels'][] = ['payload' => $payload, 'at' => microtime(true),
'timeout_ms' => $options[CURLOPT_TIMEOUT_MS] ?? $options[CURLOPT_TIMEOUT] * 1000];
if ($GLOBALS['ocrBudgetCancelled']) throw new \RuntimeException('model must not start after cancellation');
$GLOBALS['ocrBudgetClock'] += 0.1;
if (isset($options[CURLOPT_HEADERFUNCTION])) ($options[CURLOPT_HEADERFUNCTION])($handle, "HTTP/1.1 200 Synthetic\r\n");
if (($payload['response_mode'] ?? '') === 'streaming') {
($options[CURLOPT_WRITEFUNCTION])($handle, "data: {\"event\":\"message\",\"answer\":\"SYNTHETIC_VISUAL_ANSWER\"}\n\ndata: {\"event\":\"message_end\"}\n\n");
return true;
}
return '{"answer":"SYNTHETIC_VISUAL_ANSWER"}';
}
function curl_errno(\stdClass $handle): int { return $handle->errno; }
function curl_getinfo(\stdClass $handle, int $option): int { return $handle->code; }
function curl_close(\stdClass $handle): void {}
}
namespace {
require dirname(__DIR__) . '/app/common/service/DifyRequiredAttachmentUpload.php';
require dirname(__DIR__) . '/app/common/service/DifyChatService.php';
use app\common\service\DifyChatService;
use app\common\service\DifyRequiredAttachmentUpload;
function budgetExpect(bool $condition, string $message): void { if (!$condition) throw new RuntimeException($message); }
function budgetReset(string $scenario): void {
$GLOBALS['ocrBudgetScenario'] = $scenario; $GLOBALS['ocrBudgetClock'] = 1000.0;
$GLOBALS['ocrBudgetReads'] = []; $GLOBALS['ocrBudgetModels'] = [];
$GLOBALS['ocrBudgetUploads'] = 0; $GLOBALS['ocrBudgetCancelled'] = false;
}
$root = sys_get_temp_dir() . '/ocr-budget-test-' . bin2hex(random_bytes(8));
$public = $root . '/public'; $temporary = $root . '/private';
mkdir($public . '/uploads', 0700, true); mkdir($temporary, 0700);
$source = $public . '/uploads/synthetic.png';
file_put_contents($source, base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII='));
$files = [['type' => 'image', 'url' => 'https://app.synthetic.test/uploads/synthetic.png']];
$uploadOptions = ['public_root' => $public, 'temp_root' => $temporary,
'local_origin' => 'https://app.synthetic.test',
'ocr' => ['enabled' => true, 'timeout_seconds' => 60]];
$ocrBudgetConfig = ['enable' => true, 'base_url' => 'https://model.synthetic.test/v1/chat-messages',
'timeout' => 20, 'required_attachment_transport' => 'upload',
'required_attachment_upload' => $uploadOptions, 'required_attachment_ocr' => $uploadOptions['ocr'],
'models' => ['qwen' => ['name' => 'synthetic', 'api_key' => 'SYNTHETIC_ONLY_KEY', 'max_files' => 10]]];
$cases = 0;
try {
foreach ([false, true] as $streaming) {
budgetReset('exhaust');
$result = $streaming
? DifyChatService::streamChat('qwen', [], 'SYNTHETIC_ORIGINAL_QUESTION', 'synthetic-user', static fn (): bool => true,
null, $files, ['required_files' => true])
: DifyChatService::chat('qwen', [], 'SYNTHETIC_ORIGINAL_QUESTION', 'synthetic-user', $files, ['required_files' => true]);
budgetExpect(!empty($result['ok']) && count($ocrBudgetReads) === 1 && count($ocrBudgetModels) === 1,
'OCR reaching its child deadline plus cleanup must still permit the original visual model request');
budgetExpect(1020.0 - $ocrBudgetModels[0]['at'] >= 8.0 - 0.00001 && $ocrBudgetModels[0]['timeout_ms'] >= 7999,
'reserve at least eight seconds after worst-case 0.8 second OCR cleanup');
budgetExpect(($ocrBudgetModels[0]['payload']['files'][0]['upload_file_id'] ?? '') === 'synthetic-upload-1'
&& str_contains($ocrBudgetModels[0]['payload']['query'], 'SYNTHETIC_ORIGINAL_QUESTION'), 'original uploaded image and original clinical task survive optional OCR timeout');
budgetExpect(($result['items'][0]['text_check_status'] ?? '') === 'unavailable', 'skipped/unfinished optional OCR remains explicit in receipt');
$cases++;
}
foreach ([1, 9] as $limit) {
budgetReset('exhaust'); $ocrBudgetConfig['timeout'] = $limit;
$result = DifyChatService::chat('qwen', [], 'SYNTHETIC_ORIGINAL_QUESTION', 'synthetic-user', $files, ['required_files' => true]);
budgetExpect(!empty($result['ok']) && $ocrBudgetReads === [] && count($ocrBudgetModels) === 1,
'insufficient optional OCR budget skips OCR while the original model can still start');
budgetExpect(($result['items'][0]['text_check_status'] ?? '') === 'unavailable', 'low-budget text-check status must not claim OCR processing');
$cases++;
}
$ocrBudgetConfig['timeout'] = 90;
budgetReset('fixed');
$context = (object) ['ocr_remaining_seconds' => 4.0];
$options = $uploadOptions + ['ocr_context' => $context];
foreach (range(1, 3) as $batch) {
$result = DifyRequiredAttachmentUpload::prepare($ocrBudgetConfig['base_url'], 'SYNTHETIC_ONLY_KEY', 'synthetic-user', $files, 90, null, $options);
budgetExpect(!empty($result['ok']) && count($result['files']) === 1, 'each uploaded batch remains usable with exhausted OCR allocation');
}
budgetExpect(count($ocrBudgetReads) === 2 && abs($context->ocr_remaining_seconds - 1.5) < 0.00001,
'actual OCR time is deducted across batches, and insufficient work plus cleanup balance does not restart OCR');
budgetExpect(abs($ocrBudgetReads[0]['deadline'] - $ocrBudgetReads[0]['start'] - 3.2) < 0.00001
&& abs($ocrBudgetReads[1]['deadline'] - $ocrBudgetReads[1]['start'] - 1.95) < 0.00001,
'each later batch receives only the shared balance after reserving cleanup');
$cases++;
budgetReset('exhaust');
$context = (object) ['ocr_remaining_seconds' => 3.0];
foreach ([1, 2] as $batch) {
$result = DifyRequiredAttachmentUpload::prepare($ocrBudgetConfig['base_url'], 'SYNTHETIC_ONLY_KEY', 'synthetic-user', $files, 90, null,
$uploadOptions + ['ocr_context' => $context]);
budgetExpect(!empty($result['ok']), 'shared OCR exhaustion never removes original images');
}
budgetExpect(count($ocrBudgetReads) === 1 && abs($context->ocr_remaining_seconds) < 0.00001
&& abs($ocrBudgetReads[0]['deadline'] - $ocrBudgetReads[0]['start'] - 2.2) < 0.00001,
'last OCR call plus worst-case cleanup stays within the cumulative shared allocation');
$cases++;
budgetReset('throw');
$context = (object) ['ocr_remaining_seconds' => 5.0];
$result = DifyRequiredAttachmentUpload::prepare($ocrBudgetConfig['base_url'], 'SYNTHETIC_ONLY_KEY', 'synthetic-user', $files, 90, null,
$uploadOptions + ['ocr_context' => $context]);
budgetExpect(!empty($result['ok']) && $result['image_text_check']['status'] === 'unavailable'
&& abs($context->ocr_remaining_seconds - 3.75) < 0.00001, 'failed OCR also spends its actual elapsed time without removing the original image');
$cases++;
budgetReset('fixed');
$local = $uploadOptions; $local['ocr']['timeout_seconds'] = 2.0;
foreach ([1, 2] as $batch) budgetExpect(!empty(DifyRequiredAttachmentUpload::prepare($ocrBudgetConfig['base_url'], 'SYNTHETIC_ONLY_KEY',
'synthetic-user', $files, 90, null, $local)['ok']), 'absence of a shared context uses local timeout');
budgetExpect(count($ocrBudgetReads) === 2 && abs($ocrBudgetReads[1]['deadline'] - $ocrBudgetReads[1]['start'] - 2.0) < 0.00001,
'legacy callers retain configured per-call optional timeout');
$cases++;
budgetReset('exhaust');
$context = (object) ['deadline' => 1012.0, 'ocr_remaining_seconds' => 30.0];
$result = DifyRequiredAttachmentUpload::prepare($ocrBudgetConfig['base_url'], 'SYNTHETIC_ONLY_KEY', 'synthetic-user', $files, 90, null,
$uploadOptions + ['ocr_context' => $context]);
budgetExpect(!empty($result['ok']) && 1012.0 - $ocrBudgetClock >= 8.0 - 0.00001,
'optional original absolute deadline clamps OCR even if integer upload timeout is larger');
$cases++;
budgetReset('cancel');
$result = DifyChatService::streamChat('qwen', [], 'SYNTHETIC_ORIGINAL_QUESTION', 'synthetic-user', static fn (): bool => true,
static fn (): bool => $GLOBALS['ocrBudgetCancelled'], $files, ['required_files' => true]);
budgetExpect(empty($result['ok']) && $result['error_code'] === 'AI_ASSISTANT_CANCELLED' && $ocrBudgetModels === [],
'cancellation during optional OCR stops the whole request, never falls through to model answering');
$cases++;
foreach ([0.0, 0.9, NAN] as $remaining) {
budgetReset('exhaust');
$context = (object) ['ocr_remaining_seconds' => $remaining];
$result = DifyRequiredAttachmentUpload::prepare($ocrBudgetConfig['base_url'], 'SYNTHETIC_ONLY_KEY', 'synthetic-user', $files, 90, null,
$uploadOptions + ['ocr_context' => $context]);
budgetExpect(!empty($result['ok']) && $ocrBudgetReads === [] && $result['image_text_check']['status'] === 'unavailable',
'empty/sub-second/invalid shared OCR budget never prevents the original uploaded image');
$cases++;
}
budgetExpect((glob($temporary . '/*') ?: []) === [], 'authorized request-private image copies are cleaned');
echo "Dify OCR optional budget: {$cases} offline scenarios OK; eight-second model reserve, cleanup reserve, shared elapsed accounting and cancellation\n";
} finally {
foreach (glob($temporary . '/*') ?: [] as $path) unlink($path);
unlink($source); rmdir($public . '/uploads'); rmdir($public); rmdir($temporary); rmdir($root);
}
}