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";
}