Files
sbnews/server/app/common/service/ai/AiAssistantPlanner.php

335 lines
13 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\common\service\ai;
use app\common\model\ai\AiConfig;
class AiAssistantPlanner
{
private const ALLOWED_DOMAINS = ['article', 'post', 'lottery', 'match', 'crypto'];
private const DEFAULT_DOMAINS = ['article', 'post', 'lottery', 'match'];
private const DATE_PRESETS = ['today', 'tomorrow', 'yesterday', 'latest', 'recent'];
private const CRYPTO_SYMBOLS = ['BTC', 'ETH', 'BNB', 'SOL', 'XRP', 'DOGE', 'TON', 'TRX', 'ADA', 'AVAX'];
public static function plan(string $message, array $historyMessages = []): array
{
if ((int) AiConfig::getVal('assistant_enable_query_planner', '1') !== 1) {
return self::fallbackPlan($message);
}
$client = new DeepSeekClient();
$result = $client->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(字符串数组)、domainsarticle/post/lottery/match/crypto 数组)、'
. 'preferred_domain(单个领域)、entitiesteams/leagues/symbols/keywords 数组)、'
. 'date_rangepreset/date/start_date/end_date,可为空对象)、needs_realtime(布尔)、'
. 'answer_format(固定 markdown)、confidence0-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 'news_search';
}
if (preg_match('/赛程|今天|今日|明天|下一场|下场|即将|未开赛/u', $message)) {
return 'match_schedule';
}
if (preg_match('/结果|比分|赛果|战绩|上一场|上场|最近比赛/u', $message)) {
return 'match_result';
}
if (preg_match('/六合|彩票|号码|特码|开奖结果|开奖记录/u', $message)) {
return 'lottery_analysis';
}
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)));
}
}