200 lines
12 KiB
PHP
200 lines
12 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
/** Offline public-endpoint integration. No network, database, provider or patient data. */
|
||
namespace think\facade {
|
||
class Log {
|
||
public static function warning(string $message, array $context = []): void {}
|
||
public static function write(string $message, string $level = ''): void {}
|
||
}
|
||
}
|
||
namespace app\common\cache {
|
||
class AdminAuthCache {
|
||
public function __construct(int $id) {}
|
||
public function getAdminUri(): array {
|
||
return ['tcm.diagnosis/aiassistant', 'tcm.diagnosis/aianalysis', 'tcm.diagnosis/aigenerateprescription'];
|
||
}
|
||
}
|
||
}
|
||
namespace app\adminapi\logic\firstvisit {
|
||
class MyPatientLogic {
|
||
public static function canAccessDiagnosis($id, $adminId, $adminInfo): bool { return true; }
|
||
}
|
||
}
|
||
namespace app\adminapi\logic\tcm {
|
||
function config(string $key): array {
|
||
return ['timeout' => 90, 'models' => [
|
||
'qwen' => ['name' => 'r37-local-qwen', 'label' => 'R37 local Qwen'],
|
||
'openai' => ['name' => 'r37-local-openai', 'label' => 'R37 local OpenAI'],
|
||
]];
|
||
}
|
||
class DiagnosisLogic {
|
||
public static function detail($params, $adminInfo): array { return $GLOBALS['r37Current']; }
|
||
}
|
||
class PatientAiReportLogic {
|
||
public static function contextForAuthorizedDiagnosis($diagnosis, $adminId, $adminInfo): array {
|
||
return $GLOBALS['r37Context'];
|
||
}
|
||
public static function compactSourceForPrompt(...$args): array {
|
||
throw new \RuntimeException('Unexpected compaction; finite source is below the existing budget.');
|
||
}
|
||
}
|
||
}
|
||
namespace app\common\service {
|
||
class DifyChatService {
|
||
public static array $calls = [];
|
||
public static array $responses = [];
|
||
public static int $totalCalls = 0;
|
||
public static bool $streaming = false;
|
||
private static function take(): array {
|
||
if (self::$responses === []) throw new \RuntimeException('Unexpected extra local transport invocation.');
|
||
return array_shift(self::$responses);
|
||
}
|
||
public static function chat($profile, $inputs, $query, $user, $files = [], $options = []): array {
|
||
++self::$totalCalls;
|
||
self::$calls[] = compact('profile', 'inputs', 'query', 'user', 'files', 'options') + ['transport' => 'chat'];
|
||
return self::take();
|
||
}
|
||
public static function streamChat($profile, $inputs, $query, $user, $delta, $abort, $files = [], $options = []): array {
|
||
++self::$totalCalls;
|
||
self::$calls[] = compact('profile', 'inputs', 'query', 'user', 'files', 'options') + ['transport' => 'stream'];
|
||
$result = self::take();
|
||
if (empty($result['ok'])) return $result;
|
||
self::$streaming = true;
|
||
try {
|
||
foreach (mb_str_split($result['content'], 19, 'UTF-8') as $chunk) {
|
||
if (($abort !== null && $abort()) || $delta($chunk) === false) {
|
||
return ['ok' => false, 'error_code' => 'AI_ASSISTANT_CANCELLED'];
|
||
}
|
||
}
|
||
} finally { self::$streaming = false; }
|
||
return $result;
|
||
}
|
||
}
|
||
}
|
||
namespace {
|
||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||
|
||
use app\adminapi\logic\tcm\DiagnosisAiLogic;
|
||
use app\adminapi\service\AiRequiredSource;
|
||
use app\adminapi\service\TcmEvidenceGuard;
|
||
use app\adminapi\service\TcmClinicalFactStateGuard;
|
||
use app\common\service\DifyChatService;
|
||
|
||
$checks = [];
|
||
function r37Check(bool $passed, string $name): void { $GLOBALS['checks'][] = compact('passed', 'name'); }
|
||
function r37Reset(array $responses): void {
|
||
DifyChatService::$calls = [];
|
||
DifyChatService::$responses = $responses;
|
||
}
|
||
function r37Fixture(string $variant): void {
|
||
$GLOBALS['r37Current'] = [
|
||
'id' => 37, 'patient_id' => 999, 'diagnosis_date' => '2026-10-26', 'age' => 46,
|
||
'chief_complaint' => $variant === 'tongue_missing' ? '口干两周。' : '未记录',
|
||
'tongue' => $variant === 'symptoms_missing' ? '舌淡红,苔薄白。' : '未记录',
|
||
'pulse' => '脉细', 'allergy_history' => '不详',
|
||
'past_history' => '2020年外院糖尿病诊断记录。',
|
||
'current_medications' => '二甲双胍500mg,每日2次。',
|
||
'fasting_blood_sugar' => 7.9, 'systolic_pressure' => 132, 'diastolic_pressure' => 82,
|
||
];
|
||
$sections = []; $metadata = [];
|
||
foreach (AiRequiredSource::LABELS as $key => $label) {
|
||
$value = [['marker' => 'R37_FULL_SOURCE_' . $key]];
|
||
if ($key === 'diagnosis_info') $value[] = $GLOBALS['r37Current'];
|
||
if ($key === 'prescriptions') $value[] = ['prescription_date' => '2020-02-01',
|
||
'prescription_name' => '历史用药记录', 'usage' => '格列美脲已停用。'];
|
||
if ($key === 'daily_tracking') $value[] = ['record_date' => '2026-10-25',
|
||
'fasting_blood_sugar' => 8.3, 'note' => '晚餐主食较多,晚饭后步行20分钟。'];
|
||
if ($key === 'chat') $value[] = ['msg_time' => '2020-02-01', 'text' => '历史自述口渴,非本次采集。'];
|
||
$sections[] = compact('key', 'label', 'value');
|
||
$metadata[] = ['key' => $key, 'label' => $label, 'availability' => 'present', 'count' => count($value), 'status' => 'prepared'];
|
||
}
|
||
$GLOBALS['r37Context'] = [
|
||
'snapshot' => ['required_content' => $sections],
|
||
'source_summary' => ['required_sections' => $metadata, 'snapshot_complete' => true],
|
||
'source_diagnosis_ids' => [12, 37],
|
||
'files' => [['type' => 'image', 'url' => '/offline-r37/attachment.png']],
|
||
];
|
||
}
|
||
|
||
// Namespace transport/auth/context helpers reused from the independently saved
|
||
// source_ids_repro.php. New content-contract expectations below are root-authored.
|
||
$checks = []; $responses = [];
|
||
$ids = ['f1', 'f1, f2', "f1\nf2", 'f1、f2;f3', '["f1","f2"]',
|
||
"```json\n[\"f1\",\"f2\"]\n```", "```text\nf1, f2\n```", "\xEF\xBB\xBFf1 f2"];
|
||
foreach ($ids as $text) {
|
||
r37Check(TcmEvidenceGuard::isSourceIdListOnly($text), 'closed ID-only shape recognized: '.json_encode($text));
|
||
}
|
||
r37Fixture('both_missing');
|
||
foreach (['case_analysis','glucose_trend','medication_review','complication_risk','summary','lifestyle_advice'] as $task) {
|
||
foreach (['ordinary','stream'] as $mode) {
|
||
r37Reset([['ok'=>true,'content'=>'f1, f2']]); $visible='';
|
||
$p=DiagnosisAiLogic::prepareAssistant(37,$task,'请分析已有资料及其局限。',7,[]);
|
||
$result=$mode==='ordinary' ? DiagnosisAiLogic::assistant(37,$task,'请分析已有资料及其局限。',7,[])
|
||
: DiagnosisAiLogic::streamPreparedAssistant($p,static function($delta)use(&$visible){$visible.=$delta;return true;});
|
||
r37Check($result===null && $visible==='' && DiagnosisAiLogic::getAssistantErrorCode()==='TASK_ANALYSIS_MISSING'
|
||
&& count(DifyChatService::$calls)===1, "$task/$mode rejects exact independent counterexample honestly");
|
||
}
|
||
}
|
||
foreach ($ids as $text) foreach (['ordinary','stream'] as $mode) {
|
||
r37Reset([['ok'=>true,'content'=>$text]]);$visible='';
|
||
$p=DiagnosisAiLogic::prepareAssistant(37,'glucose_trend','请分析已有血糖记录及局限。',7,[]);
|
||
$result=$mode==='ordinary' ? DiagnosisAiLogic::assistant(37,'glucose_trend','请分析已有血糖记录及局限。',7,[])
|
||
: DiagnosisAiLogic::streamPreparedAssistant($p,static function($delta)use(&$visible){$visible.=$delta;return true;});
|
||
r37Check($result===null && $visible==='' && DiagnosisAiLogic::getAssistantErrorCode()==='TASK_ANALYSIS_MISSING',
|
||
"$mode ID-only variant rejected before output: ".json_encode($text));
|
||
$guard=TcmEvidenceGuard::assess($r37Current,$r37Context['snapshot']);
|
||
$review=TcmEvidenceGuard::review($guard,$text);
|
||
r37Check(!$review['valid'] && $review['answer']==='' && in_array('TASK_ANALYSIS_MISSING',array_column($review['issues'],'code'),true),
|
||
'compatibility review does not manufacture ID-only analysis');
|
||
}
|
||
foreach (['当前分析引用资料编号f1和f2;既往记录与本次判断保留时点限制。',
|
||
'f1, f2仅为引文编号,已有血糖记录的采样条件仍待核实。'] as $text) foreach (['ordinary','stream'] as $mode) {
|
||
r37Reset([['ok'=>true,'content'=>$text]]);$visible='';
|
||
$p=DiagnosisAiLogic::prepareAssistant(37,'glucose_trend','请分析已有资料及局限。',7,[]);
|
||
$result=$mode==='ordinary' ? DiagnosisAiLogic::assistant(37,'glucose_trend','请分析已有资料及局限。',7,[])
|
||
: DiagnosisAiLogic::streamPreparedAssistant($p,static function($delta)use(&$visible){$visible.=$delta;return true;});
|
||
r37Check(is_array($result) && $result['answer']===$text && ($mode==='ordinary'||$visible===$text),
|
||
"$mode actual prose containing source IDs remains byte-identical");
|
||
}
|
||
|
||
$analysis='可比较记录\n2026-10-25空腹血糖8.3 mmol/L,2026-10-26为7.9 mmol/L;两个记录点不能代表长期趋势。';
|
||
$analysis=str_replace('\\n',"\n",$analysis);
|
||
foreach (['f11, f23', '{"fact_ids":["f11","f23"]}', '["f11","f23"]'] as $citation) {
|
||
foreach ([$citation."\n\n".$analysis,$analysis."\n\n".$citation] as $text) {
|
||
foreach (['ordinary','stream'] as $mode) {
|
||
r37Reset([['ok'=>true,'content'=>$text]]);$visible='';
|
||
$p=DiagnosisAiLogic::prepareAssistant(37,'glucose_trend','请分析已有记录和局限。',7,[]);
|
||
$result=$mode==='ordinary'?DiagnosisAiLogic::assistant(37,'glucose_trend','请分析已有记录和局限。',7,[])
|
||
:DiagnosisAiLogic::streamPreparedAssistant($p,static function($d)use(&$visible){$visible.=$d;return true;});
|
||
r37Check(is_array($result)&&$result['answer']===$text&&($mode==='ordinary'||$visible===$text),
|
||
"$mode complete analysis with leading/trailing citation paragraph preserved");
|
||
}
|
||
}
|
||
}
|
||
foreach (["f11, f23\n\nf24, f25", "{\"fact_ids\":[\"f11\"]}\n\n[\"f23\"]"] as $text) {
|
||
foreach (['ordinary','stream'] as $mode) {
|
||
r37Reset([['ok'=>true,'content'=>$text]]);$visible='';
|
||
$p=DiagnosisAiLogic::prepareAssistant(37,'glucose_trend','请分析已有记录和局限。',7,[]);
|
||
$result=$mode==='ordinary'?DiagnosisAiLogic::assistant(37,'glucose_trend','请分析已有记录和局限。',7,[])
|
||
:DiagnosisAiLogic::streamPreparedAssistant($p,static function($d)use(&$visible){$visible.=$d;return true;});
|
||
r37Check($result===null&&$visible===''&&DiagnosisAiLogic::getAssistantErrorCode()==='TASK_ANALYSIS_MISSING',
|
||
"$mode source-only multi-paragraph response not delivered");
|
||
}
|
||
}
|
||
|
||
foreach (['模型拒绝后只返回{"fact_ids":[]}。','资料处理仅输出{"fact_ids":["f1"]}。'] as $text) foreach (['ordinary','stream'] as $mode) {
|
||
r37Reset([['ok'=>true,'content'=>$text]]);$visible='';
|
||
$p=DiagnosisAiLogic::prepareAssistant(37,'glucose_trend','请分析已有记录和局限。',7,[]);
|
||
$result=$mode==='ordinary'?DiagnosisAiLogic::assistant(37,'glucose_trend','请分析已有记录和局限。',7,[])
|
||
:DiagnosisAiLogic::streamPreparedAssistant($p,static function($d)use(&$visible){$visible.=$d;return true;});
|
||
r37Check($result===null&&$visible===''&&DiagnosisAiLogic::getAssistantErrorCode()==='TASK_ANALYSIS_MISSING',
|
||
"$mode exclusive protocol-only wrapper is not analysis");
|
||
}
|
||
echo json_encode(['suite'=>'RootR37SourceIdAnalysisContent','passed'=>count(array_filter($checks,fn($c)=>$c['passed'])),
|
||
'total'=>count($checks),'checks'=>$checks,'transport_calls'=>DifyChatService::$totalCalls,
|
||
'live_model_calls'=>0,'network_calls'=>0],JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT)."\n";
|
||
exit(in_array(false,array_column($checks,'passed'),true)?1:0);
|
||
}
|