AIを駆使してSlackだけで課題管理が完結できちゃうBOTをOSSにして公開しました

まともにOSS活動をするのははじめてなので、とても緊張しています。よければご意見などくだされば嬉しいです。

https://github.com/maiko-ando/AITaskManager

以下、作成をした理由などについて書かせていただきます。

# 課題管理に感じるペイン
課題管理。ちゃんとしなくてはいけないとわかりつつ、きちんとした運用をしようとすると非常にリソースとコストが掛かる、チーム運営上のジレンマになりやすい要素です。また、世の中にはプロジェクトや課題管理のための色々なツールが存在していますが、前述の運用コストの高さのペインを取り払いきれたものは今のところ自分は存じておりません。

### 自社ではどうやって課題を管理しているのか
自分は半月ほど前に10年ぶりに会社員になったのですが、仕事をすすめる上で自社内の各部署でどのように課題管理が行われているか調査したところ、黄ばんだTrello、閑古鳥が鳴いているBacklog、めちゃめちゃに膨大になったSpreadsheet、しかもそれらがチームや部署ごとに存在しているという、ちょっと大変な状況になっていることがわかりました。

# 課題管理を楽に・形骸化しない形で実現したい
いちいち課題管理ツールを開いて、そこに適切な概要文をフォーマット通りに記載して、適切な内容を記録して…終了したかを管理して…というのを、全メンバーにやってもらいつつ高い精度を求めるというのは結構無理筋ではないかと考えます。それが部署が異なったりする場合、文脈や文化が違うため尚更です。

課題管理を楽に・形骸化しない形で実現したいというお題はちょっと前までは理想論でしたが、今現在なら過去に人類が夢見たようなAIが存在し、しかもAPIがあります。OpenAIに感謝。

# Slack BOTにメンションするだけで課題管理を完結させる
いちいち課題管理ツールを開いて、そこに適切な概要文をフォーマット通りに記載して、適切な内容を記録して…終了したかを管理して…というのを、全メンバーにやってもらいつつ高い精度を求めるというのは結構無理筋ではないかと考えます。それが部署が異なったりする場合、文脈や文化が違うため尚更です。

そもそもまず課題管理ツールを開いてもらうことすらハードルが高いと考えられます。
のであれば、平時業務で使っているツール、Slackに自然な文脈で入れられるようにして、人々は課題管理ツールを開く必要さえない状態にしてしまえば良いのではないでしょうか。

ということを踏まえ、こういう感じのBOTを作成しました。
![起票_AdobeExpress_AdobeExpress_AdobeExpress.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7193/5ecd142f-616e-69f6-7e03-0f04f2f82409.gif)


# 機能説明
リポジトリのREADMEと同様の内容を記載します

## 起票機能
BOT に「起票して」など起票を促すメンションを行うことで起動します。 スレッドの会話の流れから行うべきタスクに関連する人物、行うべき作業、考慮すべき事項、現在の状況、期限、起票元の Slack のスレッドを判別できる範囲で解析し、その内容を Issue として自動起票します。 また、関連する人物をラベル付けします。

コマンド実行時にスレッド内ですでに起票している投稿が確認出来る場合は起票処理されません。
<img width="1049" alt="スクリーンショット 2023-04-18 18 59 57" src="https://user-images.githubusercontent.com/13215919/232743049-cfb79849-bd6d-4dae-9b9d-3ed86d72613e.png">


## 経過記録機能


BOT に「記録して」など記録を促すメンションを行うことで起動します。
スレッド内で起票しているログが確認できる場合、起票または経過記録以後に行われた会話から
現在の状況、追加された関係者、追加で行うべき作業、考慮すべき事項についてを判別できる範囲で解析し、Issue にコメントします。
<img width="1040" alt="スクリーンショット 2023-04-18 19 00 48" src="https://user-images.githubusercontent.com/13215919/232743274-5a0d9b62-a7b3-4f69-846e-09ae51f43301.png">

## 終了機能

BOT に「終了した」など終了をしたことを伝えるメンションを行うことで起動します。
スレッド内で課題に対するやり取りから、終了に至った経緯を判断し Issue へコメントを行ってクローズします。

<img width="1045" alt="スクリーンショット 2023-04-18 19 04 15" src="https://user-images.githubusercontent.com/13215919/232744320-66786b50-dcb9-4e1b-a4aa-7bd06ee63bd4.png">

## まとめ機能（すでに会話が終わっているスレッドについてをまとめて課題として記録）


すでに会話が終了してている過去のスレッド向けの機能です。やり取りをタスクとしてログ化することを目的としています。
BOT に「まとめて」などまとめを示唆するメンションを行うことで起動します。
スレッド内で起票されたログがない場合、そのスレッド内の会話からタスクに関連する人物、行われた作業、現在の状況、特記事項を判別できる範囲で解析し、その内容を Issue として起票して同時にクローズします。
もしスレッド内で起票されたログがある場合、経過記録と同じ挙動となり記録コメントが Issue に投稿されます。

