$ cat post.metadata

提案書を自動生成するGTMエンジニアリング -- Claude API × Google Slides × CRMデータで商談品質を底上げする

GTMエンジニアリングSLG

CRMの商談データとClaude APIを組み合わせ、顧客ごとにカスタマイズされた提案書を自動生成する仕組みを構築する。Google Slides APIとの連携、テンプレート設計、品質管理のワークフローをTypeScriptで実装。

$ cat post.content | render --format=markdown

提案書を自動生成するGTMエンジニアリング -- Claude API × Google Slides × CRMデータで商談品質を底上げする

この記事の位置づけ

SLG営業フェーズ × GTMエンジニアの介入ポイントで定義した6フェーズのうち、フェーズ4「提案/稟議」をコードで加速する実装記事だ。

前回の記事ではClaude APIを使ったリードスコアリング・企業リサーチ・メッセージ生成の3つを実装した。今回はその延長線上にある「提案書の自動生成パイプライン」に踏み込む。CLAUDE.mdを使った営業ナレッジ管理で構築した知識基盤と、商談インサイトの活用で蓄積した学びを、提案書という成果物に結実させる実装だ。

提案書作成の現状課題

営業担当の1日を計測すると、資料作成に費やす時間は全体の30-40%に達する。SLG型の営業組織において、提案書は「営業の武器」であると同時に「営業の負債」でもある。

時間の内訳

営業マネージャー5名へのヒアリングから得た典型的な提案書作成フローを見ると、以下のようになる。

工程所要時間課題
CRM・過去商談の情報収集30-60分どこに何があるか分からない。CRMの入力漏れで情報が歯抜け
企業リサーチ(IR・ニュース・競合)30-60分毎回ゼロから調べ直す。過去に同業種を担当した人の知見が共有されない
スライド構成の検討30-45分毎回「表紙→課題→ソリューション→...」を組み立て直す
テキスト・図表の作成60-120分コピペと微修正の繰り返し。数値の差し替え忘れが頻発する
ROI試算30-45分Excel手計算。前提条件が曖昧なまま「年間○○万円の削減」と書く
社内レビュー・修正30-60分マネージャーのレビュー待ち。差し戻しで2往復
合計3-5時間

3-5時間を案件ごとに繰り返している。月に10件の提案があれば30-50時間。営業の「顧客と向き合う時間」がこれだけ削られている。

構造的な問題

時間の問題だけではない。

車輪の再発明: 同じ業種・同じ課題の提案を、別の営業担当が独立して作り直している。過去に作成した提案書のうち、再利用可能なパーツが60-70%あるにもかかわらず、検索できない・見つけられない。

品質の属人化: 提案書の質が営業個人のスキルに依存する。トップセールスの提案書は課題の深掘りが的確で、ROI試算に具体性がある。一方で新人の提案書は汎用的な表現に終始し、顧客の課題に刺さらない。このノウハウの差が組織全体の受注率のばらつきを生む。

数値根拠の弱さ: 「年間1,000時間の工数削減」と書かれた提案書を見て、「この数字の根拠は?」と聞くと答えられない営業がいる。ROI試算が「それっぽい数字」になっている。

これらの課題を、CRMデータ × Claude API × Google Slides APIの組み合わせで解決する。

自動生成パイプラインのアーキテクチャ

全体像を先に示す。

[トリガー: CRMで商談ステージが「提案」に変更]
  |
  v
[データ収集]
  ├── CRM: 商談情報(企業名、課題、規模、予算、担当者)
  ├── 企業リサーチ: IR/ニュース/技術スタック(前回記事の仕組みを再利用)
  └── 過去提案: 同業種の成功事例・テンプレート
  |
  v
[AI処理: Claude API]
  ├── 課題 × ソリューションのマッピング
  ├── ROI試算(業種別テンプレート × 顧客パラメータ)
  ├── 導入スケジュール案
  └── 競合比較ポイント
  |
  v
[スライド生成: Google Slides API]
  ├── テンプレート複製
  ├── 各スライドにコンテンツ挿入
  └── 図表・グラフの自動生成
  |
  v
[品質チェック]
  ├── AI: 提案書の一貫性・完成度スコアリング
  └── 営業マネージャーへのレビュー依頼
  |
  v
[CRM更新 + 通知]
  ├── 提案書URLをDealに紐付け
  └── 営業にSlack通知

各ステップを順にTypeScriptで実装していく。

型定義とスキーマ設計

提案書パイプラインで扱うデータの型を先に定義する。前回の記事で定義したCompanyLeadを拡張する形で進める。

typescript
// src/proposal/types.ts import { z } from 'zod' export const DealSchema = z.object({ dealId: z.string(), companyName: z.string(), contactName: z.string(), contactTitle: z.string(), industry: z.string(), employeeCount: z.number().int().positive(), annualRevenue: z.number().optional(), challenges: z.array(z.string()), budget: z.number().optional(), timeline: z.string().optional(), competitors: z.array(z.string()).default([]), currentTools: z.array(z.string()).default([]), dealStage: z.string(), notes: z.string().optional(), }) export type Deal = z.infer<typeof DealSchema> export const ProposalContentSchema = z.object({ title: z.string(), subtitle: z.string(), executiveSummary: z.string(), challengeAnalysis: z.array(z.object({ challenge: z.string(), impact: z.string(), currentCost: z.string(), })), solutionMapping: z.array(z.object({ challenge: z.string(), solution: z.string(), feature: z.string(), expectedOutcome: z.string(), })), roiEstimate: z.object({ currentCost: z.number(), projectedCost: z.number(), annualSavings: z.number(), paybackMonths: z.number(), threeYearROI: z.number(), assumptions: z.array(z.string()), }), implementationPlan: z.array(z.object({ phase: z.string(), duration: z.string(), activities: z.array(z.string()), deliverables: z.array(z.string()), })), competitiveAdvantages: z.array(z.object({ aspect: z.string(), ourProduct: z.string(), competitor: z.string(), advantage: z.string(), })), nextSteps: z.array(z.object({ step: z.string(), owner: z.string(), deadline: z.string(), })), caseStudies: z.array(z.object({ companyName: z.string(), industry: z.string(), challenge: z.string(), result: z.string(), })), }) export type ProposalContent = z.infer<typeof ProposalContentSchema> export const QualityScoreSchema = z.object({ overall: z.number().min(0).max(100), breakdown: z.object({ relevance: z.number().min(0).max(25), specificity: z.number().min(0).max(25), roiCredibility: z.number().min(0).max(25), actionability: z.number().min(0).max(25), }), issues: z.array(z.object({ severity: z.enum(['critical', 'warning', 'info']), section: z.string(), message: z.string(), suggestion: z.string(), })), approved: z.boolean(), }) export type QualityScore = z.infer<typeof QualityScoreSchema>

ProposalContentがこのパイプラインの中核データだ。CRMの商談データから生成し、Google Slidesに流し込み、品質チェックを通す。すべてこの型を起点に動く。

CRMからの商談データ取得

