Files
zyt/server/app/adminapi/service/TcmClinicalReportStream.php
grandClaude Opus 5.5 ea40cfdfed AI助手临床校验改为只提示不拦截,资料不全也给出解答
- TcmClinicalReportStream 新增 BLOCKING 开关(默认 false),校验命中时照常交付原文,问题码记入 warnings()
- 问答、长报告、结构化分析、报告生成的脉象/事实状态/溯源校验改为随结果返回 clinical_review_warnings
- 处方草稿丢弃多余脉象字段、重复药名保留首味,不再整张失败
- 接诊提示词补充互联网面诊仅舌象无脉象、须合理推断给出结论

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:00:10 +08:00

189 lines
8.9 KiB
PHP

<?php
declare(strict_types=1);
namespace app\adminapi\service;
/** Validate complete prose units before exposing their original bytes to clients. */
final class TcmClinicalReportStream
{
/**
* 互联网面诊只有舌象等部分资料,校验规则命中时不再截断回答:
* 原文照常交付,问题码记入 warnings() 供医师复核。设为 true 恢复拦截模式。
*/
public const BLOCKING = false;
private string $pending = '';
private string $accepted = '';
private bool $stopped = false;
private array $issues = [];
private array $warnings = [];
private \Closure $forward;
private ?\Closure $onReject;
private string $deferred = '';
private bool $holdingFinal = false;
private ?\Closure $shouldAbort;
private ?array $finalContract = null;
private array $priorAnswerUnits = [];
public function __construct(private array $ledger, callable $forward, ?callable $onReject = null,
string $validatedPrefix = '', private ?string $continuationQuery = null,
private ?array $evidenceIndex = null, private ?string $finalCheckpointQuery = null,
?callable $shouldAbort = null, private bool $pulseExcluded = false, bool $preventPrefixRepeat = false)
{
// Context from a previous, already delivered attempt is validated with
// every new unit, but is never sent through forward a second time.
$this->accepted = $validatedPrefix;
$this->forward = \Closure::fromCallable($forward);
$this->onReject = $onReject === null ? null : \Closure::fromCallable($onReject);
$this->shouldAbort = $shouldAbort === null ? null : \Closure::fromCallable($shouldAbort);
if ($preventPrefixRepeat) {
$this->priorAnswerUnits = array_values(array_filter(array_map('trim',
preg_split('/(?:\r?\n){2}/', $validatedPrefix) ?: []), static fn (string $unit): bool => $unit !== ''));
}
}
public function push(string $delta): bool
{
if ($this->stopped) return false;
if ($this->finalCheckpointQuery !== null && $this->shouldAbort !== null && ($this->shouldAbort)()) {
$this->stopped = true;
return false;
}
$this->pending .= $delta;
if (strlen($this->accepted) + strlen($this->deferred) + strlen($this->pending) > 100000) {
$this->issues = [['code' => 'REPORT_BOUND_EXCEEDED', 'source_id' => 'report']];
$this->stopped = true;
return false;
}
// A token prefix is not a complete claim. Do not release it before its
// containing paragraph is available for the source-state check.
while (preg_match('/(?:\r?\n){2}/', $this->pending, $boundary, PREG_OFFSET_CAPTURE) === 1) {
$end = $boundary[0][1] + strlen($boundary[0][0]);
$unit = substr($this->pending, 0, $end);
$this->pending = substr($this->pending, $end);
if (!$this->release($unit)) return false;
}
return true;
}
public function finish(): bool
{
if ($this->stopped) return false;
$unit = $this->pending;
$this->pending = '';
if (!$this->release($unit)) return false;
if ($this->finalCheckpointQuery === null) return true;
$candidate = $this->accepted . $this->deferred;
$review = $this->review($candidate, true);
if (empty($review['valid']) && !$this->reject($this->deferred, $review['issues'])) return false;
$this->finalContract = TcmLongReportContract::validate($candidate, $this->finalCheckpointQuery);
if (empty($this->finalContract['valid'])
&& !$this->reject($this->deferred, [['code' => 'REPORT_FINAL_CONTRACT_INVALID', 'source_id' => 'report']])) {
return false;
}
if ($this->shouldAbort !== null && ($this->shouldAbort)()) { $this->stopped = true; return false; }
if ($this->deferred === '') return true;
// This is exactly the original, never-delivered model tail. No repair,
// trimming, padding or local composition is permitted at this boundary.
if (($this->forward)($this->deferred) === false) { $this->stopped = true; return false; }
$this->accepted .= $this->deferred;
$this->deferred = '';
return true;
}
public function rejected(): bool { return $this->issues !== []; }
public function issues(): array { return $this->issues; }
public function warnings(): array { return $this->warnings; }
public function acceptedContent(): string { return $this->accepted; }
public function finalContract(): ?array { return $this->finalContract; }
private function release(string $unit): bool
{
if ($unit === '') return true;
if (in_array(trim($unit), $this->priorAnswerUnits, true) && !$this->isOrdinaryHeadingUnit($unit)
&& !$this->reject($unit, [['code' => 'REPORT_CONTINUATION_REPEATED_SECTION', 'source_id' => 'report']])) {
return false;
}
if ($this->finalCheckpointQuery !== null && !$this->holdingFinal) {
$offset = 0;
foreach (preg_split('/(?<=\n)|(?<=\r)(?!\n)/', $unit) ?: [] as $line) {
$shape = TcmLongReportContract::validate($line, $this->finalCheckpointQuery);
if (in_array(6, $shape['section_order'], true)) {
if ($offset > 0 && !$this->release(substr($unit, 0, $offset))) return false;
$unit = substr($unit, $offset);
$this->holdingFinal = true;
break;
}
$offset += strlen($line);
}
}
if ($this->holdingFinal) {
$this->deferred .= $unit;
$review = $this->review($this->accepted . $this->deferred);
return !empty($review['valid']) || $this->reject($this->deferred, $review['issues']);
}
$candidate = $this->accepted . $unit;
$review = $this->review($candidate);
if (empty($review['valid']) && !$this->reject($unit, $review['issues'])) return false;
if (($this->forward)($unit) === false) {
$this->stopped = true;
return false;
}
$this->accepted .= $unit;
return true;
}
private function isOrdinaryHeadingUnit(string $unit): bool
{
// Ordinary answers may retain a repeated explicit Markdown heading.
// Skip only duplicate detection: clinical review and raw forwarding
// below still apply. Long-report continuations remain strict.
if ($this->continuationQuery !== null || $this->finalCheckpointQuery !== null) return false;
if (preg_match('/\A {0,3}#{1,6}[ \t]+([^\r\n]+)(?:\r?\n){0,2}\z/u', $unit, $heading) !== 1) return false;
return preg_match('/[^ \t#]/u', $heading[1]) === 1;
}
private function review(string $candidate, bool $complete = false): array
{
$review = $this->pulseExcluded
? TcmConsultationPulsePolicy::validate($this->ledger, $candidate) : ['valid' => true];
if (!empty($review['valid'])) $review = TcmClinicalFactStateGuard::validate($this->ledger, $candidate);
if (!empty($review['valid']) && $this->evidenceIndex !== null) {
$review = TcmClinicalEvidenceContract::validate($this->evidenceIndex, $candidate, $complete);
}
if (!empty($review['valid']) && $this->continuationQuery !== null) {
$shape = TcmLongReportContract::validate($candidate, $this->continuationQuery);
if (in_array('DUPLICATE_REPORT_SECTION', array_column($shape['issues'], 'code'), true)) {
$review = ['valid' => false, 'issues' => [
['code' => 'REPORT_CONTINUATION_REPEATED_SECTION', 'source_id' => 'report'],
]];
}
}
if (!empty($review['valid']) && $this->finalCheckpointQuery !== null) {
$review = TcmHerbalPremiseGuard::validate($candidate, $this->ledger);
}
return $review;
}
private function reject(string $unit, array $issues): bool
{
if (!self::BLOCKING) {
// 非拦截模式:只记录问题码,调用方继续按原流程交付该段原文。
foreach ($issues as $issue) {
if (!is_array($issue)) continue;
$key = ($issue['code'] ?? '') . '|' . ($issue['source_id'] ?? '');
if (!isset($this->warnings[$key]) && count($this->warnings) < 32) $this->warnings[$key] = $issue;
}
return true;
}
if ($this->holdingFinal) {
$issues[] = ['code' => 'REPORT_FINAL_SECTION_UNDELIVERED', 'source_id' => 'report'];
}
$this->issues = $issues;
$this->stopped = true;
if ($this->onReject !== null) ($this->onReject)($unit, $this->issues);
return false;
}
}