import { config } from '../config.ts'; interface BriefAnalysisResult { requirements: Array<{ label: string; value: string; confidence: number }>; searchCriteria: { platforms: string[]; keywords: string[]; fanRange: { min: number; max: number }; budgetRange: { min: number; max: number }; region?: string; gender?: string; contentTags?: string[]; excludeTags?: string[]; targetCount?: number; evaluationNeeds?: EvaluationNeeds; }; } export interface EvaluationNeeds { cpmCpe?: boolean; commercialStability?: boolean; recentPerformance?: boolean; updateFrequency?: boolean; commentQuality?: boolean; publicSentiment?: boolean; audienceGender?: boolean; reasons?: string[]; } const BRIEF_ANALYSIS_PROMPT = `你是一个专业的媒介策划助手。请分析以下客户Brief文件内容,提取出结构化的投放需求。 要求提取以下信息: 1. 客户/品牌名称 2. 投放目标(种草、转化、品宣等) 3. 平台需求(小红书、抖音、B站等及各平台需求人数) 4. 粉丝数要求范围 5. 预算范围 6. 内容风格要求 7. 地区要求 8. 排除项(竞品、不合适的类型等) 9. 合作形式(图文、视频等) 请以JSON格式输出,包含requirements数组和searchCriteria对象。 requirements格式:[{label: "字段名", value: "提取值", confidence: 0-100}] searchCriteria格式:{platforms: [], keywords: [], fanRange: {min, max}, budgetRange: {min, max}, region?, gender?, contentTags?: [], excludeTags?: [], targetCount?} 如果 Brief 写明达人数量,请额外输出 targetCount,表示最终需求达人总数(不是候选池数量)。 Brief内容: `; const EVALUATION_NEEDS_PROMPT = ` 请同时在 searchCriteria 中输出 evaluationNeeds,用来控制是否启用额外判断流程。 只有 Brief 明确提到对应维度时才置 true,不要默认开启。 格式:{ cpmCpe?: boolean, commercialStability?: boolean, recentPerformance?: boolean, updateFrequency?: boolean, commentQuality?: boolean, publicSentiment?: boolean, audienceGender?: boolean, reasons?: string[] } `; export async function analyzeBrief(briefText: string): Promise { if (!config.llm.apiKey) { console.warn('[LLM] No API key configured, using fallback analysis'); return fallbackAnalysis(briefText); } try { console.log('[LLM] 发起请求:', { url: `${config.llm.baseUrl}/chat/completions`, model: config.llm.model, briefLength: briefText.length }); const response = await fetch(`${config.llm.baseUrl}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.llm.apiKey}`, }, body: JSON.stringify({ model: config.llm.model, messages: [ { role: 'system', content: '你是专业的媒介策划AI助手,擅长从Brief文档中提取结构化投放需求。你的回复必须是纯JSON格式,不要包含markdown代码块标记。' }, { role: 'user', content: BRIEF_ANALYSIS_PROMPT + EVALUATION_NEEDS_PROMPT + briefText }, ], temperature: 0.3, }), }); if (!response.ok) { const errorBody = await response.text(); console.error(`[LLM] API error: ${response.status} ${response.statusText}`, errorBody); return fallbackAnalysis(briefText); } const data = await response.json(); const content = data.choices?.[0]?.message?.content; if (!content) { return fallbackAnalysis(briefText); } // 提取 JSON(可能被 markdown 代码块包裹) let jsonStr = content.trim(); const codeBlockMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/); if (codeBlockMatch) { jsonStr = codeBlockMatch[1].trim(); } const parsed = JSON.parse(jsonStr); const result = { requirements: parsed.requirements || [], searchCriteria: parsed.searchCriteria || { platforms: [], keywords: [], fanRange: { min: 10000, max: 500000 }, budgetRange: { min: 2000, max: 20000 } }, }; result.searchCriteria.targetCount = normalizeTargetCount(result.searchCriteria.targetCount) || inferTargetCount(briefText, result.requirements); result.searchCriteria.evaluationNeeds = normalizeEvaluationNeeds( result.searchCriteria.evaluationNeeds, briefText, result.requirements, ); console.log('[LLM] 解析结果:', JSON.stringify(result, null, 2)); return result; } catch (error) { console.error('[LLM] Analysis failed:', error); return fallbackAnalysis(briefText); } } function fallbackAnalysis(briefText: string): BriefAnalysisResult { // 简单的关键词匹配作为降级方案 const platforms: string[] = []; if (briefText.includes('小红书') || briefText.includes('红书')) platforms.push('xiaohongshu'); if (briefText.includes('抖音') || briefText.includes('TikTok')) platforms.push('douyin'); if (briefText.includes('B站') || briefText.includes('哔哩哔哩')) platforms.push('bilibili'); if (briefText.includes('微博')) platforms.push('weibo'); if (briefText.includes('微信') || briefText.includes('公众号')) platforms.push('weixin'); if (platforms.length === 0) platforms.push('xiaohongshu', 'douyin'); const keywords: string[] = []; const keywordPatterns = ['护肤', '美妆', '母婴', '数码', '穿搭', '美食', '家居', '运动', '旅行', '教育']; for (const kw of keywordPatterns) { if (briefText.includes(kw)) keywords.push(kw); } if (keywords.length === 0) keywords.push('生活方式'); return { requirements: [ { label: '平台需求', value: platforms.join('、'), confidence: 75 }, { label: '内容关键词', value: keywords.join('、'), confidence: 70 }, { label: '预算范围', value: '2000-20000', confidence: 60 }, { label: '粉丝范围', value: '1万-50万', confidence: 60 }, ], searchCriteria: { platforms, keywords, fanRange: { min: 10000, max: 500000 }, budgetRange: { min: 2000, max: 20000 }, targetCount: inferTargetCount(briefText, []), evaluationNeeds: inferEvaluationNeeds(briefText, []), }, }; } function normalizeEvaluationNeeds( parsedNeeds: unknown, briefText: string, requirements: Array<{ label: string; value: string; confidence: number }> ): EvaluationNeeds { const inferred = inferEvaluationNeeds(briefText, requirements); const parsed = typeof parsedNeeds === 'object' && parsedNeeds !== null ? parsedNeeds as Record : {}; const reasons = [ ...toStringArray(parsed.reasons), ...(inferred.reasons || []), ]; return { cpmCpe: Boolean(parsed.cpmCpe) || Boolean(inferred.cpmCpe), commercialStability: Boolean(parsed.commercialStability) || Boolean(inferred.commercialStability), recentPerformance: Boolean(parsed.recentPerformance) || Boolean(inferred.recentPerformance), updateFrequency: Boolean(parsed.updateFrequency) || Boolean(inferred.updateFrequency), commentQuality: Boolean(parsed.commentQuality) || Boolean(inferred.commentQuality), publicSentiment: Boolean(parsed.publicSentiment) || Boolean(inferred.publicSentiment), audienceGender: Boolean(parsed.audienceGender) || Boolean(inferred.audienceGender), reasons: [...new Set(reasons)].slice(0, 12), }; } function inferEvaluationNeeds( briefText: string, requirements: Array<{ label: string; value: string; confidence: number }> ): EvaluationNeeds { const text = `${briefText}\n${requirements.map((item) => `${item.label}:${item.value}`).join('\n')}`.toLowerCase(); const reasons: string[] = []; const hasAny = (...terms: string[]) => terms.some((term) => text.includes(term.toLowerCase())); const mark = (enabled: boolean, reason: string) => { if (enabled) reasons.push(reason); return enabled; }; return { cpmCpe: mark(hasAny('cpm', 'cpe', '千次曝光', '互动成本'), 'Brief 提到 CPM/CPE 或投放成本效率'), commercialStability: mark(hasAny('商单', '蒲公英', '合作数据', '广告数据', '含广'), 'Brief 提到蒲公英/商单/广告数据'), recentPerformance: mark(hasAny('近期数据', '近30', '最近30', '数据下降', '互动量落差', '稳定性'), 'Brief 提到近期表现或数据稳定性'), updateFrequency: mark(hasAny('更新率', '更新频率', '低更新', '活跃度', 'low active'), 'Brief 提到更新频率或活跃度'), commentQuality: mark(hasAny('评论区', '良性互动', '评论质量', '评论'), 'Brief 提到评论区质量'), publicSentiment: mark(hasAny('舆情', '负面', '黑料', '争议', '翻车'), 'Brief 提到舆情或负面风险'), audienceGender: mark(hasAny('粉丝男女', '男女占比', '女性占比', '粉丝性别', '品牌ta'), 'Brief 提到粉丝性别画像'), reasons, }; } function toStringArray(value: unknown): string[] { return Array.isArray(value) ? value.map(String).filter(Boolean) : []; } function normalizeTargetCount(value: unknown): number | undefined { const count = Number(value || 0); return count > 0 ? Math.ceil(count) : undefined; } function inferTargetCount( briefText: string, requirements: Array<{ label: string; value: string; confidence: number }> ): number | undefined { const text = `${briefText}\n${requirements.map((item) => `${item.label}:${item.value}`).join('\n')}`; const perCityMatch = text.match(/(\d+)\s*(?:个|座)?城市[\s\S]{0,40}?(\d+)\s*位\s*\/\s*城市/); if (perCityMatch) { return Number(perCityMatch[1]) * Number(perCityMatch[2]); } const cityCountMatch = text.match(/(\d+)\s*(?:个|座)?城市/); const perCityLooseMatch = text.match(/每(?:个)?城市\s*(\d+)\s*位|(\d+)\s*位\s*\/\s*城市/); if (cityCountMatch && perCityLooseMatch) { return Number(cityCountMatch[1]) * Number(perCityLooseMatch[1] || perCityLooseMatch[2]); } const directMatch = text.match(/(?:达人|博主|KOL|账号|人数)[^\d]{0,10}(\d+)\s*位|(\d+)\s*位\s*(?:达人|博主|KOL|账号)/i); if (directMatch) { return Number(directMatch[1] || directMatch[2]); } return undefined; }