商談ステージが「提案」に変わったタイミングでWebhookが飛ぶ。HubSpotのDeal情報を取得し、前回実装したリサーチ結果と統合する。

typescript
// src/proposal/deal-collector.ts import { z } from 'zod' import { DealSchema, type Deal } from './types' import type { ResearchResult } from '../research/types' import { researchCompany } from '../research/company-researcher' interface HubSpotDealResponse { id: string properties: Record<string, string | null> associations?: { contacts?: { results: { id: string }[] } companies?: { results: { id: string }[] } } } export async function collectDealData( dealId: string, hubspotApiKey: string ): Promise<{ deal: Deal; research: ResearchResult }> { const baseUrl = 'https://api.hubapi.com' const headers = { Authorization: `Bearer ${hubspotApiKey}`, 'Content-Type': 'application/json', } // Deal本体の取得 const dealResponse = await fetch( `${baseUrl}/crm/v3/objects/deals/${dealId}?` + `properties=dealname,dealstage,amount,closedate,` + `hs_deal_stage_probability,description,notes_last_updated&` + `associations=contacts,companies`, { headers } ) if (!dealResponse.ok) { const error = await dealResponse.text() throw new Error(`HubSpot Deal fetch failed: ${dealResponse.status} ${error}`) } const dealData = await dealResponse.json() as HubSpotDealResponse // 関連するCompany情報の取得 const companyId = dealData.associations?.companies?.results[0]?.id const companyData = companyId ? await fetchCompanyDetails(companyId, baseUrl, headers) : null // 関連するContact情報の取得 const contactId = dealData.associations?.contacts?.results[0]?.id const contactData = contactId ? await fetchContactDetails(contactId, baseUrl, headers) : null // Deal notesからチャレンジ情報を抽出 const notes = await fetchDealNotes(dealId, baseUrl, headers) const challenges = extractChallenges(notes) const deal = DealSchema.parse({ dealId, companyName: companyData?.name ?? dealData.properties.dealname ?? '', contactName: contactData?.name ?? '', contactTitle: contactData?.title ?? '', industry: companyData?.industry ?? '', employeeCount: Number(companyData?.employeeCount ?? 100), annualRevenue: companyData?.annualRevenue ? Number(companyData.annualRevenue) : undefined, challenges, budget: dealData.properties.amount ? Number(dealData.properties.amount) : undefined, timeline: dealData.properties.closedate ?? undefined, competitors: extractCompetitors(notes), currentTools: extractCurrentTools(notes), dealStage: dealData.properties.dealstage ?? 'proposal', notes: notes.join('\n'), }) // 企業リサーチの実行(前回記事の仕組みを再利用) const research = await researchCompany({ name: deal.companyName, domain: companyData?.domain ?? `https://${deal.companyName.toLowerCase()}.co.jp`, industry: deal.industry, employeeCount: deal.employeeCount, techStack: deal.currentTools, recentNews: [], }) return { deal, research } } async function fetchCompanyDetails( companyId: string, baseUrl: string, headers: Record<string, string> ): Promise<{ name: string domain: string industry: string employeeCount: string annualRevenue: string | null }> { const response = await fetch( `${baseUrl}/crm/v3/objects/companies/${companyId}?` + `properties=name,domain,industry,numberofemployees,annualrevenue`, { headers } ) if (!response.ok) return { name: '', domain: '', industry: '', employeeCount: '100', annualRevenue: null } const data = await response.json() as { properties: Record<string, string | null> } return { name: data.properties.name ?? '', domain: data.properties.domain ?? '', industry: data.properties.industry ?? '', employeeCount: data.properties.numberofemployees ?? '100', annualRevenue: data.properties.annualrevenue ?? null, } } async function fetchContactDetails( contactId: string, baseUrl: string, headers: Record<string, string> ): Promise<{ name: string; title: string }> { const response = await fetch( `${baseUrl}/crm/v3/objects/contacts/${contactId}?` + `properties=firstname,lastname,jobtitle`, { headers } ) if (!response.ok) return { name: '', title: '' } const data = await response.json() as { properties: Record<string, string | null> } const firstname = data.properties.firstname ?? '' const lastname = data.properties.lastname ?? '' return { name: `${lastname} ${firstname}`.trim(), title: data.properties.jobtitle ?? '', } } async function fetchDealNotes( dealId: string, baseUrl: string, headers: Record<string, string> ): Promise<string[]> { const response = await fetch( `${baseUrl}/crm/v3/objects/notes?` + `associations.deal=${dealId}&limit=20&` + `properties=hs_note_body`, { headers } ) if (!response.ok) return [] const data = await response.json() as { results: { properties: { hs_note_body: string | null } }[] } return data.results .map((n) => n.properties.hs_note_body ?? '') .filter((body) => body.length > 0) } function extractChallenges(notes: string[]): string[] { const challengePatterns = [ /課題[::]\s*(.+)/g, /困っている[::]\s*(.+)/g, /ペイン[::]\s*(.+)/g, /problem[::]\s*(.+)/gi, ] const challenges: string[] = [] for (const note of notes) { for (const pattern of challengePatterns) { const matches = note.matchAll(pattern) for (const match of matches) { challenges.push(match[1].trim()) } } } return challenges.length > 0 ? challenges : ['業務効率化', 'コスト削減'] } function extractCompetitors(notes: string[]): string[] { const joined = notes.join(' ') const competitorPatterns = [ /競合[::]\s*(.+?)(?:\n||$)/g, /比較検討[::]\s*(.+?)(?:\n||$)/g, ] const competitors: string[] = [] for (const pattern of competitorPatterns) { const matches = joined.matchAll(pattern) for (const match of matches) { competitors.push(...match[1].split(/[、,]/).map((s) => s.trim())) } } return competitors } function extractCurrentTools(notes: string[]): string[] { const joined = notes.join(' ') const toolPatterns = [ /利用ツール[::]\s*(.+?)(?:\n||$)/g, /現状のツール[::]\s*(.+?)(?:\n||$)/g, /既存システム[::]\s*(.+?)(?:\n||$)/g, ] const tools: string[] = [] for (const pattern of toolPatterns) { const matches = joined.matchAll(pattern) for (const match of matches) { tools.push(...match[1].split(/[、,]/).map((s) => s.trim())) } } return tools }

CRMのDeal notes からキーワードパターンマッチで課題・競合・既存ツールを抽出している。営業担当が商談メモを構造化して記録する習慣があるほど、このパイプラインの精度が上がる。逆に言えば、このパイプラインの存在が「CRMにちゃんと書こう」というモチベーションを営業に与える。

Claude APIで提案コンテンツを生成する

商談データとリサーチ結果を元に、提案書の全コンテンツをClaude APIで一括生成する。

