cb 3 månader sedan
förälder
incheckning
93dbaa6d41

+ 61 - 2
server/services/llm.service.ts

@@ -8,6 +8,7 @@ interface BriefAnalysisResult {
     fanRange: { min: number; max: number };
     fanRange: { min: number; max: number };
     budgetRange: { min: number; max: number };
     budgetRange: { min: number; max: number };
     region?: string;
     region?: string;
+    regionalBudgetRules?: RegionalBudgetRule[];
     gender?: string;
     gender?: string;
     contentTags?: string[];
     contentTags?: string[];
     excludeTags?: string[];
     excludeTags?: string[];
@@ -27,6 +28,12 @@ export interface EvaluationNeeds {
   reasons?: string[];
   reasons?: string[];
 }
 }
 
 
+export interface RegionalBudgetRule {
+  regions: string[];
+  min: number;
+  max: number;
+}
+
 const BRIEF_ANALYSIS_PROMPT = `你是一个专业的媒介策划助手。请分析以下客户Brief文件内容,提取出结构化的投放需求。
 const BRIEF_ANALYSIS_PROMPT = `你是一个专业的媒介策划助手。请分析以下客户Brief文件内容,提取出结构化的投放需求。
 
 
 要求提取以下信息:
 要求提取以下信息:
@@ -42,7 +49,8 @@ const BRIEF_ANALYSIS_PROMPT = `你是一个专业的媒介策划助手。请分
 
 
 请以JSON格式输出,包含requirements数组和searchCriteria对象。
 请以JSON格式输出,包含requirements数组和searchCriteria对象。
 requirements格式:[{label: "字段名", value: "提取值", confidence: 0-100}]
 requirements格式:[{label: "字段名", value: "提取值", confidence: 0-100}]
-searchCriteria格式:{platforms: [], keywords: [], fanRange: {min, max}, budgetRange: {min, max}, region?, gender?, contentTags?: [], excludeTags?: [], targetCount?}
+searchCriteria格式:{platforms: [], keywords: [], fanRange: {min, max}, budgetRange: {min, max}, region?, regionalBudgetRules?: [{regions: [], min, max}], gender?, contentTags?: [], excludeTags?: [], targetCount?}
+如果 Brief 里出现“某些城市/地区对应不同单个预算/平台价格”,请把这些规则放入 regionalBudgetRules,并保留总 budgetRange 为全部规则的最小/最大范围。
 如果 Brief 写明达人数量,请额外输出 targetCount,表示最终需求达人总数(不是候选池数量)。
 如果 Brief 写明达人数量,请额外输出 targetCount,表示最终需求达人总数(不是候选池数量)。
 
 
 Brief内容:
 Brief内容:
@@ -114,6 +122,10 @@ export async function analyzeBrief(briefText: string): Promise<BriefAnalysisResu
       searchCriteria: parsed.searchCriteria || { platforms: [], keywords: [], fanRange: { min: 10000, max: 500000 }, budgetRange: { min: 2000, max: 20000 } },
       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.targetCount = normalizeTargetCount(result.searchCriteria.targetCount) || inferTargetCount(briefText, result.requirements);
+    result.searchCriteria.regionalBudgetRules = normalizeRegionalBudgetRules(
+      result.searchCriteria.regionalBudgetRules,
+      briefText,
+    );
     result.searchCriteria.evaluationNeeds = normalizeEvaluationNeeds(
     result.searchCriteria.evaluationNeeds = normalizeEvaluationNeeds(
       result.searchCriteria.evaluationNeeds,
       result.searchCriteria.evaluationNeeds,
       briefText,
       briefText,
@@ -156,12 +168,57 @@ function fallbackAnalysis(briefText: string): BriefAnalysisResult {
       keywords,
       keywords,
       fanRange: { min: 10000, max: 500000 },
       fanRange: { min: 10000, max: 500000 },
       budgetRange: { min: 2000, max: 20000 },
       budgetRange: { min: 2000, max: 20000 },
+      regionalBudgetRules: inferRegionalBudgetRules(briefText),
       targetCount: inferTargetCount(briefText, []),
       targetCount: inferTargetCount(briefText, []),
       evaluationNeeds: inferEvaluationNeeds(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(
 function normalizeEvaluationNeeds(
   parsedNeeds: unknown,
   parsedNeeds: unknown,
   briefText: string,
   briefText: string,
@@ -213,7 +270,9 @@ function inferEvaluationNeeds(
 }
 }
 
 
 function toStringArray(value: unknown): string[] {
 function toStringArray(value: unknown): string[] {
-  return Array.isArray(value) ? value.map(String).filter(Boolean) : [];
+  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 {
 function normalizeTargetCount(value: unknown): number | undefined {

+ 60 - 1
server/services/recommendation.service.ts

@@ -45,6 +45,7 @@ interface SearchCriteria {
   fanRange: { min: number; max: number };
   fanRange: { min: number; max: number };
   budgetRange: { min: number; max: number };
   budgetRange: { min: number; max: number };
   region?: string | string[];
   region?: string | string[];
+  regionalBudgetRules?: RegionalBudgetRule[];
   gender?: string;
   gender?: string;
   contentTags?: string[];
   contentTags?: string[];
   excludeTags?: string[];
   excludeTags?: string[];
@@ -52,6 +53,12 @@ interface SearchCriteria {
   evaluationNeeds?: EvaluationNeeds;
   evaluationNeeds?: EvaluationNeeds;
 }
 }
 
 
+interface RegionalBudgetRule {
+  regions: string[];
+  min: number;
+  max: number;
+}
+
 export interface EvaluationNeeds {
 export interface EvaluationNeeds {
   cpmCpe?: boolean;
   cpmCpe?: boolean;
   commercialStability?: boolean;
   commercialStability?: boolean;
@@ -245,7 +252,10 @@ export async function generateRecommendations(
   onProgress?.('processing', `去重后 ${uniqueCandidates.length} 位候选,开始评分筛选...`);
   onProgress?.('processing', `去重后 ${uniqueCandidates.length} 位候选,开始评分筛选...`);
 
 
   // Step 3: 标准化并评分
   // Step 3: 标准化并评分
-  const normalized = uniqueCandidates.map((c, index) => normalizeCandidateFromJustOne(c, criteria, index));
+  const normalized = applyRegionalBudgetHardFilter(
+    uniqueCandidates.map((c, index) => normalizeCandidateFromJustOne(c, criteria, index)),
+    criteria,
+  );
   if (normalized.length > 0) {
   if (normalized.length > 0) {
     console.log('[Recommend] 评分样例 (前3位):');
     console.log('[Recommend] 评分样例 (前3位):');
     normalized.slice(0, 3).forEach(c => {
     normalized.slice(0, 3).forEach(c => {
@@ -856,6 +866,15 @@ function buildCompactSearchKeywords(criteria: SearchCriteria): string[] {
     .map(cleanSearchKeyword)
     .map(cleanSearchKeyword)
     .filter(Boolean);
     .filter(Boolean);
   const unique = [...new Set(candidates)];
   const unique = [...new Set(candidates)];
+  if (criteria.regionalBudgetRules?.length) {
+    const baseKeyword = unique[0] || '生活方式';
+    const regionalKeywords = criteria.regionalBudgetRules
+      .flatMap((rule) => rule.regions.map((region) => `${region} ${baseKeyword}`))
+      .map(cleanSearchKeyword)
+      .filter(Boolean);
+    const regionalUnique = [...new Set(regionalKeywords)];
+    if (regionalUnique.length > 0) return regionalUnique.slice(0, Math.max(MAX_KEYWORDS_PER_PLATFORM, 8));
+  }
   return unique.slice(0, MAX_KEYWORDS_PER_PLATFORM).length > 0
   return unique.slice(0, MAX_KEYWORDS_PER_PLATFORM).length > 0
     ? unique.slice(0, MAX_KEYWORDS_PER_PLATFORM)
     ? unique.slice(0, MAX_KEYWORDS_PER_PLATFORM)
     : ['生活方式'];
     : ['生活方式'];
@@ -874,6 +893,7 @@ function normalizeSearchCriteria(criteria: SearchCriteria): SearchCriteria {
     ...criteria,
     ...criteria,
     fanRange: normalizeFanRange(criteria.fanRange),
     fanRange: normalizeFanRange(criteria.fanRange),
     budgetRange: normalizeBudgetRange(criteria.budgetRange),
     budgetRange: normalizeBudgetRange(criteria.budgetRange),
+    regionalBudgetRules: normalizeRegionalBudgetRules(criteria.regionalBudgetRules),
     platforms: criteria.platforms?.length ? criteria.platforms : ['xiaohongshu', 'douyin'],
     platforms: criteria.platforms?.length ? criteria.platforms : ['xiaohongshu', 'douyin'],
     keywords: criteria.keywords?.length ? criteria.keywords : ['生活方式'],
     keywords: criteria.keywords?.length ? criteria.keywords : ['生活方式'],
     targetCount: normalizeTargetCount(criteria.targetCount),
     targetCount: normalizeTargetCount(criteria.targetCount),
@@ -916,6 +936,45 @@ function normalizeBudgetRange(range: { min: number; max: number }): { min: numbe
   };
   };
 }
 }
 
 
+function normalizeRegionalBudgetRules(rules?: RegionalBudgetRule[]): RegionalBudgetRule[] | undefined {
+  if (!Array.isArray(rules)) return undefined;
+  const normalized = rules
+    .map((rule) => ({
+      regions: Array.isArray(rule.regions) ? [...new Set(rule.regions.map(String).map((item) => item.trim()).filter(Boolean))] : [],
+      min: Number(rule.min || 0),
+      max: Number(rule.max || 0),
+    }))
+    .filter((rule) => rule.regions.length > 0 && rule.max > 0)
+    .map((rule) => ({
+      ...rule,
+      min: Math.min(rule.min, rule.max),
+      max: Math.max(rule.min, rule.max),
+    }));
+  return normalized.length > 0 ? normalized : undefined;
+}
+
+function applyRegionalBudgetHardFilter(candidates: NormalizedCandidate[], criteria: SearchCriteria): NormalizedCandidate[] {
+  const rules = criteria.regionalBudgetRules || [];
+  if (rules.length === 0) return candidates;
+  const filtered = candidates.filter((candidate) => {
+    const rule = matchRegionalBudgetRule(candidate, rules);
+    return rule ? hasPriceWithinRegionalBudget(candidate, rule) : false;
+  });
+  console.log(`[Recommend] Regional budget hard filter: ${filtered.length}/${candidates.length} candidates kept`);
+  return filtered;
+}
+
+function matchRegionalBudgetRule(candidate: NormalizedCandidate, rules: RegionalBudgetRule[]): RegionalBudgetRule | undefined {
+  const locationText = `${candidate.city || ''} ${candidate.location || ''} ${candidate.geoLocation || ''}`;
+  return rules.find((rule) => rule.regions.some((region) => locationText.includes(region)));
+}
+
+function hasPriceWithinRegionalBudget(candidate: NormalizedCandidate, rule: RegionalBudgetRule): boolean {
+  return [candidate.imagePrice, candidate.videoPrice, candidate.minPrice]
+    .filter((price) => price > 0)
+    .some((price) => price >= rule.min && price <= rule.max);
+}
+
 function uniquePlatforms(platforms: string[]): string[] {
 function uniquePlatforms(platforms: string[]): string[] {
   const normalized = platforms.map(mapPlatformName).filter(Boolean);
   const normalized = platforms.map(mapPlatformName).filter(Boolean);
   const supported = normalized.filter((platform) => platform === 'xiaohongshu' || platform === 'douyin');
   const supported = normalized.filter((platform) => platform === 'xiaohongshu' || platform === 'douyin');

+ 1 - 0
server/services/task-manager.service.ts

@@ -25,6 +25,7 @@ export interface Task {
     fanRange: { min: number; max: number };
     fanRange: { min: number; max: number };
     budgetRange: { min: number; max: number };
     budgetRange: { min: number; max: number };
     region?: string;
     region?: string;
+    regionalBudgetRules?: Array<{ regions: string[]; min: number; max: number }>;
     gender?: string;
     gender?: string;
     contentTags?: string[];
     contentTags?: string[];
     excludeTags?: string[];
     excludeTags?: string[];