143 lines
9.2 KiB
PHP
143 lines
9.2 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
// Pure parser/format fixtures. No config, application bootstrap, DB or network.
|
||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||
|
||
use app\common\service\DifyChatService;
|
||
use app\common\service\DifyReasoningTextFilter;
|
||
use app\common\service\DifyStreamTiming;
|
||
|
||
$assertions = 0;
|
||
function filterCheck(bool $condition, string $message): void
|
||
{
|
||
++$GLOBALS['assertions'];
|
||
if (!$condition) throw new RuntimeException($message);
|
||
}
|
||
function filterPrivate(string $method, array $args)
|
||
{
|
||
return (new ReflectionClass(DifyChatService::class))->getMethod($method)->invokeArgs(null, $args);
|
||
}
|
||
function filterFrame(array $data): string
|
||
{
|
||
return 'data: ' . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . "\r\n\r\n";
|
||
}
|
||
function filterDecode(string $protocol, array $chunks): array
|
||
{
|
||
return filterPrivate('decodeStreamChunks', [$protocol, $chunks]);
|
||
}
|
||
|
||
$cases = [
|
||
['<think>PRIVATE_PLAN</think>临床正文。', '临床正文。'],
|
||
['<ThInK>PRIVATE_PLAN</tHiNk>正文', '正文'],
|
||
['<think>outer<think>inner</think>outer</think>正文', '正文'],
|
||
['前文<think>PRIVATE_PLAN</think>后文', '前文后文'],
|
||
['<think>one</think><think>two</think>正文', '正文'],
|
||
[" \n<think>PRIVATE_PLAN</think>\n正文", " \n\n正文"],
|
||
[" \n<think>PRIVATE_PLAN</think>\n\t", ''],
|
||
['<think>PRIVATE_PLAN', ''],
|
||
['<think>PRIVATE_PLAN</thi', ''],
|
||
['<think', ''],
|
||
['前文<think', '前文'],
|
||
['前文<think>PRIVATE_PLAN</thi', '前文'],
|
||
['</think>正文', '正文'],
|
||
['普通 thinking / planning;x < y;<thinking>文字</thinking>;<think>',
|
||
'普通 thinking / planning;x < y;<thinking>文字</thinking>;<think>'],
|
||
['正文末尾 <', '正文末尾 <'],
|
||
['正文末尾 <thi', '正文末尾 <thi'], // An incomplete literal outside a block is not a tag.
|
||
['正文 <thinker> 内容', '正文 <thinker> 内容'],
|
||
];
|
||
foreach ($cases as [$raw, $expected]) {
|
||
filterCheck(DifyReasoningTextFilter::text($raw) === $expected, 'Whole text preserves exactly the visible body.');
|
||
for ($cut = 0; $cut <= strlen($raw); ++$cut) {
|
||
$filter = new DifyReasoningTextFilter();
|
||
$actual = $filter->push(substr($raw, 0, $cut)) . $filter->push(substr($raw, $cut)) . $filter->finish();
|
||
filterCheck($actual === $expected, 'Any byte split matches whole-text filtering.');
|
||
filterCheck($filter->finish() === '' && $filter->push('late') === '', 'Terminal filtering is idempotent.');
|
||
foreach ($filter->counts() as $count) filterCheck(is_int($count), 'Filter diagnostics contain only integers.');
|
||
}
|
||
$filter = new DifyReasoningTextFilter(); $visible = '';
|
||
foreach (str_split($raw) as $byte) $visible .= $filter->push($byte);
|
||
$visible .= $filter->finish();
|
||
filterCheck($visible === $expected, 'One-byte filter chunks preserve UTF-8 bytes and tag boundaries.');
|
||
}
|
||
|
||
foreach (['dify', 'openai'] as $protocol) {
|
||
$wire = '';
|
||
foreach (['<', 'th', 'ink>', 'PRIVATE_PLAN', '</', 'thi', 'nk>', '临床', '正文。'] as $part) {
|
||
$wire .= filterFrame($protocol === 'dify' ? ['event' => 'message', 'answer' => $part]
|
||
: ['choices' => [['delta' => ['content' => $part]]]]);
|
||
}
|
||
$wire .= $protocol === 'dify' ? filterFrame(['event' => 'message_end']) : "data: [DONE]\r\n\r\n";
|
||
for ($cut = 0; $cut <= strlen($wire); ++$cut) {
|
||
$out = filterDecode($protocol, [substr($wire, 0, $cut), substr($wire, $cut)]);
|
||
filterCheck($out['content'] === '临床正文。' && implode('', $out['deltas']) === $out['content'] && $out['finished'],
|
||
'SSE wire splits and token-level tag splits never leak thinking.');
|
||
}
|
||
$out = filterDecode($protocol, str_split($wire));
|
||
filterCheck($out['content'] === '临床正文。', 'One-byte SSE network reads retain exact clinical body.');
|
||
$blocking = $protocol === 'dify' ? ['answer' => '<think>PRIVATE_PLAN</think>临床正文。', 'thought' => 'PRIVATE_FIELD']
|
||
: ['choices' => [['message' => ['reasoning_content' => 'PRIVATE_FIELD', 'content' => '<think>PRIVATE_PLAN</think>临床正文。']]]];
|
||
$formatted = filterPrivate('formatResponse', [['errno' => 0, 'http_code' => 200,
|
||
'body' => json_encode($blocking, JSON_UNESCAPED_UNICODE)], microtime(true)]);
|
||
filterCheck($formatted['ok'] && $formatted['content'] === $out['content'], 'Blocking and streaming expose the same body.');
|
||
foreach (['', '<think', '<think>PRIVATE_PLAN</think>', '<think>PRIVATE_PLAN</thi'] as $reasoningOnly) {
|
||
$wire = filterFrame($protocol === 'dify' ? ['event' => 'message', 'answer' => $reasoningOnly, 'thought' => 'PRIVATE_FIELD']
|
||
: ['choices' => [['delta' => ['content' => $reasoningOnly, 'reasoning_content' => 'PRIVATE_FIELD']]]]);
|
||
$wire .= $protocol === 'dify' ? filterFrame(['event' => 'message_end']) : "data: [DONE]\n\n";
|
||
$out = filterDecode($protocol, str_split($wire));
|
||
filterCheck($out['content'] === '' && $out['deltas'] === [], 'Reasoning-only and unclosed blocks emit no clinical delta.');
|
||
$result = filterPrivate('formatStreamResponse', [['errno' => 0, 'http_code' => 200, 'content' => $out['content'],
|
||
'finished' => $out['finished']], microtime(true)]);
|
||
filterCheck(!$result['ok'] && $result['error_code'] === 'EMPTY_RESPONSE', 'Reasoning-only stream is never report success.');
|
||
$payload = $protocol === 'dify' ? ['answer' => $reasoningOnly, 'thought' => 'PRIVATE_FIELD']
|
||
: ['choices' => [['message' => ['content' => $reasoningOnly, 'reasoning_content' => 'PRIVATE_FIELD']]]];
|
||
$result = filterPrivate('formatResponse', [['errno' => 0, 'http_code' => 200,
|
||
'body' => json_encode($payload)], microtime(true)]);
|
||
filterCheck(!$result['ok'] && $result['error_code'] === 'EMPTY_RESPONSE', 'Reasoning-only blocking reply also fails empty.');
|
||
}
|
||
}
|
||
|
||
$parts = ['choices' => [['delta' => ['reasoning_content' => 'PRIVATE_FIELD', 'content' => [
|
||
['type' => 'reasoning', 'text' => 'PRIVATE_PART'], ['type' => 'text', 'text' => '<think>PRIVATE_PLAN</think>'],
|
||
['type' => 'text', 'text' => '真正正文'],
|
||
]]]]];
|
||
$out = filterDecode('openai', [filterFrame($parts), "data: [DONE]\n\n"]);
|
||
filterCheck($out['content'] === '真正正文', 'Dedicated reasoning fields/parts stay private alongside genuine text parts.');
|
||
$parts['choices'][0]['message'] = $parts['choices'][0]['delta']; unset($parts['choices'][0]['delta']);
|
||
filterCheck(filterPrivate('extractContent', [$parts]) === '真正正文', 'Blocking text-part arrays use the same filter.');
|
||
|
||
// Assert the real upstream clock never sees an inline plan as first answer.
|
||
$now = 0.0;
|
||
$timing = new DifyStreamTiming(static function () use (&$now): float { return $now; });
|
||
$state = filterPrivate('newStreamState', []); $state['timing'] = $timing;
|
||
$tasks = []; $terminals = []; $received = [];
|
||
$state['on_task'] = static function (string $id) use (&$tasks): void { $tasks[] = $id; };
|
||
$state['on_terminal'] = static function (string $id, string $status) use (&$terminals): void { $terminals[] = [$id, $status]; };
|
||
$consume = (new ReflectionClass(DifyChatService::class))->getMethod('consumeStreamFrame');
|
||
$receive = static function (string $delta) use (&$received): bool { $received[] = $delta; return true; };
|
||
foreach ([[10, ['event' => 'workflow_started', 'task_id' => 'filter-task']],
|
||
[20, ['event' => 'message', 'task_id' => 'filter-task', 'answer' => '<think>PRIVATE_PLAN']],
|
||
[30, ['event' => 'message', 'task_id' => 'filter-task', 'answer' => "</think>\n"]]] as [$now, $event]) {
|
||
$consume->invokeArgs(null, ['dify', filterFrame($event), &$state, $receive]);
|
||
filterCheck(!isset($timing->snapshot()['first_answer_ms']) && $received === [], 'No first answer/delivery from thinking or whitespace.');
|
||
}
|
||
$now = 40;
|
||
$consume->invokeArgs(null, ['dify', filterFrame(['event' => 'message', 'answer' => '正文']), &$state, $receive]);
|
||
$now = 50;
|
||
$consume->invokeArgs(null, ['dify', filterFrame(['event' => 'message_end']), &$state, $receive]);
|
||
$consume->invokeArgs(null, ['dify', filterFrame(['event' => 'workflow_finished', 'task_id' => 'filter-task', 'data' => ['status' => 'succeeded']]), &$state, $receive]);
|
||
filterCheck($timing->snapshot()['first_answer_ms'] === 40 && $timing->snapshot()['first_delivered_ms'] === 40,
|
||
'True clinical text establishes first answer and delivery.');
|
||
filterCheck($tasks === ['filter-task'] && $terminals === [['filter-task', 'succeeded']], 'Task and terminal observers survive filtering.');
|
||
filterCheck($state['finished'] && $state['content'] === "\n正文" && implode('', $received) === $state['content'], 'Exact visible bytes and terminal completion stay consistent.');
|
||
filterCheck(!str_contains(json_encode($timing->snapshot()), 'PRIVATE_'), 'Telemetry never retains reasoning contents.');
|
||
|
||
$state = filterPrivate('newStreamState', []); $state['discard_deltas'] = true; $received = [];
|
||
$consume->invokeArgs(null, ['dify', filterFrame(['event' => 'message', 'answer' => '<think>PRIVATE_PLAN</think>late']), &$state, $receive]);
|
||
$consume->invokeArgs(null, ['dify', filterFrame(['event' => 'message_end']), &$state, $receive]);
|
||
filterCheck($received === [] && $state['content'] === '', 'Cancelled/timeout discard state never releases filtered or buffered text.');
|
||
|
||
echo "Dify incremental reasoning filter: {$assertions} assertions passed (pure offline fixtures).\n";
|