typescript
// src/proposal/content-generator.ts import Anthropic from '@anthropic-ai/sdk' import { ProposalContentSchema, type ProposalContent, type Deal } from './types' import type { ResearchResult } from '../research/types' const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY, }) interface ProductConfig { name: string tagline: string features: { name: string; description: string; benefit: string }[] pricing: { plan: string; price: number; unit: string }[] caseStudies: { company: string industry: string challenge: string result: string metrics: string }[] } export async function generateProposalContent( deal: Deal, research: ResearchResult, product: ProductConfig ): Promise<ProposalContent> { const prompt = buildProposalPrompt(deal, research, product) const response = await anthropic.messages.create({ model: 'claude-sonnet-4-5-20250514', max_tokens: 4096, messages: [{ role: 'user', content: prompt }], }) const text = response.content[0].type === 'text' ? response.content[0].text : '' const jsonMatch = text.match(/\{[\s\S]*\}/) if (!jsonMatch) { throw new Error('Failed to extract JSON from proposal content response') } return ProposalContentSchema.parse(JSON.parse(jsonMatch[0])) } function buildProposalPrompt( deal: Deal, research: ResearchResult, product: ProductConfig ): string { const caseStudySameIndustry = product.caseStudies .filter((cs) => cs.industry === deal.industry) const caseStudySection = caseStudySameIndustry.length > 0 ? caseStudySameIndustry .map((cs) => `- ${cs.company}${cs.industry}): ${cs.challenge}${cs.result}${cs.metrics}`) .join('\n') : product.caseStudies .slice(0, 3) .map((cs) => `- ${cs.company}${cs.industry}): ${cs.challenge}${cs.result}${cs.metrics}`) .join('\n') return `あなたはBtoB SaaS企業のプリセールスコンサルタントです。以下の商談情報と企業リサーチに基づいて、提案書のコンテンツを生成してください。 ## 重要な制約 - 提供された情報に含まれていない事実・数字・実績は絶対に記載しないでください - ROI試算の前提条件は必ず明記してください - 推測が必要な場合は「想定」「見込み」と明示してください - 導入事例は以下に提供したものだけを使ってください ## 商談情報 - 企業名: ${deal.companyName} - 担当者: ${deal.contactName}${deal.contactTitle}- 業種: ${deal.industry} - 従業員数: ${deal.employeeCount}- 年間売上: ${deal.annualRevenue ? `${(deal.annualRevenue / 100000000).toFixed(1)}億円` : '非公開'} - 予算感: ${deal.budget ? `${(deal.budget / 10000).toFixed(0)}万円` : '未確認'} - 導入希望時期: ${deal.timeline ?? '未確認'} - 顧客の課題: ${deal.challenges.map((c) => ` - ${c}`).join('\n')} - 比較検討中の競合: ${deal.competitors.join(', ') || 'なし'} - 現在利用中のツール: ${deal.currentTools.join(', ') || '不明'} ## 企業リサーチ - 概要: ${research.summary.overview} - 経営戦略: ${research.summary.recentStrategy} - 財務状況: ${research.summary.financialHighlights} - 課題仮説: ${research.painHypotheses.map((h) => ` - [${h.confidence}] ${h.hypothesis}(根拠: ${h.evidence}`).join('\n')} - 商談ポイント: ${research.talkingPoints.join(' / ')} ## 自社プロダクト情報 - 名称: ${product.name} - タグライン: ${product.tagline} - 主要機能: ${product.features.map((f) => ` - ${f.name}: ${f.description}${f.benefit}`).join('\n')} - 料金プラン: ${product.pricing.map((p) => ` - ${p.plan}: ${p.price.toLocaleString()}円/${p.unit}`).join('\n')} ## 導入事例(同業種優先) ${caseStudySection} ## 出力要件 以下のJSON形式で出力してください。JSONのみを出力してください。 { "title": "${deal.companyName}様向け ${product.name} ご提案", "subtitle": "<提案のサブタイトル。課題にフォーカスした一文>", "executiveSummary": "<エグゼクティブサマリー。3-4文で提案の要点を凝縮>", "challengeAnalysis": [ { "challenge": "<課題の端的な表現>", "impact": "<その課題がビジネスに与える影響>", "currentCost": "<現状のコスト(時間・金額)の概算>" } ], "solutionMapping": [ { "challenge": "<対応する課題>", "solution": "<解決アプローチ>", "feature": "<関連するプロダクト機能名>", "expectedOutcome": "<期待される成果>" } ], "roiEstimate": { "currentCost": <現状の年間コスト(円)>, "projectedCost": <導入後の年間コスト(円)>, "annualSavings": <年間削減額(円)>, "paybackMonths": <投資回収期間(月)>, "threeYearROI": <3年間のROI(%)>, "assumptions": ["<前提条件1>", "<前提条件2>"] }, "implementationPlan": [ { "phase": "Phase 1: 初期導入", "duration": "<期間>", "activities": ["<アクティビティ>"], "deliverables": ["<成果物>"] } ], "competitiveAdvantages": [ { "aspect": "<比較軸>", "ourProduct": "<自社の強み>", "competitor": "<競合の状況>", "advantage": "<差別化ポイント>" } ], "nextSteps": [ { "step": "<次のアクション>", "owner": "<担当者>", "deadline": "<期限>" } ], "caseStudies": [ { "companyName": "<企業名>", "industry": "<業種>", "challenge": "<課題>", "result": "<成果>" } ] } ## 生成ルール 1. challengeAnalysisは商談情報の課題を3つに整理してください 2. solutionMappingは課題と機能を1対1でマッピングしてください 3. ROI試算はassumptionsを必ず3つ以上明記してください 4. implementationPlanは3フェーズ構成にしてください 5. competitiveAdvantagesは比較検討中の競合がある場合のみ生成してください 6. caseStudiesは提供した導入事例から同業種のものを優先で選んでください` }

ポイントは、プロンプトの冒頭に「重要な制約」としてハルシネーション対策を入れていること。前回の記事で解説したTip 2と同じ設計だが、提案書ではより厳格に適用する。営業が顧客に提出する資料に「存在しない実績」が混入するのは致命的だ。

また、ROI試算のassumptionsを必須にしている。前提条件なきROIは信頼されない。「営業担当者50名 × 月間商談数15件 × 提案書作成時間4時間の前提で計算」のように、検証可能な前提を明示することで、稟議の場での説得力が変わる。

ROI試算の自動生成

ROI試算は提案書の中で最も重要かつ最も作成が難しいパートだ。業種別のテンプレートを用意し、顧客のパラメータを入力すると数値が自動算出される仕組みを作る。

