0 && $budget <= 1000, 'terminal lookup preserves a finite IO budget'); return ['ok' => true, 'workflow_run_id' => $identity['workflow_run_id'], 'status' => $GLOBALS['liveTerminalStatus'] ?? 'succeeded']; } } } namespace { require __DIR__ . '/LiveConsultationAuthorizationTest.php'; use app\adminapi\logic\tcm\DiagnosisAiLogic as Logic; use app\adminapi\logic\tcm\PatientAiReportLogic; use app\adminapi\service\LiveConsultationContext as Context; use app\adminapi\service\LiveConsultationResult as Result; use app\adminapi\service\AssistantRequestControl as Control; use app\adminapi\service\AssistantSseProtocol; use app\adminapi\service\AssistantSseTiming; use app\common\model\tcm\CallRecord; use app\common\service\DifyChatService; use think\facade\Db; $GLOBALS['liveClockAdvanceNs'] = 0; $visible = []; $sink = static function (string $delta) use (&$visible): bool { $visible[] = $delta; return true; }; $received = []; $modelJson = Context::encode($base); DifyChatService::$streamHandler = static function ($profile, $inputs, $query, $user, $delta, $abort, $files, $transport) use (&$received, &$modelJson, &$visible): array { $received = compact('profile', 'inputs', 'query', 'user', 'files', 'transport'); foreach (mb_str_split($modelJson, 5, 'UTF-8') as $chunk) { liveExpect($delta($chunk) && !$abort(), 'valid JSON fragments stay buffered'); liveExpect($visible === [], 'no raw fragment reaches the clinical delta callback'); } return ['ok' => true, 'content' => $modelJson]; }; $prepared = Logic::prepareAssistant(7, 'live_consultation', '合成附加问题', 8, [], null, $options); liveExpect($prepared['live_context']['segment_count'] === 145, 'prepared full current source is not a UI window'); liveExpect($prepared['known_chart'] === [] && $prepared['analysis_scope'] === 'current_call_only' && !str_contains(Context::encode($prepared['inputs']) . $prepared['query'], '女 · 52岁'), 'live query and workflow variables carry no chart demographics'); liveExpect(str_contains($prepared['query'], '长段结尾标记') && !str_contains($prepared['inputs']['herbs_json'], '长段结尾标记') && str_contains($prepared['inputs']['herbs_json'], $prepared['live_context']['revision']), 'query preserves >500 char current source once; inputs retain revision manifest'); liveExpect(!str_contains($prepared['query'], '2020年授权病历') && !str_contains($prepared['query'], 'BEGIN_UNTRUSTED_AUTHORIZED_CASE_BACKGROUND') && !str_contains($prepared['query'], 'BEGIN_UNTRUSTED_KNOWN_CHART_JSON') && $prepared['source_diagnosis_ids'] === [] && $prepared['files'] === [] && $prepared['source_summary']['historical_context_included'] === false, 'dialogue-only scope omits historical chart blocks and all attachments'); $before = DifyChatService::$calls; $result = Logic::streamPreparedAssistant($prepared, $sink, null, null, ['knowledge_mode' => 'untrusted', 'reference_scope' => 'untrusted', 'response_contract' => 'untrusted']); liveExpect(is_array($result) && DifyChatService::$calls === $before + 1 && $visible === [], 'one model boundary call returns only a fully validated result'); liveExpect(array_diff_key($result['live_context'], ['sources' => true]) === array_diff_key($prepared['live_context'], ['sources' => true]) && $result['live_assistance']['summary'][0]['text'] === '患者原话:' . $texts[1], 'done binds complete source revision and quote role'); liveExpect(array_column($result['live_context']['sources'], 'id') === ['seg-1', 'seg-3'] && $result['live_context']['segment_count'] === 145, 'response projects only cited raw sources while metadata still binds all 145 persisted segments'); liveExpect($result['answer_mode'] === 'live_assistance' && $result['knowledge_source'] === null && $result['ancient_book_sources'] === [] && str_contains($result['answer'], '截至当前'), 'no fabricated knowledge provenance or completeness claim'); liveExpect($received['transport']['knowledge_mode'] === 'clinical' && $received['transport']['reference_scope'] === 'consultation' && $received['transport']['required_files'] === false && $received['transport']['response_contract'] === 'live_consultation' && $received['transport']['analysis_scope'] === 'current_call_only' && $received['files'] === [], 'server owns live response contract, knowledge mode and dialogue-only transport'); liveExpect($received['transport']['request_deadline_ns'] <= $prepared['request_deadline_ns'] && $received['transport']['timeout_ms'] > 0 && $received['transport']['timeout_ms'] <= 5000, 'finite original deadline reaches transport'); $sse = new AssistantSseProtocol(); $sse->encode('start', ['task' => 'live_consultation']); $frame = $sse->encode('done', $result); preg_match('/^data: (.+)$/m', $frame, $match); $done = json_decode($match[1], true, 512, JSON_THROW_ON_ERROR); liveExpect($done['analysis_scope'] === 'current_call_only' && $done['live_assistance'] === $result['live_assistance'] && $done['live_context'] === $result['live_context'] && $sse->encode('delta', ['text' => 'late']) === null, 'SSE done preserves the full structured contract with one terminal event'); $oldRevision = $result['live_context']['revision']; Db::$segments[0]['text'] = '同一段已经修订。'; $revised = Logic::prepareAssistant(7, 'live_consultation', '', 8, [], null, $options); liveExpect($revised['live_context']['revision'] !== $oldRevision && $prepared['live_context']['revision'] === $oldRevision, 'same-count persisted edit changes the next snapshot without mutating in-flight evidence'); Db::$segments[0]['text'] = $texts[0]; // Completed and partial reviews share the same terminal JSON/citation gate. foreach (['completed', 'partial'] as $transcriptionStatus) { CallRecord::$records = [array_replace($record, ['transcription_status' => $transcriptionStatus])]; $reviewPrepared = Logic::prepareAssistant(7, 'live_review', '', 8, [], null, $options); $reviewData = $base; $reviewData['review'] = [['text' => '后续追问需保留时间范围', 'evidence_ids' => ['seg-0']]]; $modelJson = Context::encode($reviewData); $reviewResult = Logic::streamPreparedAssistant($reviewPrepared, $sink); liveExpect(is_array($reviewResult) && $reviewResult['live_context']['transcription_status'] === $transcriptionStatus && count($reviewResult['live_assistance']['review']) === 1, 'review accepts persisted final status and cited review'); liveExpect($received['transport']['response_contract'] === 'live_consultation' && $received['transport']['analysis_scope'] === 'authorized_case' && $received['transport']['required_files'] === true && $reviewResult['analysis_scope'] === 'authorized_case' && $reviewPrepared['known_chart']['demographics'] === '女 · 52岁' && str_contains($reviewPrepared['query'], '2020年授权病历') && str_contains($reviewPrepared['query'], 'authorized_case_background_not_current_call_facts') && str_contains($reviewPrepared['query'], '重复存档') && $reviewPrepared['source_summary']['historical_context_included'] === true, 'review retains full chart/background under the existing live response contract'); liveExpect($transcriptionStatus !== 'partial' || str_contains($reviewResult['answer'], '部分保存'), 'partial warning survives model delivery'); } CallRecord::$records = [$record]; // Fail closed after a successful upstream terminal; never retry invalid clinical JSON. $invalidResults = ['{"summary":[]}', 'not-json']; $invalidData = $base; $invalidData['summary'][0]['evidence_ids'] = ['foreign-session-id']; $invalidResults[] = Context::encode($invalidData); $invalidData = $base; $invalidData['checks'][0]['text'] = '核实后建议立即服用某药'; $invalidResults[] = Context::encode($invalidData); foreach ($invalidResults as $invalidJson) { DifyChatService::$streamHandler = static function ($p, $i, $q, $u, $delta) use ($invalidJson): array { $delta($invalidJson); return ['ok' => true, 'content' => $invalidJson]; }; $before = DifyChatService::$calls; liveExpect(Logic::streamPreparedAssistant($prepared, $sink) === null && Logic::getAssistantErrorCode() === 'LIVE_RESULT_INVALID' && DifyChatService::$calls === $before + 1 && $visible === [], 'invalid JSON/claim/citation rejected without delivery or retry'); } DifyChatService::$streamHandler = static function (): array { return ['ok' => false, 'error_code' => 'INCOMPLETE_RESPONSE', 'error' => 'synthetic incomplete']; }; $before = DifyChatService::$calls; liveExpect(Logic::streamPreparedAssistant($prepared, $sink) === null && DifyChatService::$calls === $before + 1 && Logic::getAssistantErrorCode() === 'INCOMPLETE_RESPONSE', 'incomplete terminal has no automatic duplicate generation'); // Admission, cancellation, and preparation time all precede the real transport boundary. $before = DifyChatService::$calls; liveExpect(Logic::streamPreparedAssistant($prepared, $sink, static fn (): bool => true) === null && Logic::getAssistantErrorCode() === 'AI_ASSISTANT_CANCELLED' && DifyChatService::$calls === $before, 'pre-cancel starts no model work'); liveExpect(Logic::streamPreparedAssistant($prepared, $sink, null, null, ['can_start_attempt' => static fn (): bool => false]) === null && Logic::getAssistantErrorCode() === 'UPSTREAM_CLEANUP_PENDING' && DifyChatService::$calls === $before, 'unconfirmed previous task blocks admission'); $timeoutCalls = 0; $timedOut = array_replace($prepared, ['request_deadline_ns' => hrtime(true) - 1]); liveExpect(Logic::streamPreparedAssistant($timedOut, $sink, null, null, ['on_timeout' => static function () use (&$timeoutCalls): void { $timeoutCalls++; }]) === null && Logic::getAssistantErrorCode() === 'UPSTREAM_TIMEOUT' && $timeoutCalls === 1 && DifyChatService::$calls === $before, 'expired deadline signals timeout before any upstream call'); CallRecord::$records = [array_replace($record, ['transcription_status' => 'completed'])]; PatientAiReportLogic::$onAggregate = static function (): void { $GLOBALS['liveClockAdvanceNs'] += 70000000; }; $budgetPrepared = Logic::prepareAssistant(7, 'live_review', '', 8, [], null, $options + ['timeout_ms' => 100]); liveExpect(is_array($budgetPrepared), 'preparation uses a single budget'); DifyChatService::$streamHandler = static function ($p, $i, $q, $u, $delta, $abort, $files, $transport) use ($base): array { liveExpect($transport['timeout_ms'] > 0 && $transport['timeout_ms'] <= 30, '70 ms preparation leaves at most 30 ms from the original 100 ms'); $GLOBALS['liveClockAdvanceNs'] += 100000000; return ['ok' => true, 'content' => Context::encode($base)]; }; liveExpect(Logic::streamPreparedAssistant($budgetPrepared, $sink) === null && Logic::getAssistantErrorCode() === 'UPSTREAM_TIMEOUT', 'late valid JSON is rejected after the shared deadline'); $GLOBALS['liveClockAdvanceNs'] = 0; PatientAiReportLogic::$onAggregate = static function (): void { $GLOBALS['liveClockAdvanceNs'] += 200000000; }; $before = DifyChatService::$calls; liveExpect(Logic::prepareAssistant(7, 'live_review', '', 8, [], null, $options + ['timeout_ms' => 100]) === null && Logic::getAssistantErrorCode() === 'UPSTREAM_TIMEOUT' && DifyChatService::$calls === $before, 'exhausted preparation never starts final generation'); $GLOBALS['liveClockAdvanceNs'] = 0; $cancelInHistory = false; PatientAiReportLogic::$onAggregate = static function () use (&$cancelInHistory): void { $cancelInHistory = true; }; liveExpect(Logic::prepareAssistant(7, 'live_review', '', 8, [], null, $options + ['should_abort' => static function () use (&$cancelInHistory): bool { return $cancelInHistory; }]) === null && Logic::getAssistantErrorCode() === 'AI_ASSISTANT_CANCELLED', 'cancel during authorized history prevents generation'); PatientAiReportLogic::$onAggregate = null; CallRecord::$records = [$record]; // Use the literal production lifecycle callbacks with real file-backed ownership and a mock producer. $controller = file_get_contents(dirname(__DIR__) . '/app/adminapi/controller/tcm/DiagnosisController.php'); $callbackLiterals = []; foreach (['on_submission', 'on_task', 'on_terminal', 'on_transfer_end', 'can_start_attempt', 'on_timeout', 'on_cancel_task', 'on_stop'] as $key) { $pattern = in_array($key, ['on_submission', 'on_task'], true) ? '/\$options\[\'' . preg_quote($key, '/') . '\'\] = (static function.*?\n });/s' : '/\'' . preg_quote($key, '/') . '\' => (static function.*?\n }),/s'; if (preg_match_all($pattern, $controller, $matches) !== 1) throw new RuntimeException('Canonical callback not unique: ' . $key); $callbackLiterals[$key] = $matches[1][0]; } $directory = sys_get_temp_dir() . '/zyt-live-control-' . bin2hex(random_bytes(8)); try { foreach (['success', 'cancel_unconfirmed', 'oversized'] as $index => $mode) { $control = new Control(8, 7, sprintf('c1234567-1234-4234-8234-%012d', $index + 1), $directory); liveExpect($control->begin(), 'live lifecycle request begins'); $profile = 'qwen'; $upstreamUser = 'admin-diagnosis-assistant-8'; $lifecycle = []; foreach ($callbackLiterals as $key => $literal) $lifecycle[$key] = eval( 'namespace app\\adminapi\\controller\\tcm; use app\\common\\service\\DifyWorkflowRunStatus; return ' . $literal . ';'); $prepared = Logic::prepareAssistant(7, 'live_consultation', '', 8, [], null, $options); $GLOBALS['liveTerminalStatus'] = $mode === 'success' ? 'succeeded' : 'stopped'; DifyChatService::$streamHandler = static function ($p, $i, $q, $u, $delta, $abort, $files, $transport) use ($base, $mode, $control): array { $submission = ($transport['on_submission'])(); liveExpect(!$control->canStartAttempt(), 'owned submission blocks concurrent attempts before task metadata'); $taskId = 'live-synthetic-task'; $runId = $mode === 'cancel_unconfirmed' ? '' : 'live-synthetic-run'; ($transport['on_task'])($taskId, $runId, $submission); liveExpect($control->publicState()['upstream_pending_task_count'] === 1, 'original task remains owned'); if ($mode === 'cancel_unconfirmed') { $control->signalCancel(); liveExpect(!$delta('{"summary":') && $abort(), 'mid-stream cancel interrupts buffering and transport'); $receipt = ($transport['on_cancel_task'])($taskId, static fn (): bool => false, 'cancelled'); liveExpect(!$receipt['accepted'] && $control->publicState()['upstream_pending_task_count'] === 1, 'failed stop retains unconfirmed upstream work after the stop IO returns'); } elseif ($mode === 'oversized') { liveExpect(!$delta(str_repeat('x', Result::MAX_BYTES + 1)) && $abort(), 'oversized stream triggers bounded abort'); ($transport['on_cancel_task'])($taskId, static fn (): bool => true, 'cancelled'); liveExpect(!$control->publicState()['cancel_requested'], 'output validation stop does not become user cancellation'); ($transport['on_terminal'])($taskId, 'stopped', $runId); } else { liveExpect($delta(Context::encode($base)), 'success stays privately buffered'); ($transport['on_terminal'])($taskId, 'succeeded', $runId); } ($transport['on_transfer_end'])($taskId, $submission, 'unconfirmed'); return ['ok' => true, 'content' => Context::encode($base)]; }; $before = DifyChatService::$calls; $controlled = Logic::streamPreparedAssistant($prepared, $sink, static fn (): bool => $control->shouldAbort(), null, $lifecycle); liveExpect(DifyChatService::$handlerFailure === null, 'mock producer assertion: ' . DifyChatService::$handlerFailure); liveExpect(DifyChatService::$calls === $before + 1 && $visible === [], 'controlled path has no duplicate generation or raw delivery'); if ($mode === 'success') liveExpect(is_array($controlled) && $control->publicState()['upstream_pending_task_count'] === 0, 'success closes the same original generation'); elseif ($mode === 'cancel_unconfirmed') liveExpect($controlled === null && Logic::getAssistantErrorCode() === 'AI_ASSISTANT_CANCELLED' && !$control->canStartAttempt() && $control->publicState()['upstream_pending_task_count'] === 1, 'unconfirmed cancellation never reports success or releases ownership'); else liveExpect($controlled === null && Logic::getAssistantErrorCode() === 'LIVE_RESULT_INVALID', 'oversized output is validation failure'); } } finally { foreach (glob($directory . '/*') ?: [] as $path) unlink($path); if (is_file($directory . '/.gc.lock')) unlink($directory . '/.gc.lock'); if (is_dir($directory)) rmdir($directory); } liveExpect((new AssistantSseTiming())->event('upstream_done', ['error_code' => 'LIVE_RESULT_INVALID'])['error_code'] === 'LIVE_RESULT_INVALID', 'safe live validation enum is observable in timing'); echo "Live consultation streaming / SSE / budget / ownership: {$count} total assertions passed (no DB/network/model)\n"; }