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('~\s*(.*?)\s*~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]; } } }