typescript
// src/proposal/roi-calculator.ts import { z } from 'zod' const ROIInputSchema = z.object({ industry: z.string(), employeeCount: z.number().int().positive(), salesTeamSize: z.number().int().positive().optional(), monthlyDeals: z.number().int().positive().optional(), currentConversionRate: z.number().min(0).max(1).optional(), avgDealSize: z.number().positive().optional(), avgSalesCycleWeeks: z.number().positive().optional(), }) type ROIInput = z.infer<typeof ROIInputSchema> interface ROIOutput { currentState: { annualRevenue: number salesCostPerDeal: number totalSalesCost: number timePerProposal: number proposalsPerMonth: number } projectedState: { annualRevenue: number salesCostPerDeal: number totalSalesCost: number timePerProposal: number proposalsPerMonth: number } savings: { timeSavedPerMonth: number costSavedAnnually: number additionalRevenueAnnually: number totalAnnualBenefit: number } investment: { licenseCost: number implementationCost: number totalFirstYear: number } roi: { paybackMonths: number firstYearROI: number threeYearROI: number } assumptions: string[] } // 業種別のデフォルトパラメータ const industryDefaults: Record<string, { avgSalesTeamRatio: number avgDealsPerRep: number avgConversionRate: number avgDealSize: number avgSalesCycleWeeks: number proposalTimeHours: number expectedImprovements: { conversionRateIncrease: number cycleTimeReduction: number proposalTimeReduction: number } }> = { SaaS: { avgSalesTeamRatio: 0.15, avgDealsPerRep: 12, avgConversionRate: 0.22, avgDealSize: 2400000, avgSalesCycleWeeks: 12, proposalTimeHours: 4, expectedImprovements: { conversionRateIncrease: 0.08, cycleTimeReduction: 0.25, proposalTimeReduction: 0.85, }, }, 製造業: { avgSalesTeamRatio: 0.10, avgDealsPerRep: 8, avgConversionRate: 0.18, avgDealSize: 5000000, avgSalesCycleWeeks: 20, proposalTimeHours: 6, expectedImprovements: { conversionRateIncrease: 0.06, cycleTimeReduction: 0.20, proposalTimeReduction: 0.80, }, }, 金融: { avgSalesTeamRatio: 0.12, avgDealsPerRep: 6, avgConversionRate: 0.15, avgDealSize: 10000000, avgSalesCycleWeeks: 24, proposalTimeHours: 8, expectedImprovements: { conversionRateIncrease: 0.05, cycleTimeReduction: 0.15, proposalTimeReduction: 0.75, }, }, } const defaultIndustry = industryDefaults['SaaS'] export function calculateROI(input: ROIInput): ROIOutput { const industry = industryDefaults[input.industry] ?? defaultIndustry const salesTeamSize = input.salesTeamSize ?? Math.max(3, Math.round(input.employeeCount * industry.avgSalesTeamRatio)) const monthlyDeals = input.monthlyDeals ?? salesTeamSize * industry.avgDealsPerRep const conversionRate = input.currentConversionRate ?? industry.avgConversionRate const avgDealSize = input.avgDealSize ?? industry.avgDealSize const salesCycleWeeks = input.avgSalesCycleWeeks ?? industry.avgSalesCycleWeeks const avgSalaryMonthly = 600000 const proposalsPerMonth = Math.round(monthlyDeals * conversionRate * 1.5) // 現状 const currentTimePerProposal = industry.proposalTimeHours const currentTotalProposalHours = proposalsPerMonth * currentTimePerProposal const currentSalesCostPerDeal = avgSalaryMonthly / industry.avgDealsPerRep const currentTotalSalesCost = salesTeamSize * avgSalaryMonthly * 12 const currentAnnualRevenue = monthlyDeals * conversionRate * avgDealSize * 12 // 導入後 const improvedConversionRate = conversionRate + industry.expectedImprovements.conversionRateIncrease const improvedTimePerProposal = currentTimePerProposal * (1 - industry.expectedImprovements.proposalTimeReduction) const projectedAnnualRevenue = monthlyDeals * improvedConversionRate * avgDealSize * 12 const projectedSalesCostPerDeal = currentSalesCostPerDeal * 0.9 // 削減効果 const timeSavedPerMonth = (currentTimePerProposal - improvedTimePerProposal) * proposalsPerMonth const hourlyRate = avgSalaryMonthly / 160 const costSavedAnnually = timeSavedPerMonth * hourlyRate * 12 const additionalRevenueAnnually = projectedAnnualRevenue - currentAnnualRevenue const totalAnnualBenefit = costSavedAnnually + additionalRevenueAnnually // 投資コスト const licenseCostMonthly = salesTeamSize * 50000 const licenseCostAnnual = licenseCostMonthly * 12 const implementationCost = 2000000 const totalFirstYear = licenseCostAnnual + implementationCost // ROI const paybackMonths = Math.ceil(totalFirstYear / (totalAnnualBenefit / 12)) const firstYearROI = Math.round(((totalAnnualBenefit - totalFirstYear) / totalFirstYear) * 100) const threeYearBenefit = totalAnnualBenefit * 3 const threeYearCost = licenseCostAnnual * 3 + implementationCost const threeYearROI = Math.round(((threeYearBenefit - threeYearCost) / threeYearCost) * 100) return { currentState: { annualRevenue: currentAnnualRevenue, salesCostPerDeal: currentSalesCostPerDeal, totalSalesCost: currentTotalSalesCost, timePerProposal: currentTimePerProposal, proposalsPerMonth, }, projectedState: { annualRevenue: projectedAnnualRevenue, salesCostPerDeal: projectedSalesCostPerDeal, totalSalesCost: Math.round(currentTotalSalesCost * 0.9), timePerProposal: improvedTimePerProposal, proposalsPerMonth, }, savings: { timeSavedPerMonth: Math.round(timeSavedPerMonth), costSavedAnnually: Math.round(costSavedAnnually), additionalRevenueAnnually: Math.round(additionalRevenueAnnually), totalAnnualBenefit: Math.round(totalAnnualBenefit), }, investment: { licenseCost: licenseCostAnnual, implementationCost, totalFirstYear, }, roi: { paybackMonths, firstYearROI, threeYearROI, }, assumptions: [ `営業チーム人数: ${salesTeamSize}`, `月間商談数: ${monthlyDeals}`, `現状のコンバージョン率: ${(conversionRate * 100).toFixed(1)}%`, `平均案件単価: ${avgDealSize.toLocaleString()}`, `営業サイクル: ${salesCycleWeeks}週間`, `提案書作成時間(現状): ${currentTimePerProposal}時間/件`, `提案書作成時間(導入後): ${improvedTimePerProposal.toFixed(1)}時間/件`, `コンバージョン率改善: +${(industry.expectedImprovements.conversionRateIncrease * 100).toFixed(1)}%ポイント`, ], } }

ここで重要なのは、ROI計算をAIに任せるのではなく決定論的なロジックとして実装していること。AIが生成する数値は毎回変わるリスクがある。ROI試算のような顧客の意思決定に直結する数値は、再現性のあるコードで算出した方がいい。

業種別のデフォルトパラメータ(industryDefaults)は、自社の過去データや業界統計から設定する。このパラメータ自体を営業データから学習して更新する仕組みは、後続のフェーズで取り組む。

Google Slides APIでスライド生成

生成したコンテンツをGoogle Slidesのテンプレートに流し込む。テンプレートの設計が鍵になる。

テンプレート設計

テンプレートは以下の構成で用意する。各スライドにプレースホルダーを埋め込み、APIから差し替える。

