llm.service.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. import { config } from '../config.ts';
  2. interface BriefAnalysisResult {
  3. requirements: Array<{ label: string; value: string; confidence: number }>;
  4. searchCriteria: {
  5. platforms: string[];
  6. keywords: string[];
  7. fanRange: { min: number; max: number };
  8. budgetRange: { min: number; max: number };
  9. region?: string;
  10. gender?: string;
  11. contentTags?: string[];
  12. excludeTags?: string[];
  13. targetCount?: number;
  14. };
  15. }
  16. const BRIEF_ANALYSIS_PROMPT = `你是一个专业的媒介策划助手。请分析以下客户Brief文件内容,提取出结构化的投放需求。
  17. 要求提取以下信息:
  18. 1. 客户/品牌名称
  19. 2. 投放目标(种草、转化、品宣等)
  20. 3. 平台需求(小红书、抖音、B站等及各平台需求人数)
  21. 4. 粉丝数要求范围
  22. 5. 预算范围
  23. 6. 内容风格要求
  24. 7. 地区要求
  25. 8. 排除项(竞品、不合适的类型等)
  26. 9. 合作形式(图文、视频等)
  27. 请以JSON格式输出,包含requirements数组和searchCriteria对象。
  28. requirements格式:[{label: "字段名", value: "提取值", confidence: 0-100}]
  29. searchCriteria格式:{platforms: [], keywords: [], fanRange: {min, max}, budgetRange: {min, max}, region?, gender?, contentTags?: [], excludeTags?: [], targetCount?}
  30. 如果 Brief 写明达人数量,请额外输出 targetCount,表示最终需求达人总数(不是候选池数量)。
  31. Brief内容:
  32. `;
  33. export async function analyzeBrief(briefText: string): Promise<BriefAnalysisResult> {
  34. if (!config.llm.apiKey) {
  35. console.warn('[LLM] No API key configured, using fallback analysis');
  36. return fallbackAnalysis(briefText);
  37. }
  38. try {
  39. console.log('[LLM] 发起请求:', { url: `${config.llm.baseUrl}/chat/completions`, model: config.llm.model, briefLength: briefText.length });
  40. const response = await fetch(`${config.llm.baseUrl}/chat/completions`, {
  41. method: 'POST',
  42. headers: {
  43. 'Content-Type': 'application/json',
  44. Authorization: `Bearer ${config.llm.apiKey}`,
  45. },
  46. body: JSON.stringify({
  47. model: config.llm.model,
  48. messages: [
  49. { role: 'system', content: '你是专业的媒介策划AI助手,擅长从Brief文档中提取结构化投放需求。你的回复必须是纯JSON格式,不要包含markdown代码块标记。' },
  50. { role: 'user', content: BRIEF_ANALYSIS_PROMPT + briefText },
  51. ],
  52. temperature: 0.3,
  53. }),
  54. });
  55. if (!response.ok) {
  56. const errorBody = await response.text();
  57. console.error(`[LLM] API error: ${response.status} ${response.statusText}`, errorBody);
  58. return fallbackAnalysis(briefText);
  59. }
  60. const data = await response.json();
  61. const content = data.choices?.[0]?.message?.content;
  62. if (!content) {
  63. return fallbackAnalysis(briefText);
  64. }
  65. // 提取 JSON(可能被 markdown 代码块包裹)
  66. let jsonStr = content.trim();
  67. const codeBlockMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
  68. if (codeBlockMatch) {
  69. jsonStr = codeBlockMatch[1].trim();
  70. }
  71. const parsed = JSON.parse(jsonStr);
  72. const result = {
  73. requirements: parsed.requirements || [],
  74. searchCriteria: parsed.searchCriteria || { platforms: [], keywords: [], fanRange: { min: 10000, max: 500000 }, budgetRange: { min: 2000, max: 20000 } },
  75. };
  76. result.searchCriteria.targetCount = normalizeTargetCount(result.searchCriteria.targetCount) || inferTargetCount(briefText, result.requirements);
  77. console.log('[LLM] 解析结果:', JSON.stringify(result, null, 2));
  78. return result;
  79. } catch (error) {
  80. console.error('[LLM] Analysis failed:', error);
  81. return fallbackAnalysis(briefText);
  82. }
  83. }
  84. function fallbackAnalysis(briefText: string): BriefAnalysisResult {
  85. // 简单的关键词匹配作为降级方案
  86. const platforms: string[] = [];
  87. if (briefText.includes('小红书') || briefText.includes('红书')) platforms.push('xiaohongshu');
  88. if (briefText.includes('抖音') || briefText.includes('TikTok')) platforms.push('douyin');
  89. if (briefText.includes('B站') || briefText.includes('哔哩哔哩')) platforms.push('bilibili');
  90. if (briefText.includes('微博')) platforms.push('weibo');
  91. if (briefText.includes('微信') || briefText.includes('公众号')) platforms.push('weixin');
  92. if (platforms.length === 0) platforms.push('xiaohongshu', 'douyin');
  93. const keywords: string[] = [];
  94. const keywordPatterns = ['护肤', '美妆', '母婴', '数码', '穿搭', '美食', '家居', '运动', '旅行', '教育'];
  95. for (const kw of keywordPatterns) {
  96. if (briefText.includes(kw)) keywords.push(kw);
  97. }
  98. if (keywords.length === 0) keywords.push('生活方式');
  99. return {
  100. requirements: [
  101. { label: '平台需求', value: platforms.join('、'), confidence: 75 },
  102. { label: '内容关键词', value: keywords.join('、'), confidence: 70 },
  103. { label: '预算范围', value: '2000-20000', confidence: 60 },
  104. { label: '粉丝范围', value: '1万-50万', confidence: 60 },
  105. ],
  106. searchCriteria: {
  107. platforms,
  108. keywords,
  109. fanRange: { min: 10000, max: 500000 },
  110. budgetRange: { min: 2000, max: 20000 },
  111. targetCount: inferTargetCount(briefText, []),
  112. },
  113. };
  114. }
  115. function normalizeTargetCount(value: unknown): number | undefined {
  116. const count = Number(value || 0);
  117. return count > 0 ? Math.ceil(count) : undefined;
  118. }
  119. function inferTargetCount(
  120. briefText: string,
  121. requirements: Array<{ label: string; value: string; confidence: number }>
  122. ): number | undefined {
  123. const text = `${briefText}\n${requirements.map((item) => `${item.label}:${item.value}`).join('\n')}`;
  124. const perCityMatch = text.match(/(\d+)\s*(?:个|座)?城市[\s\S]{0,40}?(\d+)\s*位\s*\/\s*城市/);
  125. if (perCityMatch) {
  126. return Number(perCityMatch[1]) * Number(perCityMatch[2]);
  127. }
  128. const cityCountMatch = text.match(/(\d+)\s*(?:个|座)?城市/);
  129. const perCityLooseMatch = text.match(/每(?:个)?城市\s*(\d+)\s*位|(\d+)\s*位\s*\/\s*城市/);
  130. if (cityCountMatch && perCityLooseMatch) {
  131. return Number(cityCountMatch[1]) * Number(perCityLooseMatch[1] || perCityLooseMatch[2]);
  132. }
  133. const directMatch = text.match(/(?:达人|博主|KOL|账号|人数)[^\d]{0,10}(\d+)\s*位|(\d+)\s*位\s*(?:达人|博主|KOL|账号)/i);
  134. if (directMatch) {
  135. return Number(directMatch[1] || directMatch[2]);
  136. }
  137. return undefined;
  138. }