3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Googleフォーム×GAS×OpenAIで「売場内省AI」を完成。鍵は“かつおの追い込み”だった

3
Last updated at Posted at 2025-10-27

🧩 開発背景

食品スーパーの店舗現場では、作業の属人化や情報共有の非効率が大きな課題。
担当者ごとに作業品質がばらつき、紙・口頭・LINEなど複数の手段で情報が分散している。
さらに、勤務中のスマホ利用制限や人員不足により、管理職が確認・報告・教育に追われる状況が続いている。

こ、これは…作業効率改善の光が見える!:eye::sparkles:

!


🔍 課題の中身

  • 店だし部門での課題として人数がいても指示がないと行動ができない。
  • 作業を振るために一部の人に業務が集中し、全体最適が図れない。
  • 指示者は自分の仕事が後回しになってしまう。
  • 指示を受けた作業者が作業の進捗確認を求めてくる。達成基準が明確でない。
  • 達成基準が不明確であるため、作業スピードが向上しない。
  • 1回の指示出しに約30分は費やしている。

💡 解決の方向

GASGoogle スプレッドシートを連携し、現場とバックオフィスをつなぐ業務基盤を構築する。

  • GASスプレッドシートLINEAPI連携による裏側処理と集計

この仕組みにより、現場は自立的に動き、管理者はデータで判断できる環境へ。


🎯 概要

現場のスタッフが「写真+コメント」をフォームに送るだけで、
OpenAI APIが自動で売場の内省(自己評価・改善アクション)を出力する仕組みを作成。

GoogleフォームスプレッドシートGAS(Google Apps Script)を使って構築した「AI内省自動化フロー」。
私自身、GASを学び始めて1日目で作成・動作までたどり着けたので、同じように挑戦したい方に参考になればと思い、記録として残した。

🧩 使用ツール

ツール 用途

Googleフォーム 現場スタッフの入力(画像+コメント)
Googleスプレッドシート データ蓄積&AI出力結果保存
Google Apps Script (GAS) 自動処理&OpenAI API呼び出し
OpenAI API (GPT-4o) コメント+画像から内省を生成
Google Drive 画像アップロードと自動公開処理

🗂 構成イメージ

[Googleフォーム]
 ↓(ファイルアップロード含む)
[スプレッドシート "フォームの回答 1"]
 ↓(GASで処理)
[OpenAI API (GPT-4o)]
 ↓
[スプレッドシート "AI_RESULTS"]
 → 初回要約 / 自己評価 / 改善アクション を自動生成

🧠 実装ステップ

① フォームの作成

質問項目例:

担当(記述式)

時間帯(朝/昼/夕)

コメント(天候・お客さま動向など)

写真(ファイルアップロード)

⚠️ 「ファイルのアップロード」を使用。
画像はDriveに保存され、そのURLをGASで変換しAIに渡す。

② スプレッドシート構成

シート名 役割
フォームの回答 1 フォーム送信内容を自動受信
AI_RESULTS AIによる内省出力(自動追記)

例:

日時 担当 時間帯 コメント 写真URL 画像URL(AI用) AI_初回要約 AI_自己評価 AI_再回答 AI_改善アクション

③ GASコード全体

:imp:以下使用コード
  MODEL: "gpt-4o",
  ENDPOINT: "https://api.openai.com/v1/chat/completions",
  FORM_SHEETS: ["フォームの回答 1", "Form Responses 1"],
  RESULTS_SHEET: "AI_RESULTS",
};

// === 1) フォームの最新1件を処理 ===
function processLastFormResponse() {
  const sh = getFormSheet_();
  if (!sh) throw new Error("フォーム回答シートが見つかりません");
  const last = sh.getLastRow();
  if (last < 2) throw new Error("回答がまだありません。");

  const headers = sh.getRange(1,1,1,sh.getLastColumn()).getValues()[0];
  const vals = sh.getRange(last,1,1,sh.getLastColumn()).getValues()[0];
  const nv = {};
  headers.forEach((h,i)=> nv[h] = [String(vals[i] ?? "")]);

  const row = normalizeFromNamedValues_(nv);
  const out = callAI_(row);
  appendResult_(out);
  SpreadsheetApp.getUi().alert("OK: AI_RESULTSに追記しました。");
}