スライド番号内容プレースホルダー
1表紙{{COMPANY_NAME}}, {{TITLE}}, {{DATE}}, {{SUBTITLE}}
2エグゼクティブサマリー{{EXECUTIVE_SUMMARY}}
3御社の課題{{CHALLENGE_1}}, {{IMPACT_1}}, {{COST_1}} ...
4ソリューションマッピング{{SOLUTION_1}}, {{FEATURE_1}}, {{OUTCOME_1}} ...
5ROI試算{{CURRENT_COST}}, {{PROJECTED_COST}}, {{SAVINGS}}, {{PAYBACK}}
6ROIグラフ画像差し替え(HTML → PNG → 挿入)
7導入スケジュール{{PHASE_1}}, {{DURATION_1}}, {{ACTIVITIES_1}} ...
8競合比較{{ASPECT_1}}, {{OUR_PRODUCT_1}}, {{COMPETITOR_1}} ...
9導入事例{{CASE_COMPANY}}, {{CASE_RESULT}} ...
10ネクストステップ{{STEP_1}}, {{OWNER_1}}, {{DEADLINE_1}} ...

プレースホルダーの命名規則は{{SECTION_FIELD_INDEX}}で統一する。indexは同一スライド内の繰り返し要素に使う。

スライド生成の実装

typescript
// src/proposal/slides-generator.ts import { z } from 'zod' import type { ProposalContent } from './types' import type { ROIOutput } from './roi-calculator' interface SlidesConfig { templateId: string outputTitle: string } interface SlideReplacement { placeholder: string value: string } export async function generateSlides( content: ProposalContent, roi: ROIOutput, config: SlidesConfig ): Promise<{ presentationId: string; url: string }> { // Step 1: テンプレートを複製 const presentationId = await copyTemplate(config.templateId, config.outputTitle) // Step 2: プレースホルダーの一括置換リストを構築 const replacements = buildReplacements(content, roi) // Step 3: Google Slides APIでテキスト置換を実行 await batchReplaceText(presentationId, replacements) // Step 4: ROIグラフをHTML→PNG→画像挿入 const roiChartUrl = await generateROIChart(roi) await replaceSlideImage(presentationId, 'ROI_CHART_PLACEHOLDER', roiChartUrl) const url = `https://docs.google.com/presentation/d/${presentationId}/edit` return { presentationId, url } } async function copyTemplate( templateId: string, title: string ): Promise<string> { const response = await fetch( `https://www.googleapis.com/drive/v3/files/${templateId}/copy`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.GOOGLE_ACCESS_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: title }), } ) if (!response.ok) { const error = await response.text() throw new Error(`Google Drive copy failed: ${response.status} ${error}`) } const data = await response.json() as { id: string } return data.id } function buildReplacements( content: ProposalContent, roi: ROIOutput ): SlideReplacement[] { const replacements: SlideReplacement[] = [ // 表紙 { placeholder: '{{COMPANY_NAME}}', value: content.title.split('様')[0] + '様' }, { placeholder: '{{TITLE}}', value: content.title }, { placeholder: '{{SUBTITLE}}', value: content.subtitle }, { placeholder: '{{DATE}}', value: new Date().toLocaleDateString('ja-JP') }, // エグゼクティブサマリー { placeholder: '{{EXECUTIVE_SUMMARY}}', value: content.executiveSummary }, // ROI数値 { placeholder: '{{CURRENT_COST}}', value: `${(roi.currentState.annualRevenue / 10000).toLocaleString()}万円`, }, { placeholder: '{{PROJECTED_COST}}', value: `${(roi.projectedState.annualRevenue / 10000).toLocaleString()}万円`, }, { placeholder: '{{ANNUAL_SAVINGS}}', value: `${(roi.savings.totalAnnualBenefit / 10000).toLocaleString()}万円`, }, { placeholder: '{{PAYBACK_MONTHS}}', value: `${roi.roi.paybackMonths}ヶ月`, }, { placeholder: '{{THREE_YEAR_ROI}}', value: `${roi.roi.threeYearROI}%`, }, { placeholder: '{{ASSUMPTIONS}}', value: roi.assumptions.join('\n'), }, ] // 課題分析(最大3つ) content.challengeAnalysis.slice(0, 3).forEach((ca, i) => { const idx = i + 1 replacements.push( { placeholder: `{{CHALLENGE_${idx}}}`, value: ca.challenge }, { placeholder: `{{IMPACT_${idx}}}`, value: ca.impact }, { placeholder: `{{COST_${idx}}}`, value: ca.currentCost }, ) }) // ソリューションマッピング content.solutionMapping.slice(0, 3).forEach((sm, i) => { const idx = i + 1 replacements.push( { placeholder: `{{SOLUTION_CHALLENGE_${idx}}}`, value: sm.challenge }, { placeholder: `{{SOLUTION_${idx}}}`, value: sm.solution }, { placeholder: `{{FEATURE_${idx}}}`, value: sm.feature }, { placeholder: `{{OUTCOME_${idx}}}`, value: sm.expectedOutcome }, ) }) // 導入スケジュール content.implementationPlan.forEach((plan, i) => { const idx = i + 1 replacements.push( { placeholder: `{{PHASE_${idx}}}`, value: plan.phase }, { placeholder: `{{DURATION_${idx}}}`, value: plan.duration }, { placeholder: `{{ACTIVITIES_${idx}}}`, value: plan.activities.join('\n') }, { placeholder: `{{DELIVERABLES_${idx}}}`, value: plan.deliverables.join('\n') }, ) }) // 競合比較 content.competitiveAdvantages.slice(0, 4).forEach((ca, i) => { const idx = i + 1 replacements.push( { placeholder: `{{ASPECT_${idx}}}`, value: ca.aspect }, { placeholder: `{{OUR_PRODUCT_${idx}}}`, value: ca.ourProduct }, { placeholder: `{{COMPETITOR_${idx}}}`, value: ca.competitor }, { placeholder: `{{ADVANTAGE_${idx}}}`, value: ca.advantage }, ) }) // 導入事例 content.caseStudies.slice(0, 2).forEach((cs, i) => { const idx = i + 1 replacements.push( { placeholder: `{{CASE_COMPANY_${idx}}}`, value: cs.companyName }, { placeholder: `{{CASE_INDUSTRY_${idx}}}`, value: cs.industry }, { placeholder: `{{CASE_CHALLENGE_${idx}}}`, value: cs.challenge }, { placeholder: `{{CASE_RESULT_${idx}}}`, value: cs.result }, ) }) // ネクストステップ content.nextSteps.slice(0, 3).forEach((ns, i) => { const idx = i + 1 replacements.push( { placeholder: `{{STEP_${idx}}}`, value: ns.step }, { placeholder: `{{OWNER_${idx}}}`, value: ns.owner }, { placeholder: `{{DEADLINE_${idx}}}`, value: ns.deadline }, ) }) return replacements } async function batchReplaceText( presentationId: string, replacements: SlideReplacement[] ): Promise<void> { const requests = replacements.map((r) => ({ replaceAllText: { containsText: { text: r.placeholder, matchCase: true, }, replaceText: r.value, }, })) const response = await fetch( `https://slides.googleapis.com/v1/presentations/${presentationId}:batchUpdate`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.GOOGLE_ACCESS_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ requests }), } ) if (!response.ok) { const error = await response.text() throw new Error(`Google Slides batchUpdate failed: ${response.status} ${error}`) } } async function generateROIChart(roi: ROIOutput): Promise<string> { // HTML→PNG変換でROIグラフを生成 // 実運用ではPlaywright or puppeteerでスクリーンショットを取る const html = buildROIChartHTML(roi) // ここではCloud Storageにアップロードして公開URLを返す想定 const uploadUrl = await uploadToStorage(html, 'roi-chart.png') return uploadUrl } function buildROIChartHTML(roi: ROIOutput): string { const currentCostM = Math.round(roi.currentState.totalSalesCost / 10000) const projectedCostM = Math.round(roi.projectedState.totalSalesCost / 10000) const savingsM = Math.round(roi.savings.totalAnnualBenefit / 10000) return `<!DOCTYPE html> <html> <head> <style> body { font-family: 'Noto Sans JP', sans-serif; margin: 0; padding: 40px; background: white; width: 800px; } .chart-container { display: flex; align-items: flex-end; gap: 40px; height: 300px; padding: 20px; } .bar-group { display: flex; flex-direction: column; align-items: center; } .bar { width: 120px; border-radius: 8px 8px 0 0; display: flex; align-items: flex-end; justify-content: center; padding-bottom: 8px; color: white; font-weight: bold; font-size: 18px; } .bar-current { background: #94a3b8; } .bar-projected { background: #3b82f6; } .bar-savings { background: #22c55e; } .label { margin-top: 12px; font-size: 14px; color: #374151; text-align: center; } .value { font-size: 24px; font-weight: bold; margin-top: 4px; } h3 { color: #1e293b; margin-bottom: 24px; } </style> </head> <body> <h3>年間コスト比較 / 削減効果</h3> <div class="chart-container"> <div class="bar-group"> <div class="bar bar-current" style="height: ${Math.min(280, currentCostM / 10)}px">${currentCostM}万</div> <div class="label">現状コスト</div> </div> <div class="bar-group"> <div class="bar bar-projected" style="height: ${Math.min(280, projectedCostM / 10)}px">${projectedCostM}万</div> <div class="label">導入後コスト</div> </div> <div class="bar-group"> <div class="bar bar-savings" style="height: ${Math.min(280, savingsM / 10)}px">${savingsM}万</div> <div class="label">年間効果額</div> </div> </div> <div style="margin-top: 24px; padding: 16px; background: #f0fdf4; border-radius: 8px;"> <span style="font-size: 14px; color: #166534;">投資回収期間: <strong>${roi.roi.paybackMonths}ヶ月</strong> / 3年ROI: <strong>${roi.roi.threeYearROI}%</strong></span> </div> </body> </html>` } async function replaceSlideImage( presentationId: string, placeholderImageId: string, imageUrl: string ): Promise<void> { const response = await fetch( `https://slides.googleapis.com/v1/presentations/${presentationId}:batchUpdate`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.GOOGLE_ACCESS_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ requests: [{ replaceAllShapesWithImage: { imageUrl, replaceMethod: 'CENTER_INSIDE', containsText: { text: placeholderImageId, matchCase: true, }, }, }], }), } ) if (!response.ok) { const error = await response.text() throw new Error(`Slide image replacement failed: ${response.status} ${error}`) } } async function uploadToStorage(html: string, filename: string): Promise<string> { // Cloud Storage(GCS/R2等)へのアップロード処理 // 実装はインフラ構成に依存するため、ここではインターフェースのみ示す throw new Error('uploadToStorage: implement based on your storage provider') }

