【はじめに】
いつも記事をご覧いただきありがとうございます。2025年5月から、株式会社Kaienでシステムエンジニアとして働いている島田と申します。
弊社は、障害がある方などを対象に、就労移行支援や自立訓練を展開している事業会社です。私はシステムエンジニアとして、日々事業所スタッフとコミュニケーションを取りながら、社内の業務システム(kintone)の改修・運用を行っています。
👀今回書くこと
タイトルにもある通り、「GeminiAPIでPDF管理を効率化した件」について、事業・技術の両方の観点から記そうと思います。
想定している読者は、
- GeminiAPIを使ってみたい方
- GeminiAPIで疑似OCRを実現したい方
- 弊社エンジニアの取り組みに関心がある方
です。
【kintone × GeminiAPIの実装】
👀経緯
事業所の就労スタッフとMTGをしている際、こんな話題が上がりました。
- 行政へ補助金申請を行う際、「我々が支援して就職したこの方、今もしっかり働いていますよ~」という証明のために、その方の雇用契約書の提出を求められることがある。
- しかし、例えば契約社員→正社員のステップを踏む方の場合、契約社員時代の雇用契約書はあるけど、正社員登用後の契約書もらってない(雇用契約書の有効期限が切れていて、働き続けている証明ができない)ということが時々起きてしまう。
今は各事業所で、定期的に契約書の期限を一枚ずつ確認したり、スプレッドシートで管理したり、と工夫されているようなのですが、やはりミスは起こります。そして何より、手動・目視でそれらを行う手間は計り知れません。
こういった背景から、このオペレーションを効率化するためのシステム開発を行うことになりました。
👀エンジニアとして行ったこと
スタッフからヒアリングしたことをもとに、下記のようなアラートシステムの構築を試みました。
スタッフがkintone上にPDFをアップロードすると、Geminiが自動でPDFを解析→Lambdaが有効期限をkintoneに添えてくれる、というシステムです。有効期限が近くなっているレコードがあればアラートを飛ばすシステムを、別で定期的に動作させておけば、雇用契約書の有効期限切れはかなり防げるようになります。
これでスタッフが必要な対応は、PDFのkintoneへのアップロード・期限切れアラートの確認、だけになり、オペレーションがかなり簡略化されます。
👀実装
◎kintone → APIGateway
kintoneにはWebhookというサービスがあり、レコードの追加・編集・削除等をトリガーにした別サービスへのAPIコールを設定することができます。その際、追加や編集が行われたレコードの情報が、event
としてAPIコール先へ渡されます。
今回はAWSのAPIGatewayへのAPIコールを設定しました。
◎APIGateway → Lambda
APIGatewayは、呼ばれたら動かすLambdaを設定できるため、今回は「Geminiへの解析依頼&kintoneレコード更新」を行ってくれるLambdaを作成しておき、そのLambdaにevent
を渡して発火させています。
◎Lambda → Gemini解析 → kintoneレコード更新
LambdaはJavaScriptで動かしており、下記の流れで処理を行っています。
- APIGatewayから
event
を受け取る
(進路が就職ではなかったり、雇用契約書がアップされていなかったりしたら、解析対象外なのでリターンする) - 雇用契約書のfileKeyが渡されるので、kintoneからダウンロードし、GeminiAPIに渡すために
base64Blobs
の形式に変換する。 - GeminiAPIに解析リクエストを送り、JSON形式で有効期限を返してもらう。
- 解析結果の有効期限を、kintoneに保存する。
下記、サンプルコードです。
export const handler = async (event) => {
const blobToBase64 = (arrayBuffer) => {
return Buffer.from(arrayBuffer).toString('base64');
};
// ↓kintoneの作成・更新時のeventから、レコード情報を抽出します。
const userInfo = JSON.parse(event.body).record;
// ↓就職以外の進路の人や、雇用契約書が添付されていない人の場合は、解析対象外なのでreturnします。
const validShinro = ['就職', '復職', ...;
if (!validShinro.includes(userInfo.employment_status.value) || userInfo.contract_files.value.length === 0) {
console.log("有効期限の更新が不要なので、returnします。")
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: '雇用契約書 有効期限の更新が不要なので、returnします。' }),
};
}
// ↓雇用契約書ダウンロード用の変数設定
const contractFiles = userInfo.contract_files.value;
const types = contractFiles.map(item => item.contentType);
const fileKeys = contractFiles.map(item => item.fileKey);
// ↓(ここから)雇用契約書のファイルをkintoneから取得し、GeminiAPIで送れる形式に変換する工程です。
const downloadPromises = fileKeys.map(async (fileKey) => {
const client = new KintoneRestAPIClient({
baseUrl: process.env.xxx,
auth: { apiToken: process.env.xxx },
});
const resp = await client.file.downloadFile({
fileKey: fileKey,
});
if (!resp) {
throw new Error(`File download failed for fileKey: ${fileKey}, ${resp}`);
}
return resp;
});
let blobs = [];
try {
const buffers = await Promise.all(downloadPromises);
blobs = buffers.map((buf, index) => new Blob([buf], {
type: types[index],
}));
} catch (error) {
console.error('Error downloading files:', error);
return
}
const base64Blobs = await Promise.all(blobs.map(async (blob) => {
const arrayBuffer = await blob.arrayBuffer();
return blobToBase64(arrayBuffer);
}));
// ↑(ここまで)
// GeminiにPDF解析をリクエストするための準備を行います。
// ↓リクエストURL
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${process.env.GEMINI_API_KEY}`;
const requestHeaders = {
'Content-Type': 'application/json',
};
// ↓リクエスト内容
const requestBody = {
contents: [
{
role: 'user',
parts: [
{ text:
`
これらの書類は、雇用契約書です。
この書類を分析し、下記の情報をJSON形式で出力してください。
・雇用開始日
・雇用終了日
出力結果は以下の通り、JSON.parse()メソッドでjson化できる形式で出力してください。
{
"雇用開始日": "yyyy-mm-dd",
"雇用終了日": "yyyy-mm-dd",
...
}
書類から読み取れなかった場合は、日付は空欄のまま返してください。
`
},
...blobs.map((blob, index) => ({
inlineData: {
mimeType: blob.type,
data: base64Blobs[index],
},
})),
],
},
],
};
const requestMethod = 'POST';
const requestOptions = {
method: requestMethod,
headers: requestHeaders,
body: JSON.stringify(requestBody),
};
let data;
// ↓リクエストを送ります。
const response = await fetch(geminiUrl, requestOptions);
if (!response.ok) {
console.error(`Error: Gemini API returned status ${response.status}`);
console.error(response)
return {
statusCode: response.status,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: `Error from Gemini API` }),
};
}
// レスポンスを扱いやすい形に加工
data = await response.json();
const text = data.candidates[0].content.parts[0].text;
console.log("text: ", text);
const jsonString = text.replace(/```json/g, '').replace(/```/g, '').trim();
const parsedText = JSON.parse(jsonString)
// Geminiに読み取ってもらった雇用終了日を、kintoneに保存します。
if (parsedText.雇用終了日) {
const client = new KintoneRestAPIClient({
baseUrl: process.env.xxx,
auth: { apiToken: process.env.xxx },
});
try {
await client.record.updateRecord({
app: appIds.xxxxxx,
id: userInfo.$id.value,
record: {
contract_expiry_date: {
value: parsedText.雇用終了日,
},
},
});
console.log(`レコード${userInfo.$id.value}の雇用契約書_有効期限を、${parsedText.雇用終了日}にアップデートしました。`);
} catch (error) {
console.error('Error updating kintone record:', error);
return {
statusCode: 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'Error updating kintone record' }),
};
}
}
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'Successful' }),
};
}
【結果・考察】
まだシステムを作成しただけの段階で、実際にスタッフの方に使っていただくのはもう少し先になりそうです。引き続き、プロンプトの調整やスタッフとのMTGを重ねていきながら、より効果的なシステムにできればと思っています。
今回はkintoneとGeminiをLambdaで連携させましたが、GeminiAPIでの文書・写真解析は他にもいろんな場面で活用できそうだと感じました。例えば、「GoogleドライブからGASでPDFを取ってきて、Geminiに解析してもらう→解析結果からGASでPDFをリネームする」等々。引き続きAIを活用した業務効率化に尽力してまいります🔥