259 lines
14 KiB
PHP
259 lines
14 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace app\adminapi\service;
|
||
|
||
/** Pure delivery-shape check. Passing never certifies clinical correctness. */
|
||
final class TcmLongReportContract
|
||
{
|
||
public const VERSION = 'tcm-long-report-pulse-excluded-r36';
|
||
|
||
// Counts letters in actual section bodies, not Markdown headings/digits.
|
||
// These are disclosure thresholds, never evidence of medical substance.
|
||
private const MIN_BODY_LETTERS = [1 => 80, 2 => 60, 3 => 50, 4 => 30, 5 => 40, 6 => 100];
|
||
private const PHASES = ['first' => [1], 'continuation' => [2, 3, 4, 5, 6], 'full' => [1, 2, 3, 4, 5, 6]];
|
||
|
||
/**
|
||
* Always pass the ORIGINAL query, before a stage rewrites USER_QUESTION.
|
||
* Returned metadata contains no question or clinical response fragments.
|
||
* Original response bytes are neither altered nor returned by this check.
|
||
*/
|
||
public static function validate(string $answer, string $originalQuery, string $phase = 'full'): array
|
||
{
|
||
$contract = self::contract($originalQuery);
|
||
$result = ['version' => self::VERSION, 'applicable' => $contract['applicable'], 'valid' => true,
|
||
'phase' => $phase, 'raw_characters' => mb_strlen($answer, 'UTF-8'),
|
||
'length_checked' => false, 'requested_range' => $contract['range'],
|
||
'section_order' => [], 'section_body_letters' => [], 'issues' => []];
|
||
if (!$contract['applicable']) {
|
||
if ($phase !== 'full') {
|
||
$result['issues'][] = ['code' => 'ORIGINAL_LONG_REPORT_QUESTION_REQUIRED'];
|
||
$result['valid'] = false;
|
||
}
|
||
return $result;
|
||
}
|
||
$result['issues'] = $contract['issues'];
|
||
if (!isset(self::PHASES[$phase])) {
|
||
$result['issues'][] = ['code' => 'INVALID_REPORT_PHASE'];
|
||
$result['valid'] = false;
|
||
return $result;
|
||
}
|
||
if (!mb_check_encoding($answer, 'UTF-8')) {
|
||
$result['issues'][] = ['code' => 'INVALID_REPORT_UTF8'];
|
||
$result['valid'] = false;
|
||
return $result;
|
||
}
|
||
$sections = [];
|
||
$position = null;
|
||
foreach (preg_split('/\r\n|\r|\n/', $answer) ?: [] as $line) {
|
||
$id = self::heading($line);
|
||
if ($id !== null) {
|
||
$sections[] = ['id' => $id, 'lines' => []];
|
||
$position = count($sections) - 1;
|
||
} elseif ($position !== null) {
|
||
$sections[$position]['lines'][] = $line;
|
||
}
|
||
}
|
||
$actual = array_column($sections, 'id');
|
||
$expected = self::PHASES[$phase];
|
||
$result['section_order'] = $actual;
|
||
foreach (range(1, 6) as $id) {
|
||
$count = count(array_filter($actual, static fn (int $value): bool => $value === $id));
|
||
if ($count > 1) $result['issues'][] = ['code' => 'DUPLICATE_REPORT_SECTION', 'section' => $id];
|
||
if (in_array($id, $expected, true) && $count === 0) {
|
||
$result['issues'][] = ['code' => 'MISSING_REPORT_SECTION', 'section' => $id];
|
||
}
|
||
if (!in_array($id, $expected, true) && $count > 0) {
|
||
$result['issues'][] = ['code' => 'UNEXPECTED_REPORT_SECTION', 'section' => $id];
|
||
}
|
||
}
|
||
if (count($actual) === count($expected) && $actual !== $expected) {
|
||
$result['issues'][] = ['code' => 'REPORT_SECTION_ORDER'];
|
||
}
|
||
foreach ($sections as $section) {
|
||
$id = $section['id'];
|
||
$letters = self::bodyLetters($section['lines']);
|
||
$result['section_body_letters'][] = ['section' => $id, 'letters' => $letters];
|
||
if ($letters < self::MIN_BODY_LETTERS[$id]) {
|
||
$result['issues'][] = ['code' => 'REPORT_SECTION_BODY_TOO_SHORT', 'section' => $id,
|
||
'minimum_letters' => self::MIN_BODY_LETTERS[$id], 'actual_letters' => $letters];
|
||
}
|
||
if ($id === 1) self::checkExams($section['lines'], $result['issues']);
|
||
}
|
||
if (preg_match('/(?:未完待续|\[未完成\]|【未完成】|正文待续|后文省略)[\s。.!!]*$/u', $answer) === 1) {
|
||
$result['issues'][] = ['code' => 'REPORT_EXPLICITLY_UNFINISHED'];
|
||
}
|
||
// A whole-report range must never be applied separately to each stage.
|
||
if ($phase === 'full' && $contract['range'] !== null) {
|
||
$result['length_checked'] = true;
|
||
[$minimum, $maximum] = $contract['range'];
|
||
if (($minimum !== null && $result['raw_characters'] < $minimum)
|
||
|| ($maximum !== null && $result['raw_characters'] > $maximum)) {
|
||
$result['issues'][] = ['code' => 'REPORT_LENGTH_OUT_OF_RANGE',
|
||
'minimum_characters' => $minimum, 'maximum_characters' => $maximum,
|
||
'actual_characters' => $result['raw_characters']];
|
||
}
|
||
}
|
||
$result['valid'] = $result['issues'] === [];
|
||
return $result;
|
||
}
|
||
|
||
private static function contract(string $query): array
|
||
{
|
||
preg_match_all('~<USER_QUESTION>\s*(.*?)\s*</USER_QUESTION>~su', $query, $matches);
|
||
$questions = $matches[1] ?? [];
|
||
$applicable = false;
|
||
foreach ($questions as $question) {
|
||
if (preg_match('/长报告|完整[^。\n]{0,16}(?:辨证报告|中医报告|临床报告)/u', $question) === 1) $applicable = true;
|
||
}
|
||
$result = ['applicable' => $applicable, 'range' => null, 'issues' => []];
|
||
if (!$applicable) return $result;
|
||
if (count($questions) !== 1) {
|
||
$result['issues'][] = ['code' => 'AMBIGUOUS_ORIGINAL_QUESTION'];
|
||
return $result;
|
||
}
|
||
$question = $questions[0];
|
||
$minimum = null; $maximum = null;
|
||
$separator = '(?:至|到|[-‐‑‒–—−~~])';
|
||
$unit = '(?:个)?(?:汉字|中文字符|字符|字)';
|
||
preg_match_all('/([1-9][0-9]{0,5})\s*' . $separator . '\s*([1-9][0-9]{0,5})\s*' . $unit . '/u',
|
||
$question, $ranges, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
|
||
foreach ($ranges as $range) {
|
||
if (!self::wholeReportBound($question, $range[0][1])) continue;
|
||
$lower = (int) $range[1][0]; $upper = (int) $range[2][0];
|
||
if ($lower > $upper) $result['issues'][] = ['code' => 'INVALID_REPORT_LENGTH_REQUEST'];
|
||
$minimum = $minimum === null ? $lower : max($minimum, $lower);
|
||
$maximum = $maximum === null ? $upper : min($maximum, $upper);
|
||
}
|
||
preg_match_all('/(?:总字数|全文字数|报告字数|字数)\s*(?:为|在|是|:|:)?\s*([1-9][0-9]{0,5})\s*'
|
||
. $separator . '\s*([1-9][0-9]{0,5})(?![0-9])/u', $question, $ranges, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
|
||
foreach ($ranges as $range) {
|
||
if (!self::wholeReportBound($question, $range[0][1])) continue;
|
||
$lower = (int) $range[1][0]; $upper = (int) $range[2][0];
|
||
if ($lower > $upper) $result['issues'][] = ['code' => 'INVALID_REPORT_LENGTH_REQUEST'];
|
||
$minimum = $minimum === null ? $lower : max($minimum, $lower);
|
||
$maximum = $maximum === null ? $upper : min($maximum, $upper);
|
||
}
|
||
foreach (['minimum' => '(?:不少于|不低于|至少)', 'maximum' => '(?:不超过|不多于|至多|最多)'] as $kind => $prefix) {
|
||
preg_match_all('/' . $prefix . '\s*([1-9][0-9]{0,5})\s*' . $unit . '/u',
|
||
$question, $bounds, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
|
||
foreach ($bounds as $bound) {
|
||
if (!self::wholeReportBound($question, $bound[0][1])) continue;
|
||
$value = (int) $bound[1][0];
|
||
if ($kind === 'minimum') $minimum = $minimum === null ? $value : max($minimum, $value);
|
||
else $maximum = $maximum === null ? $value : min($maximum, $value);
|
||
}
|
||
}
|
||
preg_match_all('/(?:严格(?:为|要求)?|恰好|正好)\s*([1-9][0-9]{0,5})\s*' . $unit . '/u',
|
||
$question, $exactBounds, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
|
||
foreach ($exactBounds as $bound) {
|
||
if (!self::wholeReportBound($question, $bound[0][1])) continue;
|
||
$value = (int) $bound[1][0];
|
||
$minimum = $minimum === null ? $value : max($minimum, $value);
|
||
$maximum = $maximum === null ? $value : min($maximum, $value);
|
||
}
|
||
// A direct, unqualified "write N characters" is an exact request.
|
||
// Approximate requests and subsection allocations create no new bound.
|
||
preg_match_all('/(?:生成|撰写|输出|写)(?:一份|一篇)?\s*([1-9][0-9]{0,5})\s*' . $unit . '/u',
|
||
$question, $directBounds, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
|
||
foreach ($directBounds as $bound) {
|
||
if (!self::wholeReportBound($question, $bound[0][1])) continue;
|
||
$before = mb_substr(substr($question, 0, $bound[0][1]), -12, null, 'UTF-8');
|
||
$after = substr($question, $bound[0][1] + strlen($bound[0][0]));
|
||
if (preg_match('/(?:不|不要|不得|不能|禁止|约|大约)\s*$/u', $before) === 1
|
||
|| preg_match('/^\s*(?:左右|上下|大致|大约)/u', $after) === 1) continue;
|
||
$value = (int) $bound[1][0];
|
||
$minimum = $minimum === null ? $value : max($minimum, $value);
|
||
$maximum = $maximum === null ? $value : min($maximum, $value);
|
||
}
|
||
if ($minimum !== null || $maximum !== null) $result['range'] = [$minimum, $maximum];
|
||
if ($minimum !== null && $maximum !== null && $minimum > $maximum) {
|
||
$result['issues'][] = ['code' => 'INVALID_REPORT_LENGTH_REQUEST'];
|
||
}
|
||
return $result;
|
||
}
|
||
|
||
/** Subsection allocation is not a global report range. Offsets are UTF-8 bytes. */
|
||
private static function wholeReportBound(string $question, int $offset): bool
|
||
{
|
||
$prefix = mb_substr(substr($question, 0, $offset), -36, null, 'UTF-8');
|
||
if (preg_match('/(?:总字数|全文|整篇|整份|报告字数)[^,。;\n]{0,12}$/u', $prefix) === 1) return true;
|
||
return preg_match('/(?:第[一二三四五六1-6]+节|首节|第一部分|续篇|四诊|八纲|气血津液|脏腑|证名|治法|方药|各节|每节)[^,。;\n]{0,18}$/u', $prefix) !== 1;
|
||
}
|
||
|
||
private static function plainLine(string $line): string
|
||
{
|
||
$line = trim($line);
|
||
$line = preg_replace('/^(?:#{1,6}\s*|[-*+]\s+)/u', '', $line) ?? $line;
|
||
return trim(str_replace(['**', '__', '`'], '', $line));
|
||
}
|
||
|
||
private static function heading(string $line): ?int
|
||
{
|
||
$line = self::plainLine($line);
|
||
$line = self::withoutOrdinal($line);
|
||
$line = preg_replace('/\s*#{1,6}\s*$/u', '', $line) ?? $line;
|
||
$line = preg_replace('/[((][^()()]*[))]\s*[::]?$/u', '', $line) ?? $line;
|
||
$line = preg_replace('/[\s::]+/u', '', $line) ?? $line;
|
||
$patterns = [
|
||
1 => '/^(?:四诊(?:采集|资料|信息|记录|整理|分析|合参|情况)?|望闻问切)$/u',
|
||
2 => '/^八纲(?:四对|辨证|分析|判断|归纳)?$/u',
|
||
3 => '/^气血[、,,\/]?津液(?:及|与|和|、|\/)?脏腑(?:定位|辨证|分析|归纳)?$/u',
|
||
4 => '/^(?:(?:中医)?证名|证候|证名与证候|辨证结论|证候结论|辨证证名)(?:及辨证结论)?$/u',
|
||
5 => '/^(?:(?:对应)?治法|治则(?:与|及)?治法|治则)(?:方向|与治疗原则|与治则|及治则)?$/u',
|
||
6 => '/^(?:(?:中药)?方药(?:方向|建议|选择|思路)?|中药方向|中药)(?:(?:与|及|和)(?:复核(?:与|及)?监测|监测|复核|用药复核|安全监测|复核要点))?$/u',
|
||
];
|
||
foreach ($patterns as $id => $pattern) if (preg_match($pattern, $line) === 1) return $id;
|
||
return null;
|
||
}
|
||
|
||
private static function withoutOrdinal(string $line): string
|
||
{
|
||
return preg_replace('/^(?:第\s*[一二三四五六七八九十0-9]+\s*(?:节|章|部分)|[((]?[一二三四五六七八九十0-9]+[))\..、]|[一二三四五六0-9]+\s+)\s*[、::]?\s*/u', '', $line) ?? $line;
|
||
}
|
||
|
||
private static function bodyLetters(array $lines): int
|
||
{
|
||
$body = '';
|
||
foreach ($lines as $line) {
|
||
if (preg_match('/^\s*#{1,6}\s+/u', $line) === 1) continue;
|
||
$plain = self::withoutOrdinal(self::plainLine($line));
|
||
if (preg_match('/^(?:望诊|闻诊|问诊|切诊|舌象|舌诊|舌|脉象|脉诊|脉)[::]?$/u', $plain) === 1) continue;
|
||
$withoutPlaceholders = preg_replace('/(?:本节|正文|内容|待补充|待完善|待生成|占位|同上|略|TODO|TBD|N\/A)/iu', '', $plain) ?? '';
|
||
if (preg_match('/\p{L}/u', $withoutPlaceholders) !== 1) continue;
|
||
$body .= $plain;
|
||
}
|
||
return mb_strlen(preg_replace('/[^\p{L}]/u', '', $body) ?? '', 'UTF-8');
|
||
}
|
||
|
||
private static function checkExams(array $lines, array &$issues): void
|
||
{
|
||
$counts = ['tongue' => 0];
|
||
$bodies = ['tongue' => []];
|
||
$active = null;
|
||
foreach ($lines as $line) {
|
||
$plain = self::withoutOrdinal(self::plainLine($line));
|
||
if (preg_match('/^(舌象|舌诊|舌|脉象|脉诊|脉)\s*(?:[::]\s*(.*)|$)/u', $plain, $label) === 1) {
|
||
$active = str_starts_with($label[1], '舌') ? 'tongue' : 'pulse';
|
||
if ($active === 'pulse') {
|
||
$issues[] = ['code' => 'PULSE_EXCLUDED_FROM_ASSESSMENT', 'exam' => 'pulse'];
|
||
$active = null;
|
||
continue;
|
||
}
|
||
++$counts[$active];
|
||
$bodies[$active][] = $label[2] ?? '';
|
||
} elseif (preg_match('/^(?:望诊|闻诊|问诊|切诊)\s*(?:[::]|$)/u', $plain) === 1
|
||
|| preg_match('/^\s*#{1,6}\s+/u', $line) === 1) {
|
||
$active = null;
|
||
} elseif ($active !== null) {
|
||
$bodies[$active][] = $line;
|
||
}
|
||
}
|
||
foreach ($counts as $exam => $count) {
|
||
if ($count !== 1) $issues[] = ['code' => $count === 0 ? 'MISSING_SEPARATE_EXAM' : 'DUPLICATE_SEPARATE_EXAM', 'exam' => $exam];
|
||
elseif (self::bodyLetters($bodies[$exam]) < 2) $issues[] = ['code' => 'EMPTY_SEPARATE_EXAM', 'exam' => $exam];
|
||
}
|
||
}
|
||
}
|