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): 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): string[] { return (n.reply_hooks || []).filter(Boolean).slice(0, 3); } /** * mock 上網研究:標題 + 學習重點 + 回帖鉤子 + 摘要 + URL * 前幾則 core,後則 adjacent */ export async function mockWebResearch(query: string): Promise { await mockDelay(650); const q = query.trim() || "主題"; const slug = q.replace(/\s+/g, "-").slice(0, 32) || "topic"; const pages: Array> = [ { 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 { 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 = { 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] })); }