deploy: auto commit server changes 2026-06-12 14:57:05

This commit is contained in:
hajimi
2026-06-12 14:57:05 +08:00
parent dabde9abaf
commit de2a36107a
95 changed files with 1039 additions and 137 deletions
@@ -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(字符串数组)、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 '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)));
}
}