Files
zyt/server/tests/TcmLongReportBoundedResumeTest.php
T
2026-10-27 14:03:35 +08:00

314 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace think\facade {
class Log {
public static function warning(string $message, array $context = []): void {}
public static function write(string $message, string $level = ''): void {}
}
}
namespace app\adminapi\logic\tcm {
function config(string $key): array { return ['timeout' => 90]; }
function hrtime(bool $asNumber = false): int|array {
$now = $GLOBALS['resumeClockNs'];
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', 'user', 'files', 'options')
+ ['clock_ns' => $GLOBALS['resumeClockNs']];
if (self::$queue === []) throw new \RuntimeException('Unexpected third generation.');
$response = array_shift(self::$queue);
$GLOBALS['resumeClockNs'] += (int) ($response['advance_ms'] ?? 0) * 1000000;
$id = 'resume-task-' . count(self::$calls);
if (isset($options['on_task'])) ($options['on_task'])($id);
if (!empty($response['timeout']) && isset($options['on_timeout'])) ($options['on_timeout'])($id);
foreach ($response['chunks'] ?? [] as $chunk) {
if ($onDelta($chunk) !== false) continue;
if (isset($options['on_cancel_task'])) {
($options['on_cancel_task'])($id, static fn (): bool => true, 'cancelled');
}
$GLOBALS['resumeClockNs'] += (int) ($response['cleanup_ms'] ?? 0) * 1000000;
if (!empty($response['terminal']) && isset($options['on_terminal'])) {
($options['on_terminal'])($id, 'stopped');
}
return ['ok' => false, 'error_code' => 'AI_ASSISTANT_CANCELLED'];
}
if (isset($options['on_terminal'])) ($options['on_terminal'])($id, 'succeeded');
return $response;
}
public static function chat(...$arguments): array { throw new \RuntimeException('Unexpected blocking compatibility attempt.'); }
}
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\tcm\DiagnosisAiLogic;
use app\adminapi\service\AssistantRequestControl;
use app\adminapi\service\TcmClinicalEvidenceContract;
use app\adminapi\service\TcmClinicalEvidenceIndex;
use app\adminapi\service\TcmClinicalFactStateGuard;
use app\adminapi\service\TcmClinicalReportStream;
use app\adminapi\service\TcmEvidenceGuard;
use app\adminapi\service\TcmLongReportContract;
use app\common\service\DifyChatService;
$assertions = 0;
$resumeClockNs = 1000000000;
function resumeExpect(bool $condition, string $message): void {
++$GLOBALS['assertions'];
if (!$condition) throw new \RuntimeException($message);
}
$current = ['id' => 1, 'diagnosis_date' => '2026-10-23',
'chief_complaint' => '乏力持续两周,活动后较明显,休息后可缓解;患者自述仍能完成日常活动,近两日以居家家务为主,白天与傍晚均有乏力,具体起止和活动关系按本次访谈记录。',
'present_illness' => '口干;未记录固定刺痛。', 'tongue' => '舌淡红苔少', 'pulse' => '脉细弱'];
$guard = TcmEvidenceGuard::assess($current, []);
$prepared = ['task' => 'custom', 'profile' => 'qwen', 'admin_id' => 1,
'query' => '原安全规则<CASE_DATA>ALL_13_UNCHANGED_SOURCE_BYTES</CASE_DATA>'
. '<USER_QUESTION>请生成1800至2200字完整中医辨证长报告</USER_QUESTION>',
'inputs' => ['source' => 'ALL_13_ORIGINAL_SECTIONS', 'opaque' => "original\nbytes"],
'user' => 'same-original-user', 'tcm_evidence_guard' => $guard,
'files' => [['type' => 'image', 'url' => 'https://synthetic.invalid/original.png']],
'upstream_timeout' => 90, 'required_sections' => [], 'source_summary' => []];
// A synthetic delivery fixture, never a clinical quality claim. Its range
// and body thresholds are checked by the unchanged production contract.
$body = str_repeat('本段只用于离线工程传输契约测试,验证模型原始文字的次序与来源上下文,不能替代真实病例的医师审核。', 7);
// Hardcoded factual first-section protocol; engineering filler starts only
// in section two. IDs follow the original four clinical fields.
$prefix = "一、四诊\r\n\r\n望诊:未记录。\r\n\r\n闻诊:未记录。\r\n\r\n"
. "问诊:2026-10-23“乏力持续两周,活动后较明显,休息后可缓解;患者自述仍能完成日常活动,近两日以居家家务为主,白天与傍晚均有乏力,具体起止和活动关系按本次访谈记录。”(s1);2026-10-23“口干;未记录固定刺痛。”(s2)。\r\n\r\n"
. "切诊:其他触诊未记录,脉象见独立脉象项。\r\n\r\n"
. "舌象:2026-10-23“舌淡红苔少”(s3)。\r\n\r\n脉象:2026-10-23“脉细弱”(s4)。\r\n\r\n";
$headings = ['二、八纲', '三、气血津液与脏腑', '四、证名', '五、治法', '六、方药'];
$suffix = implode("\r\n\r\n", array_map(static fn (string $heading): string => $heading . "\r\n\r\n" . $body, $headings));
$complete = $prefix . $suffix;
$fixtureShape = TcmLongReportContract::validate($complete, $prepared['query']);
resumeExpect($fixtureShape['valid'], 'Fixture satisfies the original1800–2200 full-report contract: ' . json_encode($fixtureShape));
resumeExpect(TcmClinicalFactStateGuard::validate($guard['fact_state_ledger'], $complete)['valid'], 'Fixture has no source-state conflict.');
resumeExpect(TcmClinicalEvidenceContract::validate(TcmClinicalEvidenceIndex::build($guard['fact_state_ledger']), $complete, true)['valid'],
'Hardcoded transport fixture obeys source/role protocol; this is not a clinical-quality result.');
$bad = "当前无固定刺痛。\r\n\r\n";
$controlRoot = sys_get_temp_dir() . '/tcm-bounded-resume-' . bin2hex(random_bytes(6));
mkdir($controlRoot, 0700);
function runResume(array $prepared, array $responses, array $overrides = []): array {
$GLOBALS['resumeClockNs'] = 1000000000;
DifyChatService::$calls = [];
DifyChatService::$queue = $responses;
$control = new AssistantRequestControl(1, 1, '12345678-1234-4234-8234-' . bin2hex(random_bytes(6)), $GLOBALS['controlRoot']);
$control->begin();
$visible = '';
$chunks = [];
$reasons = [];
$options = [
'can_start_attempt' => static fn (): bool => $control->canStartAttempt(),
'on_task' => static fn (string $id) => $control->bindTask($prepared['profile'], $prepared['user'], $id),
'on_terminal' => static fn (string $id, string $status) => $control->markTerminal($id, $status),
'on_timeout' => static fn () => $control->signalTimeout(),
'on_cancel_task' => static function (string $id, callable $stop, string $reason) use ($control, &$reasons, $overrides): array {
$reasons[] = $reason;
if ($reason === 'validation') $control->signalValidationFailure();
else $control->signalCancel();
if (!empty($overrides['cancel_during_cleanup'])) $control->signalCancel();
return $control->stopBoundTask($stop, $id);
},
];
if (isset($overrides['gate'])) $options['can_start_attempt'] = $overrides['gate'];
if (!empty($overrides['without_gate'])) unset($options['can_start_attempt']);
if (isset($overrides['on_progress'])) $options['on_progress'] = $overrides['on_progress'];
if (isset($overrides['timeout_ms'])) $options['timeout_ms'] = $overrides['timeout_ms'];
$out = DiagnosisAiLogic::streamPreparedAssistant($prepared, static function (string $delta) use (&$visible, &$chunks, $control, $overrides): bool {
if (!empty($overrides['client_rejects'])) { $control->signalCancel(); return false; }
$visible .= $delta;
$chunks[] = ['bytes' => $delta, 'stage' => count(DifyChatService::$calls)];
return true;
}, static fn (): bool => $control->shouldAbort(), null, $options);
return ['out' => $out, 'visible' => $visible, 'chunks' => $chunks, 'reasons' => $reasons,
'error' => DiagnosisAiLogic::getAssistantErrorCode(), 'meta' => DiagnosisAiLogic::getAssistantErrorMeta(),
'state' => $control->publicState(), 'calls' => DifyChatService::$calls];
}
foreach (['qwen', 'openai'] as $profile) {
$p = $prepared; $p['profile'] = $profile;
$firstFailure = ['ok' => false, 'chunks' => [$prefix, $bad, 'NEVER_DELIVER_THIS'], 'advance_ms' => 45000, 'cleanup_ms' => 1500, 'terminal' => true];
$success = ['ok' => true, 'content' => $suffix, 'chunks' => mb_str_split($suffix, 7, 'UTF-8')];
$r = runResume($p, [$firstFailure, $success]);
resumeExpect($r['out'] !== null && $r['out']['answer'] === $complete && $r['visible'] === $complete,
$profile . ': original prefix plus validated suffix are byte-identical to the final answer.');
resumeExpect($r['out']['report_stage_count'] === 2 && $r['out']['clinical_repair_count'] === 1 && count($r['calls']) === 2,
'Exactly two actual stream calls and one clinical recovery are disclosed.');
resumeExpect(!str_contains($r['visible'], $bad) && !str_contains($r['visible'], 'NEVER_DELIVER_THIS')
&& substr_count($r['visible'], $prefix) === 1 && $r['reasons'] === ['validation'],
'Rejected and subsequent old-task bytes never reach the client; prefix appears once.');
foreach ($r['calls'] as $call) {
resumeExpect($call['profile'] === $profile && $call['inputs'] === $p['inputs'] && $call['files'] === $p['files'] && $call['user'] === $p['user'],
'Recovery retains the original profile, all sources, files and user.');
resumeExpect(str_starts_with($call['query'], $p['query']) && substr_count($call['query'], '<USER_QUESTION>') === 1,
'Original question and source bytes are unchanged and are not replaced by a suffix question.');
resumeExpect(!isset($call['options']['_validated_report_prefix']) && !isset($call['options']['_report_attempt_observer']),
'Private context and observers never become transport options.');
}
resumeExpect($r['calls'][0]['options']['timeout_ms'] === 90000 && $r['calls'][1]['options']['timeout_ms'] === 43500,
'Generation and confirmed-stop cleanup consume the same90s absolute deadline.');
preg_match('~<REPORT_CONTINUATION_DATA>\s*(.*?)\s*</REPORT_CONTINUATION_DATA>~su', $r['calls'][1]['query'], $continuationData);
$continuationData = json_decode($continuationData[1] ?? '', true);
resumeExpect(($continuationData['delivered_prefix'] ?? null) === $prefix
&& ($continuationData['rejected_undelivered_unit'] ?? null) === $bad
&& ($continuationData['original_whole_report_range'] ?? null) === [1800, 2200],
'Continuation receives the exact accepted prefix, private rejected unit and unchanged whole-report range.');
resumeExpect($r['meta'] === [], 'A completed recovery clears obsolete first-attempt error metadata.');
$r = runResume($p, [array_replace($firstFailure, ['terminal' => false])]);
resumeExpect($r['out'] === null && count($r['calls']) === 1 && $r['error'] === 'UPSTREAM_CLEANUP_PENDING'
&& $r['state']['upstream_stop_confirmed'] && !$r['state']['upstream_terminal_confirmed'],
'Accepted stop without actual terminal never permits another call.');
$r = runResume($p, [$firstFailure], ['cancel_during_cleanup' => true]);
resumeExpect($r['out'] === null && count($r['calls']) === 1 && $r['error'] === 'AI_ASSISTANT_CANCELLED',
'Doctor cancellation during cleanup cannot start recovery even after terminal.');
$r = runResume($p, [array_replace($firstFailure, ['timeout' => true])]);
resumeExpect($r['out'] === null && count($r['calls']) === 1 && $r['state']['timeout_reached'],
'A timeout tombstone blocks recovery even with remaining local milliseconds.');
$r = runResume($p, [array_replace($firstFailure, ['advance_ms' => 89000, 'cleanup_ms' => 1000])]);
resumeExpect($r['out'] === null && count($r['calls']) === 1 && $r['error'] === 'UPSTREAM_TIMEOUT',
'No new call starts after the shared generation-and-stop budget expires.');
$r = runResume($p, [$firstFailure], ['without_gate' => true]);
resumeExpect($r['out'] === null && count($r['calls']) === 1 && $r['error'] === 'TCM_FACT_STATE_CONFLICT',
'A missing readiness verifier never means the old task has stopped.');
$r = runResume($p, [$firstFailure, ['ok' => false, 'chunks' => [$bad], 'terminal' => true]]);
resumeExpect($r['out'] === null && count($r['calls']) === 2 && $r['visible'] === $prefix
&& $r['error'] === 'TCM_FACT_STATE_CONFLICT' && $r['meta']['report_stage_count'] === 2 && $r['meta']['clinical_repair_count'] === 1,
'A second source-state rejection remains failure and never starts a third call.');
resumeExpect(!str_contains(json_encode($r['meta'], JSON_UNESCAPED_UNICODE), '固定刺痛'),
'Public failure metadata never exposes rejected or accepted clinical prose.');
$r = runResume($p, [$firstFailure, ['ok' => true, 'content' => $complete, 'chunks' => [$complete]]]);
resumeExpect($r['out'] === null && count($r['calls']) === 2 && $r['visible'] === $prefix,
'A repeated first-section heading is rejected before duplicate prefix bytes are delivered.');
$r = runResume($p, [$firstFailure, ['ok' => false, 'error_code' => 'UPSTREAM_TIMEOUT', 'chunks' => [], 'timeout' => true]]);
resumeExpect($r['out'] === null && count($r['calls']) === 2 && $r['error'] === 'UPSTREAM_TIMEOUT'
&& $r['visible'] === $prefix, 'A second-attempt timeout preserves only the delivered prefix and remains failed.');
$shortSuffix = "二、八纲\r\n\r\n只交付一句话。";
$r = runResume($p, [$firstFailure, ['ok' => true, 'content' => $shortSuffix, 'chunks' => [$shortSuffix]]]);
resumeExpect($r['out'] === null && count($r['calls']) === 2 && $r['error'] === 'TCM_REPORT_INCOMPLETE',
'A short suffix never becomes a hidden partial-report success.');
$r = runResume($p, [$firstFailure, array_replace($success, ['content' => $suffix . 'INVISIBLE_RESULT_BYTES'])]);
resumeExpect($r['out'] === null && count($r['calls']) === 2 && $r['error'] === 'STREAM_CONTENT_MISMATCH',
'Final transport content must equal the exact accepted aggregate.');
$r = runResume($p, [['ok' => true, 'content' => $complete, 'chunks' => [$complete]]]);
resumeExpect(count($r['calls']) === 1 && $r['out'] !== null && $r['visible'] === $complete,
'A valid full response flushes its exact private final section without recovery.');
$finalOffset = strpos($complete, '六、方药');
resumeExpect($finalOffset !== false, 'The transport fixture has a final-section boundary.');
$finalTail = substr($complete, $finalOffset);
$firstFive = substr($complete, 0, $finalOffset);
$overlong = $complete . $body . $body;
$r = runResume($p, [
['ok' => true, 'content' => $overlong, 'chunks' => [$overlong]],
['ok' => true, 'content' => $finalTail, 'chunks' => [$finalTail]],
]);
$firstAttemptVisible = implode('', array_column(array_filter($r['chunks'],
static fn (array $chunk): bool => $chunk['stage'] === 1), 'bytes'));
resumeExpect(count($r['calls']) === 2 && $r['out'] !== null && $r['visible'] === $complete
&& $firstAttemptVisible === $firstFive && $r['out']['clinical_repair_count'] === 1,
'An overlong private final section can use the sole recovery; rejected tail bytes never become visible.');
$r = runResume($p, [$firstFailure], ['client_rejects' => true]);
resumeExpect($r['out'] === null && count($r['calls']) === 1 && $r['error'] === 'AI_ASSISTANT_CANCELLED',
'Client rejection never starts recovery.');
$r = runResume($p, [['ok' => false, 'error_code' => 'EMPTY_RESPONSE', 'chunks' => []]]);
resumeExpect($r['out'] === null && count($r['calls']) === 1, 'An empty transport failure never hides a blocking compatibility generation.');
$r = runResume($p, [array_replace($firstFailure, ['advance_ms' => 3000, 'cleanup_ms' => 1000]), $success],
['timeout_ms' => 12500, 'on_progress' => static function (): void { $GLOBALS['resumeClockNs'] += 500000000; }]);
resumeExpect($r['out'] !== null && $r['calls'][0]['options']['timeout_ms'] === 12500
&& $r['calls'][1]['options']['timeout_ms'] === 8000,
'A shorter caller deadline includes continuation preparation/progress time and is never reset.');
$r = runResume($p, [$firstFailure], ['gate' => static fn (): bool => false]);
resumeExpect($r['out'] === null && $r['calls'] === [] && $r['meta']['report_stage_count'] === 0
&& $r['meta']['clinical_repair_count'] === 0, 'A blocked initial gate discloses zero actual calls.');
$r = runResume($p, [array_replace($firstFailure, ['advance_ms' => 200, 'cleanup_ms' => 100])], ['timeout_ms' => 1000,
'on_progress' => static function (): void { $GLOBALS['resumeClockNs'] += 2000000000; }]);
resumeExpect($r['out'] === null && count($r['calls']) === 1 && $r['error'] === 'UPSTREAM_TIMEOUT'
&& $r['meta']['clinical_repair_count'] === 0,
'Continuation preparation cannot reset an exhausted shared deadline or inflate actual repair counts.');
}
// An accepted prefix may end within a section, not just at a heading.
$inside = "一、四诊\r\n\r\n望诊:未记录。\r\n\r\n";
$remaining = substr($complete, strlen($inside));
$r = runResume($prepared, [
['ok' => false, 'chunks' => [$inside, $bad], 'terminal' => true],
['ok' => true, 'content' => $remaining, 'chunks' => [$remaining]],
]);
resumeExpect($r['out'] !== null && $r['visible'] === $inside . $remaining && substr_count($r['visible'], '一、四诊') === 1,
'Continuation finishes a partially delivered section without inserting another heading.');
$r = runResume($prepared, [
['ok' => true, 'content' => $prefix . rtrim($bad), 'chunks' => [$prefix, rtrim($bad)]],
['ok' => true, 'content' => $suffix, 'chunks' => [$suffix]],
]);
resumeExpect($r['out'] !== null && $r['visible'] === $complete && $r['reasons'] === [] && count($r['calls']) === 2,
'A rejected final buffered unit may recover after the original natural terminal without fake doctor cancellation.');
$r = runResume($prepared, [
['ok' => false, 'chunks' => [$bad], 'terminal' => true],
['ok' => true, 'content' => $complete, 'chunks' => [$complete]],
]);
resumeExpect($r['out'] !== null && $r['visible'] === $complete && count($r['calls']) === 2,
'An empty accepted prefix can recover once with a complete new report after actual terminal.');
$contextVisible = '';
$context = new TcmClinicalReportStream($guard['fact_state_ledger'], static function (string $text) use (&$contextVisible): bool {
$contextVisible .= $text; return true;
}, null, "过敏史:\n\n");
resumeExpect(!$context->push("无。\n\n") && $contextVisible === '' && $context->rejected(),
'A field label in the old prefix retains unknown-to-negative protection across the attempt boundary.');
$context = new TcmClinicalReportStream($guard['fact_state_ledger'], static function (string $text) use (&$contextVisible): bool {
$contextVisible .= $text; return true;
}, null, "过敏史:\n\n");
resumeExpect($context->push("未记录。\n\n") && $context->finish() && $contextVisible === "未记录。\n\n",
'A faithful field value continues the old label while forwarding suffix bytes only.');
$sourceId = array_values(array_filter($guard['fact_state_ledger']['sources'], static fn (array $row): bool => $row['field'] === 'present_illness'))[0]['id'];
$context = new TcmClinicalReportStream($guard['fact_state_ledger'], static fn (): bool => true, null,
"一、四诊\n\n固定刺痛未记录(" . $sourceId . ")。\n\n");
resumeExpect(!$context->push("当前无固定刺痛。\n\n"), 'Earlier truthful source citations never excuse a new false negative after recovery.');
$context = new TcmClinicalReportStream($guard['fact_state_ledger'], static fn (): bool => true, null,
"一、四诊\n\n固定刺痛未记录(" . $sourceId . ")。\n\n");
resumeExpect(!$context->push("本次固定刺痛未记录(s999)。\n\n"), 'Source annotation validation stays active after carrying a prior source context.');
// The repair hint identifies the exact authorized source and current
// position. It never rewrites the delivered prefix or original inputs.
$delivery = (new ReflectionMethod(DiagnosisAiLogic::class, 'prepareLongReportDelivery'))->invoke(null, $prepared);
$sourceRow = $guard['fact_state_ledger']['sources'][$sourceId];
$hinted = (new ReflectionMethod(DiagnosisAiLogic::class, 'prepareLongReportContinuation'))->invoke(null,
$delivery, $prefix, $bad, [
['code' => 'NEGATION_SCOPE_WIDENED', 'source_id' => $sourceId],
['code' => 'NEGATIVE_TO_POSITIVE', 'source_id' => $sourceId],
['code' => 'UNKNOWN_TO_NEGATIVE', 'source_id' => 's999999'],
]);
preg_match('~<REPORT_CONTINUATION_DATA>\n(.*?)\n</REPORT_CONTINUATION_DATA>~s', $hinted['query'], $hintBlock);
$hint = json_decode($hintBlock[1], true, 512, JSON_THROW_ON_ERROR);
resumeExpect($hint['issue_source_quotations'] === [['source_id' => $sourceId,
'field' => $sourceRow['field'], 'scope' => $sourceRow['scope'],
'date' => $sourceRow['date'], 'quote' => $sourceRow['quote']]],
'Feedback quotes the exact authorized source once; unknown IDs cannot supply invented evidence.');
resumeExpect($hint['delivered_prefix'] === $prefix && $hint['rejected_undelivered_unit'] === $bad,
'Repair feedback preserves original clinical bytes rather than substituting a corrected report.');
resumeExpect($hint['remaining_suffix_character_range'] === [1800 - mb_strlen($prefix), 2200 - mb_strlen($prefix)]
&& $hint['original_whole_report_range'] === [1800, 2200],
'Suffix arithmetic includes all raw prefix characters and does not loosen the original whole range.');
resumeExpect($hint['resume_existing_section'] === 1 && $hint['already_delivered_section_headings'] === [1],
'An in-section retry identifies an existing section instead of instructing a repeated heading.');
resumeExpect(str_starts_with($hinted['query'], $delivery['query']) && $hinted['inputs'] === $prepared['inputs']
&& $hinted['files'] === $prepared['files'] && $hinted['profile'] === $prepared['profile']
&& $hinted['user'] === $prepared['user'], 'All original inputs/question/source/files/profile/user survive unchanged.');
$lastPrefix = "一、四诊\n\n二、八纲\n\n三、气血津液与脏腑\n\n四、证名\n\n五、治法\n\n六、方药\n\n";
$lastHinted = (new ReflectionMethod(DiagnosisAiLogic::class, 'prepareLongReportContinuation'))->invoke(null,
$delivery, $lastPrefix, $bad, []);
preg_match('~<REPORT_CONTINUATION_DATA>\n(.*?)\n</REPORT_CONTINUATION_DATA>~s', $lastHinted['query'], $lastBlock);
$lastHint = json_decode($lastBlock[1], true, 512, JSON_THROW_ON_ERROR);
resumeExpect($lastHint['resume_existing_section'] === 6 && $lastHint['already_delivered_section_headings'] === range(1, 6)
&& $lastHint['issue_source_quotations'] === [], 'A late retry identifies section six and never invents a report-only source.');
foreach (glob($controlRoot . '/*') ?: [] as $path) if (is_file($path)) unlink($path);
if (is_file($controlRoot . '/.gc.lock')) unlink($controlRoot . '/.gc.lock');
rmdir($controlRoot);
echo "TCM bounded same-channel long report recovery: {$assertions} assertions passed (offline; no model/network).\n";
}