335 lines
13 KiB
PHP
335 lines
13 KiB
PHP
<?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(字符串数组)、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)));
|
||
}
|
||
}
|