haixunMaster/lib/research-content-band.ts

171 lines
4.9 KiB
TypeScript
Raw Permalink Normal View History

2026-06-21 12:50:31 +00:00
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`;
}