| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306 |
- 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<BriefAnalysisResult> {
- 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<string, unknown>;
- 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<string, RegionalBudgetRule>();
- 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<string, unknown>
- : {};
- 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;
- }
|