Skip to content

Prompt & API設計 — AI口コミ自動生成

項目内容
ステータス🟡 議論中
関連#10 AI口コミ自動生成

1. Prompt設計

1.1 Prompt一覧

#Prompt名目的使用LLM呼び出し元
1generate_review最も星が高い項目からフル口コミを生成(1回呼び出し)Claude Sonnet 4ReviewGeneratorService
2quality_check品質チェック(LLM部分)Claude Haiku 4.5ReviewGeneratorService
3regenerate_with_feedbackNG時フィードバック付き再生成Claude Sonnet 4ReviewGeneratorService
4regenerate_with_tonetone変更時の再生成Claude Sonnet 4AiReviewController@regenerateReview

1.2 口コミ生成Prompt (generate_review)

最も星が高い項目のみから200-400文字のフル口コミを生成。1回だけ呼び出し、プレビュー段階なし。

評価項目の選択ロジック(LLM呼び出し前にバックエンドで処理):

  • 星評価の質問の中から最も星が高い項目を取得
  • 複数の項目が同じ最高星数の場合 → 元の質問順を優先(例:接客 → 清潔感 → 満足度、3項目とも★5なら接客を選択)
  • 項目名 + 星数のみをプロンプトに送信。他のアンケート回答(残りの星評価項目、テキスト回答、Y/N回答)は送信しない

入力変数:

変数説明
top_itemobject最も星が高い項目(tie-break後){ name: "接客", rating: 5 }
tonestring口コミのtoneデフォルト 丁寧
shop_namestring店舗名銀座ヘアサロンA
store_keywordsstring店舗キーワード(カンマ区切り)丁寧な接客, 駅近, 清潔感

survey_answers含まない。他の星評価項目や他の回答は送信しない — プロンプトが簡潔になり、inputが約150 tokens(従来は約800 tokens)に削減。

System Prompt:

あなたは実際の顧客として口コミを書くアシスタントです。
指定された1つの高評価項目を中心に、完全な口コミ文を生成してください。

【トーン】{tone}

【トーン別の文体ガイド】
- 丁寧: 敬語を使用。「〜していただきました」「大変満足しております」。30〜50代の落ち着いた印象。
- カジュアル: 友人に話すような口調。「〜だった!」「めっちゃ良かった」。20〜30代の明るい印象。
- ビジネス: 客観的で簡潔。「サービスの質が高い」「費用対効果が良い」。ビジネスパーソンの印象。

【生成ルール】
1. 文字数: 200〜400文字
2. 焦点: 指定された1つの項目を中心に書く。他の話題に広げすぎない
3. 構成: 来店のきっかけ → 指定項目の体験詳細 → 感想・まとめ
4. キーワード: 指定キーワードを文脈に合わせて自然に1〜3個含める。無理に全て入れる必要はない
5. 自然さ: 以下を避ける
   - 箇条書き形式
   - 「まず」「次に」「最後に」のような明確な段落構成語
   - 過度な褒め言葉の連続
   - 全てのキーワードを機械的に詰め込む
   - 「〜がおすすめです」のような宣伝口調
6. 人間らしさ: 小さな感想や個人的なエピソードを含めて、実体験感を出す

Human Prompt:

以下の情報を元に、200〜400文字の完全な口コミ文を生成してください。

【店舗名】{shop_name}
【中心にする項目】{top_item.name}(★{top_item.rating})
【店舗キーワード】{store_keywords}
【トーン】{tone}

LLMパラメータ:

パラメータ理由
modelclaude-sonnet-4-20250514日本語品質が高い
temperature0.8多様な文体を生成
max_tokens800400文字 (~600トークン) + 余裕

出力例:

選択された項目: 接客 (★5)

初めて銀座ヘアサロンAを利用いたしました。スタッフの方がとても親切で、カウンセリングも丁寧に対応してくださり、初めてでも安心して任せられる雰囲気でした。髪の悩みをしっかり聞いた上で、似合うスタイルを提案してくださり、仕上がりも想像以上でした。駅から近く通いやすい立地も魅力で、次回もぜひお願いしたいと思います。

※ アンケートに他の項目があっても、口コミ全体が「接客」1項目に焦点を絞っている

レスポンスタイム: 5-10秒 (Sonnet + 出力 ~400文字)

1.4 品質チェック — Hybrid方式