Google Slides APIのreplaceAllTextが便利で、プレースホルダーの文字列を一括で差し替えられる。テンプレートをGoogleスライド上でデザイナーが整え、エンジニアはプレースホルダーの命名規則さえ守れば、デザインとコンテンツ生成を完全に分離できる。

カスタムデモ環境の記事で書いたマルチテナントのデモ環境構築と同じ発想で、提案書テンプレートも業種別バリエーションを持たせる。SaaS企業向け、製造業向け、金融機関向けで、強調する課題やROIの見せ方が変わる。

品質チェックのAI実装

生成された提案書コンテンツを、別のClaude APIリクエストで品質チェックする。生成と検証を分離することで、ハルシネーションやロジックの不整合を検出する。

typescript
// src/proposal/quality-checker.ts import Anthropic from '@anthropic-ai/sdk' import { QualityScoreSchema, type QualityScore, type ProposalContent, type Deal, } from './types' import type { ROIOutput } from './roi-calculator' const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY, }) export async function checkProposalQuality( content: ProposalContent, deal: Deal, roi: ROIOutput ): Promise<QualityScore> { const prompt = `あなたは提案書の品質管理責任者です。以下の提案書コンテンツの品質を評価してください。 ## 提案書コンテンツ ${JSON.stringify(content, null, 2)} ## 元の商談情報 - 企業名: ${deal.companyName} - 業種: ${deal.industry} - 従業員数: ${deal.employeeCount}- 顧客の課題: ${deal.challenges.join(', ')} - 予算: ${deal.budget ? `${deal.budget.toLocaleString()}` : '未確認'} ## ROI試算データ - 年間効果額: ${roi.savings.totalAnnualBenefit.toLocaleString()}- 投資回収期間: ${roi.roi.paybackMonths}ヶ月 - 前提条件: ${roi.assumptions.join(' / ')} ## 評価基準(各25点満点、合計100点) 1. **relevance(顧客課題との関連性)**: 25点 - 顧客の課題に直接対応しているか - 業種・規模に合った提案になっているか - 担当者の役職に適切な粒度か 2. **specificity(具体性)**: 25点 - 汎用的な表現ではなく、この企業固有の内容が含まれているか - 数値が具体的で検証可能か - 「御社の場合」と言える内容になっているか 3. **roiCredibility(ROIの信頼性)**: 25点 - 前提条件が明示されているか - 数値の根拠が論理的か - 楽観的すぎる見積もりになっていないか - 投資回収期間は現実的か 4. **actionability(実行可能性)**: 25点 - 導入スケジュールが具体的か - ネクストステップが明確か - 障壁(技術的・組織的)への対策が含まれているか ## チェック項目 - [ ] 事実と異なる実績や数字が含まれていないか - [ ] 競合情報は最新か(古い情報を参照していないか) - [ ] 法的・コンプライアンス上の問題がないか - [ ] 機密情報(他社の詳細な契約条件等)が含まれていないか ## 出力 以下のJSON形式で出力してください。JSONのみを出力してください。 { "overall": <0-100>, "breakdown": { "relevance": <0-25>, "specificity": <0-25>, "roiCredibility": <0-25>, "actionability": <0-25> }, "issues": [ { "severity": "<critical|warning|info>", "section": "<問題のあるセクション名>", "message": "<問題の内容>", "suggestion": "<修正提案>" } ], "approved": <true: 70点以上かつcriticalなissueがない場合, false: それ以外> }` const response = await anthropic.messages.create({ model: 'claude-sonnet-4-5-20250514', max_tokens: 2048, messages: [{ role: 'user', content: prompt }], }) const text = response.content[0].type === 'text' ? response.content[0].text : '' const jsonMatch = text.match(/\{[\s\S]*\}/) if (!jsonMatch) { throw new Error('Failed to extract quality score JSON') } return QualityScoreSchema.parse(JSON.parse(jsonMatch[0])) }