<img width="1584" alt="スクリーンショット 2023-04-18 18 36 04" src="https://user-images.githubusercontent.com/13215919/232744429-33522333-b51e-41b3-bcab-dd01fa267e8d.png">


# 実装内容
実装内容やプロンプトについてをちょっと解説します。
※今回の実装ではGPT-4を使用しているのですが、GPT-3.5だと結果が期待通りにならない可能性があります。

## どのようにIssueと概要文を作成しているか
Slackのスレッドの会話内容を `【発言者の名前】: 【発言内容】` の名前でまとめて文字列にし、conversationとして引数に渡します。
ただ、この方法で行っているので**Slackの表示名をコロコロ変える人がいる場合うまく対応できなくなる**という問題があるのですが…いや、それ問題か？コロコロ変えんじゃねえという割り切りのもと無視しています。
Slackの会話内容をOpenAIのAPIに問い合わせるプロンプト内に差し込み、その内容から所定のフォーマットに沿って流し込みをするような形で指示しています。
また、BOTの発言とBOTに対するメンションは無視します。

```md:スレッドの内容を文字列として整形
'\n' +
'あんどサブ: 先方に確認してるとこです\n' +
'\n' +
'\n' +
'あんどサブ: 返事きました、来週の月曜に初稿予定らしいです\n' +
'\n' +
'\n' +
'あんどサブ: 初稿ざっくり確認しました、山田さんにも確認してもらわないといけないですね\n' +
'ampersand_xyz: 山田さんのスケジュールどうなってる？\n' +
'あんどサブ: 明日出社してくるらしいので、朝イチで確認してもらいます\n' +
'\n' +
'\n' +
'あんどサブ: 良さそうでした！これベースで作業進めます\n' +
'ampersand_xyz: OK!ありがとう！\n'
```

```js:createIssue.js
import { Configuration, OpenAIApi } from "openai";
import { Octokit } from "@octokit/rest";
import { postSlackMessage } from "./postSlackMessage.js";

const openaiConfig = new Configuration({ apiKey: process.env.OPENAI_API_KEY });
const openaiClient = new OpenAIApi(openaiConfig);

// issueを作成する
export const createIssue = async ({ thread_ts, replies, channel, ts, slackThreadUrl, conversation, repository }) => {
  // 「起票しました https://github.com/xxxx/xxxx/issues/1」 のようなメッセージにマッチする正規表現
  const issueMessageRegex = /起票しました <https:\/\/github.com\/.*\/.*\/issues\/\d*>/;
  // 正規表現に当てはまるメッセージを取得する
  const issueMessage = replies.messages.find((message) => message.text.match(issueMessageRegex));

  // 起票しましたのメッセージが見つかった場合はSlackにメッセージを投稿して処理を終了する
  if (issueMessage) {
    // メッセージに含まれるissueのURLを取得する
    const issueUrl = issueMessage.text.match(/<(.*)>/)[1];
    await postSlackMessage(channel, thread_ts, `このスレッドではすでに起票が行われています。${issueUrl}`);
    return;
  }

  const openaiResponse = await createIssueDescription(conversation);

  // 作成したタスクの内容をgithubのissueに登録する
  const octokit = new Octokit({
    auth: process.env.GITHUB_TOKEN,
  });
  // タイトルを取得する
  const title = openaiResponse.match(/# (.*)/)[1];
  // ボディを取得する
  // ボディの内容に「, undefined」が含まれている場合は削除する
  const taskBody = openaiResponse.replace(/# (.*)/, "").replace(/, undefined/g, "");

  // オーナー名
  const owner = repository.split("/")[0];
  // リポジトリ名
  const repo = repository.split("/")[1];

  const appendSlackUrlBody = `
${taskBody}

## 起票元のSlackスレッド
${slackThreadUrl}
  `;

  // issueを作成する
  const issue = await octokit.issues.create({
    owner: owner,
    repo: repo,
    title: title,
    body: appendSlackUrlBody,
  });

  // ラベルを取得する
  const label = taskBody
    .match(/## 課題の関係者\n(.*)/)[1]
    .split("\n")
    // @と空文字を削除する
    .map((label) => label.replace(/@/, "").replace(/ /g, ""))
    .join("");

  // 句読点でlabelを分割する（担当者名をラベルにする）
  const labels = label.split(",");

  // labelsに空文字列が含まれている場合は削除する
  labels.filter((label) => label !== "");

  // issueにラベルを複数設定する
  await octokit.issues.addLabels({
    owner: owner,
    repo: repo,
    issue_number: issue.data.number,
    labels: labels,
  });

  // issueのURLを取得する
  const issueUrl = issue.data.html_url;
  return issueUrl;
};

// 会話の内容からgithubのissueの概要分を作成する
async function createIssueDescription(conversation) {
  const prompt = `
これから以下のフォーマットで業務に関する会話の記録を渡します。

ーーー
・フォーマット
【発言者の名前】: 【発言内容】

・会話の記録
${conversation}
ーーー

