Files
zyt/server/app/adminapi/service/TcmLongReportContract.php
T
2026-10-27 14:03:35 +08:00

259 lines
14 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 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];
}
}
}