品質チェックでapproved: falseが返った場合、自動的にSlackで営業マネージャーにレビュー依頼を送る。criticalなissueがある場合はスライド生成を中断し、人間の確認を必須にする。

この「生成AIが作ったものを、別の生成AI呼び出しで検証する」パターンは、コードレビューにおけるダブルチェックと同じ発想だ。1回のAPI呼び出しで生成と検証を同時にやると、自分の出力を自分で正当化するバイアスがかかる。プロセスを分離することで、客観的な品質評価が可能になる。

パイプラインオーケストレーター

ここまでの各モジュールを統合し、CRMのWebhookトリガーからスライド完成・通知までを一気通貫で実行するオーケストレーターを実装する。

typescript
// src/proposal/orchestrator.ts import { collectDealData } from './deal-collector' import { generateProposalContent } from './content-generator' import { calculateROI } from './roi-calculator' import { generateSlides } from './slides-generator' import { checkProposalQuality } from './quality-checker' import type { ProposalContent, QualityScore, Deal } from './types' import type { ROIOutput } from './roi-calculator' interface ProposalPipelineResult { deal: Deal content: ProposalContent roi: ROIOutput quality: QualityScore slidesUrl: string | null processingTimeMs: number status: 'completed' | 'needs_review' | 'failed' } const productConfig = { name: 'SalesFlow', tagline: '営業チームのパイプライン管理を自動化し、商談速度を2倍にする', features: [ { name: 'AIリードスコアリング', description: 'Claude APIによるリードの自動評価', benefit: 'スコアリング工数を90%削減', }, { name: 'CRM自動連携', description: 'HubSpot/Salesforceとのリアルタイム同期', benefit: 'CRM入力工数を70%削減', }, { name: '提案書自動生成', description: 'CRMデータから提案書を自動作成', benefit: '提案書作成時間を3-5時間から30分に短縮', }, { name: 'ROI自動試算', description: '業種別テンプレートによる投資対効果の自動算出', benefit: '稟議通過率を20%向上', }, ], pricing: [ { plan: 'Starter', price: 30000, unit: '月/ユーザー' }, { plan: 'Professional', price: 50000, unit: '月/ユーザー' }, { plan: 'Enterprise', price: 80000, unit: '月/ユーザー' }, ], caseStudies: [ { company: 'A社', industry: 'SaaS', challenge: '営業チーム20名のリサーチ工数が月間200時間', result: 'リサーチ工数を月間30時間に削減', metrics: 'リサーチ工数85%削減、商談化率12%向上', }, { company: 'B社', industry: '製造業', challenge: '提案書の品質が担当者によってばらつき、受注率に差', result: '提案書の品質スコアが全社平均75→90に向上', metrics: '受注率8%向上、提案書作成時間60%短縮', }, { company: 'C社', industry: '金融', challenge: 'エンタープライズ営業のサイクルが平均24週間', result: 'ROI試算と稟議支援の自動化で営業サイクルを18週間に短縮', metrics: '営業サイクル25%短縮、年間パイプライン金額40%増加', }, ], } export async function runProposalPipeline( dealId: string ): Promise<ProposalPipelineResult> { const startTime = Date.now() const hubspotApiKey = process.env.HUBSPOT_API_KEY ?? '' const templateId = process.env.SLIDES_TEMPLATE_ID ?? '' try { // Step 1: CRMからデータ収集 + 企業リサーチ const { deal, research } = await collectDealData(dealId, hubspotApiKey) // Step 2: ROI試算(決定論的計算) const roi = calculateROI({ industry: deal.industry, employeeCount: deal.employeeCount, salesTeamSize: undefined, avgDealSize: deal.budget ?? undefined, }) // Step 3: Claude APIで提案コンテンツ生成 const content = await generateProposalContent(deal, research, productConfig) // Step 4: 品質チェック const quality = await checkProposalQuality(content, deal, roi) // Step 5: 品質チェック通過時のみスライド生成 let slidesUrl: string | null = null if (quality.approved) { const slides = await generateSlides(content, roi, { templateId, outputTitle: content.title, }) slidesUrl = slides.url // CRMにスライドURLを紐付け await updateDealWithProposal(dealId, slidesUrl, hubspotApiKey) } // Step 6: 通知 await notifyTeam(deal, quality, slidesUrl) return { deal, content, roi, quality, slidesUrl, processingTimeMs: Date.now() - startTime, status: quality.approved ? 'completed' : 'needs_review', } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error' await notifyError(dealId, message) return { deal: { dealId, companyName: '', contactName: '', contactTitle: '', industry: '', employeeCount: 0, challenges: [], dealStage: '' }, content: {} as ProposalContent, roi: {} as ROIOutput, quality: { overall: 0, breakdown: { relevance: 0, specificity: 0, roiCredibility: 0, actionability: 0 }, issues: [{ severity: 'critical', section: 'pipeline', message, suggestion: 'Check logs' }], approved: false }, slidesUrl: null, processingTimeMs: Date.now() - startTime, status: 'failed', } } } async function updateDealWithProposal( dealId: string, slidesUrl: string, apiKey: string ): Promise<void> { await fetch( `https://api.hubapi.com/crm/v3/objects/deals/${dealId}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ properties: { proposal_url: slidesUrl, proposal_generated_at: new Date().toISOString(), dealstage: 'presentationscheduled', }, }), } ) } async function notifyTeam( deal: Deal, quality: QualityScore, slidesUrl: string | null ): Promise<void> { const webhookUrl = process.env.SLACK_SALES_WEBHOOK_URL if (!webhookUrl) return const statusEmoji = quality.approved ? ':white_check_mark:' : ':warning:' const criticalIssues = quality.issues.filter((i) => i.severity === 'critical') await fetch(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blocks: [ { type: 'header', text: { type: 'plain_text', text: `${statusEmoji} 提案書生成: ${deal.companyName}`, }, }, { type: 'section', fields: [ { type: 'mrkdwn', text: `*品質スコア:* ${quality.overall}/100` }, { type: 'mrkdwn', text: `*ステータス:* ${quality.approved ? '自動承認' : 'レビュー待ち'}` }, { type: 'mrkdwn', text: `*担当者:* ${deal.contactName}${deal.contactTitle}` }, { type: 'mrkdwn', text: `*業種:* ${deal.industry}` }, ], }, ...(criticalIssues.length > 0 ? [{ type: 'section' as const, text: { type: 'mrkdwn' as const, text: `*要確認:*\n${criticalIssues.map((i) => `- ${i.message}`).join('\n')}`, }, }] : []), ...(slidesUrl ? [{ type: 'actions' as const, elements: [{ type: 'button' as const, text: { type: 'plain_text' as const, text: '提案書を開く' }, url: slidesUrl, }], }] : []), ], }), }) } async function notifyError(dealId: string, message: string): Promise<void> { const webhookUrl = process.env.SLACK_SALES_WEBHOOK_URL if (!webhookUrl) return await fetch(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: `:x: 提案書生成に失敗しました\nDeal ID: ${dealId}\nエラー: ${message}`, }), }) }

