149 lines
6.9 KiB
PHP
Executable File
149 lines
6.9 KiB
PHP
Executable File
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace app\adminapi\service;
|
|
|
|
use app\common\service\DifyStreamTiming;
|
|
use think\facade\Log;
|
|
|
|
/** A request-local, patient-free timing record for the diagnosis assistant SSE path. */
|
|
final class AssistantSseTiming
|
|
{
|
|
private const PHASES = ['prepare', 'sse_start', 'attachment', 'first_delta', 'first_live_update', 'upstream_done', 'sse_done'];
|
|
|
|
private const NUMBER_FIELDS = [
|
|
'duration_ms', 'aggregate_ms', 'compaction_ms', 'retry_ms',
|
|
'source_bytes', 'query_bytes', 'attachment_count',
|
|
'delta_bytes', 'delta_count', 'first_delta_ms', 'inputs_bytes',
|
|
'source_ms', 'upload_ms', 'ocr_ms', 'reuse_wait_ms', 'upload_cache_hits', 'upload_cache_misses',
|
|
'clinical_issue_count', 'clinical_repair_count',
|
|
'first_valid_update_ms', 'live_update_count',
|
|
];
|
|
|
|
private const BOOLEAN_FIELDS = ['compaction_attempted', 'retry_attempted', 'first_delta_observed', 'compaction_cache_hit'];
|
|
|
|
private const ERROR_CODES = [
|
|
'AI_ASSISTANT_FAILED', 'UPSTREAM_UNAVAILABLE', 'UPSTREAM_REJECTED',
|
|
'INCOMPLETE_RESPONSE', 'EMPTY_RESPONSE', 'UPSTREAM_TIMEOUT',
|
|
'CONFIG_DISABLED', 'INVALID_PROFILE', 'CONFIG_MISSING', 'CONFIG_INVALID',
|
|
'CURL_UNAVAILABLE', 'SKILL_UNAVAILABLE', 'CLIENT_DISCONNECTED',
|
|
'STREAM_DELIVERY_FAILED', 'REQUEST_BUILD_FAILED', 'CURL_INIT_FAILED',
|
|
'UPSTREAM_BUSY', 'INVALID_RESPONSE', 'AI_ASSISTANT_CANCELLED',
|
|
'TCM_FACT_STATE_CONFLICT', 'TCM_REPORT_INCOMPLETE', 'TASK_ANALYSIS_MISSING',
|
|
'STREAM_CONTENT_MISMATCH', 'UPSTREAM_CLEANUP_PENDING', 'PULSE_EXCLUDED_FROM_ASSESSMENT',
|
|
'LIVE_CONTEXT_UNAVAILABLE', 'LIVE_STREAM_REQUIRED', 'LIVE_RESULT_INVALID',
|
|
];
|
|
|
|
/** Exact issue enums emitted by the consultation stream's local guards. */
|
|
private const CLINICAL_ISSUE_CODES = [
|
|
'FACT_STATE_LEDGER_UNAVAILABLE', 'FACT_STATE_INVALID_UTF8', 'SOURCE_ANNOTATION_INVALID',
|
|
'UNSUPPORTED_NEGATIVE_REFERENCE', 'ATTRIBUTED_SOURCE_QUOTE_INVALID',
|
|
'UNKNOWN_TO_NEGATIVE', 'UNKNOWN_TO_POSITIVE', 'POSITIVE_TO_NEGATIVE', 'NEGATIVE_TO_POSITIVE',
|
|
'NEGATION_SCOPE_WIDENED', 'NEGATIVE_NOT_IN_SOURCE', 'NEGATIVE_TO_UNKNOWN',
|
|
'POSITIVE_TO_UNKNOWN', 'RECORDED_TO_UNKNOWN', 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE',
|
|
'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE',
|
|
'CONSULTATION_INVALID_UTF8', 'PULSE_EXCLUDED_FROM_ASSESSMENT',
|
|
'EVIDENCE_INDEX_UNAVAILABLE', 'EVIDENCE_INVALID_UTF8', 'EXAM_ROLE_MISSING',
|
|
'EXAM_HEADING_METADATA_UNSUPPORTED', 'EXAM_FACT_PROTOCOL_INVALID',
|
|
'EXAM_NEGATIVE_HEADING_UNSUPPORTED', 'LONGITUDINAL_FACT_PROTOCOL_INVALID',
|
|
'EXAM_KNOWN_ROLE_TO_UNKNOWN', 'EVIDENCE_SOURCE_DATE_MISMATCH',
|
|
'LONGITUDINAL_SOURCE_NOT_HISTORICAL', 'EXAM_SOURCE_QUOTE_INVALID',
|
|
'EXAM_SOURCE_NOT_CURRENT', 'EXAM_ROLE_MISMATCH', 'DENIAL_SOURCE_QUOTE_INVALID',
|
|
'UNQUOTED_PATIENT_DENIAL', 'HERBAL_PREMISE_SELF_CONFLICT',
|
|
'REPORT_BOUND_EXCEEDED', 'REPORT_FINAL_CONTRACT_INVALID',
|
|
'REPORT_CONTINUATION_REPEATED_SECTION', 'REPORT_FINAL_SECTION_UNDELIVERED',
|
|
];
|
|
|
|
private string $traceId;
|
|
private int $startedAt;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->traceId = bin2hex(random_bytes(16));
|
|
$this->startedAt = hrtime(true);
|
|
}
|
|
|
|
public function id(): string
|
|
{
|
|
return $this->traceId;
|
|
}
|
|
|
|
public static function elapsedMs(int $startedAt): int
|
|
{
|
|
return max(0, (int) floor((hrtime(true) - $startedAt) / 1000000));
|
|
}
|
|
|
|
/**
|
|
* Only fixed numeric, boolean and enum metadata reaches the log. In particular,
|
|
* arbitrary prompt text, patient identifiers, URLs and credentials are discarded.
|
|
*
|
|
* @param array<string,mixed> $metrics
|
|
* @return array<string,int|bool|string|list<string>>
|
|
*/
|
|
public function event(string $phase, array $metrics = []): array
|
|
{
|
|
$event = [
|
|
'trace_id' => $this->traceId,
|
|
'phase' => in_array($phase, self::PHASES, true) ? $phase : 'upstream_done',
|
|
// Includes authorization, aggregation and compaction before SSE starts.
|
|
'request_elapsed_ms' => self::elapsedMs($this->startedAt),
|
|
];
|
|
foreach (self::NUMBER_FIELDS as $field) {
|
|
if (isset($metrics[$field]) && is_int($metrics[$field])) {
|
|
$event[$field] = max(0, $metrics[$field]);
|
|
}
|
|
}
|
|
foreach (self::BOOLEAN_FIELDS as $field) {
|
|
if (isset($metrics[$field]) && is_bool($metrics[$field])) {
|
|
$event[$field] = $metrics[$field];
|
|
}
|
|
}
|
|
if (isset($metrics['status']) && in_array($metrics['status'], ['ok', 'error', 'exception', 'disconnected', 'cancelled'], true)) {
|
|
$event['status'] = $metrics['status'];
|
|
}
|
|
if (isset($metrics['error_code']) && in_array($metrics['error_code'], self::ERROR_CODES, true)) {
|
|
$event['error_code'] = $metrics['error_code'];
|
|
}
|
|
if (in_array($metrics['live_error_category'] ?? null,
|
|
['context', 'format', 'citation', 'safety', 'relevance', 'timeout', 'upstream', 'cancelled'], true))
|
|
$event['live_error_category'] = $metrics['live_error_category'];
|
|
if (in_array($metrics['live_validation_reason'] ?? null,
|
|
['LENGTH_LIMIT', 'JSON_INVALID', 'SCHEMA_INVALID', 'CITATION_INVALID', 'SAFETY_REJECTED'], true))
|
|
$event['live_validation_reason'] = $metrics['live_validation_reason'];
|
|
if ($event['phase'] === 'upstream_done' && is_array($metrics['clinical_issue_codes'] ?? null)) {
|
|
$codes = [];
|
|
foreach ($metrics['clinical_issue_codes'] as $code) {
|
|
// Never accept source IDs, raw issue objects or a regex-shaped arbitrary string.
|
|
if (!is_string($code) || !in_array($code, self::CLINICAL_ISSUE_CODES, true)) continue;
|
|
$codes[$code] = true;
|
|
if (count($codes) === 16) break;
|
|
}
|
|
$event['clinical_issue_codes'] = array_keys($codes);
|
|
}
|
|
return $event;
|
|
}
|
|
|
|
/** @param array<string,mixed> $metrics */
|
|
public function log(string $phase, array $metrics = []): void
|
|
{
|
|
try {
|
|
// This endpoint exits after SSE and bypasses normal HTTP-end log flushing.
|
|
Log::write('diagnosis ai assistant timing ' . json_encode($this->event($phase, $metrics)), 'info');
|
|
} catch (\Throwable $ignored) {
|
|
// Telemetry must never interrupt the clinical request.
|
|
}
|
|
}
|
|
|
|
/** The typed collector exposes only bounded numeric timing and enum metadata. */
|
|
public function logUpstream(DifyStreamTiming $upstream): void
|
|
{
|
|
try {
|
|
Log::write('diagnosis ai upstream stages ' . json_encode([
|
|
'trace_id' => $this->traceId, 'request_elapsed_ms' => self::elapsedMs($this->startedAt),
|
|
'timing' => $upstream->snapshot(),
|
|
]), 'info');
|
|
} catch (\Throwable $ignored) {}
|
|
}
|
|
}
|