以上の会話を踏まえて、以下のようなフォーマットでタスクを起票する文章を書いてください。
会話の内容から分からない部分は「不明」、特に存在しない場合は「特になし」として記載してください。

ーーー
# 【このタスクにつけるべきタイトル】

## 課題の関係者
【この部分に会話の記録からこの課題に関係している人を半角のカンマ区切りで記載】

## 行うべき作業
【タスクを実行するために行うべき作業をマークダウンの箇条書きで記載。行頭にチェックボックスを付与する。】

## 考慮すべき事項
【タスクを実行するために考慮するべき事項を箇条書きで記載。特になければこの項目は不要】

## 現在の状況
【会話の内容から誰が何をしている状態かを記載。特になければ特記事項なしとして記載。】

## 期限
【会話の内容からいつまでに行うべきか判断できれば記載。判断できなければ未定として記載。】
`;

  try {
    const response = await openaiClient.createChatCompletion({
      model: "gpt-4",
      messages: [{ role: "user", content: prompt }],
    });
    return response.data.choices[0].message?.content;
  } catch (err) {
    console.error(err);
  }
}

```



## コマンドの判断
メンションされた内容に対して何をすべきなのかは以下のような関数を作成しています。
多少メンションされた内容がブレても動作するようにしていますが、固定の文字列をトリガーにするほうがよかったかもしれません:thinking: 

```js:getAction.js
import { Configuration, OpenAIApi } from "openai";
const openaiConfig = new Configuration({ apiKey: process.env.OPENAI_API_KEY });
const openaiClient = new OpenAIApi(openaiConfig);

// ユーザーの発言内容から、何をしてほしいか類推して行うべき内容を返す
export const getAction = async (text) => {
  return await judgeCommand(text);
};

// 会話の内容からgithubのissueの概要分を作成する
async function judgeCommand(conversation) {
  const prompt = `
以下の発言は下記3つのコマンドのどれかに当てはまるか判断してください。
・「起票」：新しく課題を作成したい
・「記録」：課題に記録用のコメントをしたい
・「終了」：課題をクローズしたい
・「まとめ」：経緯をまとめたい

ーーー
${conversation}
ーーー
判断ができたら、そのコマンドを答えてください。
必ず「起票」、「記録」、「終了」、「まとめ」のいずれかを答えてください。
    `;

  try {
    const response = await openaiClient.createChatCompletion({
      model: "gpt-3.5-turbo", // ここは節約のためにgpt-3.5-turboを使用する
      messages: [{ role: "user", content: prompt }],
    });
    console.log(response.data.choices);
    return response.data.choices[0].message?.content;
  } catch (err) {
    console.error(err);
  }
}

```

取得したコマンド文字列は必ずしもコマンド文字列ではなく `起票` `起票です` `起票コマンドです` など、ちょっとブレることがあるので、判断に使うときはコマンドと指定した文言が含まれているかどうかなど揺らぐことを前提で分岐します

```js
const action = await getAction(text);
// textに「起票」という文字が含まれているか
if (action.includes("起票")) {
  console.info("新規issueを作成する");
  // 処理内容...
}
...
```

### TIPS
#### プロンプト内の「半角のカンマ区切り」という指定
最初は「句読点で区切る」という指示にしていたのですが、関係者の表示名がすべて日本語なら「、（句読点）」で区切られるのですが、半角英字の場合「,（カンマ）」で区切ってしまうというゆらぎの挙動などがあり、明確にセパレータとして使用すべき文字を伝えてあげなくては安定しませんでした。ここはちょっとAIを相手にしてるな〜という気持ちになって新しい感覚でした。

#### Slackのユーザー名、取得安定しない問題
https://api.slack.com/methods/users.profile.get
Slackのユーザー情報の取得APIなのですが、名前に関するプロパティがたくさんあります。
`display_name`か`real_name`を使用するのですが、なぜかどっちもUndefinedとして取得してしまうことがあるうえ、取得できたりできなかったりという意味のわからない挙動をします。本当、マジ、意味わからん。解決しなかったので誰かいい方法を教えて下さい・・・。


# 運用の検証はこれから
さて、こういうBOTを作ってはみたんですが、うまくワークするかはこれからの検証になります。
うまくいくと良いな〜〜！！そしてタスク管理とか実績の評価とか他部署間のやり取りとか全部全部円滑になってくれ〜〜！！！！！


## 謝辞
このBOTは社内ハッカソンイベントがあり、そこで1日で大枠が作成できたのですが、社としてOpenAIのAPIが触れる環境やGithub Copilotが使えるようにしてくれていたこと、AIを使った問題解決を積極的に楽しんでいる上長や周りのメンバーがいたこと、課題解決をしたいという自分の割と勝手な意見とか口出しに本当に周りの人たちが協力的に話を聞いてくれたり良いリアクションをくれたことによってモチベーションを得て開発することができました。

また、デバッグや改善意見をくださった [運営者ギルド](https://qiita.com/organizations/admin-guild)の皆さんにも圧倒的感謝を申し上げたい次第です。

ここまで読んでくださりありがとうございました。