Webhookエンドポイント

typescript
// src/routes/proposal.ts import { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' import { runProposalPipeline } from '../proposal/orchestrator' const proposal = new Hono() const WebhookPayloadSchema = z.object({ dealId: z.string(), portalId: z.string().optional(), subscriptionType: z.string().optional(), }) proposal.post( '/webhook/deal-stage-change', zValidator('json', WebhookPayloadSchema), async (c) => { const { dealId } = c.req.valid('json') // 非同期で実行(Webhookは即座にレスポンスを返す) const resultPromise = runProposalPipeline(dealId) // バックグラウンドで実行(Cloudflare Workers の waitUntil 等を使う) c.executionCtx.waitUntil(resultPromise) return c.json({ status: 'accepted', dealId, message: 'Proposal generation started', }) } ) // 手動トリガー用エンドポイント proposal.post( '/generate', zValidator('json', z.object({ dealId: z.string() })), async (c) => { const { dealId } = c.req.valid('json') const result = await runProposalPipeline(dealId) return c.json({ status: result.status, qualityScore: result.quality.overall, slidesUrl: result.slidesUrl, processingTimeMs: result.processingTimeMs, issues: result.quality.issues, }) } ) export { proposal }

パイプライン全体の処理時間は、Claude API呼び出し2回(コンテンツ生成 + 品質チェック)とGoogle Slides API呼び出しを合わせて30-90秒程度に収まる。CRM Webhookからのトリガーでは非同期実行し、完了時にSlack通知で営業に届ける。

品質管理のワークフロー

提案書の自動生成は「作って終わり」ではない。継続的に品質を改善するフィードバックループが必要だ。

チェックリスト

品質チェックAIが確認する項目と、人間がレビューすべき項目を分離する。

チェック項目AI自動人間レビュー理由
事実確認(企業情報の正確性)--必須AIは最新情報を持たない場合がある
数値の根拠(ROI前提条件)対応推奨計算ロジックはコードで保証。前提の妥当性は人間判断
競合情報の最新性--必須競合の機能・価格は頻繁に変わる
法的・コンプライアンス--必須NDA範囲、景表法等の判断はAIに委ねない
文章の一貫性対応--文体・トーンの統一はAIが得意
テンプレート構造の遵守対応--プレースホルダーの埋め漏れ等は自動検出
顧客課題との対応対応推奨マッピングの妥当性はAIで一次チェック、人間で最終確認

フィードバックループ

営業が提案書を修正した箇所を記録し、テンプレートとプロンプトの改善に活かす。

typescript
// src/proposal/feedback.ts import { z } from 'zod' const FeedbackSchema = z.object({ proposalId: z.string(), dealId: z.string(), modifications: z.array(z.object({ section: z.string(), original: z.string(), modified: z.string(), reason: z.string().optional(), })), overallRating: z.number().min(1).max(5), dealOutcome: z.enum(['won', 'lost', 'pending']).optional(), comments: z.string().optional(), }) type Feedback = z.infer<typeof FeedbackSchema> interface FeedbackAnalysis { commonModifications: { section: string; frequency: number; pattern: string }[] averageRating: number winRateByQualityScore: { scoreRange: string; winRate: number }[] recommendedPromptAdjustments: string[] } export async function analyzeFeedback( feedbacks: Feedback[] ): Promise<FeedbackAnalysis> { // セクション別の修正頻度を集計 const sectionCounts = new Map<string, number>() for (const fb of feedbacks) { for (const mod of fb.modifications) { const count = sectionCounts.get(mod.section) ?? 0 sectionCounts.set(mod.section, count + 1) } } const commonModifications = [...sectionCounts.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, 5) .map(([section, frequency]) => ({ section, frequency, pattern: extractModificationPattern(feedbacks, section), })) const averageRating = feedbacks.reduce((sum, fb) => sum + fb.overallRating, 0) / feedbacks.length return { commonModifications, averageRating, winRateByQualityScore: [], recommendedPromptAdjustments: commonModifications.map((m) => `${m.section}セクションで「${m.pattern}」への修正が${m.frequency}件。プロンプトの該当箇所を調整` ), } } function extractModificationPattern( feedbacks: Feedback[], section: string ): string { const modifications = feedbacks .flatMap((fb) => fb.modifications) .filter((m) => m.section === section) if (modifications.length === 0) return '' // 修正パターンの要約(簡易版) const reasons = modifications .map((m) => m.reason) .filter((r): r is string => r !== undefined) if (reasons.length > 0) return reasons[0] return `${modifications.length}件の修正` }

フィードバックデータが溜まると「ROIセクションの前提条件を営業が毎回修正している」「競合比較の表現が弱い」といったパターンが見えてくる。これをプロンプトの改善に反映させることで、提案書の品質は案件を重ねるごとに上がっていく。

効果測定

このパイプラインの効果を、導入前後で比較する。

定量的な効果

メトリクス導入前導入後改善率
提案書作成時間3-5時間/件30分/件(レビュー込み)-85%
提案書の品質スコア(マネージャー評価)平均65/100平均82/100+26%
提案→受注コンバージョン率22%28%+6pt
営業1人あたりの月間提案数4-5件8-10件+100%
営業の「顧客対話時間」比率25-30%45-50%+20pt

定性的な効果

品質の底上げ: トップセールスの提案ノウハウがテンプレートとプロンプトに組み込まれるため、新人でも一定水準の提案書を出せるようになる。属人化が解消される。

ナレッジの蓄積: 過去の提案書コンテンツ、フィードバック、受注/失注結果がすべて構造化データとして蓄積される。「同業種×同規模の提案で何が効いたか」を検索できるようになる。

営業の役割変化: 提案書作成が自動化されることで、営業担当は「スライドを作る人」から「顧客の課題を深掘りし、提案書の方向性を決める人」へ役割がシフトする。

コスト

項目月額
Claude API(コンテンツ生成 + 品質チェック、50件/月)約$35
Google Workspace(Slides API)既存契約に含まれる
Cloudflare Workers(APIサーバー)$5
合計約$40

月額$40で提案書の自動生成が回る。営業1人が提案書作成に費やす時間を月20時間削減できるとして、時給換算で月25万円の工数削減。営業チーム10人なら月250万円。ROIは明白だ。


ここまでのパイプラインは、すべてHubSpot APIとClaude APIの組み合わせで構築してきた。しかし、CRMとの連携が深まるにつれて、APIの直接呼び出しでは管理が煩雑になる場面が出てくる。

次の記事ではMCP(Model Context Protocol)によるCRM統合を書く。Claude APIとCRMの連携をプロトコルレベルで標準化し、より柔軟で保守性の高いアーキテクチャへ進化させる。


参考資料

$ echo $TAGS
#提案書自動生成#Claude API#Google Slides#CRM#SLG#TypeScript