chatJson(self::systemPrompt(), self::userPrompt($message, $historyMessages), self::modelOptions()); if (!empty($result['success']) && is_array($result['parsed'] ?? null)) { $plan = self::normalizePlan($result['parsed'], $message); $plan['_meta'] = [ 'source' => 'llm', 'model' => (string) ($result['model'] ?? ''), 'provider_label' => (string) ($result['provider_label'] ?? ''), ]; return $plan; } $plan = self::fallbackPlan($message); $plan['_meta'] = [ 'source' => 'fallback', 'error' => (string) ($result['error'] ?? 'planner_failed'), ]; return $plan; } private static function systemPrompt(): string { $today = date('Y-m-d'); return '你是世博头条 AI 助手的查询规划器。' . '你的任务不是直接回答用户,而是把问题改写成便于后续检索和回答的结构化 JSON。' . '今天日期是 ' . $today . '。' . '必须只输出一个 JSON 对象,不要输出额外说明。' . '字段要求:' . 'intent(如 match_schedule/match_result/crypto_quote/lottery_analysis/news_search/general_search)、' . 'rewritten_query(字符串)、alternate_queries(字符串数组)、domains(article/post/lottery/match/crypto 数组)、' . 'preferred_domain(单个领域)、entities(teams/leagues/symbols/keywords 数组)、' . 'date_range(preset/date/start_date/end_date,可为空对象)、needs_realtime(布尔)、' . 'answer_format(固定 markdown)、confidence(0-1 数值)。' . '如果用户提到今天/明天/昨天/最新/最近,要在 date_range 或 intent 中明确体现。'; } private static function userPrompt(string $message, array $historyMessages): string { $history = []; foreach (array_slice($historyMessages, -6) as $row) { $role = (string) ($row['role'] ?? 'user'); $content = trim((string) ($row['content'] ?? '')); if ($content === '') { continue; } $history[] = [ 'role' => $role, 'content' => mb_substr($content, 0, 300), ]; } return "对话历史:\n" . json_encode($history, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n\n当前用户问题:\n" . $message . "\n\n请输出查询规划 JSON。"; } private static function modelOptions(): array { return DeepSeekClient::defaultModelOptions([ 'max_tokens' => 800, 'temperature' => 0.15, 'retry_times' => 1, 'retry_sleep_ms' => 600, ]); } private static function fallbackPlan(string $message): array { return self::normalizePlan([ 'intent' => self::inferIntent($message), 'rewritten_query' => $message, 'alternate_queries' => self::buildAlternateQueries($message), 'domains' => self::inferDomains($message), 'preferred_domain' => self::inferPreferredDomain($message), 'entities' => self::inferEntities($message), 'date_range' => self::inferDateRange($message), 'needs_realtime' => self::inferNeedsRealtime($message), 'answer_format' => 'markdown', 'confidence' => 0.45, ], $message); } private static function normalizePlan(array $parsed, string $message): array { $intent = trim((string) ($parsed['intent'] ?? '')); if ($intent === '') { $intent = self::inferIntent($message); } $rewrittenQuery = trim((string) ($parsed['rewritten_query'] ?? '')); if ($rewrittenQuery === '') { $rewrittenQuery = $message; } $alternateQueries = []; foreach ((array) ($parsed['alternate_queries'] ?? []) as $query) { $query = trim((string) $query); if ($query !== '' && $query !== $rewrittenQuery) { $alternateQueries[] = $query; } } $alternateQueries = array_slice(array_values(array_unique($alternateQueries)), 0, 4); $domains = []; foreach ((array) ($parsed['domains'] ?? []) as $domain) { $domain = trim((string) $domain); if (in_array($domain, self::ALLOWED_DOMAINS, true)) { $domains[] = $domain; } } if (empty($domains)) { $domains = self::inferDomains($message); } $domains = array_values(array_unique($domains)); $preferredDomain = trim((string) ($parsed['preferred_domain'] ?? '')); if (!in_array($preferredDomain, $domains, true)) { $preferredDomain = self::inferPreferredDomain($message); } if (!in_array($preferredDomain, $domains, true)) { $preferredDomain = (string) ($domains[0] ?? ''); } $entities = [ 'teams' => self::normalizeTextArray($parsed['entities']['teams'] ?? []), 'leagues' => self::normalizeTextArray($parsed['entities']['leagues'] ?? []), 'symbols' => self::normalizeTextArray($parsed['entities']['symbols'] ?? []), 'keywords' => self::normalizeTextArray($parsed['entities']['keywords'] ?? []), ]; if (empty($entities['teams']) && empty($entities['leagues']) && empty($entities['symbols']) && empty($entities['keywords'])) { $entities = self::inferEntities($message); } $dateRange = self::normalizeDateRange((array) ($parsed['date_range'] ?? []), $message); $needsRealtime = filter_var($parsed['needs_realtime'] ?? self::inferNeedsRealtime($message), FILTER_VALIDATE_BOOL); $answerFormat = trim((string) ($parsed['answer_format'] ?? 'markdown')) ?: 'markdown'; $confidence = (float) ($parsed['confidence'] ?? 0.55); $confidence = max(0.0, min(1.0, $confidence)); return [ 'intent' => $intent, 'rewritten_query' => $rewrittenQuery, 'alternate_queries' => $alternateQueries, 'domains' => $domains, 'preferred_domain' => $preferredDomain, 'entities' => $entities, 'date_range' => $dateRange, 'needs_realtime' => $needsRealtime, 'answer_format' => $answerFormat === 'markdown' ? 'markdown' : 'markdown', 'confidence' => $confidence, ]; } private static function normalizeDateRange(array $dateRange, string $message): array { $preset = trim((string) ($dateRange['preset'] ?? '')); if (!in_array($preset, self::DATE_PRESETS, true)) { $preset = (string) (self::inferDateRange($message)['preset'] ?? ''); } $date = self::normalizeDateText((string) ($dateRange['date'] ?? '')); $startDate = self::normalizeDateText((string) ($dateRange['start_date'] ?? '')); $endDate = self::normalizeDateText((string) ($dateRange['end_date'] ?? '')); if ($preset === '' && $date === '' && $startDate === '' && $endDate === '') { return self::inferDateRange($message); } return array_filter([ 'preset' => $preset, 'date' => $date, 'start_date' => $startDate, 'end_date' => $endDate, ], static fn($value) => $value !== ''); } private static function normalizeDateText(string $value): string { $value = trim($value); if ($value !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { return $value; } return ''; } private static function normalizeTextArray($values): array { $rows = []; foreach ((array) $values as $value) { $value = trim((string) $value); if ($value !== '') { $rows[] = $value; } } return array_values(array_unique($rows)); } private static function inferIntent(string $message): string { if (preg_match('/BTC|ETH|BNB|SOL|XRP|DOGE|TON|TRX|ADA|AVAX|比特币|以太坊|行情/u', $message)) { return 'crypto_quote'; } if (preg_match('/赛程|今天|今日|明天|下一场|下场|即将|未开赛/u', $message)) { return 'match_schedule'; } if (preg_match('/结果|比分|赛果|战绩|上一场|上场|最近比赛/u', $message)) { return 'match_result'; } if (preg_match('/六合|彩票|号码|特码|开奖结果|开奖记录/u', $message)) { return 'lottery_analysis'; } if (preg_match('/资讯|新闻|文章|帖子|社区/u', $message)) { return 'news_search'; } return 'general_search'; } private static function inferDomains(string $message): array { return match (self::inferIntent($message)) { 'crypto_quote' => ['crypto', 'article'], 'match_schedule', 'match_result' => ['match', 'article', 'post'], 'lottery_analysis' => ['lottery', 'post'], 'news_search' => ['article', 'post'], default => self::DEFAULT_DOMAINS, }; } private static function inferPreferredDomain(string $message): string { return match (self::inferIntent($message)) { 'crypto_quote' => 'crypto', 'match_schedule', 'match_result' => 'match', 'lottery_analysis' => 'lottery', 'news_search' => 'article', default => 'article', }; } private static function inferEntities(string $message): array { $keywords = preg_split('/[\s,,。!?;、::\/\\\\]+/u', trim($message)) ?: []; $keywords = array_values(array_filter(array_map(static fn($item) => trim((string) $item), $keywords), static fn($item) => mb_strlen($item) >= 2)); $leagues = []; foreach (['世界杯', 'NBA', '英超', '西甲', '欧冠', '中超'] as $league) { if (mb_strpos($message, $league) !== false) { $leagues[] = $league; } } $symbols = []; foreach (self::CRYPTO_SYMBOLS as $symbol) { if (preg_match('/\b' . preg_quote($symbol, '/') . '\b/i', $message)) { $symbols[] = $symbol; } } $teams = []; foreach ($keywords as $keyword) { if (preg_match('/队|联|赛|资讯|分析|行情/u', $keyword)) { continue; } if (!in_array($keyword, $leagues, true) && !in_array(strtoupper($keyword), $symbols, true)) { $teams[] = $keyword; } } return [ 'teams' => array_values(array_unique($teams)), 'leagues' => array_values(array_unique($leagues)), 'symbols' => array_values(array_unique($symbols)), 'keywords' => array_values(array_unique($keywords)), ]; } private static function inferDateRange(string $message): array { if (preg_match('/今天|今日/u', $message)) { return ['preset' => 'today', 'date' => date('Y-m-d')]; } if (preg_match('/明天/u', $message)) { return ['preset' => 'tomorrow', 'date' => date('Y-m-d', strtotime('+1 day'))]; } if (preg_match('/昨天|昨日/u', $message)) { return ['preset' => 'yesterday', 'date' => date('Y-m-d', strtotime('-1 day'))]; } if (preg_match('/最新|最近/u', $message)) { return ['preset' => 'latest']; } return []; } private static function inferNeedsRealtime(string $message): bool { return in_array(self::inferIntent($message), ['crypto_quote', 'match_schedule', 'match_result'], true); } private static function buildAlternateQueries(string $message): array { $queries = []; if (preg_match('/今天|今日/u', $message)) { $queries[] = str_replace(['今天', '今日'], date('Y-m-d'), $message); } if (preg_match('/世界杯/u', $message)) { $queries[] = '世界杯 今日赛程'; } if (preg_match('/BTC|比特币/u', $message)) { $queries[] = 'BTC 实时行情'; } return array_values(array_unique(array_filter($queries))); } }