haixunMaster/lib/services/analyze.ts

228 lines
7.5 KiB
TypeScript
Raw Normal View History

2026-06-21 12:50:31 +00:00
import { prisma } from "@/lib/db";
import { getOrCreateSettings } from "@/lib/user-settings";
import { analyzeTopicIntent } from "@/lib/ai/analyze-topic";
import { parseProviderApiKeys } from "@/lib/ai/keys";
import { assertJobNotCancelled } from "@/lib/jobs/cancel";
import { updateJobProgress } from "@/lib/jobs/progress-server";
import type { JobProgressDetail } from "@/lib/jobs/types";
import { discoverSimilarAccounts } from "@/lib/services/discover-accounts";
import type { ResearchMap, SearchIntent } from "@/lib/types/research";
import { isPlacementGoal } from "@/lib/types/topic-goal";
import { normalizeUsername, parseSelectedTags } from "@/lib/types/research";
import { reconcileSelectedTags } from "@/lib/services/preserve-selected-tags";
import { pickDefaultSelectedTags } from "@/lib/services/scan-tasks";
import { getActiveAccount, refreshSession } from "@/lib/threads-browser";
import { resolvePersona } from "@/lib/db";
import { getActiveAccountProfile } from "@/lib/account-context";
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label}逾時`)), timeoutMs);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
function enrichTagsWithDiscoveredAccounts(map: ResearchMap): ResearchMap {
const accounts = map.similarAccounts ?? [];
if (accounts.length === 0) return map;
const existing = new Set(
map.suggestedTags.map((t) => normalizeUsername(t.tag).toLowerCase())
);
const accountTags = accounts
.slice(0, 6)
.filter((a) => !existing.has(a.username.toLowerCase()))
.map((a) => ({
tag: `@${a.username}`,
reason: (a.reason || "網路搜尋找到的同領域帳號").slice(0, 80),
searchIntent: "經驗" as SearchIntent,
searchType: "帳號" as const,
}));
if (accountTags.length === 0) return map;
return {
...map,
suggestedTags: [...map.suggestedTags, ...accountTags].slice(0, 18),
};
}
export async function analyzeTopic(
topicId: string,
briefOverride?: string | null,
options?: {
jobId?: string;
onProgress?: (message: string) => void | Promise<void>;
}
): Promise<ResearchMap> {
const jobId = options?.jobId;
const report = async (detail: JobProgressDetail) => {
await options?.onProgress?.(detail.summary);
if (jobId) await updateJobProgress(jobId, detail);
};
const topic = await prisma.topic.findUnique({ where: { id: topicId } });
if (!topic) throw new Error("找不到主題");
const brief =
briefOverride !== undefined ? briefOverride?.trim() || null : topic.brief;
if (briefOverride !== undefined) {
await prisma.topic.update({
where: { id: topicId },
data: { brief },
});
}
const settings = await getOrCreateSettings();
const activeAccount = await getActiveAccountProfile();
const apiKeys = parseProviderApiKeys(settings.providerApiKeys);
const provider = settings.researchAiProvider ?? settings.aiProvider;
const model = settings.researchAiModel ?? settings.aiModel;
console.log(`[analyze-topic] start topic=${topicId} provider=${provider} model=${model}`);
await report({
summary: "AI 分析研究地圖中…",
phase: "ai",
tasks: [{ id: "ai", label: "AI 研究地圖", status: "running" }],
});
await assertJobNotCancelled(jobId);
const baseMap = await analyzeTopicIntent({
label: topic.label,
query: topic.query,
brief,
productContext: topic.productContext,
topicGoal: topic.topicGoal,
persona: resolvePersona(settings, activeAccount),
aiProvider: settings.researchAiProvider ?? settings.aiProvider,
aiModel: settings.researchAiModel ?? settings.aiModel,
apiKeys,
});
await assertJobNotCancelled(jobId);
const placementMode = isPlacementGoal(topic.topicGoal);
let similarAccounts: ResearchMap["similarAccounts"] = [];
if (placementMode) {
await report({
summary: "AI 研究地圖完成(置入模式略過相似帳號)",
phase: "ai",
tasks: [{ id: "ai", label: "AI 研究地圖", status: "done" }],
});
} else {
await report({
summary: "AI 研究地圖完成,搜尋相似帳號中…",
phase: "accounts",
tasks: [
{ id: "ai", label: "AI 研究地圖", status: "done" },
{ id: "accounts", label: "搜尋相似帳號", status: "running" },
],
});
await assertJobNotCancelled(jobId);
const account = await getActiveAccount().catch(() => null);
let storageState: string | undefined;
if (account) {
const refreshed = await withTimeout(refreshSession(account), 20_000, "Threads Session 更新")
.catch(() => null);
if (refreshed?.valid) storageState = refreshed.storageState;
}
similarAccounts = await withTimeout(
discoverSimilarAccounts({
label: topic.label,
query: topic.query,
brief,
productContext: topic.productContext,
pillars: baseMap.pillars,
suggestedTags: baseMap.suggestedTags.map((t) => t.tag),
exclusions: baseMap.exclusions,
storageState,
limit: 8,
aiProvider: provider,
aiModel: model,
apiKeys,
}),
90_000,
"相似帳號搜尋"
).catch(async (error) => {
console.warn(`[analyze-topic] similar account search skipped: ${String(error)}`);
await report({
summary: "研究地圖完成(相似帳號搜尋逾時,已略過)",
phase: "accounts",
tasks: [
{ id: "ai", label: "AI 研究地圖", status: "done" },
{ id: "accounts", label: "搜尋相似帳號(已略過)", status: "done" },
],
});
return [];
});
await assertJobNotCancelled(jobId);
console.log(
`[analyze-topic] discovered accounts=${similarAccounts.length} topic=${topicId}`
);
await report({
summary: `AI 研究地圖完成 · 找到 ${similarAccounts.length} 個相似帳號`,
phase: "accounts",
tasks: [
{ id: "ai", label: "AI 研究地圖", status: "done" },
{ id: "accounts", label: "搜尋相似帳號", status: "done", found: similarAccounts.length },
],
});
}
let researchMap: ResearchMap = { ...baseMap, similarAccounts };
if (placementMode) {
researchMap = {
...researchMap,
similarAccounts: [],
suggestedTags: researchMap.suggestedTags.filter(
(t) => t.searchType !== "帳號" && !t.tag.startsWith("@")
),
};
} else {
researchMap = enrichTagsWithDiscoveredAccounts(researchMap);
}
const existingSelected = parseSelectedTags(topic.selectedTags);
const reconciled = reconcileSelectedTags(existingSelected, researchMap, {
label: topic.label,
query: topic.query,
brief,
});
researchMap = reconciled.researchMap;
const defaults = pickDefaultSelectedTags(researchMap, topic.topicGoal);
const finalSelected = existingSelected.length > 0
? [...new Set([...reconciled.selectedTags, ...defaults])].slice(
0,
Math.max(6, Math.min(8, researchMap.suggestedTags.length))
)
: defaults;
await assertJobNotCancelled(jobId);
await prisma.topic.update({
where: { id: topicId },
data: {
researchMap: JSON.stringify(researchMap),
selectedTags: JSON.stringify(finalSelected),
},
});
console.log(
`[analyze-topic] done topic=${topicId} tags=${researchMap.suggestedTags.length} accounts=${similarAccounts.length}`
);
return researchMap;
}