thread-master/apps/web/src/lib/mockResearch.ts

324 lines
11 KiB
TypeScript
Raw 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 {
ResearchHit,
ScoutKnowledgeRelation,
ScoutResearchNote,
ScoutResearchTier,
} from "../domain/types";
import { mockDelay } from "./mockAi";
import { newId } from "./id";
const SOURCES = [
{ host: "www.commonhealth.com.tw", label: "康健雜誌" },
{ host: "www.edh.tw", label: "早安健康" },
{ host: "heho.com.tw", label: "Heho健康" },
{ host: "www.dcard.tw", label: "Dcard" },
{ host: "www.ptt.cc", label: "PTT" },
{ host: "medium.com", label: "Medium" },
];
export const RESEARCH_TIER_META: Record<
ScoutResearchTier,
{ label: string; hint: string; order: number }
> = {
core: { label: "最貼主題", hint: "直接對準痛點/關鍵語,優先讀", order: 0 },
adjacent: { label: "相關周邊", hint: "鄰近情境,擴搜尋面", order: 1 },
broad: { label: "最廣泛", hint: "背景與對照,選讀即可", order: 2 },
};
export const KNOWLEDGE_RELATION_META: Record<
ScoutKnowledgeRelation,
{ label: string }
> = {
solves_pain: { label: "對準痛點" },
nearby_scene: { label: "鄰近場景" },
myth: { label: "迷思澄清" },
contrast: { label: "對照選購" },
background: { label: "背景脈絡" },
};
function pageUrl(host: string, slug: string): string {
return `https://${host}/article/${encodeURIComponent(slug)}`;
}
/** 漂亮顯示用:來源站名 + 精簡 host */
export function formatResearchLink(url: string, sourceLabel?: string): {
label: string;
host: string;
} {
let host = "";
try {
host = new URL(url).hostname.replace(/^www\./, "");
} catch {
host = url.replace(/^https?:\/\//, "").split("/")[0] || "";
}
return {
label: sourceLabel?.trim() || host || "來源",
host,
};
}
/**
* 取得可顯示的學習重點(優先 learn_points舊資料從 summary 拆)
*/
export function noteLearnPoints(n: Pick<ScoutResearchNote, "learn_points" | "summary">): string[] {
if (n.learn_points && n.learn_points.length > 0) {
return n.learn_points.slice(0, 5);
}
const raw = (n.summary || "").trim();
if (!raw) return [];
const byNum = raw
.split(/(?=\d)|(?=\d\.)|(?=)/)
.map((s) => s.replace(/^\d+[.]\s*/, "").replace(/^\s*/, "").trim())
.filter((s) => s.length >= 8 && s.length <= 80);
if (byNum.length >= 2) return byNum.slice(0, 4);
const bySentence = raw
.split(/[。!?\n]+/)
.map((s) => s.trim())
.filter((s) => s.length >= 8);
return bySentence.slice(0, 3);
}
export function noteReplyHooks(n: Pick<ScoutResearchNote, "reply_hooks">): string[] {
return (n.reply_hooks || []).filter(Boolean).slice(0, 3);
}
/**
* mock 上網研究:標題 + 學習重點 + 回帖鉤子 + 摘要 + URL
* 前幾則 core後則 adjacent
*/
export async function mockWebResearch(query: string): Promise<ResearchHit[]> {
await mockDelay(650);
const q = query.trim() || "主題";
const slug = q.replace(/\s+/g, "-").slice(0, 32) || "topic";
const pages: Array<Omit<ResearchHit, "id">> = [
{
title: `${q}:常見迷思與實際差異`,
snippet: `多數人討論「${q}」時會忽略使用情境,只看行銷字。`,
learn_points: [
`「溫和/天然」要對應使用頻率與膚況,不是萬能標籤`,
`別只看成分表第一行,香精、防腐與殘留感也會刺`,
`先寫自己的痛點句,再對規格,比問「哪個最好」準`,
],
reply_hooks: [
`先確認是刺鼻、刺癢還是洗不乾淨,三種解法不太一樣`,
`無香也不等於一定不刺激,可以一起對成分表看`,
],
summary: [
`本篇整理「${q}」在公開討論中的三個常見誤區:`,
`把「溫和/天然」當萬能標籤;只比成分表第一行;聽完業配再決策。`,
`建議先寫下痛點句(刺鼻、刺癢、洗不乾淨),再回頭對規格。`,
].join(""),
url: pageUrl(SOURCES[0]!.host, `${slug}-myths`),
source_label: SOURCES[0]!.label,
tier: "core",
},
{
title: `專家觀點:如何評估 ${q}`,
snippet: `可從安全性、長期成本、真實回饋三維度評估。`,
learn_points: [
`優先問:刺激來源能不能排除、有沒有可驗證使用情境`,
`敏感或長時間接觸:先收集失敗案例,不要只看成功廣告`,
`可操作三問:什麼會變糟?多久改善?有無生活替代?`,
],
reply_hooks: [
`你試過之後是「當下就刺」還是「用幾天後才不舒服」?`,
`若目標是敏感肌,失敗案例通常比業配文更有參考價值`,
],
summary: [
`科普向文章:評估「${q}」時優先看刺激來源與使用情境。`,
`問句可直接當 Threads 接話鉤子。`,
].join(""),
url: pageUrl(SOURCES[1]!.host, `${slug}-howto`),
source_label: SOURCES[1]!.label,
tier: "core",
},
{
title: `論壇實測串:${q} 用過的人怎麼說`,
snippet: `公開討論裡「實際用過」的留言互動通常高於純規格文。`,
learn_points: [
`高頻抱怨:一用就刺鼻/刺癢、洗完有殘留、假無香`,
`正向經驗多來自小範圍試用、對照香精欄、接受磨合期`,
`海巡關鍵語:真的無香嗎、有人也刺痛嗎、求非業配`,
],
reply_hooks: [
`也有人一用就刺鼻,你是碰到味道還是接觸後刺癢?`,
`論壇裡「求非業配」串通常比較敢講失敗經驗`,
],
summary: [
`彙整論壇約 40 則心得:抱怨集中在刺鼻、殘留、假無香。`,
`回覆時先共感具體症狀,再輕提解法。`,
].join(""),
url: pageUrl(SOURCES[3]!.host, `f-mood-${slug}-reviews`),
source_label: SOURCES[3]!.label,
tier: "adjacent",
},
{
title: `${q} 對照表與選購清單(摘要)`,
snippet: `把規格拆成痛點對照,比堆疊功能點更容易說服人。`,
learn_points: [
`拆成:刺激源、使用步驟、價格帶、適合誰/不適合誰`,
`適合「已明確知道自己的雷」的人,不適合亂槍打鳥`,
`對方痛點講清楚前,先不要急著丟產品連結`,
],
reply_hooks: [
`你比較在意「完全無味」還是「接觸後不刺」?兩個規格不一樣`,
`若你已經知道自己的雷,對照表會比推薦清單好用`,
],
summary: [
`清單文把「${q}」拆成刺激源、步驟、價格、適合對象。`,
`置入啟發:先確認痛點再決定要不要帶產品。`,
].join(""),
url: pageUrl(SOURCES[2]!.host, `${slug}-checklist`),
source_label: SOURCES[2]!.label,
tier: "adjacent",
},
];
return pages.map((p) => ({ ...p, id: newId("hit") }));
}
export function formatResearchInsert(hits: ResearchHit[]): string {
if (!hits.length) return "";
return (
"\n\n——\n補充資料\n" +
hits
.map((h, i) => {
const points = h.learn_points?.length
? h.learn_points.map((p) => `· ${p}`).join("\n")
: h.summary || h.snippet;
const link = formatResearchLink(h.url, h.source_label);
return `${i + 1}. ${h.title}\n${points}\n${link.label} · ${link.host}`;
})
.join("\n\n")
);
}
function relationForTier(tier: ScoutResearchTier, title: string): ScoutKnowledgeRelation {
if (title.includes("迷思")) return "myth";
if (title.includes("對照") || title.includes("選購")) return "contrast";
if (tier === "core") return "solves_pain";
if (tier === "adjacent") return "nearby_scene";
return "background";
}
/** ResearchHit → 海巡功課用的知識節點 */
export function researchHitsToScoutNotes(hits: ResearchHit[]): ScoutResearchNote[] {
return hits.map((h) => {
const tier = h.tier || "core";
const summary = (h.summary || h.snippet || "").trim();
const learn_points =
h.learn_points && h.learn_points.length > 0
? h.learn_points
: noteLearnPoints({ summary, learn_points: undefined });
return {
id: h.id,
title: h.title,
summary,
learn_points,
reply_hooks: h.reply_hooks?.length ? h.reply_hooks : undefined,
url: h.url,
source_label: h.source_label,
keywords: extractKeywordsFromNote(h.title, h.summary || h.snippet),
tier,
relation: relationForTier(tier, h.title),
};
});
}
function extractKeywordsFromNote(title: string, body: string): string[] {
const blob = `${title} ${body}`;
const candidates = [
"無香",
"敏感肌",
"刺鼻",
"刺癢",
"香精",
"成分表",
"溫和",
"實測",
"非業配",
"插座",
"第三空間",
"久坐",
];
const found = candidates.filter((k) => blob.includes(k));
const head = title.split(/[:]/)[0]?.trim();
if (head && head.length >= 2 && head.length <= 16 && !found.includes(head)) {
found.unshift(head.slice(0, 12));
}
return [...new Set(found)].slice(0, 5);
}
/**
* 周邊詞 → 分層延伸頁
* 前半 adjacent後半 broad
*/
export async function mockExpandKnowledgePages(
terms: string[],
contextLabel?: string,
): Promise<ScoutResearchNote[]> {
await mockDelay(350);
const take = terms.filter(Boolean).slice(0, 6);
return take.map((term, i) => {
const src = SOURCES[(i + 2) % SOURCES.length]!;
const ctx = contextLabel ? `(對齊 ${contextLabel}` : "";
const tier: ScoutResearchTier = i < 2 ? "adjacent" : "broad";
if (tier === "broad") {
return {
id: newId("ek"),
title: `背景:${term} 的更大討論脈絡${ctx}`,
summary: `較廣角閱讀:「${term}」在生活/消費文化裡如何被框定。`,
learn_points: [
`${term}」常被包進更大的生活/消費敘事,不只是單點規格`,
`用途是聽懂對方從哪個場景開講,不是立刻給選品答案`,
`語氣很散時:先接背景一句,再收斂到具體痛點`,
],
reply_hooks: [
`聽起來你是從「${term}」這條線在煩,我先對一下場景`,
],
url: pageUrl(src.host, `expand-${term.slice(0, 16)}-${i + 1}`),
source_label: src.label,
keywords: [term],
tier,
relation: "background",
};
}
return {
id: newId("ek"),
title: `周邊:${term} 為什麼常被一起談${ctx}`,
summary: `針對關鍵語「${term}」的延伸摘要。`,
learn_points: [
`社群常把「${term}」和相鄰痛點綁在一起講`,
`對方未必搜產品名,而是用症狀/場景說話`,
`可帶走:用對方的詞接話 → 先釐清情境 → 再談規格`,
],
reply_hooks: [
`很多人提到「${term}」時,其實在講隔壁的痛,你是哪一種?`,
],
url: pageUrl(src.host, `expand-${term.slice(0, 16)}-${i + 1}`),
source_label: src.label,
keywords: [term],
tier,
relation: "nearby_scene",
};
});
}
export function groupNotesByTier(
notes: ScoutResearchNote[],
): { tier: ScoutResearchTier; notes: ScoutResearchNote[] }[] {
const buckets: Record<ScoutResearchTier, ScoutResearchNote[]> = {
core: [],
adjacent: [],
broad: [],
};
for (const n of notes) {
const t = n.tier || "adjacent";
buckets[t].push(n);
}
return (["core", "adjacent", "broad"] as ScoutResearchTier[])
.filter((t) => buckets[t].length > 0)
.map((tier) => ({ tier, notes: buckets[tier] }));
}