llm.service.ts 13 KB

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