← GPT-AI-LP
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));