LoginSignup
1
0

問い合わせフォームの内容をシステム入れ替えをしないでAIでいい感じにしてAsanaに投入する

Last updated at Posted at 2024-05-27

なにをしたいか

  • 日々来る問い合わせフォームで効率よく処理をしたい
  • タスク管理ツールで管理をしたい

できたもの

特定のメールアドレスに文章を投げるといい感じでAIがサマライズしてAsanaにタスクを作ってくれるサービス
image.png

Asanaに作られたタスク例

image.png
image.png
image.png

※当たり障りないように営業系のスパムっぽいやつをピックアップ

いままで

  • Wordpressのフォーム系プラグインからSlackにメールで投稿
  • Slack上でメンションつけて担当にふる

問題点

  • パッと見で何かわからななくて中を見ないとわからない
  • これは処理中なのかどうかはスタンプつけたりして確認なので古いやつがどうなったか不明なままになっている

本来なら

  • Zendeskなどのサービス導入

内容

AsanaにプロジェクトをつくりWebhookの設定

  • タスクを作るプロジェクト作成
  • そこに対してWebhookの設定

詳しくはこちら
https://developers.asana.com/docs/webhooks-guide

AmazonSESにメールを受信できるように設定

  • 問い合わせ用のメールを受けるサブドメインを作る社内手続き
  • そのドメインのDNS設定
  • SESのもろもろ設定しメールのデータをS3に保存させる

詳しくはこちら
https://dev.classmethod.jp/articles/ses-email-receive-tokyo/

メール内容を保存するS3の作成

  • S3にファイルが作られたらLambdaを呼び出す設定
    ※AmazonSESからLambdaを直接起動させると本文が取れないため一時保存

Bedrockの設定

  • BedrockのClaudeモデル使うためにAWSに対して使用申請
    ※今回はClaude-v2:1を利用

Lambdaのコードを書く

※以下のコードは一部改変してあり動いてるコードとは違います
※そのため動作確認していないためコピペしてもエラーする可能性があります

