185 lines
12 KiB
PHP
185 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_once dirname(__DIR__) . '/app/common/service/DifyReasoningTextFilter.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, 'reuse_uploaded_files' => false,
|
|
'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);
|
|
}
|
|
}
|