171 lines
4.9 KiB
TypeScript
171 lines
4.9 KiB
TypeScript
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<string>();
|
||
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<ResearchMap, "questions" | "pillars" | "exclusions"> | 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<ResearchMap, "questions" | "pillars" | "exclusions"> | 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。`;
|
||
} |