llm.service.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  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. evaluationNeeds?: EvaluationNeeds;
  15. };
  16. }
  17. export interface EvaluationNeeds {
  18. cpmCpe?: boolean;
  19. commercialStability?: boolean;
  20. recentPerformance?: boolean;
  21. updateFrequency?: boolean;
  22. commentQuality?: boolean;
  23. publicSentiment?: boolean;
  24. audienceGender?: boolean;
  25. reasons?: string[];
  26. }
  27. const BRIEF_ANALYSIS_PROMPT = `你是一个专业的媒介策划助手。请分析以下客户Brief文件内容,提取出结构化的投放需求。
  28. 要求提取以下信息:
  29. 1. 客户/品牌名称
  30. 2. 投放目标(种草、转化、品宣等)
  31. 3. 平台需求(小红书、抖音、B站等及各平台需求人数)
  32. 4. 粉丝数要求范围
  33. 5. 预算范围
  34. 6. 内容风格要求
  35. 7. 地区要求
  36. 8. 排除项(竞品、不合适的类型等)
  37. 9. 合作形式(图文、视频等)
  38. 请以JSON格式输出,包含requirements数组和searchCriteria对象。
  39. requirements格式:[{label: "字段名", value: "提取值", confidence: 0-100}]
  40. searchCriteria格式:{platforms: [], keywords: [], fanRange: {min, max}, budgetRange: {min, max}, region?, gender?, contentTags?: [], excludeTags?: [], targetCount?}
  41. 如果 Brief 写明达人数量,请额外输出 targetCount,表示最终需求达人总数(不是候选池数量)。
  42. Brief内容:
  43. `;
  44. const EVALUATION_NEEDS_PROMPT = `
  45. 请同时在 searchCriteria 中输出 evaluationNeeds,用来控制是否启用额外判断流程。
  46. 只有 Brief 明确提到对应维度时才置 true,不要默认开启。
  47. 格式:{
  48. cpmCpe?: boolean,
  49. commercialStability?: boolean,
  50. recentPerformance?: boolean,
  51. updateFrequency?: boolean,
  52. commentQuality?: boolean,
  53. publicSentiment?: boolean,
  54. audienceGender?: boolean,
  55. reasons?: string[]
  56. }
  57. `;
  58. export async function analyzeBrief(briefText: string): Promise<BriefAnalysisResult> {
  59. if (!config.llm.apiKey) {
  60. console.warn('[LLM] No API key configured, using fallback analysis');
  61. return fallbackAnalysis(briefText);
  62. }
  63. try {
  64. console.log('[LLM] 发起请求:', { url: `${config.llm.baseUrl}/chat/completions`, model: config.llm.model, briefLength: briefText.length });
  65. const response = await fetch(`${config.llm.baseUrl}/chat/completions`, {
  66. method: 'POST',
  67. headers: {
  68. 'Content-Type': 'application/json',
  69. Authorization: `Bearer ${config.llm.apiKey}`,
  70. },
  71. body: JSON.stringify({
  72. model: config.llm.model,
  73. messages: [
  74. { role: 'system', content: '你是专业的媒介策划AI助手,擅长从Brief文档中提取结构化投放需求。你的回复必须是纯JSON格式,不要包含markdown代码块标记。' },
  75. { role: 'user', content: BRIEF_ANALYSIS_PROMPT + EVALUATION_NEEDS_PROMPT + briefText },
  76. ],
  77. temperature: 0.3,
  78. }),
  79. });
  80. if (!response.ok) {
  81. const errorBody = await response.text();
  82. console.error(`[LLM] API error: ${response.status} ${response.statusText}`, errorBody);
  83. return fallbackAnalysis(briefText);
  84. }
  85. const data = await response.json();
  86. const content = data.choices?.[0]?.message?.content;
  87. if (!content) {
  88. return fallbackAnalysis(briefText);
  89. }
  90. // 提取 JSON(可能被 markdown 代码块包裹)
  91. let jsonStr = content.trim();
  92. const codeBlockMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
  93. if (codeBlockMatch) {
  94. jsonStr = codeBlockMatch[1].trim();
  95. }
  96. const parsed = JSON.parse(jsonStr);
  97. const result = {
  98. requirements: parsed.requirements || [],
  99. searchCriteria: parsed.searchCriteria || { platforms: [], keywords: [], fanRange: { min: 10000, max: 500000 }, budgetRange: { min: 2000, max: 20000 } },
  100. };
  101. result.searchCriteria.targetCount = normalizeTargetCount(result.searchCriteria.targetCount) || inferTargetCount(briefText, result.requirements);
  102. result.searchCriteria.evaluationNeeds = normalizeEvaluationNeeds(
  103. result.searchCriteria.evaluationNeeds,
  104. briefText,
  105. result.requirements,
  106. );
  107. console.log('[LLM] 解析结果:', JSON.stringify(result, null, 2));
  108. return result;
  109. } catch (error) {
  110. console.error('[LLM] Analysis failed:', error);
  111. return fallbackAnalysis(briefText);
  112. }
  113. }
  114. function fallbackAnalysis(briefText: string): BriefAnalysisResult {
  115. // 简单的关键词匹配作为降级方案
  116. const platforms: string[] = [];
  117. if (briefText.includes('小红书') || briefText.includes('红书')) platforms.push('xiaohongshu');
  118. if (briefText.includes('抖音') || briefText.includes('TikTok')) platforms.push('douyin');
  119. if (briefText.includes('B站') || briefText.includes('哔哩哔哩')) platforms.push('bilibili');
  120. if (briefText.includes('微博')) platforms.push('weibo');
  121. if (briefText.includes('微信') || briefText.includes('公众号')) platforms.push('weixin');
  122. if (platforms.length === 0) platforms.push('xiaohongshu', 'douyin');
  123. const keywords: string[] = [];
  124. const keywordPatterns = ['护肤', '美妆', '母婴', '数码', '穿搭', '美食', '家居', '运动', '旅行', '教育'];
  125. for (const kw of keywordPatterns) {
  126. if (briefText.includes(kw)) keywords.push(kw);
  127. }
  128. if (keywords.length === 0) keywords.push('生活方式');
  129. return {
  130. requirements: [
  131. { label: '平台需求', value: platforms.join('、'), confidence: 75 },
  132. { label: '内容关键词', value: keywords.join('、'), confidence: 70 },
  133. { label: '预算范围', value: '2000-20000', confidence: 60 },
  134. { label: '粉丝范围', value: '1万-50万', confidence: 60 },
  135. ],
  136. searchCriteria: {
  137. platforms,
  138. keywords,
  139. fanRange: { min: 10000, max: 500000 },
  140. budgetRange: { min: 2000, max: 20000 },
  141. targetCount: inferTargetCount(briefText, []),
  142. evaluationNeeds: inferEvaluationNeeds(briefText, []),
  143. },
  144. };
  145. }
  146. function normalizeEvaluationNeeds(
  147. parsedNeeds: unknown,
  148. briefText: string,
  149. requirements: Array<{ label: string; value: string; confidence: number }>
  150. ): EvaluationNeeds {
  151. const inferred = inferEvaluationNeeds(briefText, requirements);
  152. const parsed = typeof parsedNeeds === 'object' && parsedNeeds !== null
  153. ? parsedNeeds as Record<string, unknown>
  154. : {};
  155. const reasons = [
  156. ...toStringArray(parsed.reasons),
  157. ...(inferred.reasons || []),
  158. ];
  159. return {
  160. cpmCpe: Boolean(parsed.cpmCpe) || Boolean(inferred.cpmCpe),
  161. commercialStability: Boolean(parsed.commercialStability) || Boolean(inferred.commercialStability),
  162. recentPerformance: Boolean(parsed.recentPerformance) || Boolean(inferred.recentPerformance),
  163. updateFrequency: Boolean(parsed.updateFrequency) || Boolean(inferred.updateFrequency),
  164. commentQuality: Boolean(parsed.commentQuality) || Boolean(inferred.commentQuality),
  165. publicSentiment: Boolean(parsed.publicSentiment) || Boolean(inferred.publicSentiment),
  166. audienceGender: Boolean(parsed.audienceGender) || Boolean(inferred.audienceGender),
  167. reasons: [...new Set(reasons)].slice(0, 12),
  168. };
  169. }
  170. function inferEvaluationNeeds(
  171. briefText: string,
  172. requirements: Array<{ label: string; value: string; confidence: number }>
  173. ): EvaluationNeeds {
  174. const text = `${briefText}\n${requirements.map((item) => `${item.label}:${item.value}`).join('\n')}`.toLowerCase();
  175. const reasons: string[] = [];
  176. const hasAny = (...terms: string[]) => terms.some((term) => text.includes(term.toLowerCase()));
  177. const mark = (enabled: boolean, reason: string) => {
  178. if (enabled) reasons.push(reason);
  179. return enabled;
  180. };
  181. return {
  182. cpmCpe: mark(hasAny('cpm', 'cpe', '千次曝光', '互动成本'), 'Brief 提到 CPM/CPE 或投放成本效率'),
  183. commercialStability: mark(hasAny('商单', '蒲公英', '合作数据', '广告数据', '含广'), 'Brief 提到蒲公英/商单/广告数据'),
  184. recentPerformance: mark(hasAny('近期数据', '近30', '最近30', '数据下降', '互动量落差', '稳定性'), 'Brief 提到近期表现或数据稳定性'),
  185. updateFrequency: mark(hasAny('更新率', '更新频率', '低更新', '活跃度', 'low active'), 'Brief 提到更新频率或活跃度'),
  186. commentQuality: mark(hasAny('评论区', '良性互动', '评论质量', '评论'), 'Brief 提到评论区质量'),
  187. publicSentiment: mark(hasAny('舆情', '负面', '黑料', '争议', '翻车'), 'Brief 提到舆情或负面风险'),
  188. audienceGender: mark(hasAny('粉丝男女', '男女占比', '女性占比', '粉丝性别', '品牌ta'), 'Brief 提到粉丝性别画像'),
  189. reasons,
  190. };
  191. }
  192. function toStringArray(value: unknown): string[] {
  193. return Array.isArray(value) ? value.map(String).filter(Boolean) : [];
  194. }
  195. function normalizeTargetCount(value: unknown): number | undefined {
  196. const count = Number(value || 0);
  197. return count > 0 ? Math.ceil(count) : undefined;
  198. }
  199. function inferTargetCount(
  200. briefText: string,
  201. requirements: Array<{ label: string; value: string; confidence: number }>
  202. ): number | undefined {
  203. const text = `${briefText}\n${requirements.map((item) => `${item.label}:${item.value}`).join('\n')}`;
  204. const perCityMatch = text.match(/(\d+)\s*(?:个|座)?城市[\s\S]{0,40}?(\d+)\s*位\s*\/\s*城市/);
  205. if (perCityMatch) {
  206. return Number(perCityMatch[1]) * Number(perCityMatch[2]);
  207. }
  208. const cityCountMatch = text.match(/(\d+)\s*(?:个|座)?城市/);
  209. const perCityLooseMatch = text.match(/每(?:个)?城市\s*(\d+)\s*位|(\d+)\s*位\s*\/\s*城市/);
  210. if (cityCountMatch && perCityLooseMatch) {
  211. return Number(cityCountMatch[1]) * Number(perCityLooseMatch[1] || perCityLooseMatch[2]);
  212. }
  213. const directMatch = text.match(/(?:达人|博主|KOL|账号|人数)[^\d]{0,10}(\d+)\s*位|(\d+)\s*位\s*(?:达人|博主|KOL|账号)/i);
  214. if (directMatch) {
  215. return Number(directMatch[1] || directMatch[2]);
  216. }
  217. return undefined;
  218. }