Files
MoonTVPlus/src/lib/ai-orchestrator.ts
2026-01-23 16:41:49 +08:00

810 lines
24 KiB
TypeScript
Raw 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.
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* AI数据源协调器
* 负责协调AI与联网搜索、豆瓣API、TMDB API之间的数据交互
*/
import { fetchDoubanData as fetchDoubanAPI } from '@/lib/douban';
import { getNextApiKey } from '@/lib/tmdb.client';
export interface VideoContext {
title?: string;
year?: string;
douban_id?: number;
tmdb_id?: number;
type?: 'movie' | 'tv';
currentEpisode?: number;
}
export interface IntentAnalysisResult {
type: 'recommendation' | 'query' | 'detail' | 'general';
mediaType?: 'movie' | 'tv' | 'variety' | 'anime';
genre?: string;
needWebSearch: boolean;
needDouban: boolean;
needTMDB: boolean;
keywords: string[];
entities: Array<{ type: string; value: string }>;
}
export interface DecisionResult {
needWebSearch: boolean;
needDouban: boolean;
needTMDB: boolean;
webSearchQuery?: string;
doubanQuery?: string;
reasoning?: string;
}
export interface OrchestrationResult {
systemPrompt: string;
webSearchResults?: any;
doubanData?: any;
tmdbData?: any;
}
/**
* 分析用户意图
*/
export function analyzeIntent(
message: string,
context?: VideoContext
): IntentAnalysisResult {
const lowerMessage = message.toLowerCase();
// 时效性关键词 - 需要最新信息的问题
const timeKeywords = [
'最新', '今年', '2024', '2025', '即将', '上映', '新出',
'什么时候', '何时', '几时', '播出', '更新', '下一季',
'第二季', '第三季', '续集', '下季', '下部'
];
const hasTimeKeyword = timeKeywords.some((k) => message.includes(k));
// 推荐类关键词
const recommendKeywords = ['推荐', '有什么', '好看', '值得', '介绍'];
const isRecommendation = recommendKeywords.some((k) => message.includes(k));
// 演员/导演关键词
const personKeywords = ['演员', '导演', '主演', '出演', '作品'];
const isPerson = personKeywords.some((k) => message.includes(k));
// 剧情相关关键词
const plotKeywords = ['讲什么', '剧情', '故事', '内容', '讲的是'];
const isPlotQuery = plotKeywords.some((k) => message.includes(k));
// 媒体类型判断
let mediaType: 'movie' | 'tv' | 'variety' | 'anime' | undefined;
if (message.includes('电影')) mediaType = 'movie';
else if (message.includes('电视剧') || message.includes('剧集'))
mediaType = 'tv';
else if (message.includes('综艺')) mediaType = 'variety';
else if (message.includes('动漫') || message.includes('动画'))
mediaType = 'anime';
else if (context?.type) mediaType = context.type;
// 类型判断
let type: IntentAnalysisResult['type'] = 'general';
if (isRecommendation) type = 'recommendation';
else if (context?.title && (isPlotQuery || lowerMessage.includes('这部')))
type = 'detail';
else if (isPerson || hasTimeKeyword) type = 'query';
// 决定是否需要各个数据源
// 联网搜索: 只在真正需要实时信息时启用
const needWebSearch =
hasTimeKeyword ||
isPerson ||
message.includes('新闻') ||
(isRecommendation && hasTimeKeyword) || // 推荐+时效性
type === 'query';
const needDouban =
isRecommendation ||
type === 'detail' ||
(context?.douban_id !== undefined && context.douban_id > 0);
const needTMDB =
type === 'detail' ||
(context?.tmdb_id !== undefined && context.tmdb_id > 0);
return {
type,
mediaType,
needWebSearch,
needDouban,
needTMDB,
keywords: timeKeywords.filter((k) => message.includes(k)),
entities: extractEntities(message),
};
}
/**
* 提取实体(简化版,基于关键词匹配)
*/
function extractEntities(message: string): Array<{ type: string; value: string }> {
const entities: Array<{ type: string; value: string }> = [];
// 简单的人名匹配中文2-4字
const personPattern = /([一-龥]{2,4})(的|是|演|导)/g;
let match;
while ((match = personPattern.exec(message)) !== null) {
entities.push({ type: 'person', value: match[1] });
}
return entities;
}
/**
* 获取联网搜索结果
*/
async function fetchWebSearch(
query: string,
provider: 'tavily' | 'serper' | 'serpapi',
apiKey: string
): Promise<any> {
try {
if (provider === 'tavily') {
const response = await fetch('https://api.tavily.com/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
api_key: apiKey,
query,
search_depth: 'basic',
include_domains: ['douban.com', 'imdb.com', 'themoviedb.org', 'mtime.com'],
max_results: 5,
}),
});
if (!response.ok) {
throw new Error(`Tavily API error: ${response.status}`);
}
return await response.json();
} else if (provider === 'serper') {
const response = await fetch('https://google.serper.dev/search', {
method: 'POST',
headers: {
'X-API-KEY': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
q: query,
num: 5,
}),
});
if (!response.ok) {
throw new Error(`Serper API error: ${response.status}`);
}
return await response.json();
} else if (provider === 'serpapi') {
const response = await fetch(
`https://serpapi.com/search?engine=google&q=${encodeURIComponent(query)}&api_key=${apiKey}&num=5`
);
if (!response.ok) {
throw new Error(`SerpAPI error: ${response.status}`);
}
return await response.json();
}
} catch (error) {
console.error('Web search error:', error);
return null;
}
}
/**
* 获取豆瓣数据
* 服务器端直接调用豆瓣API
*/
async function fetchDoubanData(params: {
id?: number;
query?: string;
kind?: string;
category?: string;
type?: string;
}): Promise<any> {
try {
// 1. 通过 ID 获取详情
if (params.id) {
const url = `https://m.douban.com/rexxar/api/v2/subject/${params.id}`;
console.log('📡 获取豆瓣详情:', params.id);
return await fetchDoubanAPI(url);
}
// 2. 通过分类获取热门列表
if (params.kind && params.category && params.type) {
const url = `https://m.douban.com/rexxar/api/v2/subject/recent_hot/${params.kind}?start=0&limit=20&category=${encodeURIComponent(params.category)}&type=${encodeURIComponent(params.type)}`;
console.log('📡 获取豆瓣分类:', params.kind, params.category, params.type);
return await fetchDoubanAPI(url);
}
// 3. 通过搜索查询
if (params.query) {
const kind = params.kind || 'movie';
const url = `https://movie.douban.com/j/search_subjects?type=${kind}&tag=${encodeURIComponent(params.query)}&sort=recommend&page_limit=20&page_start=0`;
console.log('📡 搜索豆瓣:', params.query, kind);
return await fetchDoubanAPI(url);
}
console.log('⚠️ 豆瓣数据获取参数不完整:', params);
return null;
} catch (error) {
console.error('❌ 豆瓣数据获取失败:', error);
return null;
}
}
/**
* 获取TMDB数据
* 服务器端直接调用TMDB API
*/
async function fetchTMDBData(
params: {
id?: number;
type?: 'movie' | 'tv';
},
tmdbApiKey?: string,
tmdbProxy?: string,
tmdbReverseProxy?: string
): Promise<any> {
try {
const actualKey = getNextApiKey(tmdbApiKey || '');
if (!actualKey) {
console.log('⚠️ TMDB API Key 未配置跳过TMDB数据获取');
return null;
}
if (!params.id || !params.type) {
console.log('⚠️ TMDB数据获取参数不完整:', params);
return null;
}
// 使用反代代理或默认 Base URL
const baseUrl = tmdbReverseProxy || 'https://api.themoviedb.org';
// 使用 TMDB API 获取详情
// TMDB API: https://api.themoviedb.org/3/{type}/{id}
const url = `${baseUrl}/3/${params.type}/${params.id}?api_key=${actualKey}&language=zh-CN&append_to_response=keywords,similar`;
console.log('📡 获取TMDB详情:', params.type, params.id);
const fetchOptions: any = tmdbProxy
? {
// 如果有代理,使用 node-fetch 和代理
signal: AbortSignal.timeout(15000),
}
: {
signal: AbortSignal.timeout(15000),
};
const response = await fetch(url, fetchOptions);
if (!response.ok) {
console.error('❌ TMDB API 请求失败:', response.status, response.statusText);
return null;
}
return await response.json();
} catch (error) {
console.error('❌ TMDB数据获取失败:', error);
return null;
}
}
/**
* 格式化搜索结果为文本
*/
function formatSearchResults(
results: any,
provider: 'tavily' | 'serper' | 'serpapi'
): string {
if (!results) return '';
try {
if (provider === 'tavily' && results.results) {
return results.results
.map(
(r: any) => `
标题: ${r.title}
内容: ${r.content}
来源: ${r.url}
`
)
.join('\n');
} else if (provider === 'serper' && results.organic) {
return results.organic
.map(
(r: any) => `
标题: ${r.title}
摘要: ${r.snippet}
来源: ${r.link}
`
)
.join('\n');
} else if (provider === 'serpapi' && results.organic_results) {
return results.organic_results
.map(
(r: any) => `
标题: ${r.title}
摘要: ${r.snippet}
来源: ${r.link}
`
)
.join('\n');
}
} catch (error) {
console.error('Format search results error:', error);
}
return ''
}
/**
* 清理可能被代码块包裹的JSON字符串
*/
function cleanJsonResponse(content: string): string {
// 去除可能的markdown代码块标记
let cleaned = content.trim();
// 移除 ```json 或 ``` 开头
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/i, '');
// 移除 ``` 结尾
cleaned = cleaned.replace(/\n?```\s*$/, '');
return cleaned.trim();
}
/**
* 使用决策模型判断是否需要调用各个数据源
*/
async function callDecisionModel(
userMessage: string,
context: VideoContext | undefined,
config: {
provider: 'openai' | 'claude' | 'custom';
apiKey: string;
baseURL?: string;
model: string;
},
availableDataSources: {
webSearch: boolean;
douban: boolean;
tmdb: boolean;
}
): Promise<DecisionResult> {
// 构建可用数据源列表
const availableSources: string[] = [];
if (availableDataSources.webSearch) {
availableSources.push('1. **联网搜索** - 获取最新的实时信息(新闻、上映时间、续集信息等)');
}
if (availableDataSources.douban) {
availableSources.push('2. **豆瓣API** - 获取中文影视数据(评分、演员、简介、用户评论等)');
}
if (availableDataSources.tmdb) {
availableSources.push('3. **TMDB API** - 获取国际影视数据(详细元数据、相似推荐等)');
}
const systemPrompt = `你是一个影视问答决策系统。请分析用户的问题,判断需要调用哪些数据源来回答。
当前可用的数据源:
${availableSources.join('\n')}
${availableSources.length === 0 ? '⚠️ 没有可用的数据源请返回所有字段为false' : ''}
请以JSON格式返回决策结果包含以下字段
{
"needWebSearch": boolean, // 是否需要联网搜索${!availableDataSources.webSearch ? ' (当前不可用必须返回false)' : ''}
"needDouban": boolean, // 是否需要豆瓣数据${!availableDataSources.douban ? ' (当前不可用必须返回false)' : ''}
"needTMDB": boolean, // 是否需要TMDB数据${!availableDataSources.tmdb ? ' (当前不可用必须返回false)' : ''}
"webSearchQuery": string, // 如果需要联网,用什么关键词搜索(可选)
"doubanQuery": string, // 如果需要豆瓣,用什么关键词搜索(可选)
"reasoning": string // 简要说明决策理由
}
决策原则:
- **只能选择当前可用的数据源不可用的数据源必须返回false**
- **优先使用最少的数据源来满足需求避免不必要的API调用**
- 时效性问题(最新、上映时间、续集、播出、更新等)→ 需要联网搜索${!availableDataSources.webSearch ? '(但当前不可用)' : ''}
- 演员/导演相关问题 → 优先豆瓣,如果问"最近作品"则额外联网
- 推荐类问题 → 仅豆瓣(如果包含"最新""今年"等时效性关键词则额外联网)
- 剧情、评分等静态信息 → 仅豆瓣或TMDB不需要联网
- 当前视频的详细信息(有视频上下文) → 豆瓣+TMDB通常不需要联网
- 新闻、热点、讨论等 → 需要联网搜索${!availableDataSources.webSearch ? '(但当前不可用)' : ''}
只返回JSON不要其他内容。`;
let contextInfo = '';
if (context?.title) {
contextInfo = `\n\n当前视频上下文\n- 标题:${context.title}`;
if (context.year) contextInfo += `\n- 年份:${context.year}`;
if (context.type) contextInfo += `\n- 类型:${context.type === 'movie' ? '电影' : '电视剧'}`;
if (context.currentEpisode) contextInfo += `\n- 当前集数:第${context.currentEpisode}`;
}
const userPrompt = `用户问题:${userMessage}${contextInfo}`;
try {
let response: Response;
if (config.provider === 'claude') {
response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': config.apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: config.model,
max_tokens: 500,
temperature: 0,
system: systemPrompt,
messages: [{ role: 'user', content: userPrompt }],
}),
});
if (!response.ok) {
throw new Error(`Claude API error: ${response.status}`);
}
const data = await response.json();
const content = data.content?.[0]?.text || '';
// 清理可能的代码块标记
const cleanedContent = cleanJsonResponse(content);
// 提取JSON
const jsonMatch = cleanedContent.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
} else {
// OpenAI 或 自定义 (OpenAI兼容格式)
const baseURL = config.baseURL || 'https://api.openai.com/v1';
response = await fetch(`${baseURL}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({
model: config.model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature: 0,
max_tokens: 500,
response_format: { type: 'json_object' },
}),
});
if (!response.ok) {
throw new Error(`OpenAI API error: ${response.status}`);
}
const data = await response.json();
const content = data.choices?.[0]?.message?.content || '{}';
// 清理可能的代码块标记
const cleanedContent = cleanJsonResponse(content);
return JSON.parse(cleanedContent);
}
} catch (error) {
console.error('❌ 决策模型调用失败:', error);
// 失败时返回null由调用方降级到传统意图分析
return null as any;
}
// 不应该到达这里
return null as any;
}
/**
* 主协调函数
*/
export async function orchestrateDataSources(
userMessage: string,
context?: VideoContext,
config?: {
enableWebSearch: boolean;
webSearchProvider?: 'tavily' | 'serper' | 'serpapi';
tavilyApiKey?: string;
serperApiKey?: string;
serpApiKey?: string;
// TMDB 配置
tmdbApiKey?: string;
tmdbProxy?: string;
tmdbReverseProxy?: string;
// 决策模型配置
enableDecisionModel?: boolean;
decisionProvider?: 'openai' | 'claude' | 'custom';
decisionApiKey?: string;
decisionBaseURL?: string;
decisionModel?: string;
}
): Promise<OrchestrationResult> {
let intent: IntentAnalysisResult;
// 1. 使用决策模型或传统意图分析
let decision: DecisionResult | null = null;
if (config?.enableDecisionModel && config.decisionProvider && config.decisionApiKey && config.decisionModel) {
console.log('🤖 使用决策模型分析...');
// 确定哪些数据源是可用的
const hasWebSearchProvider = !!(config.enableWebSearch &&
config.webSearchProvider &&
(
(config.webSearchProvider === 'tavily' && config.tavilyApiKey) ||
(config.webSearchProvider === 'serper' && config.serperApiKey) ||
(config.webSearchProvider === 'serpapi' && config.serpApiKey)
));
const hasTMDB = !!(config.tmdbApiKey);
decision = await callDecisionModel(
userMessage,
context,
{
provider: config.decisionProvider,
apiKey: config.decisionApiKey,
baseURL: config.decisionBaseURL,
model: config.decisionModel,
},
{
webSearch: hasWebSearchProvider,
douban: true, // 豆瓣始终可用(服务器端直接调用)
tmdb: hasTMDB,
}
);
console.log('🎯 决策模型结果:', decision);
}
// 如果决策模型失败或未启用,降级到传统意图分析
if (!decision) {
if (config?.enableDecisionModel) {
console.log('⚠️ 决策模型失败,降级到传统意图分析');
}
// 传统关键词匹配分析
intent = analyzeIntent(userMessage, context);
console.log('📊 意图分析结果:', intent);
} else {
// 将决策结果转换为 IntentAnalysisResult 格式
// 保留决策模型的查询优化
intent = {
type: decision.needDouban && !decision.needWebSearch ? 'detail' :
decision.needWebSearch ? 'query' : 'general',
needWebSearch: decision.needWebSearch,
needDouban: decision.needDouban,
needTMDB: decision.needTMDB,
keywords: decision.webSearchQuery ? [decision.webSearchQuery] : [],
entities: [],
mediaType: context?.type,
};
// 保存优化的查询字符串
(intent as any).optimizedWebSearchQuery = decision.webSearchQuery;
(intent as any).optimizedDoubanQuery = decision.doubanQuery;
}
// 2. 并行获取所需的数据源
const dataPromises: Promise<any>[] = [];
let webSearchPromise: Promise<any> | null = null;
let doubanPromise: Promise<any> | null = null;
let tmdbPromise: Promise<any> | null = null;
// 联网搜索
if (
intent.needWebSearch &&
config?.enableWebSearch &&
config.webSearchProvider
) {
const provider = config.webSearchProvider;
const apiKey =
provider === 'tavily'
? config.tavilyApiKey
: provider === 'serper'
? config.serperApiKey
: config.serpApiKey;
if (apiKey) {
// 使用决策模型优化的查询,如果没有则使用原始消息
const searchQuery = (intent as any).optimizedWebSearchQuery || userMessage;
webSearchPromise = fetchWebSearch(searchQuery, provider, apiKey);
dataPromises.push(webSearchPromise);
}
}
// 豆瓣数据
if (intent.needDouban) {
if (context?.douban_id) {
doubanPromise = fetchDoubanData({ id: context.douban_id });
} else if (intent.type === 'recommendation') {
doubanPromise = fetchDoubanData({
kind: intent.mediaType || 'movie',
category: '热门',
type: intent.genre || '全部',
});
} else if ((intent as any).optimizedDoubanQuery) {
// 使用决策模型优化的豆瓣查询
doubanPromise = fetchDoubanData({
query: (intent as any).optimizedDoubanQuery,
kind: intent.mediaType || context?.type,
});
} else if (context?.title) {
doubanPromise = fetchDoubanData({
query: context.title,
kind: context.type,
});
}
if (doubanPromise) {
dataPromises.push(doubanPromise);
}
}
// TMDB数据
if (intent.needTMDB && context?.tmdb_id && context?.type) {
tmdbPromise = fetchTMDBData(
{
id: context.tmdb_id,
type: context.type,
},
config?.tmdbApiKey,
config?.tmdbProxy,
config?.tmdbReverseProxy
);
dataPromises.push(tmdbPromise);
}
// 3. 等待所有数据获取完成
const results = await Promise.allSettled(dataPromises);
let webSearchData = null;
let doubanData = null;
let tmdbData = null;
let resultIndex = 0;
if (webSearchPromise) {
const result = results[resultIndex++];
if (result.status === 'fulfilled') {
webSearchData = result.value;
}
}
if (doubanPromise) {
const result = results[resultIndex++];
if (result.status === 'fulfilled') {
doubanData = result.value;
}
}
if (tmdbPromise) {
const result = results[resultIndex++];
if (result.status === 'fulfilled') {
tmdbData = result.value;
}
}
// 4. 构建系统提示词
let systemPrompt = `你是 MoonTVPlus 的 AI 影视助手,专门帮助用户发现和了解影视内容。
## 你的能力
- 提供影视推荐基于豆瓣热门榜单和TMDB数据
- 回答影视相关问题(剧情、演员、评分等)
- 搜索最新影视资讯(如果启用了联网搜索)
## 回复要求
1. 语言风格:友好、专业、简洁
2. 信息来源:优先使用提供的数据,诚实告知数据不足
3. 推荐理由:说明为什么值得看,包括评分、类型、特色等
4. 格式清晰:使用分段、列表等让内容易读
`;
// 添加联网搜索结果
if (webSearchData && config?.webSearchProvider) {
const formattedSearch = formatSearchResults(
webSearchData,
config.webSearchProvider
);
if (formattedSearch) {
systemPrompt += `\n## 【联网搜索结果】(最新实时信息)\n${formattedSearch}\n`;
}
}
// 添加豆瓣数据
if (doubanData) {
systemPrompt += `\n## 【豆瓣数据】(权威中文评分和信息)\n`;
if (doubanData.list) {
// 列表数据
systemPrompt += `推荐列表(${doubanData.list.length}部):\n${JSON.stringify(
doubanData.list.slice(0, 10).map((item: any) => ({
title: item.title,
rating: item.rating,
year: item.year,
genres: item.genres,
directors: item.directors,
actors: item.actors,
})),
null,
2
)}\n`;
} else if (doubanData.items) {
// 搜索结果
systemPrompt += `搜索结果:\n${JSON.stringify(
doubanData.items.slice(0, 5),
null,
2
)}\n`;
} else {
// 详情数据
systemPrompt += JSON.stringify(
{
title: doubanData.title,
rating: doubanData.rating,
year: doubanData.year,
genres: doubanData.genres,
directors: doubanData.directors,
actors: doubanData.actors,
intro: doubanData.intro,
reviews: doubanData.reviews?.slice(0, 2),
},
null,
2
);
systemPrompt += '\n';
}
}
// 添加TMDB数据
if (tmdbData) {
systemPrompt += `\n## 【TMDB数据】国际数据和详细元信息\n`;
systemPrompt += JSON.stringify(
{
title: tmdbData.title || tmdbData.name,
overview: tmdbData.overview,
vote_average: tmdbData.vote_average,
genres: tmdbData.genres,
keywords: tmdbData.keywords,
similar: tmdbData.similar?.slice(0, 5),
},
null,
2
);
systemPrompt += '\n';
}
// 添加当前视频上下文
if (context?.title) {
systemPrompt += `\n## 【当前视频上下文】\n`;
systemPrompt += `用户正在浏览: ${context.title}`;
if (context.year) systemPrompt += ` (${context.year})`;
if (context.currentEpisode) {
systemPrompt += `,当前第 ${context.currentEpisode}`;
}
systemPrompt += '\n';
}
systemPrompt += `\n## 数据来源优先级
1. 如果有联网搜索结果,优先使用其最新信息
2. 豆瓣数据提供中文评价和评分(更适合中文用户)
3. TMDB数据更国际化提供关键词和相似推荐
4. 如果多个数据源有冲突,以联网搜索为准
5. 如果数据不足以回答问题,诚实告知用户
现在请回答用户的问题。`;
console.log('📝 生成的系统提示词长度:', systemPrompt.length);
return {
systemPrompt,
webSearchResults: webSearchData,
doubanData,
tmdbData,
};
}