169 lines
16 KiB
PHP
169 lines
16 KiB
PHP
<?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";
|