品質チェックは2段階(ハイブリッド)に分割:

段階1: Rule-based (即時、無料)

#基準配点チェック方法早期失敗?
1文字数 (200〜400)20点mb_strlen()100未満または600超 → 即リトライ
2キーワード反映 (1〜3語)15点str_contains()
  • 段階1の合計 < 15点 → 段階2をスキップ、即リトライ(LLM呼び出し1回分の節約)
  • 段階1の合計 >= 15点 → 段階2へ進む

段階2: LLM (意味解析が必要な基準のみ)

#基準配点チェック方法
3自然さ (AI感がないか)30点Claude Haiku 4.5
4不適切な表現20点Claude Haiku 4.5
5焦点の反映(選択された項目)15点Claude Haiku 4.5

使用モデル: Claude Haiku 4.5 (temperature: 0.1 — 評価結果の安定性のため)

合格基準: 合計 (段階1 + 段階2) >= 70点 → 合格 / 70未満 → 再生成(最大3回)

LLM品質チェック用Prompt (段階2):

以下の口コミ文を評価してください。JSON形式で回答してください。

【評価項目】
1. naturalness (0〜30点): AI生成と分かる表現がないか。箇条書き・過度な褒め・均一な構成を減点
2. inappropriate (0〜20点): 不適切表現・誇大広告・事実と異なる表現がないか
3. topic_reflection (0〜15点): 指定された項目({top_item.name})が中心に反映されているか

【口コミ文】
{review_text}

【中心にすべき項目】{top_item.name}(★{top_item.rating})

JSON形式で回答:
{"naturalness": 点数, "inappropriate": 点数, "topic_reflection": 点数, "feedback": "改善点(なければ空文字)"}

品質チェックは項目名 + 星数のみを受け取り、アンケート回答全体は受け取らない — 生成プロンプトと同じく1つのトピックのみに集中させる。

出力JSONフォーマット:

json
{
  "naturalness": 25,
  "inappropriate": 20,
  "topic_reflection": 12,
  "feedback": "「まず」「次に」の接続詞が目立つ。もう少し自然な流れに。"
}

JSON parseの安全対策

LLMがJSONの前後に余分なテキストを返す場合がある。parseの前にregexでJSONを抽出する必要がある:

php
// レスポンスからJSONを抽出(LLMが余分なテキストを返す場合がある)
private function parseJsonResponse(string $response): array
{
    // レスポンス内の最初のJSONオブジェクトを検索
    if (preg_match('/\{[^{}]*\}/', $response, $matches)) {
        $decoded = json_decode($matches[0], true);
        if (json_last_error() === JSON_ERROR_NONE) {
            return $decoded;
        }
    }
    // JSON parse失敗 → 品質チェック失敗として扱い、リトライをトリガー
    return [
        'naturalness' => 0,
        'inappropriate' => 0,
        'topic_reflection' => 0,
        'feedback' => 'Quality check JSON parse failed, retrying...',
    ];
}

JSON parse失敗時は例外をスローせず、スコア0を返す → フロー内で自然にリトライをトリガーする。

1.5 再生成Prompt

品質NG時 (regenerate_with_feedback)

System Prompt: generate_review (1.2節) と同じ

Human Prompt:

以下の情報から口コミ文を生成してください。
前回の生成は品質チェックで不合格でした。フィードバックを参考に改善してください。

【前回のスコア】{previous_score}/100
【改善フィードバック】{feedback}

【店舗名】{shop_name}
【中心にする項目】{top_item.name}(★{top_item.rating})
【店舗キーワード】{store_keywords}
【トーン】{tone}

※ 前回と異なる書き出し・構成で書いてください。
パラメータ理由
modelclaude-sonnet-4-20250514品質を維持
temperature0.9前回と異なる文章を生成するため高めに設定
max_tokens800

tone変更時 (regenerate_with_tone)

System Prompt: generate_review と同じだが {tone} が変更

Human Prompt:

以下の情報から口コミ文を生成してください。
前回は「{previous_tone}」トーンで生成しましたが、今回は「{tone}」トーンで新しく生成してください。

【店舗名】{shop_name}
【中心にする項目】{top_item.name}(★{top_item.rating})
【店舗キーワード】{store_keywords}
【トーン】{tone}

