接入后台架构标签与端到端验证
This commit is contained in:
+27
-2
@@ -7,6 +7,7 @@ import {
|
|||||||
ArticleInputForm,
|
ArticleInputForm,
|
||||||
type ArticleInputPayload,
|
type ArticleInputPayload,
|
||||||
} from "../components/article-input-form";
|
} from "../components/article-input-form";
|
||||||
|
import { ArchitectureObserverPanel } from "../components/architecture/architecture-observer-panel";
|
||||||
import { FactCardEditor } from "../components/fact-card-editor";
|
import { FactCardEditor } from "../components/fact-card-editor";
|
||||||
import { OptimizedPreview } from "../components/optimized-preview";
|
import { OptimizedPreview } from "../components/optimized-preview";
|
||||||
import { PerformanceCalibrationPanel } from "../components/performance-calibration-panel";
|
import { PerformanceCalibrationPanel } from "../components/performance-calibration-panel";
|
||||||
@@ -47,7 +48,7 @@ interface TimingSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [activeTab, setActiveTab] = useState<"geo" | "copy">("geo");
|
const [activeTab, setActiveTab] = useState<"geo" | "copy" | "architecture">("geo");
|
||||||
const [input, setInput] = useState(initialInput);
|
const [input, setInput] = useState(initialInput);
|
||||||
const [jobId, setJobId] = useState<string | null>(null);
|
const [jobId, setJobId] = useState<string | null>(null);
|
||||||
const [factCard, setFactCard] = useState<OptimizationFactCard | null>(null);
|
const [factCard, setFactCard] = useState<OptimizationFactCard | null>(null);
|
||||||
@@ -61,6 +62,7 @@ export default function Home() {
|
|||||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||||
const [lastTiming, setLastTiming] = useState<TimingSummary | null>(null);
|
const [lastTiming, setLastTiming] = useState<TimingSummary | null>(null);
|
||||||
const [streamActivity, setStreamActivity] = useState("");
|
const [streamActivity, setStreamActivity] = useState("");
|
||||||
|
const [architectureEvents, setArchitectureEvents] = useState<OptimizationStreamEvent[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!busyAction) return;
|
if (!busyAction) return;
|
||||||
@@ -84,6 +86,7 @@ export default function Home() {
|
|||||||
setOptimizedArticle(null);
|
setOptimizedArticle(null);
|
||||||
setQaReport(null);
|
setQaReport(null);
|
||||||
setStreamActivity("正在创建任务");
|
setStreamActivity("正在创建任务");
|
||||||
|
setArchitectureEvents([]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload: ArticleInputPayload & {
|
const payload: ArticleInputPayload & {
|
||||||
@@ -116,6 +119,9 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleStreamEvent(event: OptimizationStreamEvent) {
|
function handleStreamEvent(event: OptimizationStreamEvent) {
|
||||||
|
setArchitectureEvents((current) => event.type === "job_created"
|
||||||
|
? [event]
|
||||||
|
: [...current, event]);
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "job_created":
|
case "job_created":
|
||||||
setJobId(event.job.id);
|
setJobId(event.job.id);
|
||||||
@@ -157,6 +163,12 @@ export default function Home() {
|
|||||||
: "优化完成。",
|
: "优化完成。",
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case "llm_call_started":
|
||||||
|
case "llm_call_responded":
|
||||||
|
case "llm_call_validated":
|
||||||
|
case "llm_call_failed":
|
||||||
|
case "trace_warning":
|
||||||
|
break;
|
||||||
case "failed":
|
case "failed":
|
||||||
setStreamActivity("优化失败");
|
setStreamActivity("优化失败");
|
||||||
throw new Error(event.error);
|
throw new Error(event.error);
|
||||||
@@ -200,6 +212,13 @@ export default function Home() {
|
|||||||
>
|
>
|
||||||
普通文案优化
|
普通文案优化
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={activeTab === "architecture" ? "active-tab" : undefined}
|
||||||
|
onClick={() => setActiveTab("architecture")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
后台架构
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
{activeTab === "geo" ? (
|
{activeTab === "geo" ? (
|
||||||
<>
|
<>
|
||||||
@@ -237,10 +256,16 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : activeTab === "copy" ? (
|
||||||
<RenweiCopyOptimizerPanel
|
<RenweiCopyOptimizerPanel
|
||||||
apiAccessKey={apiAccessKey}
|
apiAccessKey={apiAccessKey}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<ArchitectureObserverPanel
|
||||||
|
apiAccessKey={apiAccessKey}
|
||||||
|
currentJobId={jobId}
|
||||||
|
liveEvents={architectureEvents}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
LlmTraceRun,
|
LlmTraceRun,
|
||||||
} from "../../../lib/llm/trace-types";
|
} from "../../../lib/llm/trace-types";
|
||||||
import {
|
import {
|
||||||
|
applyLiveOptimizationEvent,
|
||||||
applyLiveTraceEvent,
|
applyLiveTraceEvent,
|
||||||
deriveArchitectureNodes,
|
deriveArchitectureNodes,
|
||||||
formatCallLabel,
|
formatCallLabel,
|
||||||
@@ -144,4 +145,49 @@ describe("architecture trace state", () => {
|
|||||||
});
|
});
|
||||||
expect(manifest.calls[0].status).toBe("validated");
|
expect(manifest.calls[0].status).toBe("validated");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses workflow events to finish the run and retain QA business status", () => {
|
||||||
|
const withQa = applyLiveOptimizationEvent(
|
||||||
|
{ run, calls: [call({ business_status: null })] },
|
||||||
|
{
|
||||||
|
type: "qa_ready",
|
||||||
|
job_id: "job_1",
|
||||||
|
qa_report: { overall_status: "fail", checks: [] } as never,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const completed = applyLiveOptimizationEvent(withQa, {
|
||||||
|
type: "final_ready",
|
||||||
|
job_id: "job_1",
|
||||||
|
optimized_article: {} as never,
|
||||||
|
qa_report: { overall_status: "fail", checks: [] } as never,
|
||||||
|
export_paths: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(withQa.calls[0].business_status).toBe("fail");
|
||||||
|
expect(completed.run).toMatchObject({
|
||||||
|
status: "completed",
|
||||||
|
current_stage: "final",
|
||||||
|
});
|
||||||
|
expect(deriveArchitectureNodes(completed.run, completed.calls).final.status)
|
||||||
|
.toBe("completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses workflow failure events to mark the matching architecture stage", () => {
|
||||||
|
const failed = applyLiveOptimizationEvent(
|
||||||
|
{ run: { ...run, current_stage: "draft" }, calls: [] },
|
||||||
|
{
|
||||||
|
type: "failed",
|
||||||
|
job_id: "job_1",
|
||||||
|
stage: "qa",
|
||||||
|
error: "质量检查失败",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(failed.run).toMatchObject({
|
||||||
|
status: "failed",
|
||||||
|
current_stage: "qa",
|
||||||
|
error_stage: "qa",
|
||||||
|
error_summary: "质量检查失败",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
LlmTraceManifest,
|
LlmTraceManifest,
|
||||||
LlmTraceStreamEvent,
|
|
||||||
} from "../../lib/llm/trace-types";
|
} from "../../lib/llm/trace-types";
|
||||||
|
import type { OptimizationStreamEvent } from "../../lib/workflow/stream-events";
|
||||||
import { getJobTrace, getLatestTrace } from "./api-client";
|
import { getJobTrace, getLatestTrace } from "./api-client";
|
||||||
import { ArchitectureFlow } from "./architecture-flow";
|
import { ArchitectureFlow } from "./architecture-flow";
|
||||||
import { LlmCallDetail } from "./llm-call-detail";
|
import { LlmCallDetail } from "./llm-call-detail";
|
||||||
import {
|
import {
|
||||||
applyLiveTraceEvent,
|
applyLiveOptimizationEvent,
|
||||||
deriveArchitectureNodes,
|
deriveArchitectureNodes,
|
||||||
formatCallLabel,
|
formatCallLabel,
|
||||||
formatNodeStatus,
|
formatNodeStatus,
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
interface ArchitectureObserverPanelProps {
|
interface ArchitectureObserverPanelProps {
|
||||||
apiAccessKey: string;
|
apiAccessKey: string;
|
||||||
currentJobId: string | null;
|
currentJobId: string | null;
|
||||||
liveEvents: LlmTraceStreamEvent[];
|
liveEvents: OptimizationStreamEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ManifestLoadState {
|
interface ManifestLoadState {
|
||||||
@@ -90,7 +90,7 @@ export function ArchitectureObserverPanel({
|
|||||||
|
|
||||||
const manifest = useMemo(() => {
|
const manifest = useMemo(() => {
|
||||||
if (loadState?.key !== loadKey || !loadState.manifest) return null;
|
if (loadState?.key !== loadKey || !loadState.manifest) return null;
|
||||||
return liveEvents.reduce(applyLiveTraceEvent, loadState.manifest);
|
return liveEvents.reduce(applyLiveOptimizationEvent, loadState.manifest);
|
||||||
}, [liveEvents, loadKey, loadState]);
|
}, [liveEvents, loadKey, loadState]);
|
||||||
|
|
||||||
if (loadState?.key !== loadKey) {
|
if (loadState?.key !== loadKey) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
LlmTraceStreamEvent,
|
LlmTraceStreamEvent,
|
||||||
LlmTraceWorkflowStage,
|
LlmTraceWorkflowStage,
|
||||||
} from "../../lib/llm/trace-types";
|
} from "../../lib/llm/trace-types";
|
||||||
|
import type { OptimizationStreamEvent } from "../../lib/workflow/stream-events";
|
||||||
|
|
||||||
export type ArchitectureNodeStatus =
|
export type ArchitectureNodeStatus =
|
||||||
| "waiting"
|
| "waiting"
|
||||||
@@ -183,6 +184,87 @@ export function applyLiveTraceEvent(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTraceEvent(
|
||||||
|
event: OptimizationStreamEvent,
|
||||||
|
): event is LlmTraceStreamEvent {
|
||||||
|
return event.type === "llm_call_started"
|
||||||
|
|| event.type === "llm_call_responded"
|
||||||
|
|| event.type === "llm_call_validated"
|
||||||
|
|| event.type === "llm_call_failed"
|
||||||
|
|| event.type === "trace_warning";
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowStageForEvent(
|
||||||
|
event: OptimizationStreamEvent,
|
||||||
|
): LlmTraceWorkflowStage | null {
|
||||||
|
if (event.type === "fact_card_ready") return "fact_card";
|
||||||
|
if (event.type === "draft_started" || event.type === "draft_ready") return "draft";
|
||||||
|
if (event.type === "qa_started" || event.type === "qa_ready") return "qa";
|
||||||
|
if (event.type === "rewrite_started" || event.type === "rewrite_ready") return "rewrite";
|
||||||
|
if (event.type === "final_ready") return "final";
|
||||||
|
if (event.type === "failed") {
|
||||||
|
return event.stage === "job" ? "input" : event.stage;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyLiveOptimizationEvent(
|
||||||
|
state: LlmTraceManifest,
|
||||||
|
event: OptimizationStreamEvent,
|
||||||
|
): LlmTraceManifest {
|
||||||
|
if (isTraceEvent(event)) return applyLiveTraceEvent(state, event);
|
||||||
|
if (event.type === "job_created") return state;
|
||||||
|
if (event.job_id !== state.run.job_id) return state;
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
if (event.type === "qa_ready") {
|
||||||
|
const qaCalls = state.calls.filter((call) => call.task === "quality_inspector");
|
||||||
|
const latestQaCall = qaCalls.at(-1);
|
||||||
|
return {
|
||||||
|
run: { ...state.run, current_stage: "qa", updated_at: now },
|
||||||
|
calls: latestQaCall
|
||||||
|
? updateCall(state.calls, latestQaCall.call_id, (call) => ({
|
||||||
|
...call,
|
||||||
|
business_status: event.qa_report.overall_status,
|
||||||
|
}))
|
||||||
|
: state.calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const stage = workflowStageForEvent(event);
|
||||||
|
if (!stage) return state;
|
||||||
|
if (event.type === "final_ready") {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
run: {
|
||||||
|
...state.run,
|
||||||
|
status: "completed",
|
||||||
|
current_stage: "final",
|
||||||
|
finished_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (event.type === "failed") {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
run: {
|
||||||
|
...state.run,
|
||||||
|
status: "failed",
|
||||||
|
current_stage: stage,
|
||||||
|
error_stage: event.stage,
|
||||||
|
error_summary: event.error,
|
||||||
|
finished_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
run: { ...state.run, current_stage: stage, updated_at: now },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function callDetail(call: LlmTraceCallPublic) {
|
function callDetail(call: LlmTraceCallPublic) {
|
||||||
if (call.status === "failed" || call.schema_valid === false) {
|
if (call.status === "failed" || call.schema_valid === false) {
|
||||||
if (call.error_type === "provider") return "模型服务调用失败";
|
if (call.error_type === "provider") return "模型服务调用失败";
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
const jobId = "job_architecture";
|
||||||
|
const callId = "llmcall_draft";
|
||||||
|
|
||||||
|
const run = {
|
||||||
|
job_id: jobId,
|
||||||
|
case_id: "case_architecture",
|
||||||
|
status: "completed",
|
||||||
|
current_stage: "final",
|
||||||
|
trace_completeness: "complete",
|
||||||
|
error_stage: null,
|
||||||
|
error_summary: null,
|
||||||
|
started_at: "2026-07-16T00:00:00.000Z",
|
||||||
|
finished_at: "2026-07-16T00:00:03.000Z",
|
||||||
|
updated_at: "2026-07-16T00:00:03.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const call = {
|
||||||
|
call_id: callId,
|
||||||
|
job_id: jobId,
|
||||||
|
sequence: 2,
|
||||||
|
task: "article_optimizer",
|
||||||
|
workflow_stage: "draft",
|
||||||
|
rewrite_round: null,
|
||||||
|
provider: "deepseek",
|
||||||
|
model: "deepseek-chat",
|
||||||
|
status: "validated",
|
||||||
|
token_usage: { prompt_tokens: 120, completion_tokens: 80, total_tokens: 200 },
|
||||||
|
schema_name: "optimizedArticleSchema",
|
||||||
|
schema_valid: true,
|
||||||
|
validation_issues: [],
|
||||||
|
business_status: null,
|
||||||
|
duration_ms: 1600,
|
||||||
|
started_at: "2026-07-16T00:00:01.000Z",
|
||||||
|
responded_at: "2026-07-16T00:00:02.500Z",
|
||||||
|
validated_at: "2026-07-16T00:00:02.600Z",
|
||||||
|
failed_at: null,
|
||||||
|
error_type: null,
|
||||||
|
error_summary: null,
|
||||||
|
request_available: true,
|
||||||
|
response_available: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const article = {
|
||||||
|
job_id: jobId,
|
||||||
|
revision: 1,
|
||||||
|
title: "可观测的 GEO 优化稿",
|
||||||
|
summary: "展示真实后台调用。",
|
||||||
|
body_markdown: "## 优化结果\n正文内容。",
|
||||||
|
image_suggestions: [],
|
||||||
|
changed_sections: ["title", "body"],
|
||||||
|
requires_user_confirmation: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const qaReport = {
|
||||||
|
job_id: jobId,
|
||||||
|
revision: 1,
|
||||||
|
overall_status: "pass",
|
||||||
|
checks: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
test("后台架构标签按需展示完整 LLM 请求与响应", async ({ page }) => {
|
||||||
|
const payloadReads = { request: 0, response: 0 };
|
||||||
|
const streamEvents = [
|
||||||
|
{ type: "job_created", job: { id: jobId } },
|
||||||
|
{
|
||||||
|
type: "llm_call_started",
|
||||||
|
job_id: jobId,
|
||||||
|
call_id: callId,
|
||||||
|
sequence: 2,
|
||||||
|
task: "article_optimizer",
|
||||||
|
workflow_stage: "draft",
|
||||||
|
rewrite_round: null,
|
||||||
|
provider: "deepseek",
|
||||||
|
model: "deepseek-chat",
|
||||||
|
started_at: call.started_at,
|
||||||
|
request_available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "llm_call_responded",
|
||||||
|
job_id: jobId,
|
||||||
|
call_id: callId,
|
||||||
|
duration_ms: call.duration_ms,
|
||||||
|
token_usage: call.token_usage,
|
||||||
|
responded_at: call.responded_at,
|
||||||
|
response_available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "llm_call_validated",
|
||||||
|
job_id: jobId,
|
||||||
|
call_id: callId,
|
||||||
|
schema_name: call.schema_name,
|
||||||
|
schema_valid: true,
|
||||||
|
validation_issues: [],
|
||||||
|
validated_at: call.validated_at,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "final_ready",
|
||||||
|
job_id: jobId,
|
||||||
|
optimized_article: article,
|
||||||
|
qa_report: qaReport,
|
||||||
|
export_paths: {
|
||||||
|
markdown: `/api/jobs/${jobId}/exports/optimized.md`,
|
||||||
|
docx: `/api/jobs/${jobId}/exports/optimized.docx`,
|
||||||
|
qa_report: `/api/jobs/${jobId}/exports/qa_report.json`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await page.route("**/api/jobs/optimize-stream", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/x-ndjson; charset=utf-8",
|
||||||
|
body: `${streamEvents.map((event) => JSON.stringify(event)).join("\n")}\n`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route(`**/api/jobs/${jobId}/llm-trace`, async (route) => {
|
||||||
|
await route.fulfill({ json: { run, calls: [call] } });
|
||||||
|
});
|
||||||
|
await page.route("**/api/llm-traces/latest", async (route) => {
|
||||||
|
await route.fulfill({ json: { run, calls: [call] } });
|
||||||
|
});
|
||||||
|
await page.route(`**/api/jobs/${jobId}/llm-trace/${callId}/request`, async (route) => {
|
||||||
|
payloadReads.request += 1;
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
model: "deepseek-chat",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: "你是 GEO 文章优化器。" },
|
||||||
|
{ role: "user", content: "优化这篇原始文章。" },
|
||||||
|
],
|
||||||
|
response_format: { type: "json_object" },
|
||||||
|
temperature: 0.2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route(`**/api/jobs/${jobId}/llm-trace/${callId}/response`, async (route) => {
|
||||||
|
payloadReads.response += 1;
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
id: "chatcmpl_observer",
|
||||||
|
choices: [{ message: { role: "assistant", content: JSON.stringify(article) } }],
|
||||||
|
usage: call.token_usage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByLabel("访问密钥").fill("local-dev-key");
|
||||||
|
await page.getByLabel("文章内容").fill("这是一篇需要优化的原始文章。");
|
||||||
|
await page.getByRole("button", { name: "开始优化" }).click();
|
||||||
|
await page.getByRole("button", { name: "后台架构" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByLabel("文章优化后台架构")).toBeVisible();
|
||||||
|
await expect(page.locator(".llm-call-list code", { hasText: "article_optimizer" }))
|
||||||
|
.toBeVisible();
|
||||||
|
await expect(page.locator('[data-node="final"]')).toContainText("已完成");
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: "请求" }).click();
|
||||||
|
await expect(page.locator(".llm-json-view"))
|
||||||
|
.toContainText("开启技术详情后按需读取完整正文。");
|
||||||
|
expect(payloadReads.request).toBe(0);
|
||||||
|
expect(payloadReads.response).toBe(0);
|
||||||
|
|
||||||
|
await page.getByLabel("技术详情").check();
|
||||||
|
await expect(page.locator(".llm-json-view")).toContainText("messages");
|
||||||
|
await expect(page.locator(".llm-json-view")).toContainText("你是 GEO 文章优化器。");
|
||||||
|
expect(payloadReads.request).toBe(1);
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: "响应" }).click();
|
||||||
|
await expect(page.locator(".llm-json-view")).toContainText("choices");
|
||||||
|
await expect(page.locator(".llm-json-view")).toContainText("chatcmpl_observer");
|
||||||
|
expect(payloadReads.response).toBe(1);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "GEO 文章优化" }).click();
|
||||||
|
await expect(page.getByText("优化完成。", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: "optimized.md" })).toBeVisible();
|
||||||
|
});
|
||||||
+1
-16
@@ -6,29 +6,14 @@ test("中文界面可以生成优化文章和导出链接", async ({ page }) =>
|
|||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
await page.getByLabel("访问密钥").fill("local-dev-key");
|
await page.getByLabel("访问密钥").fill("local-dev-key");
|
||||||
await page.getByLabel("标题").fill("Example Technology Co., Ltd. GEO 指南");
|
|
||||||
await page
|
await page
|
||||||
.getByLabel("正文")
|
.getByLabel("文章内容")
|
||||||
.fill(
|
.fill(
|
||||||
"Example Technology Co., Ltd. has 8 years of GEO optimization experience. Example GEO 帮助市场团队优化内容结构。",
|
"Example Technology Co., Ltd. has 8 years of GEO optimization experience. Example GEO 帮助市场团队优化内容结构。",
|
||||||
);
|
);
|
||||||
await page.getByLabel("图片描述或图片链接").fill("产品仪表盘截图");
|
await page.getByLabel("图片描述或图片链接").fill("产品仪表盘截图");
|
||||||
await page.getByLabel("用户要求").fill("保持事实准确,语气自然。");
|
await page.getByLabel("用户要求").fill("保持事实准确,语气自然。");
|
||||||
|
|
||||||
await page.getByRole("button", { name: "分析文章" }).click();
|
|
||||||
await expect(page.getByText("候选事实卡已生成")).toBeVisible({
|
|
||||||
timeout: 70_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const confirmUncertainItemButtons = page.getByRole("button", {
|
|
||||||
name: "采纳为核心事实",
|
|
||||||
});
|
|
||||||
while ((await confirmUncertainItemButtons.count()) > 0) {
|
|
||||||
await confirmUncertainItemButtons.first().click();
|
|
||||||
}
|
|
||||||
await page.getByRole("button", { name: "确认事实卡" }).click();
|
|
||||||
await expect(page.getByText("事实卡已确认。")).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "开始优化" }).click();
|
await page.getByRole("button", { name: "开始优化" }).click();
|
||||||
await expect(page.getByRole("link", { name: "optimized.md" })).toBeVisible({
|
await expect(page.getByRole("link", { name: "optimized.md" })).toBeVisible({
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ interface QaReportJson {
|
|||||||
checks?: Array<{ rule_id?: string; status?: "pass" | "warn" | "fail" }>;
|
checks?: Array<{ rule_id?: string; status?: "pass" | "warn" | "fail" }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TraceManifestJson {
|
||||||
|
run?: { job_id?: string; status?: string; trace_completeness?: string };
|
||||||
|
calls?: Array<{
|
||||||
|
task?: string;
|
||||||
|
status?: string;
|
||||||
|
request_available?: boolean;
|
||||||
|
response_available?: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
export async function runSamplePageFlow({
|
export async function runSamplePageFlow({
|
||||||
page,
|
page,
|
||||||
request,
|
request,
|
||||||
@@ -87,17 +97,29 @@ export async function runSamplePageFlow({
|
|||||||
jobId,
|
jobId,
|
||||||
exportsDir,
|
exportsDir,
|
||||||
});
|
});
|
||||||
|
const traceResult = await validateLlmTrace({
|
||||||
|
request,
|
||||||
|
baseURL,
|
||||||
|
apiAccessKey,
|
||||||
|
jobId,
|
||||||
|
});
|
||||||
const qa = readQaReport(exportsDir);
|
const qa = readQaReport(exportsDir);
|
||||||
const finalScreenshot = join(sampleDir, "final.png");
|
const finalScreenshot = join(sampleDir, "final.png");
|
||||||
await page.screenshot({ path: finalScreenshot, fullPage: true });
|
await page.screenshot({ path: finalScreenshot, fullPage: true });
|
||||||
|
|
||||||
const failedExports = exportResults.filter((result) => result.status === "failed");
|
const failedExports = exportResults.filter((result) => result.status === "failed");
|
||||||
|
const failureMessage = [
|
||||||
|
...failedExports.map(
|
||||||
|
(result) => `${result.fileName}: ${result.error ?? result.statusCode}`,
|
||||||
|
),
|
||||||
|
...(traceResult.error ? [`LLM trace: ${traceResult.error}`] : []),
|
||||||
|
].join("; ");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
file: sample.filePath,
|
file: sample.filePath,
|
||||||
name: sample.name,
|
name: sample.name,
|
||||||
slug: sample.slug,
|
slug: sample.slug,
|
||||||
status: failedExports.length === 0 ? "passed" : "failed",
|
status: failedExports.length === 0 && !traceResult.error ? "passed" : "failed",
|
||||||
duration_ms: Date.now() - startedAt,
|
duration_ms: Date.now() - startedAt,
|
||||||
job_id: jobId,
|
job_id: jobId,
|
||||||
qa_status: qa.overall_status,
|
qa_status: qa.overall_status,
|
||||||
@@ -108,18 +130,61 @@ export async function runSamplePageFlow({
|
|||||||
exports: Object.fromEntries(
|
exports: Object.fromEntries(
|
||||||
exportResults.map((result) => [result.fileName, result.status]),
|
exportResults.map((result) => [result.fileName, result.status]),
|
||||||
),
|
),
|
||||||
llm_tasks: [],
|
llm_tasks: traceResult.tasks,
|
||||||
failure_category: failedExports.length > 0 ? "export_failed" : undefined,
|
failure_category: failedExports.length > 0
|
||||||
failure_message:
|
? "export_failed"
|
||||||
failedExports
|
: traceResult.error
|
||||||
.map((result) => `${result.fileName}: ${result.error ?? result.statusCode}`)
|
? "llm_failed"
|
||||||
.join("; ") || undefined,
|
: undefined,
|
||||||
|
failure_message: failureMessage || undefined,
|
||||||
artifacts: {
|
artifacts: {
|
||||||
final_screenshot: finalScreenshot,
|
final_screenshot: finalScreenshot,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function validateLlmTrace({
|
||||||
|
request,
|
||||||
|
baseURL,
|
||||||
|
apiAccessKey,
|
||||||
|
jobId,
|
||||||
|
}: {
|
||||||
|
request: APIRequestContext;
|
||||||
|
baseURL: string;
|
||||||
|
apiAccessKey: string;
|
||||||
|
jobId: string;
|
||||||
|
}) {
|
||||||
|
const headers: Record<string, string> | undefined = apiAccessKey
|
||||||
|
? { "x-api-key": apiAccessKey }
|
||||||
|
: undefined;
|
||||||
|
const response = await request.get(
|
||||||
|
`${baseURL}/api/jobs/${jobId}/llm-trace`,
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
if (!response.ok()) {
|
||||||
|
return { tasks: [], error: `metadata endpoint returned ${response.status()}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = await response.json() as TraceManifestJson;
|
||||||
|
const calls = manifest.calls ?? [];
|
||||||
|
const tasks = calls
|
||||||
|
.map((call) => call.task)
|
||||||
|
.filter((task): task is string => Boolean(task));
|
||||||
|
if (manifest.run?.job_id !== jobId) {
|
||||||
|
return { tasks, error: "run job_id does not match completed job" };
|
||||||
|
}
|
||||||
|
if (manifest.run.status !== "completed") {
|
||||||
|
return { tasks, error: `run status is ${manifest.run.status ?? "missing"}` };
|
||||||
|
}
|
||||||
|
if (calls.length === 0) {
|
||||||
|
return { tasks, error: "no LLM calls were recorded" };
|
||||||
|
}
|
||||||
|
if (calls.some((call) => !call.status || call.request_available !== true)) {
|
||||||
|
return { tasks, error: "call metadata is incomplete" };
|
||||||
|
}
|
||||||
|
return { tasks, error: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
async function fillFirstAvailable(page: Page, labels: string[], value: string) {
|
async function fillFirstAvailable(page: Page, labels: string[], value: string) {
|
||||||
for (const label of labels) {
|
for (const label of labels) {
|
||||||
const locator = page.getByLabel(label);
|
const locator = page.getByLabel(label);
|
||||||
|
|||||||
Reference in New Issue
Block a user