import type { ResearchMap } from "@/lib/types/research"; export interface ContentBandInput { questions: string[]; pillars: string[]; exclusions: string[]; } export interface ContentBandScore { score: number; inBand: boolean; excluded: boolean; exclusionHit?: string; matchedQuestions: string[]; matchedPillars: string[]; reason: string; } const PARTICLE_RE = /^[的了嗎呢吧啊有在是不與及或但而這那]/; function normalizeText(text: string): string { return text.replace(/\s+/g, "").trim(); } function significantSubstrings(phrase: string, minLen = 2, maxLen = 6): string[] { const p = normalizeText(phrase); if (p.length < minLen) return []; const found = new Set(); if (p.length <= maxLen) { found.add(p); } for (let len = Math.min(maxLen, p.length); len >= minLen; len--) { for (let i = 0; i <= p.length - len; i++) { const sub = p.slice(i, i + len); if (PARTICLE_RE.test(sub)) continue; found.add(sub); } } return [...found].sort((a, b) => b.length - a.length); } function overlapScore(text: string, phrase: string): number { const t = normalizeText(text); const p = normalizeText(phrase); if (!t || !p) return 0; if (t.includes(p)) return Math.min(12, p.length + 2); let score = 0; for (const sub of significantSubstrings(p)) { if (t.includes(sub)) score += sub.length; } return Math.min(10, score); } function bestMatches(text: string, items: string[], minScore = 3): string[] { return items .map((item) => ({ item, score: overlapScore(text, item) })) .filter((x) => x.score >= minScore) .sort((a, b) => b.score - a.score) .map((x) => x.item); } export function scoreContentBand(text: string, band: ContentBandInput): ContentBandScore { const t = text.trim(); if (!t) { return { score: -10, inBand: false, excluded: false, matchedQuestions: [], matchedPillars: [], reason: "內容為空", }; } for (const ex of band.exclusions) { const term = ex.trim(); if (term.length >= 2 && t.includes(term)) { return { score: -100, inBand: false, excluded: true, exclusionHit: term, matchedQuestions: [], matchedPillars: [], reason: `觸及排除項目:${term}`, }; } } const matchedQuestions = bestMatches(t, band.questions); const matchedPillars = bestMatches(t, band.pillars); const questionScore = matchedQuestions.reduce((sum, q) => sum + overlapScore(t, q), 0); const pillarScore = matchedPillars.reduce((sum, p) => sum + overlapScore(t, p), 0); const score = questionScore * 1.15 + pillarScore; const inBand = matchedQuestions.length > 0 || matchedPillars.length > 0; let reason = ""; if (!inBand) { reason = "未呼應受眾問題或內容支柱"; } else { const parts: string[] = []; if (matchedQuestions.length > 0) { parts.push(`呼應問題:${matchedQuestions[0]}`); } if (matchedPillars.length > 0) { parts.push(`符合支柱:${matchedPillars[0]}`); } reason = parts.join(";"); } return { score, inBand, excluded: false, matchedQuestions, matchedPillars, reason, }; } export function isInContentBand( text: string, band: ContentBandInput, minScore = 3 ): boolean { const result = scoreContentBand(text, band); if (result.excluded) return false; return result.inBand && result.score >= minScore; } export function contentBandFromResearchMap( map: Pick | null | undefined ): ContentBandInput | null { if (!map) return null; const questions = map.questions?.filter(Boolean) ?? []; const pillars = map.pillars?.filter(Boolean) ?? []; const exclusions = map.exclusions?.filter(Boolean) ?? []; if (questions.length === 0 && pillars.length === 0) return null; return { questions, pillars, exclusions }; } export function buildContentBandPromptBlock( map: Pick | null | undefined ): string { if (!map) return ""; const questions = map.questions?.filter(Boolean) ?? []; const pillars = map.pillars?.filter(Boolean) ?? []; const exclusions = map.exclusions?.filter(Boolean) ?? []; if (questions.length === 0 && pillars.length === 0) return ""; return `【內容區間】貼文必須落在研究地圖定義的範圍內,否則 EXCLUDE: ${ questions.length > 0 ? `- 受眾會問什麼(須呼應至少一項痛點/疑問):\n ${questions.map((q) => `· ${q}`).join("\n ")}` : "" } ${ pillars.length > 0 ? `- 內容支柱(須符合至少一項方向):\n ${pillars.map((p) => `· ${p}`).join("\n ")}` : "" } ${ exclusions.length > 0 ? `- 不要碰(觸及任一項 → 一律 EXCLUDE):\n ${exclusions.map((e) => `· ${e}`).join("\n ")}` : "" } 判定原則:只有「有產品需求」但完全不在上述區間內的貼文 → EXCLUDE;閒聊、晒照、跑題亦 EXCLUDE。`; }