haixunMaster/lib/research-content-band.ts

171 lines
4.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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。`;
}