index.mjs
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { BedrockRuntimeClient, InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime";
import { simpleParser } from 'mailparser';
import https from 'https';

import words from "./Words.js";
import asana from "./Asana.js";

const s3Client = new S3Client({ region: '(S3のリージョン)' });
const bedrockClient = new BedrockRuntimeClient({ region: '(Bedrockのリージョン)' });

export const handler = async (event) => {

  let s3Datas = [];

  // S3 event
  try {
    for (let i in event.Records) {
      if (event.Records[i].eventSource != "aws:s3") {
        continue;
      }
      s3Datas.push({
        bucket: event.Records[i].s3.bucket.name,
        key: event.Records[i].s3.object.key
      });
    }
  }
  catch (e) {
    console.error(e);
  }

  // get s3

  for (let d of s3Datas) {
    let mailData, aiData;

    let isError = false;

    // メールデータを抽出
    try {
      mailData = await getMailData(d.bucket, d.key);
    }catch (e) {
      console.error(e);
    }
    if (!mailData) continue;

    try {
  	  // AIに分析かける
      aiData = await getAiData(mailData.mailBodyText);
  	  // Asanaにタスクを作る
      let asanaTaskData = createAsanaTaskData(mailData,aiData);
      let res = await sendAsanaTask(asanaTaskData);
      // 正しくタスクが出来たらメールデータを完了済みに
      await completeMoveMailData(res);
    }catch(e){
      console.log(e);
      // エラーが発生した場合は通知しリトライ対象へ
      await taskCreateError(e);
    }
  }

  const response = {
    statusCode: 200,
    body: JSON.stringify('ok'),
  };
  return response;
};

// streamのテキストを文字列に
async function streamToString(stream) {
  return new Promise((resolve, reject) => {
    const chunks = [];
    stream.on('data', (chunk) => chunks.push(chunk));
    stream.on('error', reject);
    stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
  });
}

// s3のバケットからメールのテキストを抽出し一気に成形
async function getMailData(bucket, key) {

  let command = new GetObjectCommand({
    Bucket: bucket,
    Key: key
  });
  const response = await s3Client.send(command);
  const bodyContents = await streamToString(response.Body);
  const parsed = await simpleParser(bodyContents);

  let fromMails = [];
  let toMail = parsed.to.text;
  let attachmentsFileNames = [];
  for (let i = 0; i < parsed.from.value.length; i++) {
    fromMails.push(parsed.from.value[i].address);
  }
  for (let i = 0; i < parsed.attachments.length; i++) {
    const attachment = parsed.attachments[i];
    attachmentsFileNames.push(attachment.filename);
  }
  return {
    fromMails: fromMails,
    toMail:toMail,
    subject: parsed.subject,
    attachmentsFileNames: attachmentsFileNames,
    mailBodyHtml: parsed.hmtl,
    mailBodyText: parsed.text
  };
}

//----------------------------------------------
// Beadrock
async function getAiData(userPrompt) {
  const systemPrompt = "あなたは文章の判定と要約を行う優秀な秘書です。以下の文章を他社からの営業や求人サイトからの文章の場合はsales、投資などの話の場合はinvestment、求人への応募に関してはrecruit、そのほかのサービス紹介などの問い合わせはnoneと判定しtypeという値にいれ、20文字以内の概要をsummaryという値に、この文章をメールで返信する文章例を200文字以内のreplyとして考えてください。返答は必ずJSON形式のみで返し前後のスペースなどを含まず{\"type\":xxxx,\"summary\":xxxx,\"reply\":xxxx}という形にしてください。";
  const dynamicPrompt = `Human:${systemPrompt}\n\n${userPrompt}\n\nAssistant:`;

  const input = {
    "modelId": "anthropic.claude-v2:1",
    "contentType": "application/json",
    "accept": "*/*",
    "body": JSON.stringify({
      "prompt": dynamicPrompt,
      "max_tokens_to_sample": 500,
      "temperature": 0.7
    })
  };

  try {
    const data = await bedrockClient.send(new InvokeModelCommand(input));
    const parsedData = JSON.parse(Buffer.from(data.body).toString('utf8'));

    let resString = parsedData.completion;
    let startIndex = resString.indexOf("{");
    let lastIndex = resString.lastIndexOf("}");

    // 返答に余計な文字列がある場合に対応
    const jsonString = resString.substring(startIndex - 1, lastIndex + 1);

    let replyBodyJson = JSON.parse(jsonString);
    return replyBodyJson;

  }
  catch (e) {
    console.log(e);

  }
}

//----------------------------------------------
// asana send data
function createAsanaTaskData(mailData, aiData) {

  let mailBodyText = mailData.mailBodyText;
  let summary = undefined;
  if(aiData){
    summary = aiData.summary;
  }
  
  const nowDate = new Date();
  nowDate.setHours(nowDate.getHours() + 9); // 日本時間にあわせる
  const dueDate = new Date();
  dueDate.setHours(dueDate.getHours() + 9 + 6); // 日本時間にあわせる+18時以降は次の日扱い
  const nowText = nowDate.getFullYear() + "-" + ("0" + (nowDate.getMonth() + 1)).slice(-2) + "-" + ("0" + nowDate.getDate()).slice(-2);
  // 締め切りを設定
  let limitNum = 3;
  if (dueDate.getDay() == 0) {
    limitNum += 1;
  }
  else if (dueDate.getDay() == 6) {
    limitNum += 2;
  }
  else {
    if (dueDate.getDay() + limitNum >= 6) {
      // 土日を挟むので+2
      limitNum += 2;
    }
  }

  dueDate.setDate(dueDate.getDate() + limitNum);
  const dueText = dueDate.getFullYear() + "-" + ("0" + (dueDate.getMonth() + 1)).slice(-2) + "-" + ("0" + dueDate.getDate()).slice(-2);

  let assigneeId, titleId, levelId, levelNames = [],
    wordList = [];

  if(aiData && aiData.type){
    wordList.push(aiData.type);
  }


  let titleData;
  for (let key in asana.titleToData) {
    if (mailBodyText.indexOf(key) > 0) {
      titleData = asana.titleToData[key];
    }
  }
  // タイトルのIDがあった場合はアサインが決まる
  if (titleData) {
    titleId = titleData.id;
    assigneeId = asana.assigneeToId[titleData.assignee];
  }

  // キーワードをチェック
  for (let key in words.message) {
    const pattern = new RegExp('(' + words.message[key] + ')', 'g');
    const regResult = mailBodyText.match(pattern);
    if (regResult && regResult.length > 0) {
      // メッセージの内容で重要度のレベルが変わる
      levelNames.push(key);
      // ヒットしたものはワードリストに追加
      for (let w of regResult) {
        wordList.push(w);
      }
    }
  }

  // メアド検索
  for (let key in words.mail) {
    const pattern = new RegExp('(' + words.mail[key] + ')', 'g');
    let regResult = mailBodyText.match(pattern);
    if (regResult && regResult.length > 0) {
      // メールアドレスによって重要度のレベルに影響
      levelNames.push(key);
    }
    
    for(let i in mailData.fromMails){
      regResult = mailData.fromMails[i].match(pattern);
      if (regResult && regResult.length > 0) {
        // メールアドレスによって重要度のレベルに影響
        levelNames.push(key);
      }
    }
  }

  if (levelNames.length > 0) {
    if (levelNames.indexOf("high") != -1) {
      levelId = asana.levelToId["high"];
    }
    else if (levelNames.indexOf("middle") != -1) {
      levelId = asana.levelToId["middle"];
    }
    else if (levelNames.indexOf("low") != -1) {
      levelId = asana.levelToId["low"];
    }
  }

  const outWordList = wordList.filter((element, index) => wordList.indexOf(element) === index);

  let notes = mailBodyText;
  notes =  "Subject: "+mailData.subject+"\n-----\nFrom: "+mailData.fromMails.join(",")+"\n============\n\n"+notes;

  if(aiData && aiData.reply){
    notes+="\n\n\n------------------------\n以下AIによる返信例文\n------------------------\n"+aiData.reply;  
  }

  return {
    "data": {
      "name": summary?summary:"問合せシステムからのタスク",
      "due_on": dueText,
      "projects": [
        "(AsanaのプロジェクトID)"
      ],
      "html_notes": '<body>'+notes+'</body>',
      "assignee": assigneeId,
      "custom_fields": {
        "(Asanaで付け足した属性ID 1)": levelId,
        "(Asanaで付け足した属性ID 2)": outWordList.join(","),
        "(Asanaで付け足した属性ID 3)": titleId,
        "(Asanaで付け足した属性ID 4)": { "date": nowText }
      }
    }
  }
}

// asanaに送信
async function sendAsanaTask(postData) {
    let postDataStr = JSON.stringify(postData);

    let options = {
        host: "app.asana.com",
        path: "/api/1.0/tasks",
        method: 'POST',
        headers: {
            'Authorization': 'Bearer (Asanaで表示されたAuth情報))',
            'Content-Type': 'application/json',
            'Content-Length': Buffer.byteLength(postDataStr)
        }
    };
    return new Promise((resolve, reject) => {
        let req = https.request(options, (res) => {
            let data = ''
            res.setEncoding('utf8');
            res.on('data', (chunk) => {
                data += chunk
            })
            res.on('end', () => {
                resolve(data)
            })
        });
        
        req.on('error', (err) => {
            reject(err)
        });
        req.write(postDataStr);
        req.end();
    })
}
Word.js
module.exports = {
    mail:{
        high:".co.jp"
    },
    message:{
        high:"テレビ取材|ラジオ出演|制作会社",
        low: "新規営業|営業代行|アポイント|経営支援|紹介|採用|受託|業務委託|請負|派遣|補助金|成長|グロース",
    }   
}
Asana.js
module.exports = {
    "titleToData":{
        "製品":{"id":"(Asanaの選択肢ID a1)","assignee":"yamada"},
        "サービス":{"id":"(Asanaの選択肢ID a2)","assignee":"suzuki"},
        "採用情報":{"id":"(Asanaの選択肢ID a3)","assignee":"wakasa"},
        "その他":{"id":"(Asanaの選択肢ID a4)","assignee":"wakasa"}
    },
    "levelToId":{
        "high":"(Asanaの選択肢ID b1)",
        "middle":"(Asanaの選択肢ID b2)",
        "low":"(Asanaの選択肢ID b3)"
    },
    "assigneeToId":{
        "yamada":"(AsanaユーザのID c1)",
        "suzuki":"(AsanaユーザのID c2)",
        "hogehoge":"(AsanaユーザのID c3)",
        "wakasa":"(AsanaユーザのID c4)"
    }
}

注意

  • AsanaのAPIの癖
    • カテゴリとかはIDを指定しないといけないんだけどそのIDがわからんのでWeb画面でダミーで作ったものをAPIで読み込んでこれがこれか?とマッチングする(他に方法あるのかわからん
    • html_notesはで囲まないとだめ。HTMLも一部しか対応してないから要注意
  • SESのメール受信が正しくできてるかとかDNSが伝播したのか?とかチェックが非常にめんどくさい。特にGmailから送ってもメールのセキュリティにひっかかって届かないとかあった
  • プロンプトがいまいち決まらない。現状のだとたまに「はい。JSONで返します。{xxxxx}」とかテキストが付いてくることがあるのでそれが気づくまでハマる

まとめ

  • Webサイト側でメール送信をしていたので何も改修無く動かすことができた
  • タイトルがいい感じにサマリーで出ると一覧性がめっちゃいい。会社名とか出るのもわかりやすい。
  • メールにまとめたためメールでダイレクトにくる問い合わせににも対応できた
  • 最初はWordpressからWebhookでダイレクトに受けるとLambdaで受けて非同期でLambda立ち上げてみたいなのを作っていたが色々面倒だったのをメールにすると気にしなくてもよくなった
  • Asanaで内容に応じてタスク割り当てするようにしたけど結局使われなかった(涙)
  • タグがいい感じになるよう頑張ったけど後で手動で付けた方がよさそうだった(涙)
  • なんとなくいけるかもって返信文例を付けたけどやっぱりいらなかったな…
1
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
1
0