deploy: auto commit server changes 2026-06-12 14:57:05
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
<?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)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user