※ 前回の文章「{previous_review_snippet}」の言い回しを流用しないでください。
パラメータ理由
modelclaude-sonnet-4-20250514品質を維持
temperature0.8多様性を保ちつつ安定
max_tokens800

previous_review_snippet: 前回の先頭50文字。LLMが繰り返しを避けるための参照用。

1.6 チューニング用設定値

設定デフォルト値調整範囲
合格スコア7050〜90
最大リトライ回数31〜5
最小文字数200100〜300
最大文字数400300〜600

config/anthropic.phpreview_generator で定数管理。将来的に管理画面から変更可能にする。

php
// config/anthropic.php
'review_generator' => [
    'pass_score' => env('REVIEW_PASS_SCORE', 70),
    'max_retries' => env('REVIEW_MAX_RETRIES', 3),
    'min_chars' => env('REVIEW_MIN_CHARS', 200),
    'max_chars' => env('REVIEW_MAX_CHARS', 400),
],

2. API設計 (Laravel直接呼び出し)

2.1 システム構成

Frontend (Vue.js) → Laravel API (既存) → Anthropic API (HTTPS)
  • Frontend → Laravel: 認証/セッション管理は既存Laravelで処理
  • Laravel → Anthropic API: Guzzle HTTPクライアントでHTTPS直接呼び出し
  • 専用マイクロサービス不要、Python FastAPIも不要

2.2 Laravel APIエンドポイント (Guest — ログイン不要)

以下のエンドポイントはアンケート画面(guest)から呼び出され、既存のguestルートグループに配置。

POST /guest/questionnaires/generate-review — 最も星が高い項目からフル口コミを生成(1回呼び出し)

Request:

json
{
  "questionnaireId": 201,
  "answerSet": 42,
  "hash": "abc123"
}

answerSetsaveAnswers レスポンスから返される値。race condition(複数の顧客が同時にsubmitする場合)を避けるため、具体的な値を指定する必要がある。

selected_previewselectedItem、アンケート回答は不要 — バックエンドが answerSet から最も星が高い項目を自動選択する。

