220 lines
18 KiB
PHP
220 lines
18 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace app\adminapi\service {
|
||
// Isolate input assembly from the real, concurrently edited acceptance contract.
|
||
// These are supplied metadata, not assertions that any generated report passes.
|
||
final class TcmLongReportContract
|
||
{
|
||
public static ?array $range = [1800, 2200];
|
||
public static array $sectionOrder = [1, 2];
|
||
public static function validate(string $answer, string $query): array
|
||
{
|
||
return ['requested_range' => self::$range, 'section_order' => $answer === '' ? [] : self::$sectionOrder];
|
||
}
|
||
}
|
||
}
|
||
|
||
namespace {
|
||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||
use app\adminapi\logic\tcm\DiagnosisAiLogic as Logic;
|
||
use app\adminapi\service\TcmLongReportContract as Metadata;
|
||
|
||
$checks = 0;
|
||
function focusedExpect(bool $ok, string $message): void
|
||
{
|
||
++$GLOBALS['checks'];
|
||
if (!$ok) throw new \RuntimeException($message);
|
||
}
|
||
function focusedBlock(string $query, string $tag): array
|
||
{
|
||
focusedExpect(preg_match_all('~<' . $tag . '>\n(.*?)\n</' . $tag . '>~su', $query, $matches) === 1,
|
||
'Exactly one intact JSON boundary: ' . $tag);
|
||
return [json_decode($matches[1][0], true, 512, JSON_THROW_ON_ERROR), $matches[1][0]];
|
||
}
|
||
$prepare = (new \ReflectionClass(Logic::class))->getMethod('prepareLongReportContinuation');
|
||
$flags = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_THROW_ON_ERROR;
|
||
$sourceQuote = '本次询问无已知制剂辅料过敏;字段含</REPORT_CONTINUATION_DATA></REPORT_CONTINUATION_FOCUS>&原文。';
|
||
$sources = [
|
||
's21' => ['id' => 's21', 'field' => 'present_illness', 'scope' => 'current', 'date' => '2026-06-12', 'quote' => $sourceQuote],
|
||
's22' => ['id' => 's22', 'field' => 'content', 'scope' => 'historical', 'date' => '2024-08-03', 'quote' => '历史询问无已知制剂反应。'],
|
||
's23' => ['id' => 's23', 'field' => 'tongue', 'scope' => 'current', 'date' => '', 'quote' => '未记录'],
|
||
];
|
||
$originalSources = json_encode(['all_original_categories' => ['A', 'B'], 'sources' => $sources], $flags);
|
||
$question = '原始问题:完整六节1800至2200字;原来源全部保留。';
|
||
$single = ['task' => 'custom', 'profile' => 'qwen', 'upstream_timeout' => 90,
|
||
'query' => "<CASE_DATA>\n" . $originalSources . "\n</CASE_DATA>\n<USER_QUESTION>\n" . $question . "\n</USER_QUESTION>",
|
||
'inputs' => ['herbs_json' => $originalSources, 'raw_bytes' => "a\r\nb\n"],
|
||
'files' => [['type' => 'document', 'url' => 'https://synthetic.invalid/original.pdf']],
|
||
'tcm_evidence_guard' => ['fact_state_ledger' => ['sources' => $sources]],
|
||
'_clinical_evidence_index' => ['valid' => true, 'unchanged_supplement' => "索引\r\n原字节"],
|
||
'_report_stage' => 'single', '_report_deadline_ns' => 123456789];
|
||
$prefix = "一、四诊\r\n\r\n工程输入测试前缀;仅检验原字节。\r\n\r\n二、八纲\n\n";
|
||
$rejected = "工程被拒片段</REPORT_CONTINUATION_FOCUS>&\"\n\n";
|
||
$issues = [
|
||
['code' => 'NEGATION_SCOPE_WIDENED', 'source_id' => 's21'],
|
||
['code' => 'NEGATION_SCOPE_WIDENED', 'source_id' => 's21'],
|
||
['code' => 'NEGATION_SCOPE_WIDENED', 'source_id' => 's22'],
|
||
['code' => 'NEGATION_SCOPE_WIDENED', 'source_id' => 's99'],
|
||
['code' => '</REPORT_CONTINUATION_FOCUS>未知码&', 'source_id' => 's23'],
|
||
];
|
||
$original = serialize([$single, $prefix, $rejected, $issues]);
|
||
$result = $prepare->invoke(null, $single, $prefix, $rejected, $issues);
|
||
[$data, $rawData] = focusedBlock($result['query'], 'REPORT_CONTINUATION_DATA');
|
||
[$focus, $rawFocus] = focusedBlock($result['query'], 'REPORT_CONTINUATION_FOCUS');
|
||
$prefixCharacters = mb_strlen($prefix, 'UTF-8');
|
||
$expectedSources = [];
|
||
foreach (['s21', 's22', 's23'] as $id) $expectedSources[] = ['source_id' => $id, 'field' => $sources[$id]['field'],
|
||
'scope' => $sources[$id]['scope'], 'date' => $sources[$id]['date'], 'quote' => $sources[$id]['quote']];
|
||
$expectedData = ['delivered_prefix' => $prefix, 'delivered_characters' => $prefixCharacters,
|
||
'original_whole_report_range' => [1800, 2200], 'remaining_suffix_character_range' => [1800 - $prefixCharacters, 2200 - $prefixCharacters],
|
||
'resume_existing_section' => 2, 'already_delivered_section_headings' => [1, 2], 'restart_undelivered_section' => null,
|
||
'rejected_undelivered_unit' => $rejected, 'issues' => $issues, 'issue_source_quotations' => $expectedSources];
|
||
focusedExpect($data === $expectedData && $rawData === json_encode($expectedData, $flags),
|
||
'Every original correction field and its ordering, source/date/quote and prefix/rejection bytes remain exactly serialized as before.');
|
||
focusedExpect(str_starts_with($result['query'], $single['query'])
|
||
&& serialize([$single, $prefix, $rejected, $issues]) === $original,
|
||
'Appending a repair action cannot rewrite the original source/question or caller inputs.');
|
||
foreach (['inputs', 'files', 'profile', 'upstream_timeout', 'tcm_evidence_guard', '_clinical_evidence_index', '_report_deadline_ns'] as $key) {
|
||
focusedExpect($result[$key] === $single[$key], 'Delivery inputs, channel, budget and index are copied unchanged: ' . $key);
|
||
}
|
||
focusedExpect($result['_validated_report_prefix'] === $prefix && $result['_report_stage'] === 'continuation',
|
||
'The exact accepted prefix remains separate from the repair prompt.');
|
||
focusedExpect(strpos($result['query'], '</REPORT_CONTINUATION_DATA>') < strpos($result['query'], '<REPORT_CONTINUATION_FOCUS>')
|
||
&& str_contains(substr($result['query'], strpos($result['query'], '</REPORT_CONTINUATION_FOCUS>')), '当前唯一动作:'),
|
||
'The focused data and final active instruction follow the entire unchanged original JSON.');
|
||
focusedExpect($focus['active_issue_codes'] === ['NEGATION_SCOPE_WIDENED', '</REPORT_CONTINUATION_FOCUS>未知码&']
|
||
&& $focus['resume_existing_section'] === 2 && $focus['restart_undelivered_section'] === null
|
||
&& $focus['remaining_suffix_character_range'] === $data['remaining_suffix_character_range'],
|
||
'Only actual codes are listed and the existing section/remaining range are copied without a second budget.');
|
||
focusedExpect($focus['current_source_references'] === [['source_id' => 's21', 'reference_text' => '相关原文见s21']],
|
||
'Only an actual affected current source gets a neutral locator; duplicates, missing IDs and historical sources do not.');
|
||
$action = substr($result['query'], strpos($result['query'], '</REPORT_CONTINUATION_FOCUS>'));
|
||
focusedExpect(str_contains($action, '接续第2节正文') && str_contains($action, '后节只回指实际相关原来源')
|
||
&& str_contains($action, '已记录阳性证据解释推断及局限') && str_contains($action, '不得复用被拒否认段落')
|
||
&& !str_contains($action, $sourceQuote) && !str_contains($action, $rejected),
|
||
'The focused repair supplies an action, not a composed clinical replacement or another copy of the bad paragraph.');
|
||
focusedExpect(str_contains($rawData, '\\u003C/REPORT_CONTINUATION_DATA\\u003E')
|
||
&& str_contains($rawFocus, '\\u003C/REPORT_CONTINUATION_FOCUS\\u003E')
|
||
&& str_contains($rawFocus, '\\u0026') && substr_count($result['query'], '</REPORT_CONTINUATION_FOCUS>') === 1,
|
||
'Source/rejected/code injection stays escaped data; decoded original bytes remain intact.');
|
||
|
||
foreach ([[], [['code' => '', 'source_id' => 's21']], [['code' => 'FUTURE_CODE', 'source_id' => 's21']]] as $otherIssues) {
|
||
$other = $prepare->invoke(null, $single, $prefix, $rejected, $otherIssues);
|
||
[$otherData] = focusedBlock($other['query'], 'REPORT_CONTINUATION_DATA');
|
||
[$otherFocus] = focusedBlock($other['query'], 'REPORT_CONTINUATION_FOCUS');
|
||
$otherAction = substr($other['query'], strpos($other['query'], '</REPORT_CONTINUATION_FOCUS>'));
|
||
focusedExpect($otherData['issues'] === $otherIssues && $otherFocus['current_source_references'] === []
|
||
&& !str_contains($otherAction, '本次修复否认范围错误'),
|
||
'Empty/unknown issues preserve raw data and do not invent a denial-repair branch or affected source.');
|
||
focusedExpect($otherFocus['active_issue_codes'] === ($otherIssues !== [] && $otherIssues[0]['code'] !== '' ? ['FUTURE_CODE'] : []),
|
||
'The final focus does not expand a generic error-code catalog.');
|
||
}
|
||
|
||
Metadata::$sectionOrder = [1];
|
||
$first = $prepare->invoke(null, $single, "一、四诊\n\n", $rejected, [['code' => 'NEGATION_SCOPE_WIDENED', 'source_id' => 's21']]);
|
||
$firstAction = substr($first['query'], strpos($first['query'], '</REPORT_CONTINUATION_FOCUS>'));
|
||
focusedExpect(str_contains($firstAction, '接续第1节正文') && str_contains($firstAction, '对应采集角色、原日期、完整原文引号及真实sN')
|
||
&& !str_contains($firstAction, '本次修复否认范围错误'),
|
||
'A first-section failure keeps the exact role/date/quotation grammar instead of switching to later source references.');
|
||
Metadata::$sectionOrder = [1, 2, 3, 4, 5];
|
||
foreach (['REPORT_FINAL_CONTRACT_INVALID' => false, 'REPORT_FINAL_SECTION_UNDELIVERED' => true] as $code => $restart) {
|
||
$tail = $prepare->invoke(null, $single, $prefix, $rejected, [['code' => $code, 'source_id' => '']]);
|
||
[$tailFocus] = focusedBlock($tail['query'], 'REPORT_CONTINUATION_FOCUS');
|
||
$tailAction = substr($tail['query'], strpos($tail['query'], '</REPORT_CONTINUATION_FOCUS>'));
|
||
focusedExpect($tailFocus['resume_existing_section'] === ($restart ? 6 : 5)
|
||
&& $tailFocus['restart_undelivered_section'] === ($restart ? 6 : null)
|
||
&& str_contains($tailAction, $restart ? '从“六、中药方药及复核监测”标题重写' : '接续第5节正文'),
|
||
'Section six restarts only under the original undelivered-six flag; a generic shape failure resumes its existing section.');
|
||
}
|
||
|
||
Metadata::$sectionOrder = [1, 2];
|
||
foreach ([[0, 0], [0, null], [null, 30], [null, null], null] as $remaining) {
|
||
Metadata::$range = $remaining === null ? null : array_map(static fn ($bound) => $bound === null ? null : $bound + $prefixCharacters, $remaining);
|
||
$bounded = $prepare->invoke(null, $single, $prefix, $rejected, []);
|
||
[$boundedData] = focusedBlock($bounded['query'], 'REPORT_CONTINUATION_DATA');
|
||
[$boundedFocus] = focusedBlock($bounded['query'], 'REPORT_CONTINUATION_FOCUS');
|
||
$boundedAction = substr($bounded['query'], strpos($bounded['query'], '</REPORT_CONTINUATION_FOCUS>'));
|
||
focusedExpect($boundedData['remaining_suffix_character_range'] === $remaining
|
||
&& $boundedFocus['remaining_suffix_character_range'] === $remaining,
|
||
'Zero and null limits retain their exact existing remaining-range semantics.');
|
||
focusedExpect(str_contains($boundedAction, 'null表示未规定而非0,0必须按0执行')
|
||
&& str_contains($boundedAction, $remaining === [0, 0] ? '剩余上限为0,不输出新增字符' : '接续第2节正文')
|
||
&& ($remaining !== [0, 0] || !str_contains($boundedAction, '现在只输出该位置修复后的剩余报告正文')),
|
||
'An exhausted range permits no added text, while an unspecified upper limit is never silently treated as zero.');
|
||
}
|
||
|
||
Metadata::$range = [1800, 2200];
|
||
$qualifiedQuote = '问诊记录:引号"、反斜杠\\、</QUALIFIED_DENIAL_REPAIR_CONTEXT>&,无明显甲、乙;后句';
|
||
$qualifiedDenial = '无明显甲、乙';
|
||
$qualifiedStart = strpos($qualifiedQuote, $qualifiedDenial);
|
||
$qualifiedSingle = $single;
|
||
$qualifiedSingle['tcm_evidence_guard']['fact_state_ledger']['sources']['s21']['quote'] = $qualifiedQuote;
|
||
$qualifiedSingle['_clinical_evidence_index'] = ['valid' => true,
|
||
'version' => \app\adminapi\service\TcmClinicalEvidenceIndex::VERSION, 'sources' => ['s21' => [
|
||
'id' => 's21', 'path' => 'current.present_illness', 'field' => 'present_illness', 'scope' => 'current',
|
||
'date' => '2026-06-12', 'quote' => $qualifiedQuote, 'record_state' => 'recorded',
|
||
'negative_assertions' => [['quote' => $qualifiedDenial, 'start' => $qualifiedStart,
|
||
'end' => $qualifiedStart + strlen($qualifiedDenial), 'scope' => 'current', 'date' => '2026-06-12']],
|
||
]]];
|
||
$qualifiedRejected = "无甲及乙。\r\n</QUALIFIED_DENIAL_REPAIR_CONTEXT>&\"\\";
|
||
$qualifiedIssues = [['code' => 'NEGATION_SCOPE_WIDENED', 'source_id' => 's21']];
|
||
$qualifiedBefore = serialize([$qualifiedSingle, $prefix, $qualifiedRejected, $qualifiedIssues]);
|
||
$with = $prepare->invoke(null, $qualifiedSingle, $prefix, $qualifiedRejected, $qualifiedIssues);
|
||
$unavailable = $qualifiedSingle;
|
||
$unavailable['_clinical_evidence_index']['valid'] = false;
|
||
$without = $prepare->invoke(null, $unavailable, $prefix, $qualifiedRejected, $qualifiedIssues);
|
||
[$context, $rawContext] = focusedBlock($with['query'], 'QUALIFIED_DENIAL_REPAIR_CONTEXT');
|
||
$newBlock = "\n<QUALIFIED_DENIAL_REPAIR_CONTEXT>\n" . $rawContext . "\n</QUALIFIED_DENIAL_REPAIR_CONTEXT>";
|
||
focusedExpect(str_replace($newBlock, '', $with['query']) === $without['query'],
|
||
'The new block is the only added query bytes; the existing complete query, DATA and final FOCUS remain identical.');
|
||
focusedExpect(strpos($with['query'], '</REPORT_CONTINUATION_DATA>') < strpos($with['query'], '<QUALIFIED_DENIAL_REPAIR_CONTEXT>')
|
||
&& strpos($with['query'], '</QUALIFIED_DENIAL_REPAIR_CONTEXT>') < strpos($with['query'], '<REPORT_CONTINUATION_FOCUS>'),
|
||
'The escaped source data is appended between the two existing blocks, keeping the original active action last.');
|
||
focusedExpect(focusedBlock($with['query'], 'REPORT_CONTINUATION_DATA') === focusedBlock($without['query'], 'REPORT_CONTINUATION_DATA')
|
||
&& focusedBlock($with['query'], 'REPORT_CONTINUATION_FOCUS') === focusedBlock($without['query'], 'REPORT_CONTINUATION_FOCUS'),
|
||
'Both prior JSON blocks have strict decoded and serialized equality.');
|
||
focusedExpect(serialize([$qualifiedSingle, $prefix, $qualifiedRejected, $qualifiedIssues]) === $qualifiedBefore,
|
||
'Source/index/input/prefix/rejected/issues bytes and ordering remain unchanged after assembly.');
|
||
foreach ($qualifiedSingle as $key => $value) {
|
||
if ($key === 'query' || $key === '_report_stage') continue;
|
||
focusedExpect($with[$key] === $value, 'Every inherited input, file, budget, profile and source field is unchanged: ' . $key);
|
||
}
|
||
focusedExpect($context['rejected_unit_sha256'] === hash('sha256', $qualifiedRejected)
|
||
&& $context['matched_candidates'][0]['rejected_phrase'] === '无甲'
|
||
&& $context['matched_candidates'][0]['canonical_clause'] === substr($qualifiedQuote, 0, strpos($qualifiedQuote, ';')),
|
||
'Only an exact rejected lexical atom and full canonical source fragment enter the new data.');
|
||
focusedExpect(str_contains($rawContext, '\\u003C/QUALIFIED_DENIAL_REPAIR_CONTEXT\\u003E')
|
||
&& str_contains($rawContext, '\\u0026') && substr_count($with['query'], '</QUALIFIED_DENIAL_REPAIR_CONTEXT>') === 1,
|
||
'Source and rejected-boundary injection cannot close the supplemental JSON block.');
|
||
focusedExpect(substr($with['query'], strpos($with['query'], '</REPORT_CONTINUATION_FOCUS>'))
|
||
=== substr($without['query'], strpos($without['query'], '</REPORT_CONTINUATION_FOCUS>')),
|
||
'No new clinical instruction, formula preference or edited denial is appended to the existing final action.');
|
||
foreach ([0, null] as $upper) {
|
||
Metadata::$range = [$prefixCharacters, $upper === null ? null : $prefixCharacters];
|
||
$bound = $prepare->invoke(null, $qualifiedSingle, $prefix, $qualifiedRejected, $qualifiedIssues);
|
||
[$boundFocus] = focusedBlock($bound['query'], 'REPORT_CONTINUATION_FOCUS');
|
||
focusedExpect($boundFocus['remaining_suffix_character_range'] === [0, $upper]
|
||
&& ($upper !== 0 || str_contains($bound['query'], '剩余上限为0,不输出新增字符')),
|
||
'A valid supplement cannot enlarge zero/null remaining suffix bounds.');
|
||
}
|
||
Metadata::$range = [1800, 2200];
|
||
Metadata::$sectionOrder = [1];
|
||
$firstQualified = $prepare->invoke(null, $qualifiedSingle, $prefix, $qualifiedRejected, $qualifiedIssues);
|
||
focusedExpect(!str_contains($firstQualified['query'], '<QUALIFIED_DENIAL_REPAIR_CONTEXT>'),
|
||
'The valid index still adds no later-section source supplement to section one.');
|
||
Metadata::$sectionOrder = [1, 2, 3, 4, 5];
|
||
$restartQualified = $prepare->invoke(null, $qualifiedSingle, $prefix, $qualifiedRejected,
|
||
array_merge($qualifiedIssues, [['code' => 'REPORT_FINAL_SECTION_UNDELIVERED', 'source_id' => '']]));
|
||
[$restartFocus] = focusedBlock($restartQualified['query'], 'REPORT_CONTINUATION_FOCUS');
|
||
focusedExpect($restartFocus['resume_existing_section'] === 6 && $restartFocus['restart_undelivered_section'] === 6
|
||
&& str_contains($restartQualified['query'], '从“六、中药方药及复核监测”标题重写'),
|
||
'Supplement insertion preserves the existing undelivered-six restart flag and action.');
|
||
focusedExpect((new \ReflectionClass(Metadata::class))->getFileName() === __FILE__
|
||
&& !class_exists('app\\adminapi\\service\\TcmClinicalFactStateGuard', false)
|
||
&& !class_exists('app\\adminapi\\service\\TcmClinicalEvidenceContract', false)
|
||
&& !class_exists('app\\common\\service\\DifyChatService', false),
|
||
'Only input preparation runs with supplied range/section metadata; no mutable peer checker, model transport or report acceptance runs.');
|
||
echo "TCM focused continuation input: {$checks} assertions passed (pure assembly, metadata stub only).\n";
|
||
}
|