90]; } // Advance a namespaced monotonic clock from the transport stub. A 90s // deadline is exercised without sleeping or touching an external service. function hrtime(bool $asNumber = false): int|array { $now = $GLOBALS['deliveryClockNs'] ?? 1000000000; return $asNumber ? $now : [intdiv($now, 1000000000), $now % 1000000000]; } } namespace app\adminapi\service { function hrtime(bool $asNumber = false): int|array { return \app\adminapi\logic\tcm\hrtime($asNumber); } } namespace app\common\service { class DifyChatService { public static array $calls = []; public static array $queue = []; public static function streamChat($profile, $inputs, $query, $user, $onDelta, $abort, $files = [], $options = []): array { self::$calls[] = compact('profile', 'inputs', 'query', 'files', 'options') + ['clock_ns' => $GLOBALS['deliveryClockNs'] ?? 1000000000]; if (self::$queue === []) throw new \RuntimeException('Unexpected generation.'); $response = array_shift(self::$queue); $GLOBALS['deliveryClockNs'] = ($GLOBALS['deliveryClockNs'] ?? 1000000000) + (int) ($response['advance_ms'] ?? 0) * 1000000; foreach ($response['chunks'] ?? [] as $chunk) { if ($onDelta($chunk) === false) { if (isset($options['on_cancel_task'])) ($options['on_cancel_task'])('local-task', static fn (): bool => true, 'cancelled'); return ['ok' => false, 'error_code' => 'AI_ASSISTANT_CANCELLED']; } } return $response; } public static function chat(...$arguments): array { throw new \RuntimeException('No blocking repair after clinical delivery.'); } } } namespace { require dirname(__DIR__) . '/vendor/autoload.php'; use app\adminapi\logic\tcm\DiagnosisAiLogic; use app\adminapi\service\TcmClinicalEvidenceContract; use app\adminapi\service\TcmClinicalEvidenceIndex; use app\adminapi\service\TcmEvidenceGuard; use app\common\service\DifyChatService; $assertions = 0; $deliveryClockNs = 1000000000; function expectDelivery(bool $value, string $message): void { ++$GLOBALS['assertions']; if (!$value) throw new \RuntimeException($message); } $current = ['id' => 1, 'diagnosis_date' => '2026-10-23', 'chief_complaint' => '乏力持续两周,活动后较明显,休息后可缓解;患者自述仍能完成日常活动,近两日以居家家务为主,白天与傍晚均有乏力,具体起止和活动关系按本次访谈记录。', 'present_illness' => '口干;未记录固定刺痛。', 'tongue' => '舌淡红苔少', 'pulse' => '脉细弱']; $guard = TcmEvidenceGuard::assess($current, []); $boundedVisible = ''; $bounded = new \app\adminapi\service\TcmClinicalReportStream($guard['fact_state_ledger'], static function (string $text) use (&$boundedVisible): bool { $boundedVisible .= $text; return true; }); expectDelivery(!$bounded->push(str_repeat('x', 100001)) && $boundedVisible === '', 'An oversized unbroken response cannot reach the client.'); expectDelivery($bounded->issues() === [['code' => 'REPORT_BOUND_EXCEEDED', 'source_id' => 'report']], 'Bound errors retain the static metadata code contract.'); $prepared = ['task' => 'custom', 'profile' => 'qwen', 'admin_id' => 1, 'query' => '静态安全规则ALL_SOURCE_BYTES请写完整中医辨证长报告', 'inputs' => ['source' => 'ALL_13_SECTIONS'], 'user' => 'test-user', 'tcm_evidence_guard' => $guard, 'files' => [['type' => 'image', 'url' => 'https://synthetic.invalid/original.png']], 'upstream_timeout' => 90, 'required_sections' => [], 'source_summary' => []]; // Engineering-only content tests delivery, never clinical report quality. $body = str_repeat('本段仅用于离线传输契约测试,不作临床结论,不能代替真实病例报告的事实与方药审核。', 3); // Source IDs are independently fixed by the four original clinical fields: // chief complaint=s1, present illness=s2, tongue=s3, pulse=s4. $first = "## 一、四诊\n\n望诊:未记录。\n\n闻诊:未记录。\n\n" . "问诊:2026-10-23“乏力持续两周,活动后较明显,休息后可缓解;患者自述仍能完成日常活动,近两日以居家家务为主,白天与傍晚均有乏力,具体起止和活动关系按本次访谈记录。”(s1);2026-10-23“口干;未记录固定刺痛。”(s2)。\n\n" . "切诊:其他触诊未记录,脉象见独立脉象项。\n\n" . "舌象:2026-10-23“舌淡红苔少”(s3)。\n\n脉象:2026-10-23“脉细弱”(s4)。"; $rest = implode("\n\n", array_map(static fn (string $heading): string => $heading . "\n\n" . $body, ['## 二、八纲', '## 三、气血津液与脏腑', '## 四、证名', '## 五、治法', '## 六、方药'])); $complete = $first . "\n\n" . $rest; expectDelivery(TcmClinicalEvidenceContract::validate(TcmClinicalEvidenceIndex::build($guard['fact_state_ledger']), $complete, true)['valid'], 'Hardcoded transport fixture obeys source/role protocol; this does not certify clinical quality.'); foreach (['qwen', 'openai'] as $profile) { $p = $prepared; $p['profile'] = $profile; $deliveryClockNs = 1000000000; DifyChatService::$calls = []; DifyChatService::$queue = [['ok' => true, 'content' => $complete, 'chunks' => [$first . "\n\n", $rest]]]; $visible = ''; $duringTransfer = []; $out = DiagnosisAiLogic::streamPreparedAssistant($p, static function (string $text) use (&$visible, &$duringTransfer): bool { $visible .= $text; $duringTransfer[] = count(DifyChatService::$calls); return true; }); expectDelivery($out !== null && $out['report_stage_count'] === 1 && $out['clinical_repair_count'] === 0, 'One generation delivers the complete report on ' . $profile); expectDelivery($out['answer'] === $complete && $visible === $complete, 'Exact model bytes match final full report.'); expectDelivery(count(DifyChatService::$calls) === 1 && count($duringTransfer) >= 2 && array_unique($duringTransfer) === [1], 'No second model startup or local prefill.'); $call = DifyChatService::$calls[0]; expectDelivery($call['profile'] === $profile && $call['inputs'] === $p['inputs'] && $call['files'] === $p['files'], 'The original channel, every source input and original file stay intact.'); expectDelivery(str_starts_with($call['query'], $p['query']) && !str_contains($call['query'], 'REJECTED_DRAFT_DATA'), 'Original question and source bytes remain unchanged.'); DifyChatService::$calls = []; // Keep first-section provenance valid so this still isolates the // original shape/incompleteness classification rather than role errors. $short = $first . "\n\n二、八纲\n\n四诊据实。\n\n六、方药\n\n方药待定。"; DifyChatService::$queue = [['ok' => true, 'content' => $short, 'chunks' => [$short]]]; $out = DiagnosisAiLogic::streamPreparedAssistant($p, static fn ($text): bool => true); expectDelivery($out === null && DiagnosisAiLogic::getAssistantErrorCode() === 'TCM_REPORT_INCOMPLETE' && count(DifyChatService::$calls) === 1, 'A valid sourced first section followed by two short sentences never counts as a successful long report.'); DifyChatService::$calls = []; $bounded = $p; $bounded['query'] = str_replace('请写完整中医辨证长报告', '请生成1800至2200字完整中医辨证长报告', $p['query']); DifyChatService::$queue = [['ok' => true, 'content' => $complete, 'chunks' => [$complete]]]; $out = DiagnosisAiLogic::streamPreparedAssistant($bounded, static fn ($text): bool => true); $meta = DiagnosisAiLogic::getAssistantErrorMeta(); expectDelivery($out === null && DiagnosisAiLogic::getAssistantErrorCode() === 'TCM_REPORT_INCOMPLETE' && in_array('REPORT_LENGTH_OUT_OF_RANGE', array_column($meta['report_contract']['issues'] ?? [], 'code'), true), 'The original whole-report length is checked, never replaced by a short output.'); DifyChatService::$calls = []; $reason = ''; $bad = "当前无固定刺痛。\n\n"; DifyChatService::$queue = [['ok' => true, 'content' => $bad, 'chunks' => [$bad]]]; $visible = ''; $out = DiagnosisAiLogic::streamPreparedAssistant($p, static function ($text) use (&$visible): bool { $visible .= $text; return true; }, null, null, ['on_cancel_task' => static function ($task, $stop, $why) use (&$reason): array { $reason = $why; return ['accepted' => $stop()]; }]); expectDelivery($out === null && DiagnosisAiLogic::getAssistantErrorCode() === 'TCM_FACT_STATE_CONFLICT' && $visible === '', 'Unknown-to-negative text is blocked before display.'); expectDelivery($reason === 'validation' && count(DifyChatService::$calls) === 1, 'Invalid text is stopped without an automatic extra model call.'); DifyChatService::$calls = []; DifyChatService::$queue = [['ok' => true, 'content' => $complete, 'chunks' => [$complete]]]; $out = DiagnosisAiLogic::streamPreparedAssistant($p, static fn ($text): bool => false); expectDelivery($out === null && DiagnosisAiLogic::getAssistantErrorCode() === 'AI_ASSISTANT_CANCELLED' && count(DifyChatService::$calls) === 1, 'Caller cancellation retains its exact semantics and starts no replacement.'); DifyChatService::$calls = []; DifyChatService::$queue = [['ok' => true, 'content' => $complete, 'chunks' => [$complete]]]; $out = DiagnosisAiLogic::streamPreparedAssistant($p, static fn ($text): bool => true, null, null, ['timeout_ms' => 12500]); expectDelivery($out !== null && DifyChatService::$calls[0]['options']['timeout_ms'] === 12500, 'A shorter caller millisecond deadline remains intact.'); DifyChatService::$calls = []; $out = DiagnosisAiLogic::streamPreparedAssistant($p, static fn ($text): bool => true, null, null, ['can_start_attempt' => static fn (): bool => false]); expectDelivery($out === null && DiagnosisAiLogic::getAssistantErrorCode() === 'UPSTREAM_CLEANUP_PENDING' && DifyChatService::$calls === [], 'Unconfirmed cleanup prevents any generation.'); } echo "TCM single-generation long report delivery: {$assertions} assertions passed (offline transport only).\n"; }