バックエンド処理フロー:

  1. questionnaireId + answerSet からアンケート回答を取得
  2. 星評価(★)の質問をフィルタし、最も星が高い項目を選択
  3. Tie-break: 元の質問順を優先(例:接客 → 清潔感 → 満足度)
  4. store_keywords を取得: mappy_review_keywords → フォールバック mappy_keywords
  5. 店舗から shop_name を取得
  6. top_item (name + rating) + keywords + tone(デフォルト 丁寧のみでプロンプトを構築。他のアンケート回答は送信しない
  7. Anthropic API (Sonnet 4) を呼び出してフル口コミを生成
  8. Hybrid品質チェック (rule-based → LLM)
  9. NGの場合リトライ(最大3回)
  10. 結果をフロントエンドに返却

Response:

json
{
  "status": "success",
  "data": {
    "review_text": "初めて銀座ヘアサロンAを利用いたしました。スタッフの方がとても親切で、カウンセリングも丁寧に対応してくださり...",
    "top_item": { "name": "接客", "rating": 5 },
    "char_count": 280,
    "quality_score": 85,
    "tone_used": "丁寧",
    "retry_count": 0
  }
}

top_item は次のregenerateリクエストでフロントエンドから送り返せるように返却(DBへの再クエリを避ける)。

タイムアウト: 30秒

POST /guest/questionnaires/regenerate-review — tone変更 / 再生成

Request:

json
{
  "questionnaireId": 201,
  "answerSet": 42,
  "hash": "abc123",
  "tone": "カジュアル",
  "top_item": { "name": "接客", "rating": 5 },
  "previous_review": "初めて銀座ヘアサロンAを...",
  "previous_tone": "丁寧"
}

フロントエンドは generate-review のレスポンスで受け取った top_item を送り返し、口コミの焦点を維持する。

Response: generate-reviewと同じフォーマット

POST /guest/questionnaires/submit-review — 口コミ保存 + Google投稿URL返却

Request:

json
{
  "questionnaireId": 201,
  "answerSet": 42,
  "hash": "abc123",
  "review_text": "初めて銀座ヘアサロンAを...",
  "top_item": { "name": "接客", "rating": 5 },
  "tone": "丁寧",
  "edited": true,
  "quality_score": 85
}

Response:

json
{
  "success": true,
  "data": {
    "review_id": 789,
    "google_review_url": "https://search.google.com/local/writereview?placeid=XXXXX"
  }
}

フロントエンドは統合された「Copy & Google Review投稿」ボタンで google_review_url を使用: 同じクリックで review_text をクリップボードにコピーし、window.open(google_review_url) でGoogle Mapsを開く。

実際の投稿はユーザーが自身のGoogleアカウントで行う(Google対策)。

2.3 Laravelコード構成

app/
├── Http/Controllers/Mappy/
│   ├── Guest/
│   │   ├── QuestionnaireController.php    ← 修正: saveAnswersがanswer_setを返す
│   │   └── AiReviewController.php         ← 新規: AI review 4エンドポイント
│   └── ReviewKeywordsController.php       ← 新規: AI reviewキーワードCRUD
├── Services/Mappy/
│   ├── ReviewGeneratorService.php         ← 新規: 生成 + 品質チェック + キーワードフォールバックロジック
│   └── AnthropicApiService.php            ← 新規: Claude API呼び出しラッパー
├── Models/Mappy/
│   ├── Review.php                         ← 新規
│   ├── ReviewGenerationLog.php            ← 新規
│   └── ReviewKeyword.php                  ← 新規
resources/js/mappy/views/
├── guest/
│   ├── Questionnaire.vue                  ← 修正: 送信後にgenerate-reviewを呼び出す(書き出し選択ステップなし)
│   └── ReviewPreview.vue                  ← 新規: フル口コミ表示 + 編集 + 統合Copy & Google投稿ボタン
└── keywords/
    └── ReviewKeywordSettings.vue          ← 新規: reviewキーワード設定画面
config/
└── anthropic.php                          ← 新規: Anthropic API + review_generator設定

AnthropicApiService — Claude API呼び出し:

php
// app/Services/Mappy/AnthropicApiService.php
class AnthropicApiService
{
    /**
     * @param string $model      モデルID (sonnet/haiku)
     * @param array  $messages   [['role' => 'user', 'content' => '...']]
     * @param float  $temperature
     * @param int    $maxTokens
     * @param string $system     System prompt (Anthropic APIはmessagesとは別に送信が必要)
     */
    public function call(string $model, array $messages, float $temperature = 0.8, int $maxTokens = 1024, string $system = ''): string
    {
        $payload = [
            'model' => $model,
            'max_tokens' => $maxTokens,
            'temperature' => $temperature,
            'messages' => $messages,
        ];

        // Anthropic API: system promptは独立したフィールドであり、messagesの中には入れない
        if ($system) {
            $payload['system'] = $system;
        }

        $response = Http::withHeaders([
            'x-api-key' => config('services.anthropic.api_key'),
            'anthropic-version' => '2023-06-01',
        ])->timeout(30)->post('https://api.anthropic.com/v1/messages', $payload);

        if ($response->failed()) {
            throw new AnthropicApiException($response->status(), $response->body());
        }

        return $response->json('content.0.text');
    }
}

Anthropic APIフォーマット

System promptは system フィールドで別途送信する必要があり、messages 内の {'role': 'system'} ではない。 これはOpenAI APIとの違いである。

1つのAPIキー (ANTHROPIC_API_KEY) を全モデル (Sonnet, Haiku) で共用。

2.4 DB設計 (新規テーブル)

mappy_review_keywords テーブル (新規):

カラム説明
idbigint (PK)
user_idvarcharユーザーID
gbp_location_idbigintGoogle Business ProfileのロケーションID
keywordvarchar(255)キーワード内容
sort_ordertinyint表示順
created_at / updated_attimestamp

キーワード数に制限なし(推奨3-10個)。mappy_keywords(スクレイピング用、最大8個)とは別テーブルにして相互影響を避ける。

キーワード取得時のフォールバックロジック:

php
// ReviewGeneratorService.php
private function getKeywords(int $gbpLocationId): array
{
    // 1. review専用キーワードを優先
    $reviewKeywords = ReviewKeyword::where('gbp_location_id', $gbpLocationId)
        ->orderBy('sort_order')
        ->pluck('keyword')
        ->toArray();

    if (!empty($reviewKeywords)) {
        return $reviewKeywords;
    }

    // 2. フォールバック: 既存のスクレイピングキーワードを継承
    return Keyword::where('gbp_location_id', $gbpLocationId)
        ->orderBy('keyword_number')
        ->pluck('keyword')
        ->toArray();
}

mappy_reviews テーブル:

カラム説明
idbigint (PK)
questionnaire_idbigint (FK)アンケートID
answer_setint回答グループ(mappy_questionnaire_answers と紐付け)
shop_idbigint (FK)店舗ID
review_texttext最終口コミ内容
tonevarchar(20)使用したtone
quality_scoreint品質スコア
editedbooleanユーザーが手動編集したかどうか
sms_log_idbigint (FK, nullable)SMSトラッキングとの紐付け
submitted_attimestamp (nullable)Googleに投稿した時間
created_at / updated_attimestamp

mappy_review_generation_logs テーブル:

カラム説明
idbigint (PK)
questionnaire_idbigint (FK)アンケートID
answer_setint回答グループ
shop_idbigint (FK)店舗ID(レート制限用)
review_texttext生成された口コミ内容
tonevarchar(20)tone
quality_scoreint品質スコア
quality_breakdownjson品質チェック詳細(フォーマットは下記)
retry_countintリトライ回数
generation_typevarchar(20)initial / retry / regenerate / tone_change
llm_modelvarchar(50)使用モデル
input_tokensint入力トークン数
output_tokensint出力トークン数
cost_yendecimal(10,4)コスト(円)
created_attimestamp

テーブルprefix

プロジェクトの既存規約に従い、全テーブルに mappy_ prefixを使用。テーブル名: mappy_reviewsmappy_review_generation_logs

quality_breakdown JSONフォーマット:

2段階(rule-based + LLM)を明確に分離してデバッグしやすくする:

json
{
  "rule_based": {
    "char_count": { "score": 20, "detail": "280文字。範囲内。" },
    "keywords": { "score": 10, "detail": "2/3 keyword found" }
  },
  "llm": {
    "naturalness": { "score": 25, "detail": "概ね自然。" },
    "inappropriate": { "score": 20, "detail": "問題なし。" },
    "topic_reflection": { "score": 12, "detail": "指定項目(接客)が中心に反映されている。" },
    "feedback": "「まず」の接続詞が目立つ"
  },
  "total": 87,
  "passed": true
}

rule-basedで早期失敗した場合(LLMスキップ):

json
{
  "rule_based": {
    "char_count": { "score": 0, "detail": "52文字。範囲外。" },
    "keywords": { "score": 5, "detail": "1/3 keyword found" }
  },
  "llm": null,
  "total": 5,
  "passed": false
}

2.5 既存APIの変更

POST /guest/questionnaires (saveAnswers) — レスポンスに answer_set を追加

現行APIは 200 OK のみでbodyなし。フロントエンドがgenerate-reviewに渡すための answer_set を追加する必要がある。

Response (追加):

json
{
  "success": true,
  "answer_set": 42
}

QuestionnaireController@saveAnswers の変更:

php
// 現在: return response()->json([], 200);
// 変更後:
return response()->json([
    'success' => true,
    'answer_set' => $answerSet,
], 200);

$answerSet は既存ロジックで計算済み (max(answer_set) + 1)、返すだけでよい。

2.6 新規ルート

php
// routes/mappy/api.php — Guestルート(ログイン不要)
Route::post('/questionnaires/generate-review', 'Mappy\Guest\AiReviewController@generateReview');
Route::post('/questionnaires/regenerate-review', 'Mappy\Guest\AiReviewController@regenerateReview');
Route::post('/questionnaires/submit-review', 'Mappy\Guest\AiReviewController@submitReview');

// routes/mappy/api.php — 認証済みルート(ログイン必要、店舗管理者向け)
Route::post('/review-keywords', 'Mappy\ReviewKeywordsController@get');
Route::post('/review-keywords/update', 'Mappy\ReviewKeywordsController@update');

2.7 エラーハンドリング

エラーコードHTTP説明
SURVEY_NOT_FOUND404アンケートが存在しない
LOW_RATING400評価が低い(閾値 display_google_review_button_at 未満)
GENERATION_FAILED500LLM生成失敗(リトライ上限超過)
LLM_UNAVAILABLE503Anthropic APIが応答しない
RATE_LIMIT429上限超過(100回/日/店舗)
INVALID_TONE400無効なtone

2.8 セキュリティ & パフォーマンス

項目内容
認証Guestルート(ログイン不要)、ただし有効な questionnaireId が必要
レート制限店舗ごとに100回/日の生成(DBでカウント)
ログ全リクエストを mappy_review_generation_logs に記録
初回生成レスポンス10秒以内(リトライなし)
最大レスポンス30秒以内(リトライあり)
APIキー.envANTHROPIC_API_KEYconfig/anthropic.php で管理
php
// .env
ANTHROPIC_API_KEY=sk-ant-xxx

// config/anthropic.php
'anthropic' => [
    'api_key' => env('ANTHROPIC_API_KEY'),
],

レート制限 — DBカウントで制御:

php
// ReviewGeneratorService.php
private function checkRateLimit(int $shopId): void
{
    $todayCount = ReviewGenerationLog::where('shop_id', $shopId)
        ->whereIn('generation_type', ['initial', 'regenerate', 'tone_change'])
        ->whereDate('created_at', today())
        ->count();

    if ($todayCount >= config('services.review_generator.daily_limit', 100)) {
        throw new RateLimitExceededException();
    }
}

generate-review + regenerate-review でカウント(Sonnet呼び出しのLLMコスト管理)。


3. Googleレビューポリシー対策

3.1 Googleの検出方法と対策

GoogleはAIレビューを以下の方法で検出する:

#検出方法説明システムの対策
1テキストパターン分析同一店舗で文体/構成の繰り返しtemperature 0.8 + 3 tone + 「毎回異なる」指示
2投稿時間分析短時間に多数のレビュー1フローにつき1件のみ、一括生成しない
3IP/デバイス分析同一IP/デバイスから複数レビュー顧客が自分のデバイス/Googleアカウントから投稿
4アカウント行動分析レビュー投稿のみの新規アカウント顧客の既存Googleアカウントを使用
5自然言語処理 (NLP)AI特有の文体(構成が完璧すぎる)Promptで「箇条書き」「過度な称賛」を禁止、「人間らしさ」を要求
6キーワード密度分析不自然なキーワード挿入(SEOスパム)「強制しない」「1〜3キーワード」をPromptに指定
7内容の一貫性実体験に基づかない一般的な内容実際のアンケートデータから生成、具体的な体験を反映

3.2 Googleポリシー (2025年時点)

Google Maps コンテンツポリシー の禁止事項:

  • 虚偽のコンテンツ: 実体験に基づかないレビュー
  • なりすまし: 他人の名義で投稿
  • スパム: 同一内容の繰り返し、広告目的の投稿
  • 自動生成コンテンツ: ボットやスクリプトによる自動投稿

重要: Googleは「AIによる自動投稿」を禁止しているが、「AIが下書きを作成し、ユーザーが確認・編集してから自分で投稿する」ことは明確に禁止していない。ただしグレーゾーンであるため、以下の対策を必ず実施すること。

3.3 必須対策

#対策実装方法優先度
1顧客自身が投稿submit後にGoogle Review URLを表示、顧客が自分のアカウントで投稿。APIによる自動投稿はしない必須
2編集を促すプレビュー画面に「投稿前にご自身の言葉で編集してください」を目立つように表示必須
3多様な文体を生成temperature 0.8、3 tone、Promptに「毎回異なる構成で」必須
4自然なキーワード挿入Promptに「強制しない」「1〜3キーワード」必須
51件ずつ生成一括生成機能は作らない。1フローにつき1件必須
6投稿間隔の推奨UIに「1日1件程度の投稿を推奨」と表示推奨
7品質チェックでAI検出基準quality_checkで「AI感のある表現」を減点必須
8ログ記録全ての生成/投稿を mappy_review_generation_logs に記録必須

3.4 UI上の警告表示 (必須)

口コミ画面(プレビュー & 編集):

⚠ この文章はお客様のアンケート回答をもとにAIが作成した下書きです。
投稿される口コミはお客様ご自身の感想として公開されます。
実際のご体験に合っているかご確認のうえ、必要に応じて編集してからご投稿ください。

Google Review投稿ボタン押下後:

口コミの下書きを保存しました。
下記のリンクからGoogle Mapsを開き、ご自身のアカウントで投稿してください。
[Google Mapsで口コミを書く →]