CODE · 14.1 KB
scripts/run-real-e2e.mjs
Workspace snapshot · 08/31 23:51
import { readFile, mkdir, writeFile, unlink } from "node:fs/promises";
import JSZip from "jszip";
import { buildExportBundle } from "../src/export-bundle.js";
import { mergeTargetedSections, sectionsToMarkdown } from "../src/lp-model.js";
import generatePageHandler from "../api/generate-page.js";
import designImagesHandler from "../api/design-images.js";
import auditWireframeHandler from "../api/audit-wireframe.js";
import { buildVisualPlan } from "../src/visual-strategy.js";
const baseUrl = process.env.AI_LP_E2E_URL || "http://127.0.0.1:3007";
const refresh = process.env.AI_LP_E2E_REFRESH === "1";
const envText = await readFile(new URL("../.env.local", import.meta.url), "utf8");
const env = Object.fromEntries(envText.split(/\r?\n/u).flatMap((line) => {
const match = line.match(/^([A-Z0-9_]+)=(.*)$/u);
if (!match) return [];
return [[match[1], match[2].replace(/^['"]|['"]$/gu, "")]];
}));
for (const [key, value] of Object.entries(env)) {
if (!process.env[key]) process.env[key] = value;
}
const token = env.AI_LP_ACCESS_TOKEN || env.AVS_LLM_RUNTIME_SERVICE_TOKEN;
process.env.AI_LP_ACCESS_TOKEN = token;
if (!token) throw new Error("AI_LP_ACCESS_TOKEN is missing");
const sessionId = `lp-e2e:${crypto.randomUUID()}`;
const headers = {
"content-type": "application/json",
"x-ai-lp-access-token": token,
"x-ai-lp-session": sessionId,
};
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const directRequest = async (path, options = {}) => {
const url = new URL(path, "http://local.test");
const handlers = {
"/api/generate-page": generatePageHandler,
"/api/design-images": designImagesHandler,
"/api/audit-wireframe": auditWireframeHandler,
};
const handler = handlers[url.pathname];
if (!handler) throw new Error(`Unknown local handler: ${url.pathname}`);
const req = {
method: options.method || "GET",
headers: { ...headers, ...(options.headers || {}) },
query: Object.fromEntries(url.searchParams),
body: options.body ? JSON.parse(options.body) : undefined,
};
let status = 200;
let data;
const res = {
status(code) { status = code; return this; },
json(value) { data = value; return this; },
};
await handler(req, res);
if (status >= 400) throw Object.assign(new Error(data?.error || `HTTP_${status}`), { status });
return data;
};
const request = async (path, options = {}) => {
if (!process.env.AI_LP_E2E_URL || path.startsWith("/api/audit-wireframe")) return directRequest(path, options);
const response = await fetch(`${baseUrl}${path}`, { ...options, headers: { ...headers, ...(options.headers || {}) } });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw Object.assign(new Error(data.error || `HTTP_${response.status}`), { status: response.status });
return data;
};
async function generate(kind, payload) {
const targetedRevision =
kind === "wireframe" &&
typeof payload?.instruction === "string" &&
/"sectionId"\s*:/u.test(payload.instruction);
for (let attempt = 0; attempt < 3; attempt += 1) {
console.log(`[${kind}] enqueue ${attempt + 1}/3`);
const started = await request("/api/generate-page", {
method: "POST",
body: JSON.stringify({ ...payload, kind, requestId: crypto.randomUUID() }),
});
let retryOutput = false;
// Runtimeのstrategy timeout (600秒) より先に再投入しない。
// 同じprovider sessionへ重複ジョブを重ねると、検証自体が出力を乱す。
for (let poll = 0; poll < 330; poll += 1) {
await wait(2000);
const partial = targetedRevision || attempt === 2 ? "&partial=1" : "";
try {
const result = await request(`/api/generate-page?jobId=${encodeURIComponent(started.jobId)}&kind=${kind}${partial}`);
if (result.status === "COMPLETED") {
console.log(`[${kind}] completed`);
return result;
}
if (result.status === "FAILED") {
console.log(`[${kind}] failed; retrying`);
retryOutput = true;
break;
}
} catch (error) {
if (error.status === 400 || error.status === 422) {
console.log(`[${kind}] invalid output; retrying`);
retryOutput = true;
break;
}
throw error;
}
}
if (retryOutput) continue;
throw new Error(`${kind} exceeded the runtime timeout`);
}
throw new Error(`${kind} could not be completed`);
}
const sourcePath = "G:\\.shortcut-targets-by-id\\10IH5s3bklKx5G7i1KYHfrLexzizpkHT9\\my-virtual-team\\projects\\pengin\\AI支援サービス\\AIコンサル\\02_制作会社向け_Claude導入伴走支援.md";
const rawSource = await readFile(sourcePath, "utf8");
const sourceText = rawSource
.replace(/## 巻末\. お問い合わせ[\s\S]*?(?=\n---\n)/u, "")
.replace(/## 制作メモ(社内用[\s\S]*$/u, "")
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/giu, "[連絡先は公開前確認]");
const sourceName = "Claude導入・伴走支援サービス(制作会社向け).md";
// Production APIは旧版のためbriefジョブ未対応。抽出結果を既知情報だけで固定し、
// 以降の構成・WF・トンマナ・KVは実VPS生成へ通す。
const brief = { brief: {
projectName: "制作会社専用 Claude導入・伴走支援",
summary: "従業員5〜50名の制作会社に、Claude Codeを中心とした仮想AIチームの設計・定着を伴走するサービス。",
answers: {},
needsConfirmation: ["実績数値の最新値", "料金の最終確定", "フォーム送信先", "競合比較の公開可否"],
} };
const project = {
name: brief.brief.projectName || "制作会社専用 Claude導入・伴走支援",
sourceName,
sourceText,
sourceSummary: brief.brief.summary,
sourceConfirmationNotes: {
"自社実績の数値": "資料記載値は公開前に最新値と照合する",
"料金": "資料記載の仮説値。契約開始前に最終承認する",
},
answers: {
...brief.brief.answers,
goal: "demo",
service: "従業員5〜50名の制作会社向けに、優秀ディレクターの定型業務と判断基準をClaudeのSKILL・専門エージェントへ移管する3か月の導入伴走支援",
category: "service",
industry: "web",
role: "owner",
companySize: "small",
problem: "優秀ディレクターが既存案件で埋まり、新規案件と若手教育の両方が止まる",
differentiator: "PENGIN自身の制作現場で稼働する業態別SKILL、Figma・Notion・Drive・Chatwork連携、3か月の業務移植",
traffic: ["sales", "partner", "search"],
proof: ["numbers", "cases", "process", "screen"],
price: "plans",
priceAmount: "1部門: 初期80万円+月15万円 / 3部門: 初期200万円+月30万円 / 全社: 初期400万円〜+月50万円〜",
evidenceDetails: "資料記載の自社運用実績は公開前に最新値を再照合。WF初稿2週間→3日、議事録90分→15分など",
formFields: "会社名,お名前,メールアドレス,現在の課題,相談希望日時",
// 実送信先は未決定。E2Eで架空URLを「合格」にせず、品質ゲートの未解決項目として残す。
formEndpoint: "",
formSuccess: "受付後1営業日以内に、制作会社のAI化可能性を整理する60分相談の日程をご連絡します。",
privacyUrl: "https://pengi-n.co.jp/privacy-policy/",
formalCompanyName: "株式会社PENGIN",
formalServiceName: "制作会社専用 Claude導入・伴走支援",
brandAssets: "PENGINロゴ、コーポレートカラー、実際のSKILL画面・WF・議事録・ダッシュボード画面。権利確認済み素材だけ使用",
compliance: "効果数値は自社計測の根拠を公開前確認。AI導入効果・利益改善を保証しない",
forbiddenClaims: "唯一、必ず、確実に、誰でも同じ品質、効果保証",
excludedAudience: "AI導入だけで組織課題が自動解決すると考える企業、運用責任者を置けない企業",
competition: "汎用ChatGPT/Gemini、AI研修会社、業務コンサル、RPA導入支援",
canonicalUrl: "https://pengi-n.co.jp/services/claude-production-company/",
ga4Id: "",
conversionDefinition: "60分無料相談フォームの送信完了",
toneIntent: "credible",
},
};
const cacheDirectory = new URL("../output/real-e2e/", import.meta.url);
await mkdir(cacheDirectory, { recursive: true });
const structureCache = new URL("structure-cache-v2.json", cacheDirectory);
let structure;
try {
if (refresh) throw new Error("refresh requested");
structure = JSON.parse(await readFile(structureCache, "utf8"));
console.log("[structure] cache loaded");
} catch {
structure = await generate("structure", project);
await writeFile(structureCache, JSON.stringify(structure, null, 2), "utf8");
}
const initialMarkdown = sectionsToMarkdown(structure.sections);
const feedback = [{ id: "e2e-feedback-1", sectionId: structure.sections[0].id, element: "headline", x: 36, y: 24, message: "経営者が売上の天井を自分事化できる20字以内の見出しへ。効果保証はしない" }];
const wireframeCache = new URL("wireframe-cache-v2.json", cacheDirectory);
let wireframeResult;
try {
if (refresh) throw new Error("refresh requested");
wireframeResult = JSON.parse(await readFile(wireframeCache, "utf8"));
console.log("[wireframe] cache loaded");
} catch {
wireframeResult = await generate("wireframe", {
...project,
service: project.answers.service,
offer: project.answers.goal,
action: project.answers.formSuccess,
// 現行VPSのprompt上限に合わせ、修正時は既存構成を正本として資料全文を再送しない。
sourceText: sourceText.slice(0, 1200),
markdown: initialMarkdown,
instruction: JSON.stringify(feedback),
});
await writeFile(wireframeCache, JSON.stringify(wireframeResult, null, 2), "utf8");
}
const sections = mergeTargetedSections(structure.sections, wireframeResult.sections, feedback);
const markdown = sectionsToMarkdown(sections);
const audit = await request("/api/audit-wireframe", { method: "POST", body: JSON.stringify({ sections, answers: project.answers }) });
const toneCache = new URL("tone-cache-v2.json", cacheDirectory);
let tone;
try {
if (refresh) throw new Error("refresh requested");
tone = JSON.parse(await readFile(toneCache, "utf8"));
console.log("[tone] cache loaded");
} catch {
tone = await generate("tone", { ...project, markdown });
await writeFile(toneCache, JSON.stringify(tone, null, 2), "utf8");
}
const direction = tone.directions[0];
console.log("[image] enqueue");
const visualPlan = buildVisualPlan({ project, sections, direction, purpose: "implementation" });
const pendingImageFile = new URL("pending-image-jobs.json", cacheDirectory);
let imageStart;
try {
if (refresh) throw new Error("refresh requested");
imageStart = JSON.parse(await readFile(pendingImageFile, "utf8"));
console.log("[image] resuming queued jobs");
} catch {
imageStart = await request("/api/design-images", {
method: "POST",
body: JSON.stringify({
visuals: visualPlan.map((visual) => ({
...visual,
colorScheme: "カラフル",
useCase: visual.type === "hero" ? "Webサイト メインビジュアル" : "Webサイト セクション画像",
useCaseId: visual.type === "hero" ? "web-hero" : "web-banner",
directions: [visual.direction],
})),
}),
});
await writeFile(pendingImageFile, JSON.stringify(imageStart, null, 2), "utf8");
}
const jobIds = (imageStart.jobs || []).map((job) => job.jobId).filter(Boolean);
if (!jobIds.length) throw new Error("image job was not created");
const sectionByJob = new Map((imageStart.jobs || []).map((job) => [job.jobId, job.sectionId]));
let images = [];
for (let poll = 0; poll < 450; poll += 1) {
await wait(2200);
let imageResult;
try {
imageResult = await request(`/api/design-images?jobIds=${encodeURIComponent(jobIds.join(","))}`);
} catch (error) {
if ([429, 500, 502, 503].includes(error.status)) {
console.log(`[image] transient poll error ${error.status}; continuing`);
continue;
}
throw error;
}
if ((imageResult.jobs || []).some((job) => job.status === "FAILED")) throw new Error("image generation failed");
if ((imageResult.jobs || []).length === jobIds.length && imageResult.jobs.every((job) => job.status === "COMPLETED")) {
images = imageResult.jobs.map((job) => ({
sectionId: sectionByJob.get(job.id || job.jobId),
textIntegrated: true,
src: job.output?.artifacts?.[0]?.signedUrl || job.artifact?.signedUrl || job.output?.artifact?.signedUrl,
})).filter((image) => image.sectionId && image.src);
break;
}
}
if (!images.length) throw new Error("image generation timed out");
console.log("[image] completed");
await unlink(pendingImageFile).catch(() => {});
const bundle = await buildExportBundle({ project, sections, tone: direction, images });
const output = cacheDirectory;
await mkdir(output, { recursive: true });
await writeFile(new URL("landing-page-package.zip", output), Buffer.from(await bundle.arrayBuffer()));
const zip = await JSZip.loadAsync(await bundle.arrayBuffer());
for (const [name, entry] of Object.entries(zip.files)) {
if (entry.dir) continue;
const file = new URL(name, output);
await mkdir(new URL("./", file), { recursive: true });
await writeFile(file, Buffer.from(await entry.async("uint8array")));
}
const summary = {
createdAt: new Date().toISOString(),
sourceName,
brief: { projectName: brief.brief.projectName, summary: brief.brief.summary, needsConfirmation: brief.brief.needsConfirmation },
sectionCount: sections.length,
headlineCount: structure.headlines.length,
toneDirections: tone.directions.map(({ id, name, concept, palette }) => ({ id, name, concept, palette })),
selectedTone: direction.id,
audit: { passed: audit.passed, total: audit.total, violations: audit.mustViolations, humanChecks: audit.humanChecks },
feedback: { sectionId: feedback[0].sectionId, before: structure.sections[0].title, after: sections[0].title },
imageCount: images.length,
imageSections: images.map((image) => image.sectionId),
};
await writeFile(new URL("e2e-summary.json", output), JSON.stringify(summary, null, 2), "utf8");
console.log(JSON.stringify(summary, null, 2));