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; regionalBudgetRules?: RegionalBudgetRule[]; 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[]; } export interface RegionalBudgetRule { regions: string[]; min: number; max: number; } 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?, regionalBudgetRules?: [{regions: [], min, max}], gender?, contentTags?: [], excludeTags?: [], targetCount?} 如果 Brief 里出现“某些城市/地区对应不同单个预算/平台价格”,请把这些规则放入 regionalBudgetRules,并保留总 budgetRange 为全部规则的最小/最大范围。 如果 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.regionalBudgetRules = normalizeRegionalBudgetRules( result.searchCriteria.regionalBudgetRules, briefText, ); 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 }, regionalBudgetRules: inferRegionalBudgetRules(briefText), targetCount: inferTargetCount(briefText, []), evaluationNeeds: inferEvaluationNeeds(briefText, []), }, }; } function normalizeRegionalBudgetRules(parsedRules: unknown, briefText: string): RegionalBudgetRule[] | undefined { const parsed = Array.isArray(parsedRules) ? parsedRules .map((item) => { if (typeof item !== 'object' || item === null) return null; const raw = item as Record; const regions = toStringArray(raw.regions || raw.region || raw.cities || raw.city); const min = Number(raw.min || raw.minPrice || raw.budgetMin || 0); const max = Number(raw.max || raw.maxPrice || raw.budgetMax || 0); return regions.length > 0 && max > 0 ? { regions, min: Math.max(0, min), max } : null; }) .filter((item): item is RegionalBudgetRule => item !== null) : []; const inferred = inferRegionalBudgetRules(briefText); const combined = [...parsed, ...inferred]; const deduped = new Map(); for (const rule of combined) { const regions = [...new Set(rule.regions.map((region) => region.trim()).filter(Boolean))]; if (regions.length === 0 || rule.max <= 0) continue; const key = `${regions.sort().join('|')}:${rule.min}-${rule.max}`; deduped.set(key, { regions, min: rule.min, max: rule.max }); } return deduped.size > 0 ? [...deduped.values()] : undefined; } function inferRegionalBudgetRules(briefText: string): RegionalBudgetRule[] { const cityNames = [ '北京', '上海', '广州', '深圳', '成都', '杭州', '武汉', '西安', '南京', '重庆', '天津', '苏州', '长沙', '郑州', '青岛', '厦门', '宁波', '合肥', '福州', '无锡', ]; const rules: RegionalBudgetRule[] = []; const pattern = /([^\n。;;::]{1,80})[::][^\n。;;]{0,30}?(\d{3,6})\s*(?:-|~|—|至|到)\s*(\d{3,6})/g; for (const match of briefText.matchAll(pattern)) { const regions = cityNames.filter((city) => match[1].includes(city)); if (regions.length === 0) continue; const min = Number(match[2]); const max = Number(match[3]); if (Number.isFinite(min) && Number.isFinite(max) && max > 0) { rules.push({ regions, min: Math.min(min, max), max: Math.max(min, max) }); } } return rules; } 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[] { if (Array.isArray(value)) return value.map(String).filter(Boolean); if (typeof value === 'string') return value.split(/[、,,\s/]+/).map((item) => item.trim()).filter(Boolean); return []; } 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; }