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

169 lines
16 KiB
PHP
Raw Permalink 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);
// Self-contained: no framework bootstrap, fixtures, DB, transport or network.
require dirname(__DIR__) . '/app/adminapi/service/TcmLongReportContract.php';
use app\adminapi\service\TcmLongReportContract;
$assertions = 0;
function expectLongContract(bool $condition, string $message): void
{
++$GLOBALS['assertions'];
if (!$condition) throw new RuntimeException($message);
}
function contractHas(array $result, string $code): bool
{
return in_array($code, array_column($result['issues'], 'code'), true);
}
function contractQuery(string $question): string
{
return '<CASE_DATA>独立工程测试资料;不是患者或临床合格样本。</CASE_DATA><USER_QUESTION>'
. $question . '</USER_QUESTION>';
}
function joinContractParts(array $parts): string { return implode("\n\n", array_values($parts)); }
$titles = [1 => '四诊采集', 2 => '八纲四对', 3 => '气血津液及脏腑定位', 4 => '证名', 5 => '对应治法', 6 => '中药方药方向与复核监测'];
$numbers = [1 => '一', 2 => '二', 3 => '三', 4 => '四', 5 => '五', 6 => '六'];
$prose = '本段为独立构造的工程验证正文,只检验完整章节是否实际交付。所有具体事实与推断仍须另行逐项核对,字数或标题合格不能证明医学结论成立,也不能代替专业审核。';
$firstBody = "望诊:本段只演示来源记录的组织方式,不把工程样本作为患者观察结论。\n"
. "闻诊:未提供的信息仍保留未知,不从其他项目自动补充未记录的内容。\n"
. "问诊:本段仅用于结构检查,不据此预设任何证名、治法或用药方向。\n"
. "切诊:本段不以其他测量替代医师切诊,也不生成需要另行采集的事实。\n"
. "**舌象:** 本工程样本的舌象条目仅演示单列格式,来源真实性需另行核对。\n"
. "**脉象:** 本工程样本的脉象条目仅演示单列格式,不由心率或图片推断。";
$parts = [];
foreach ($titles as $id => $title) $parts[$id] = '## ' . $numbers[$id] . '、' . $title . "\n\n"
. ($id === 1 ? $firstBody : str_repeat($prose, $id === 6 ? 3 : 2));
while (mb_strlen(joinContractParts($parts), 'UTF-8') < 1900) $parts[6] .= $prose;
$full = joinContractParts($parts);
$continuation = joinContractParts(array_slice($parts, 1, null, true));
$query = contractQuery('请生成1800至2200字完整中医辨证长报告,六节依次交付,不截断或本地补文。');
$noRange = contractQuery('请生成完整中医辨证长报告,按六节交付。');
$result = TcmLongReportContract::validate($full, $query);
expectLongContract($result['valid'] && $result['applicable'] && $result['length_checked'], 'All six substantial bodies satisfy the explicit global range.');
expectLongContract($result['requested_range'] === [1800, 2200] && $result['raw_characters'] === mb_strlen($full, 'UTF-8'), 'Count original raw Unicode codepoints exactly.');
expectLongContract($result['section_order'] === [1, 2, 3, 4, 5, 6], 'Preserve the six-step order.');
expectLongContract(!isset($result['answer']) && !str_contains(json_encode($result, JSON_UNESCAPED_UNICODE), $prose), 'Return only safe shape metadata, never rewritten or raw clinical text.');
$short = "Only first section.\n\nOnly second section.";
$result = TcmLongReportContract::validate($short, $query);
expectLongContract(mb_strlen($short, 'UTF-8') === 41 && !$result['valid'], 'Two 41-character sentences cannot pass as a complete long report.');
expectLongContract(contractHas($result, 'MISSING_REPORT_SECTION') && contractHas($result, 'REPORT_LENGTH_OUT_OF_RANGE'), 'The short counterexample fails independently on structure and requested length.');
$firstResult = TcmLongReportContract::validate($parts[1], $query, 'first');
$restResult = TcmLongReportContract::validate($continuation, $query, 'continuation');
expectLongContract($firstResult['valid'] && !$firstResult['length_checked'] && $firstResult['section_order'] === [1], 'Validate only first-section shape before its delivery.');
expectLongContract($restResult['valid'] && !$restResult['length_checked'] && $restResult['section_order'] === [2, 3, 4, 5, 6], 'Validate continuation shape without incorrectly imposing full-report length on it.');
expectLongContract(!TcmLongReportContract::validate($parts[1], $query)['valid'], 'The accepted first stage is never sufficient for full completion.');
expectLongContract(contractHas(TcmLongReportContract::validate($full, $query, 'first'), 'UNEXPECTED_REPORT_SECTION'), 'First-stage output may not silently include the remaining stages.');
expectLongContract(contractHas(TcmLongReportContract::validate($full, $query, 'continuation'), 'UNEXPECTED_REPORT_SECTION'), 'Continuation may not repeat the first section.');
expectLongContract(contractHas(TcmLongReportContract::validate($full, $query, 'unknown'), 'INVALID_REPORT_PHASE'), 'Unknown phases fail explicitly.');
expectLongContract(contractHas(TcmLongReportContract::validate($parts[1], '<ORIGINAL_DOCTOR_REQUEST>长报告</ORIGINAL_DOCTOR_REQUEST>', 'first'),
'ORIGINAL_LONG_REPORT_QUESTION_REQUIRED'), 'A rewritten stage query cannot accidentally bypass the original-question contract.');
foreach (range(1, 6) as $id) {
$missing = $parts; unset($missing[$id]);
expectLongContract(contractHas(TcmLongReportContract::validate(joinContractParts($missing), $noRange), 'MISSING_REPORT_SECTION'), 'A missing section fails: ' . $id);
$duplicate = $full . "\n\n" . $parts[$id];
expectLongContract(contractHas(TcmLongReportContract::validate($duplicate, $noRange), 'DUPLICATE_REPORT_SECTION'), 'A repeated section fails: ' . $id);
$empty = $parts; $empty[$id] = '## ' . $numbers[$id] . '、' . $titles[$id];
expectLongContract(contractHas(TcmLongReportContract::validate(joinContractParts($empty), $noRange), 'REPORT_SECTION_BODY_TOO_SHORT'), 'A title without a body fails: ' . $id);
$placeholder = $parts; $placeholder[$id] = '## ' . $numbers[$id] . '、' . $titles[$id] . "\n\n" . str_repeat('本节内容待补充。', 100);
expectLongContract(contractHas(TcmLongReportContract::validate(joinContractParts($placeholder), $noRange), 'REPORT_SECTION_BODY_TOO_SHORT'), 'Repeated placeholders do not become substantive body text: ' . $id);
$tiny = $parts; $tiny[$id] = '## ' . $numbers[$id] . '、' . $titles[$id] . "\n\n资料待复核。";
expectLongContract(contractHas(TcmLongReportContract::validate(joinContractParts($tiny), $noRange), 'REPORT_SECTION_BODY_TOO_SHORT'), 'A token sentence under a heading is insufficient body: ' . $id);
$nestedTitles = $parts; $nestedTitles[$id] = '## ' . $numbers[$id] . '、' . $titles[$id] . "\n\n" . str_repeat("### 详细说明但没有任何正文\n", 100);
expectLongContract(contractHas(TcmLongReportContract::validate(joinContractParts($nestedTitles), $noRange), 'REPORT_SECTION_BODY_TOO_SHORT'), 'Many nested headings cannot stand in for a section body: ' . $id);
}
$outOfOrder = $parts; [$outOfOrder[3], $outOfOrder[4]] = [$outOfOrder[4], $outOfOrder[3]];
expectLongContract(contractHas(TcmLongReportContract::validate(joinContractParts($outOfOrder), $noRange), 'REPORT_SECTION_ORDER'), 'All six sections in the wrong order still fail.');
expectLongContract(contractHas(TcmLongReportContract::validate($full . "\n\n第一节:四诊\n" . $firstBody, $noRange), 'DUPLICATE_REPORT_SECTION'),
'Different legal heading styles cannot conceal a repeated section.');
expectLongContract(!TcmLongReportContract::validate('四诊、八纲、气血津液与脏腑、证名、治法、方药。' . str_repeat($prose, 30), $noRange)['valid'], 'A long keyword paragraph cannot replace the six sections.');
$headingsOnly = implode("\n\n", array_map(static fn (string $title): string => '## ' . $title, $titles));
expectLongContract(!TcmLongReportContract::validate($headingsOnly, $noRange)['valid'], 'Six titles alone fail without a global length requirement.');
$noTongue = preg_replace('/^\*\*舌象:\*\*.*$/mu', '', $parts[1]);
$noPulse = preg_replace('/^\*\*脉象:\*\*.*$/mu', '', $parts[1]);
expectLongContract(contractHas(TcmLongReportContract::validate($noTongue, $query, 'first'), 'MISSING_SEPARATE_EXAM'), 'Tongue needs its own line or labelled block.');
expectLongContract(contractHas(TcmLongReportContract::validate($noPulse, $query, 'first'), 'MISSING_SEPARATE_EXAM'), 'Pulse needs its own line or labelled block.');
$fused = preg_replace('/\n\*\*脉象:\*\*/u', ';脉象:', $parts[1]);
expectLongContract(contractHas(TcmLongReportContract::validate($fused, $query, 'first'), 'MISSING_SEPARATE_EXAM'), 'A fused tongue/pulse sentence is not two separately listed exams.');
$emptyTongue = preg_replace('/^\*\*舌象:\*\*.*$/mu', '**舌象:**', $parts[1]);
expectLongContract(contractHas(TcmLongReportContract::validate($emptyTongue, $query, 'first'), 'EMPTY_SEPARATE_EXAM'), 'A tongue label followed immediately by the pulse label is empty.');
$unknown = preg_replace('/^\*\*舌象:\*\*.*$/mu', '**舌象:** 未记录', $parts[1]);
$unknown = preg_replace('/^\*\*脉象:\*\*.*$/mu', '**脉象:** 未记录', $unknown);
expectLongContract(TcmLongReportContract::validate($unknown, $query, 'first')['valid'], 'An explicitly unknown exam is legitimate content; the structure validator must not invent positive findings.');
$blocks = preg_replace('/\*\*(舌象|脉象):\*\* /u', "### $1\n", $parts[1]);
expectLongContract(TcmLongReportContract::validate($blocks, $query, 'first')['valid'], 'Markdown subheadings with bodies on the following line remain valid.');
$bullets = preg_replace('/^\*\*(舌象|脉象):\*\*/mu', '- **$1:**', $parts[1]);
expectLongContract(TcmLongReportContract::validate($bullets, $query, 'first')['valid'], 'Bullet labels remain valid separately listed tongue/pulse blocks.');
$numberedExams = str_replace(['**舌象:**', '**脉象:**'], ['5. **舌象:**', '6. **脉象:**'], $parts[1]);
expectLongContract(TcmLongReportContract::validate($numberedExams, $query, 'first')['valid'], 'Numbered tongue/pulse labels are compatible with Markdown lists.');
foreach (['arabic', 'ordinal', 'part', 'parenthesized', 'closing_hash'] as $style) {
$styled = $parts;
foreach ($titles as $id => $title) {
$heading = match ($style) {
'arabic' => '## ' . $id . '. ' . $title,
'ordinal' => '第' . $numbers[$id] . '节:' . $title,
'part' => '第' . $numbers[$id] . '部分:' . $title,
'parenthesized' => '**(' . $numbers[$id] . ')' . $title . '**',
'closing_hash' => '### ' . $id . ') ' . $title . ' ###',
};
$styled[$id] = preg_replace('/^.*$/mu', $heading, $parts[$id], 1);
}
expectLongContract(TcmLongReportContract::validate(joinContractParts($styled), $query)['valid'], 'Common legal report headings remain compatible: ' . $style);
}
expectLongContract(TcmLongReportContract::validate(str_replace("\n", "\r\n", $full), $query)['valid'], 'CRLF headings and bodies are compatible.');
$noteHeading = str_replace('四诊采集', '四诊采集(舌象与脉象单列)', $full);
expectLongContract(TcmLongReportContract::validate($noteHeading, $query)['valid'], 'A harmless heading annotation is not a seventh section.');
foreach (['1800至2200字', '1800–2200字', '1800-2200 字', '1800~2200汉字', '1800 到 2200 字符'] as $range) {
$r = TcmLongReportContract::validate($full, contractQuery('请生成' . $range . '完整中医辨证长报告。'));
expectLongContract($r['valid'] && $r['requested_range'] === [1800, 2200], 'Parse explicit whole-report range: ' . $range);
}
$raw = mb_strlen($full, 'UTF-8');
expectLongContract(TcmLongReportContract::validate($full, contractQuery('完整中医长报告,全文' . $raw . '至' . $raw . '字。'))['valid'], 'Exact raw lower/upper boundaries are inclusive.');
expectLongContract(contractHas(TcmLongReportContract::validate($full, contractQuery('完整中医长报告,全文' . ($raw + 1) . '至' . ($raw + 20) . '字。')), 'REPORT_LENGTH_OUT_OF_RANGE'), 'One raw codepoint below the requested lower bound fails.');
expectLongContract(contractHas(TcmLongReportContract::validate($full, contractQuery('完整中医长报告,全文1至' . ($raw - 1) . '字。')), 'REPORT_LENGTH_OUT_OF_RANGE'), 'One raw codepoint above the requested upper bound fails.');
$spaces = $full . str_repeat(' ', 300);
$r = TcmLongReportContract::validate($spaces, $query);
expectLongContract(!$r['valid'] && $r['raw_characters'] === mb_strlen($spaces, 'UTF-8') && contractHas($r, 'REPORT_LENGTH_OUT_OF_RANGE'), 'Raw whitespace is counted, never trimmed to manufacture a passing length.');
$r = TcmLongReportContract::validate($full, contractQuery('完整中医长报告,至少1800字,总字数不超过2200字。'));
expectLongContract($r['valid'] && $r['requested_range'] === [1800, 2200], 'Explicit minimum/maximum constraints share the original raw-count convention.');
$r = TcmLongReportContract::validate($full, contractQuery('完整中医长报告,总字数为1800–2200。'));
expectLongContract($r['valid'] && $r['requested_range'] === [1800, 2200], 'An explicit word-count prefix supports a range without repeating the unit.');
$r = TcmLongReportContract::validate($full, contractQuery('完整中医长报告,恰好' . $raw . '字。'));
expectLongContract($r['valid'] && $r['requested_range'] === [$raw, $raw], 'An explicitly exact count is enforced without rounding or trimming.');
$r = TcmLongReportContract::validate($full, contractQuery('完整中医长报告,恰好' . ($raw + 1) . '字。'));
expectLongContract(!$r['valid'] && contractHas($r, 'REPORT_LENGTH_OUT_OF_RANGE'), 'An exact count mismatch fails.');
$r = TcmLongReportContract::validate($full, contractQuery('请生成' . $raw . '字完整中医长报告。'));
expectLongContract($r['valid'] && $r['requested_range'] === [$raw, $raw], 'An unqualified direct write-N-characters request is enforced.');
$r = TcmLongReportContract::validate($full, contractQuery('请生成' . ($raw + 1) . '字完整中医长报告。'));
expectLongContract(!$r['valid'] && contractHas($r, 'REPORT_LENGTH_OUT_OF_RANGE'), 'Direct exact-count mismatches do not pass as approximate.');
$r = TcmLongReportContract::validate($full, contractQuery('请生成约2000字完整中医长报告。'));
expectLongContract($r['valid'] && $r['requested_range'] === null, 'An approximate target is not silently converted to an exact hard bound.');
$r = TcmLongReportContract::validate($full, contractQuery('完整中医长报告。四诊400至450字,方药700至800字;其余据实。'));
expectLongContract($r['valid'] && $r['requested_range'] === null && !$r['length_checked'], 'Subsection allocation is never mistaken for the full-report range.');
$r = TcmLongReportContract::validate($full, contractQuery('完整中医长报告,2200至1800字。'));
expectLongContract(!$r['valid'] && contractHas($r, 'INVALID_REPORT_LENGTH_REQUEST'), 'An impossible explicit range is never silently inverted.');
expectLongContract(contractHas(TcmLongReportContract::validate($full . "\n未完待续", $noRange), 'REPORT_EXPLICITLY_UNFINISHED'), 'An explicit unfinished ending cannot be called complete.');
$ordinary = TcmLongReportContract::validate('简短回复。', contractQuery('请总结病历。'));
expectLongContract($ordinary['valid'] && !$ordinary['applicable'] && !$ordinary['length_checked'], 'Ordinary short tasks remain unaffected.');
$r = TcmLongReportContract::validate('简短回复。', '<CASE_DATA>长报告1800至2200字</CASE_DATA><USER_QUESTION>请总结病历。</USER_QUESTION>');
expectLongContract($r['valid'] && !$r['applicable'] && $r['requested_range'] === null, 'Case/source text cannot activate a new long-report requirement.');
$r = TcmLongReportContract::validate($full, '<CASE_DATA>至少9000字</CASE_DATA>' . $noRange);
expectLongContract($r['valid'] && $r['requested_range'] === null, 'Only the original doctor question supplies hard length bounds.');
$r = TcmLongReportContract::validate($full, $query . '<USER_QUESTION>请总结病历</USER_QUESTION>');
expectLongContract(!$r['valid'] && contractHas($r, 'AMBIGUOUS_ORIGINAL_QUESTION'), 'Ambiguous original question tags fail the active contract.');
$copy = $full;
TcmLongReportContract::validate($copy, $query);
expectLongContract($copy === $full, 'Validation never truncates, appends or rewrites the supplied report.');
echo "TCM long-report completeness contract: {$assertions} assertions passed (standalone, no I/O).\n";