feat: 搭建阶段 0 与阶段 1 基础工程
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import { createServer } from "./server";
|
||||
|
||||
const port = Number(process.env.PORT ?? 3001);
|
||||
const host = process.env.HOST ?? "0.0.0.0";
|
||||
|
||||
const server = createServer();
|
||||
|
||||
server
|
||||
.listen({ port, host })
|
||||
.then(() => {
|
||||
console.log(`API server listening on http://${host}:${port}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { CandidateRecord, PlatformId } from "@cross-ai/domain";
|
||||
|
||||
function slugify(input: string): string {
|
||||
return input
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export function createMockCandidates(
|
||||
query: string,
|
||||
platform: PlatformId
|
||||
): CandidateRecord[] {
|
||||
if (query.includes("无结果") || query.toLowerCase().includes("none")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const platformName = platform === "tmall" ? "tmall" : "jd";
|
||||
const storeName = platform === "tmall" ? "官方旗舰店" : "京东自营";
|
||||
const basePrice = platform === "tmall" ? 7999 : 8099;
|
||||
const slug = slugify(query);
|
||||
|
||||
return Array.from({ length: 3 }, (_, index) => ({
|
||||
candidateId: `${platform}-${slug}-${index + 1}`,
|
||||
platform,
|
||||
title: `${query} ${platform === "tmall" ? "旗舰版" : "自营精选"} ${index + 1}`,
|
||||
price: basePrice + index * 120,
|
||||
priceLabel: `¥${basePrice + index * 120}`,
|
||||
storeName,
|
||||
productUrl: `https://example.com/${platformName}/${slug}-${index + 1}`,
|
||||
imageUrl: `https://placehold.co/640x480?text=${platformName.toUpperCase()}+${index + 1}`,
|
||||
salesHint:
|
||||
platform === "tmall"
|
||||
? `近 30 天 ${1200 + index * 180} 人付款`
|
||||
: `近 30 天 ${900 + index * 160} 人下单`,
|
||||
specLabel: ["标准版", "大容量", "礼盒套装"][index] ?? "标准版",
|
||||
highlights: [
|
||||
platform === "tmall" ? "强调详情页卖点叙事" : "强调自营与履约体验",
|
||||
"支持价格与评论样本模拟"
|
||||
]
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createServer } from "./server";
|
||||
|
||||
describe("API server", () => {
|
||||
it("returns platform readiness with jd blocked by default", async () => {
|
||||
const app = createServer();
|
||||
await app.ready();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/platforms/readiness"
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const payload = response.json();
|
||||
expect(payload.platforms).toHaveLength(2);
|
||||
expect(
|
||||
payload.platforms.find(
|
||||
(platform: { platform: string }) => platform.platform === "jd"
|
||||
)
|
||||
).toMatchObject({
|
||||
platform: "jd",
|
||||
ready: false,
|
||||
searchRequirement: "required"
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("creates a task and lands in AwaitingConfirmation with mock candidates", async () => {
|
||||
const app = createServer();
|
||||
await app.ready();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/tasks",
|
||||
payload: {
|
||||
query: "iPhone 15 Pro",
|
||||
perLinkLimit: 100,
|
||||
taskTotalLimit: 500
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const payload = response.json();
|
||||
expect(payload.task.taskStatus).toBe("AwaitingConfirmation");
|
||||
expect(payload.task.platformRuns).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
platform: "tmall",
|
||||
status: "AwaitingSelection"
|
||||
}),
|
||||
expect.objectContaining({
|
||||
platform: "jd",
|
||||
status: "SearchBlocked"
|
||||
})
|
||||
])
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("supports NoSelection terminal state when user confirms nothing", async () => {
|
||||
const app = createServer();
|
||||
await app.ready();
|
||||
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/tasks",
|
||||
payload: {
|
||||
query: "DJI Pocket 3",
|
||||
perLinkLimit: 100,
|
||||
taskTotalLimit: 500
|
||||
}
|
||||
});
|
||||
const taskId = createResponse.json().task.taskId;
|
||||
|
||||
const confirmResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/tasks/${taskId}/confirm`,
|
||||
payload: {
|
||||
selections: []
|
||||
}
|
||||
});
|
||||
|
||||
expect(confirmResponse.statusCode).toBe(200);
|
||||
expect(confirmResponse.json().task.taskStatus).toBe("NoSelection");
|
||||
|
||||
const reportResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/tasks/${taskId}/report`
|
||||
});
|
||||
expect(reportResponse.statusCode).toBe(404);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("generates a completed report after confirming at least one candidate", async () => {
|
||||
const app = createServer();
|
||||
await app.ready();
|
||||
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/tasks",
|
||||
payload: {
|
||||
query: "Nintendo Switch 2",
|
||||
perLinkLimit: 80,
|
||||
taskTotalLimit: 320
|
||||
}
|
||||
});
|
||||
const taskId = createResponse.json().task.taskId;
|
||||
|
||||
const candidatesResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/tasks/${taskId}/candidates`
|
||||
});
|
||||
const firstCandidateId =
|
||||
candidatesResponse.json().candidates.tmall[0].candidateId;
|
||||
|
||||
const confirmResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/tasks/${taskId}/confirm`,
|
||||
payload: {
|
||||
selections: [
|
||||
{
|
||||
platform: "tmall",
|
||||
candidateIds: [firstCandidateId]
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
expect(confirmResponse.statusCode).toBe(200);
|
||||
expect(confirmResponse.json().task.taskStatus).toBe("Completed");
|
||||
expect(confirmResponse.json().task.defaultReportVersion).toBe(1);
|
||||
|
||||
const reportResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/tasks/${taskId}/report`
|
||||
});
|
||||
|
||||
expect(reportResponse.statusCode).toBe(200);
|
||||
expect(reportResponse.json().report.task_status).toBe("Completed");
|
||||
expect(reportResponse.json().report.platform_insights).toHaveLength(2);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import type {
|
||||
ConfirmTaskPayload,
|
||||
CreateTaskInput,
|
||||
PlatformId
|
||||
} from "@cross-ai/domain";
|
||||
import cors from "@fastify/cors";
|
||||
import Fastify from "fastify";
|
||||
|
||||
import { InMemoryTaskStore } from "./store";
|
||||
|
||||
export function createServer() {
|
||||
const app = Fastify({ logger: false });
|
||||
const store = new InMemoryTaskStore();
|
||||
|
||||
app.register(cors, { origin: true });
|
||||
|
||||
app.get("/api/health", async () => ({
|
||||
status: "ok",
|
||||
service: "cross-platform-products-ai-analyst-api"
|
||||
}));
|
||||
|
||||
app.get("/api/platforms/readiness", async () => ({
|
||||
platforms: store.getPlatformReadiness()
|
||||
}));
|
||||
|
||||
app.post<{
|
||||
Params: { platform: PlatformId };
|
||||
}>("/api/platforms/:platform/prepare", async (request, reply) => {
|
||||
const readiness = store.preparePlatform(request.params.platform);
|
||||
reply.code(200);
|
||||
return {
|
||||
platform: readiness.platform,
|
||||
session_ready: readiness.ready,
|
||||
last_prepared_at: readiness.lastPreparedAt
|
||||
};
|
||||
});
|
||||
|
||||
app.post<{
|
||||
Body: CreateTaskInput;
|
||||
}>("/api/tasks", async (request, reply) => {
|
||||
const task = store.createTask(request.body);
|
||||
reply.code(201);
|
||||
return { task };
|
||||
});
|
||||
|
||||
app.get<{
|
||||
Params: { taskId: string };
|
||||
}>("/api/tasks/:taskId", async (request, reply) => {
|
||||
const task = store.getTask(request.params.taskId);
|
||||
if (!task) {
|
||||
reply.code(404);
|
||||
return { message: "Task not found." };
|
||||
}
|
||||
return { task };
|
||||
});
|
||||
|
||||
app.get<{
|
||||
Params: { taskId: string };
|
||||
}>("/api/tasks/:taskId/candidates", async (request, reply) => {
|
||||
const candidates = store.getCandidates(request.params.taskId);
|
||||
if (!candidates) {
|
||||
reply.code(404);
|
||||
return { message: "Task not found." };
|
||||
}
|
||||
return { candidates };
|
||||
});
|
||||
|
||||
app.post<{
|
||||
Params: { taskId: string };
|
||||
Body: ConfirmTaskPayload;
|
||||
}>("/api/tasks/:taskId/confirm", async (request, reply) => {
|
||||
try {
|
||||
const task = store.confirmTask(request.params.taskId, request.body);
|
||||
return { task };
|
||||
} catch {
|
||||
reply.code(404);
|
||||
return { message: "Task not found." };
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{
|
||||
Params: { taskId: string };
|
||||
Querystring: { version?: string };
|
||||
}>("/api/tasks/:taskId/report", async (request, reply) => {
|
||||
const version = request.query.version ? Number(request.query.version) : undefined;
|
||||
const report = store.getReport(request.params.taskId, version);
|
||||
if (!report) {
|
||||
reply.code(404);
|
||||
return { message: "Report not found." };
|
||||
}
|
||||
return { report };
|
||||
});
|
||||
|
||||
app.get("/api/history", async () => ({
|
||||
tasks: store.listHistory()
|
||||
}));
|
||||
|
||||
app.get<{
|
||||
Params: { taskId: string };
|
||||
}>("/api/tasks/:taskId/events", async (request, reply) => {
|
||||
const task = store.getTask(request.params.taskId);
|
||||
if (!task) {
|
||||
reply.code(404);
|
||||
return { message: "Task not found." };
|
||||
}
|
||||
|
||||
reply.raw.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive"
|
||||
});
|
||||
reply.raw.write(`event: task.snapshot\n`);
|
||||
reply.raw.write(`data: ${JSON.stringify(task)}\n\n`);
|
||||
reply.raw.end();
|
||||
return reply;
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import {
|
||||
deriveTaskStatusFromConfirmedPlatforms,
|
||||
mapPlatformStatusToExecutionStatus,
|
||||
platformCatalog,
|
||||
platformCatalogMap,
|
||||
platforms,
|
||||
type CandidateRecord,
|
||||
type ConfirmTaskPayload,
|
||||
type CreateTaskInput,
|
||||
type HistoryTaskRecord,
|
||||
type PlatformId,
|
||||
type PlatformRunRecord,
|
||||
type SessionReadinessRecord,
|
||||
type TaskEventRecord,
|
||||
type TaskRecord
|
||||
} from "@cross-ai/domain";
|
||||
import { type ReportSnapshot, parseReportSnapshot } from "@cross-ai/report-schema";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { createMockCandidates } from "./mock-data";
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function createPlatformCandidatesRecord(): Record<PlatformId, CandidateRecord[]> {
|
||||
return {
|
||||
tmall: [],
|
||||
jd: []
|
||||
};
|
||||
}
|
||||
|
||||
export class InMemoryTaskStore {
|
||||
private readonly tasks = new Map<string, TaskRecord>();
|
||||
private readonly reports = new Map<string, ReportSnapshot[]>();
|
||||
private readonly readiness = new Map<PlatformId, SessionReadinessRecord>();
|
||||
|
||||
constructor() {
|
||||
this.readiness.set("tmall", {
|
||||
platform: "tmall",
|
||||
ready: true,
|
||||
searchRequirement: "recommended",
|
||||
lastPreparedAt: nowIso()
|
||||
});
|
||||
this.readiness.set("jd", {
|
||||
platform: "jd",
|
||||
ready: false,
|
||||
searchRequirement: "required"
|
||||
});
|
||||
}
|
||||
|
||||
getPlatformReadiness(): SessionReadinessRecord[] {
|
||||
return platforms.map((platform) => this.requireReadiness(platform));
|
||||
}
|
||||
|
||||
preparePlatform(platform: PlatformId): SessionReadinessRecord {
|
||||
const readiness = this.requireReadiness(platform);
|
||||
const next: SessionReadinessRecord = {
|
||||
...readiness,
|
||||
ready: true,
|
||||
lastPreparedAt: nowIso()
|
||||
};
|
||||
this.readiness.set(platform, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
createTask(input: CreateTaskInput): TaskRecord {
|
||||
const timestamp = nowIso();
|
||||
const taskId = randomUUID();
|
||||
const task: TaskRecord = {
|
||||
taskId,
|
||||
query: input.query.trim(),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
perLinkLimit: input.perLinkLimit,
|
||||
taskTotalLimit: input.taskTotalLimit,
|
||||
taskStatus: "Draft",
|
||||
taskStage: "precheck",
|
||||
platformRuns: platformCatalog.map(
|
||||
(platform): PlatformRunRecord => ({
|
||||
platform: platform.id,
|
||||
searchRequirement: platform.searchRequirement,
|
||||
status: "Pending",
|
||||
candidateCount: 0,
|
||||
selectedCandidateIds: [],
|
||||
lastUpdatedAt: timestamp
|
||||
})
|
||||
),
|
||||
platformCandidates: createPlatformCandidatesRecord(),
|
||||
events: [],
|
||||
reportVersions: []
|
||||
};
|
||||
|
||||
this.pushEvent(task, "task.created", "任务已创建,准备进入平台预检查。");
|
||||
task.taskStatus = "Searching";
|
||||
task.taskStage = "precheck";
|
||||
this.pushEvent(task, "task.searching", "系统已开始执行平台预检查。");
|
||||
|
||||
this.runSearch(task);
|
||||
this.tasks.set(task.taskId, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
listHistory(): HistoryTaskRecord[] {
|
||||
return Array.from(this.tasks.values())
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
|
||||
.map((task) => ({
|
||||
taskId: task.taskId,
|
||||
query: task.query,
|
||||
taskStatus: task.taskStatus,
|
||||
updatedAt: task.updatedAt,
|
||||
hasReport: (this.reports.get(task.taskId)?.length ?? 0) > 0,
|
||||
defaultReportVersion: task.defaultReportVersion,
|
||||
failedPlatforms: task.platformRuns
|
||||
.filter((run) => run.status === "Failed")
|
||||
.map((run) => run.platform),
|
||||
blockedPlatforms: task.platformRuns
|
||||
.filter(
|
||||
(run) => run.status === "Blocked" || run.status === "SearchBlocked"
|
||||
)
|
||||
.map((run) => run.platform)
|
||||
}));
|
||||
}
|
||||
|
||||
getTask(taskId: string): TaskRecord | undefined {
|
||||
return this.tasks.get(taskId);
|
||||
}
|
||||
|
||||
getCandidates(taskId: string): Record<PlatformId, CandidateRecord[]> | undefined {
|
||||
return this.tasks.get(taskId)?.platformCandidates;
|
||||
}
|
||||
|
||||
confirmTask(taskId: string, payload: ConfirmTaskPayload): TaskRecord {
|
||||
const task = this.requireTask(taskId);
|
||||
const selectionMap = new Map(
|
||||
payload.selections.map((selection) => [selection.platform, selection.candidateIds])
|
||||
);
|
||||
|
||||
for (const run of task.platformRuns) {
|
||||
const selectedCandidateIds = selectionMap.get(run.platform) ?? [];
|
||||
|
||||
if (selectedCandidateIds.length > 0) {
|
||||
run.status = "Selected";
|
||||
run.selectedCandidateIds = selectedCandidateIds;
|
||||
run.lastUpdatedAt = nowIso();
|
||||
} else if (run.status === "AwaitingSelection") {
|
||||
run.status = "Skipped";
|
||||
run.selectedCandidateIds = [];
|
||||
run.lastUpdatedAt = nowIso();
|
||||
}
|
||||
}
|
||||
|
||||
task.taskStage = "confirmation";
|
||||
task.updatedAt = nowIso();
|
||||
this.pushEvent(task, "task.confirmed", "候选确认已提交。");
|
||||
|
||||
const selectedRuns = task.platformRuns.filter(
|
||||
(run) => run.selectedCandidateIds.length > 0
|
||||
);
|
||||
|
||||
if (selectedRuns.length === 0) {
|
||||
task.taskStatus = "NoSelection";
|
||||
this.pushEvent(task, "task.no_selection", "用户未确认任何商品链接,任务结束。");
|
||||
return task;
|
||||
}
|
||||
|
||||
task.taskStatus = "Running";
|
||||
task.taskStage = "session_check";
|
||||
this.pushEvent(task, "task.running", "系统开始执行抓取前校验。");
|
||||
|
||||
task.taskStage = "crawl";
|
||||
for (const run of selectedRuns) {
|
||||
run.status = "Running";
|
||||
run.lastUpdatedAt = nowIso();
|
||||
this.pushEvent(
|
||||
task,
|
||||
`platform.${run.platform}.running`,
|
||||
`${platformCatalogMap[run.platform].label} 开始抓取商品与评论。`
|
||||
);
|
||||
run.status = "Completed";
|
||||
run.lastUpdatedAt = nowIso();
|
||||
}
|
||||
|
||||
task.taskStage = "normalize";
|
||||
this.pushEvent(task, "task.normalize", "系统正在标准化商品与评论数据。");
|
||||
|
||||
task.taskStage = "analyze";
|
||||
this.pushEvent(task, "task.analyze", "系统正在生成结构化报告。");
|
||||
|
||||
task.taskStage = "publish";
|
||||
task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
|
||||
task.updatedAt = nowIso();
|
||||
const report = this.buildReport(task);
|
||||
const previousReports = this.reports.get(task.taskId) ?? [];
|
||||
this.reports.set(task.taskId, [...previousReports, report]);
|
||||
task.reportVersions = [...task.reportVersions, report.report_version];
|
||||
task.defaultReportVersion = report.report_version;
|
||||
task.latestSuccessfulReportVersion = report.report_version;
|
||||
this.pushEvent(task, "task.report_published", `报告 v${report.report_version} 已生成。`);
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
getReport(taskId: string, version?: number): ReportSnapshot | undefined {
|
||||
const reports = this.reports.get(taskId);
|
||||
if (!reports || reports.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof version === "number") {
|
||||
return reports.find((report) => report.report_version === version);
|
||||
}
|
||||
|
||||
return reports[reports.length - 1];
|
||||
}
|
||||
|
||||
private runSearch(task: TaskRecord): void {
|
||||
task.taskStage = "search";
|
||||
|
||||
for (const run of task.platformRuns) {
|
||||
const readiness = this.requireReadiness(run.platform);
|
||||
if (readiness.searchRequirement === "required" && !readiness.ready) {
|
||||
run.status = "SearchBlocked";
|
||||
run.reason = platformCatalogMap[run.platform].recoveryHint;
|
||||
run.candidateCount = 0;
|
||||
run.lastUpdatedAt = nowIso();
|
||||
this.pushEvent(
|
||||
task,
|
||||
`platform.${run.platform}.search_blocked`,
|
||||
`${platformCatalogMap[run.platform].label} 缺少有效会话,搜索被阻塞。`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
run.status = "Searching";
|
||||
run.lastUpdatedAt = nowIso();
|
||||
const candidates = createMockCandidates(task.query, run.platform);
|
||||
task.platformCandidates[run.platform] = candidates;
|
||||
run.candidateCount = candidates.length;
|
||||
run.status = candidates.length > 0 ? "AwaitingSelection" : "NoResult";
|
||||
run.reason =
|
||||
candidates.length > 0
|
||||
? undefined
|
||||
: `${platformCatalogMap[run.platform].label} 当前未找到可供确认的候选。`;
|
||||
run.lastUpdatedAt = nowIso();
|
||||
this.pushEvent(
|
||||
task,
|
||||
`platform.${run.platform}.searched`,
|
||||
candidates.length > 0
|
||||
? `${platformCatalogMap[run.platform].label} 返回 ${candidates.length} 个候选。`
|
||||
: `${platformCatalogMap[run.platform].label} 未返回候选结果。`
|
||||
);
|
||||
}
|
||||
|
||||
task.taskStage = "confirmation";
|
||||
task.taskStatus = "AwaitingConfirmation";
|
||||
task.updatedAt = nowIso();
|
||||
this.pushEvent(task, "task.awaiting_confirmation", "候选召回完成,等待人工确认。");
|
||||
}
|
||||
|
||||
private buildReport(task: TaskRecord): ReportSnapshot {
|
||||
const reportVersion = (this.reports.get(task.taskId)?.length ?? 0) + 1;
|
||||
const completedRuns = task.platformRuns.filter((run) => run.status === "Completed");
|
||||
const blockedPlatforms = task.platformRuns
|
||||
.filter((run) => run.status === "Blocked" || run.status === "SearchBlocked")
|
||||
.map((run) => run.platform);
|
||||
const failedPlatforms = task.platformRuns
|
||||
.filter((run) => run.status === "Failed")
|
||||
.map((run) => run.platform);
|
||||
const selectedCandidates = task.platformRuns.flatMap((run) =>
|
||||
task.platformCandidates[run.platform].filter((candidate) =>
|
||||
run.selectedCandidateIds.includes(candidate.candidateId)
|
||||
)
|
||||
);
|
||||
const reviewSampleCount = Math.min(
|
||||
selectedCandidates.length * task.perLinkLimit,
|
||||
task.taskTotalLimit
|
||||
);
|
||||
const evidenceIndex = selectedCandidates.map((candidate, index) => ({
|
||||
evidence_id: `evidence-${task.taskId}-${index + 1}`,
|
||||
platform: candidate.platform,
|
||||
source_type: "product" as const,
|
||||
source_url: candidate.productUrl,
|
||||
review_ref: null,
|
||||
snippet: `${candidate.title} 在 ${candidate.storeName} 下展示 ${candidate.specLabel},当前价格 ${candidate.priceLabel}。`,
|
||||
captured_at: nowIso()
|
||||
}));
|
||||
|
||||
const sharedInsight = (title: string, statement: string, evidenceIds: string[]) => ({
|
||||
card_id: randomUUID(),
|
||||
title,
|
||||
statement,
|
||||
confidence: evidenceIds.length > 0 ? ("high" as const) : ("medium" as const),
|
||||
sample_flag:
|
||||
evidenceIds.length > 0 ? ("sufficient" as const) : ("insufficient" as const),
|
||||
source_scope: {
|
||||
platforms:
|
||||
completedRuns.length > 0
|
||||
? completedRuns.map((run) => run.platform)
|
||||
: (["tmall"] as const),
|
||||
link_count: selectedCandidates.length,
|
||||
review_count: reviewSampleCount
|
||||
},
|
||||
evidence_ids: evidenceIds
|
||||
});
|
||||
|
||||
return parseReportSnapshot({
|
||||
report_id: `report-${task.taskId}-${reportVersion}`,
|
||||
report_version: reportVersion,
|
||||
task_id: task.taskId,
|
||||
generated_at: nowIso(),
|
||||
task_status: task.taskStatus,
|
||||
summary: {
|
||||
headline:
|
||||
completedRuns.length > 1
|
||||
? "双平台已形成可回看报告。"
|
||||
: "已基于当前可用平台生成首版结构化报告。",
|
||||
key_points: [
|
||||
`已确认 ${selectedCandidates.length} 个商品链接,纳入 ${reviewSampleCount} 条评论样本。`,
|
||||
blockedPlatforms.length > 0
|
||||
? `仍有 ${blockedPlatforms.length} 个平台需要先恢复会话。`
|
||||
: "当前已确认平台全部处理完成。"
|
||||
],
|
||||
limitations: [
|
||||
blockedPlatforms.length > 0
|
||||
? "被阻塞平台未进入可发布洞察范围。"
|
||||
: "当前为第一批开发样板数据,后续将替换为真实采集链路。"
|
||||
]
|
||||
},
|
||||
product_snapshot: {
|
||||
query: task.query,
|
||||
normalized_product_name: task.query,
|
||||
platform_count: task.platformRuns.length,
|
||||
selected_link_count: selectedCandidates.length,
|
||||
review_sample_count: reviewSampleCount,
|
||||
analysis_time_range: {
|
||||
start: task.createdAt,
|
||||
end: nowIso()
|
||||
}
|
||||
},
|
||||
platform_insights: task.platformRuns.map((run) => {
|
||||
const platformEvidenceIds = evidenceIndex
|
||||
.filter((evidence) => evidence.platform === run.platform)
|
||||
.map((evidence) => evidence.evidence_id);
|
||||
const selectedForPlatform = selectedCandidates.filter(
|
||||
(candidate) => candidate.platform === run.platform
|
||||
);
|
||||
const prices = selectedForPlatform.map((candidate) => candidate.price);
|
||||
return {
|
||||
platform: run.platform,
|
||||
execution_status: mapPlatformStatusToExecutionStatus(run.status),
|
||||
selected_link_count: run.selectedCandidateIds.length,
|
||||
price_range:
|
||||
prices.length > 0
|
||||
? {
|
||||
min: Math.min(...prices),
|
||||
max: Math.max(...prices)
|
||||
}
|
||||
: null,
|
||||
selling_points:
|
||||
selectedForPlatform.length > 0
|
||||
? [
|
||||
sharedInsight(
|
||||
`${platformCatalogMap[run.platform].label} 卖点稳定`,
|
||||
`${platformCatalogMap[run.platform].label} 当前候选集中强调 ${selectedForPlatform[0]?.highlights[0] ?? "基础卖点"}。`,
|
||||
platformEvidenceIds
|
||||
)
|
||||
]
|
||||
: [],
|
||||
positive_themes:
|
||||
selectedForPlatform.length > 0
|
||||
? [
|
||||
sharedInsight(
|
||||
`${platformCatalogMap[run.platform].label} 正向反馈集中`,
|
||||
"模拟样本显示用户更关注核心功能与履约确定性。",
|
||||
platformEvidenceIds
|
||||
)
|
||||
]
|
||||
: [],
|
||||
negative_themes:
|
||||
run.status === "SearchBlocked"
|
||||
? [
|
||||
sharedInsight(
|
||||
`${platformCatalogMap[run.platform].label} 当前未进入采集`,
|
||||
run.reason ?? "当前平台需要先恢复会话。",
|
||||
[]
|
||||
)
|
||||
]
|
||||
: [],
|
||||
store_diff_notes:
|
||||
selectedForPlatform.length > 1
|
||||
? [
|
||||
sharedInsight(
|
||||
`${platformCatalogMap[run.platform].label} 候选存在规格差异`,
|
||||
"同平台已确认多个链接,报告保留差异而不强行合并。",
|
||||
platformEvidenceIds
|
||||
)
|
||||
]
|
||||
: []
|
||||
};
|
||||
}),
|
||||
cross_platform_insights: [
|
||||
sharedInsight(
|
||||
"当前报告以平台级摘要为主",
|
||||
completedRuns.length > 1
|
||||
? "系统保留链接级差异,同时默认从平台级组织结论。"
|
||||
: "当前仅部分平台完成执行,跨平台对比视角仍然保留但覆盖有限。",
|
||||
evidenceIndex.map((evidence) => evidence.evidence_id)
|
||||
)
|
||||
],
|
||||
recommendations: [
|
||||
sharedInsight(
|
||||
"继续补齐受阻平台会话",
|
||||
blockedPlatforms.length > 0
|
||||
? `建议优先处理 ${blockedPlatforms
|
||||
.map((platform) => platformCatalogMap[platform].label)
|
||||
.join("、")} 的会话准备,再发起下一轮任务。`
|
||||
: "建议基于当前样板链路继续扩展到真实采集策略。",
|
||||
evidenceIndex.map((evidence) => evidence.evidence_id)
|
||||
)
|
||||
],
|
||||
evidence_index: evidenceIndex,
|
||||
quality_flags: {
|
||||
sample_insufficient: reviewSampleCount < 30,
|
||||
partial_platform_failure:
|
||||
blockedPlatforms.length > 0 || failedPlatforms.length > 0,
|
||||
blocked_platforms: blockedPlatforms,
|
||||
failed_platforms: failedPlatforms
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private pushEvent(task: TaskRecord, type: string, message: string): void {
|
||||
const event: TaskEventRecord = {
|
||||
eventId: randomUUID(),
|
||||
createdAt: nowIso(),
|
||||
type,
|
||||
message
|
||||
};
|
||||
task.events.push(event);
|
||||
task.updatedAt = event.createdAt;
|
||||
}
|
||||
|
||||
private requireTask(taskId: string): TaskRecord {
|
||||
const task = this.tasks.get(taskId);
|
||||
if (!task) {
|
||||
throw new Error(`Task ${taskId} not found.`);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
private requireReadiness(platform: PlatformId): SessionReadinessRecord {
|
||||
const readiness = this.readiness.get(platform);
|
||||
if (!readiness) {
|
||||
throw new Error(`Platform ${platform} not found.`);
|
||||
}
|
||||
return readiness;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user