// === 2) namedValuesからデータ抽出 ===
function normalizeFromNamedValues_(nv) {
  const pick = (keys)=> (keys.find(k => nv[k]) ? nv[keys.find(k=>nv[k])][0] : "");
  const dt = pick(["タイムスタンプ","Timestamp"]);
  const person = pick(["担当"]);
  const slot = pick(["時間帯"]);
  const comment = pick(["コメント(天候、お客さま動向、自由入力)","コメント"]);

  const photoRaw = pick(["写真"]);
  const firstUrl = extractFirstUrl_(photoRaw);
  const convUrl = toPublicImageUrl_(firstUrl);

  return { datetime: dt, person, slot, comment, photo_raw:firstUrl, photo_conv:convUrl };
}

function extractFirstUrl_(text) {
  if (!text) return "";
  const m = String(text).match(/https?:\/\/[^\s,]+/);
  return m ? m[0] : "";
}

// === 3) Drive画像URLをAI向けJPEG変換 ===
function toPublicImageUrl_(url) {
  if (!url) return "";
  const id =
    (url.match(/\/file\/d\/([a-zA-Z0-9_-]{10,})\//) || [])[1] ||
    (url.match(/[?&]id=([a-zA-Z0-9_-]{10,})/) || [])[1] ||
    (url.match(/\/d\/([a-zA-Z0-9_-]{10,})/) || [])[1] || "";
  if (!id) return url;
  try {
    const f = DriveApp.getFileById(id);
    f.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
    return `https://drive.google.com/thumbnail?id=${id}&sz=w1000`; // ← HEIC対応
  } catch(e){ return url; }
}

// === 4) AI呼び出し ===
function callAI_(row) {
  const key = PropertiesService.getScriptProperties().getProperty("OPENAI_API_KEY");
  if (!key) throw new Error("OPENAI_API_KEY 未設定");

  const user = [
    "以下の出力フォーマットで、売場写真とコメントから内省を生成してください。",
    "{",
    '  "初回要約":"<1段落>",',
    '  "自己評価":["<短い点検1>","<短い点検2>","<短い点検3>"],',
    '  "再回答":"<1段落>",',
    '  "要約1行":"<50字以内>",',
    '  "改善アクション":["<やる1>","<やる2>","<やる3>"],',
    `  "タグ":["時間帯:${row.slot}","担当:${row.person}","領域:日配パン"]`,
    "}",
    "",
    `日時:${row.datetime}`,
    `担当:${row.person}`,
    `時間帯:${row.slot}`,
    `コメント:${row.comment}`
  ].join("\n");

  const content = [{type:"text", text:user}];
  if (row.photo_conv) content.push({type:"image_url", image_url:{url: row.photo_conv}});

  const resp = UrlFetchApp.fetch(CFG.ENDPOINT, {
    method: "post",
    headers: { "Authorization":"Bearer "+key, "Content-Type":"application/json" },
    payload: JSON.stringify({
      model: CFG.MODEL,
      temperature: 0.2,
      response_format: { type: "json_object" },
      messages: [
        {role:"system",content:"日本語でJSONのみを出力する内省コーチ。"},
        {role:"user",content:content}
      ]
    }),
    muteHttpExceptions:true
  });

  const code = resp.getResponseCode();
  const body = resp.getContentText("utf-8");
  if (code < 200 || code >= 300) throw new Error("API "+code+": "+body);

  const parsed = JSON.parse(JSON.parse(body).choices[0].message.content);
  return {
    datetime: row.datetime, person: row.person, slot: row.slot,
    comment: row.comment, photo_raw: row.photo_raw, photo_conv: row.photo_conv,
    init: parsed["初回要約"]||"", eval: JSON.stringify(parsed["自己評価"]||[]),
    refine: parsed["再回答"]||"", one: parsed["要約1行"]||"",
    actions: JSON.stringify(parsed["改善アクション"]||[]),
    tags: JSON.stringify(parsed["タグ"]||[]), status:"OK", error:""
  };
}

// === 5) 出力をAI_RESULTSへ追記 ===
function appendResult_(r) {
  const sh = SpreadsheetApp.getActive().getSheetByName(CFG.RESULTS_SHEET)
        || SpreadsheetApp.getActive().insertSheet(CFG.RESULTS_SHEET);
  if (sh.getLastRow() === 0) {
    sh.appendRow(["日時","担当","時間帯","コメント","写真URL(生)","画像URL(AI用)","AI_初回要約","AI_自己評価(JSON)","AI_再回答","AI_要約1行","AI_改善アクション(JSON)","AI_タグ(JSON)","ステータス","エラー"]);
  }
  sh.appendRow([r.datetime,r.person,r.slot,r.comment,r.photo_raw,r.photo_conv,r.init,r.eval,r.refine,r.one,r.actions,r.tags,r.status,r.error]);
}

// === 6) フォーム回答シートを取得 ===
function getFormSheet_(){
  const ss = SpreadsheetApp.getActive();
  for (const name of CFG.FORM_SHEETS) {
    const sh = ss.getSheetByName(name);
    if (sh) return sh;
  }
  return null;
}

⚡ 実行方法

「フォームの回答 1」に1件以上データを送信

スクリプト画面で processLastFormResponse を選択

▶実行 → 権限を許可

シート「AI_RESULTS」に自動出力!

💡 よくあるエラーと解決

| ①エラー     | ②原因      | ③対応      |
|①OPENAI_API_KEY 未設定 |②キー未登録 |③スクリプトプロパティに登録 |
|①API 400 invalid_image_format |②HEIC画像 |③JPEGサムネURL変換で解決 |
|①401 unauthorized |②APIキー誤り |③正しいキーに更新 |
|①回答がまだありません |②データ未送信 |③1件フォーム送信後に実行 |

:fish::fire: 余談:鰹がくれた集中力

開発の前に、近所の方からなんと「一本釣りの小さめ鰹」いただく。
せっかくだから自分で三枚おろしにして、にんにくと生姜でお刺身に。

そのあと、なぜか集中力が爆発しGASを書き上げることに成功!(笑)。
鰹のDHAと達成感のダブルブースト。
まさに“かつおの追い込み”がAIを動かした瞬間だった。

動画はこちら(2倍速がおすすめです:eye:)
📸 https://drive.google.com/file/d/1AH9ljRZzCxByRV2rexWYAPi3ahOUlw-2/view?usp=sharing

🌱 今後の発展

フォーム送信をトリガーに自動実行

SlackやLINEへ結果通知

JSONタグをもとにダッシュボード可視化

みんなの意見

  • ステープル社員I倉さん① MD(特売日前に入荷される特売予定商品)商品を出す指示が出せるとありがたいです。
  • ステープル社員I倉さん② 庫管理データと連携し、バックヤードから何の商品を10個など具体的に指示出しできるともっといいですね。
  • ステープル担当I澤さん 自分のこと(重点発注(月間特売商品、特売商品の発注)シフト作成など)ができるようになるかも…と笑顔に。
  • 毎日指示出し、確認に約30分。1か月およそ20日出勤としたら10時間の短縮になるかもしれない!

✨ 使ってみてのまとめ

・豆腐売り場の写真なのに、【『くじら』:fish:が欠品しています。】の表記。
・お肉売り場の写真で扱いのない『こうね』に関する指示がでる。
・パン売り場を、菓子と認識してしまう。
・加工食品コーナーだと整頓するのみの抽象的な指示しか出ない。

:lifter_tone2:次回、Action

AI読み込みに対する思考の制限をする指示の工夫が必要。

GAS初心者でも、AIと連携した業務改善がここまで自動化に。
次は「組織全体で使えるフォーム連携AI」を目指して改良中。
現場の悩みを共有し改善に向けさらなる戦闘的トライ&エラーを繰り返したいと思う!

なによりも…笑顔になる、未完全だけど喜んでくれる姿にやってよかったーーーー!!!:relaxed:

🪶 著者

あづ(副店長 / 食品スーパー勤務)
現場起点のAI活用をテーマに、小売の再生型モデルを模索中。
Qiita・note・XでAI×地域共創の情報を発信しています。

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?