🐈

僕の上司はTypeScript/LangChain/LangGraphでできている~僕の上司開発記~

に公開

皆さん上司や同僚、後輩はいますか?
一度デバックをするような鋭い目でこの記事を見るのをやめて、朗らかな笑顔で周りを見渡してみてください。可能ならこの記事をその顔のままお読みください。

さて、全然関係ない前振りを横におきまして、
皆さんは一度は経験してみたいシチュエーションというのはありますか?
学生時代に思いを馳せてもよいでしょう。
新入社員で入社したばかり、仕事で失敗して同僚と居酒屋で。なんてのもいいでしょう

私も色々ありますが今回はその一つを無理矢理叶えたいと思っています。
という事で、今回はもし上司がいればしてほしかった憧れナンバー1のあれをTypeScript/LangChain/LangGraphでAI上司(以下AI-Boss)を作り実現しようというものです!

開発したもの

今回、私が開発したのが「CLIベースで動作するあなたが書くと成長できる技術記事タイトル提案AIアプリ」です
おそらく「どこが上司やねん!」と多くの人が思ったと思います

皆さんにとっての上司とはどんな人ですか?
口うるさく叱責する人?
優しく陰でサポートしてくれる人?
飲み会で性格が変わる事から怪人二面相と呼ばれている人?

私の想像上の上司はかなり美化されてますのでご了承ください
私のイマジナリー上司はプログラマーとして成長するヒントを与えてくれる人です
例えば、「TypeScriptについてより深く勉強したいんですけど、今の僕は何を勉強したらいいですかね。。」のように話した時に「お前〇〇って知ってるか?知らないなら勉強して損はないぞ。(スタスタスタ)」みたいに、私の知らないTypeScriptの技術や概念だけそっと置いてどこかに立ち去る....
かっけーーーーーー
※最近龍が如く7をプレイしているせいで憧れが若干親分と子分の関係に...

知らない技術や概念をプレゼントしてくれる
これが僕にとって憧れナンバー1の上司にしてほしいことです!

という事で、これをどうにか実現したいという思いで開発しました

※記事の要素はどこいったんじゃワレ!!と怒らないでください...プログラマーが勉強っていったら、Outputのために記事投稿する。が思いついたのでその要素を入れただけなんです。今の私は無罪です。考えるのを放棄した昔の私に言弾をお願いします

Demo

まずはDemo動画をどうぞ!
※このDemoには乗っていませんが、AI Agentの内部のやり取りを視覚化したHTMLも裏で生成されています
https://youtu.be/UOMa7Sa5s-8

基本的な動作フロー

以下の流れに沿って記事タイトルを提案するcliアプリを開発しました

  1. npm run dev -- suggestでcliツール起動
     ※個人用だからコマンドのようにしなくてもいいかなと思って開発時のコマンドのままです..
  2. 気になる技術をユーザが入力する
  3. 今後作成するペルソナに入れ込みたい技術要素を選択する
  4. 今後作成するペルソナに入れ込みたくない技術要素を選択する
  5. ユーザが入力した内容についてプロフェッショナルなペルソナを5人作成する
  6. それぞれのペルソナにどんな技術記事があれば成長できるのかを効率的に聞くための質問文を作成する
  7. それぞれのペルソナに質問に対する答えを作成してもらう
  8. ペルソナの答えた内容が記事タイトルを提案するのに十分か評価する
  9. 記事タイトルを5つ出力し、ユーザに選択してもらう
  10. 選択した記事タイトルをもとに記事で書いたら自身の勉強になる項目を作成してもらう
  11. いままでのペルソナや質問、回答などをHTML Templateに埋め込みHTMLで後々確認できるようにする

記事タイトルを作成するだけでなぜこんなめんどくさい事を?と思う方もいると思いますが、疑似的に上司を作り上げているためです。
つまり、私の気になるトピックについて専門的な知識を有するペルソナをLLMに5人生成してもらい、
その5人に私は〇〇という技術に興味あるのですが、何を勉強すればよいですか?と聞きます
そして、その5人はそれぞれの設定されたペルソナに基づいてそれぞれの解答をします。
この流れはまさしく、私が憧れてた知らない言葉ポン!ではないでしょうか!

ちなみに、実際はその回答を基に再度LLMにこの回答を基におすすめの記事タイトルを教えて。と伝えてその結果がユーザには表示されます

こうすることで、私がLLMと直接会話するよりも、疑似的ではありますが高度な専門性を有したペルソナとLLMが会話をして記事タイトルを模索するので、知らない言葉や深い洞察が得られる可能性が上がっています。。。そのはず、、、

技術スタック

今回はCLIアプリをTypeScriptで作る為、commanderを使用しました
内部ではAI Agentの仕組みを入れるためLangChainとLangGraphも使用しています
また、毎日使う前提として以前採用した記事タイトルと重複したものが表示されないようにしたいので、採用した記事を保存するための簡易DBとしてlowdbを使用してます。

フォルダ構造

ai-boss/
├── db/
│   └── logs.json
├── src/
│   ├── commands/
│   │   └── suggest.ts 
│   ├── services/
│   │   ├── storage.ts
│   │   └── visualize.ts
│   └── index.ts
├── .env
├── .gitignore
├── interviewResult.html
├── package-lock.json
├── package.json
└── tsconfig.json

今回は個人用という事もあり、シンプルな構成にしています
suggest commandが実行されたらsuggest.tsの処理が実施されるイメージです
storage.tsはlowdbの初期化やCRUD操作をまとめています
visualize.tsは最終的にAI Agentのやり取りをHTMLで出力して後から確認できるようにしているのですが、そのHTMLを生成する処理を記載しています

実装

それでは実装の解説に入ります
今回のメインはTypeScriptでのLangChainとLangGraphのため、lowdb周りについて解説を簡略化しています

storage.ts

と言いながら、いきなりlowdb周りの処理を見るという救えなさ...

 import { join } from 'path'
import { Low } from 'lowdb'
import { JSONFile } from 'lowdb/node'

export type HistoryEntry = {
  data: string
  topic: string
  title: string
  notes: string[]
}

type Schema = {
  history: HistoryEntry[]
}

const dbPath = join(process.cwd(), 'db', 'logs.json')
const adapter = new JSONFile<Schema>(dbPath)
const db = new Low<Schema>(adapter, { history: [] })

export async function initDB() {
  await db.read()
  db.data ||= { history: [] }
  await db.write()
  return db
}

export async function getHisotry() {
  const db = await initDB()
  return db.data!.history
}

export async function addHistory(entry: HistoryEntry) {
  const db = await initDB()
  db.data!.history.push(entry)
  await db.write()
}

export async function getDBPath() {
  return dbPath
}

ここはあまり面白味もないですし、やはり簡単に終わらせましょう。。
initDB関数にてlowdbを初期化してそれぞれのコマンドでは、この初期化関数を呼び出してから各種CRUD操作を行う事になります。
getHistoryは実際に今まで採用した記事タイトルを取得する関数です

.env

OpenAiの公式ライブラリはOPENAI_API_KEYという環境変数名に入っているAPI KEYをデフォルトで参照してくれるので、定義しています

OPENAI_API_KEY=dummy
LANGCHAIN_TRACING_v2=true
LANGCHAIN_ENDPOINT=https://api.smith.langchain.com
LANGCHAIN_API_KEY=dummy
LANGCHAIN_PROJECT=ai-boss

LangChainやLangGraphに慣れていない人は変な環境変数が目に入ると思います
OPEN_AP_KEY以外は全てLangSmithというLLMをデバックする時に便利なサービスを利用するための設定です
LangGraphでは特に沢山のLLMが協調して連続的に動作します
そのため、途中のLLMが何を返したなどの情報は簡単には確認できません、しかしLangSmithを使う事でWeb上でLLMのOutputや使用したトークンなどが見れる為、大変便利です

index.ts

エントリーポイントとなるコードです

import { Command } from 'commander'
import { suggestCommand } from './commands/suggest.js'

const program = new Command()

program.name('ai-boss').description('学習支援CLIアプリ').version('0.1.0')

program.command('suggest').description('学習したい事をもとに記事タイトルを提案').action(suggestCommand)

program.parse()

commanderの記法に則りコマンド情報を登録しています。
commandメソッドに渡している文字列が実際にコマンドとして実行できるようになります。
そして、actionメソッドにはコマンドが指定された際に実際に動く処理の関数を渡しています。
つまり、suggest.tsの処理が動くという事です

suggest.ts

後説明すべきファイルがもう一つvisualize.tsがあるのですが、このファイルはsuggest.tsが最後まで終わった際に格納される値を使うため、先にsuggest.tsを説明致します

ソースコード全量
import { z } from 'zod'
import readline from "readline";
import { ChatOpenAI } from '@langchain/openai'
import { ChatPromptTemplate } from '@langchain/core/prompts'
import { StateGraph, START, END, Annotation } from '@langchain/langgraph'
import inquirer from 'inquirer'
import { getHisotry, addHistory, HistoryEntry } from '../services/storage.js'
import { setMaxListeners } from 'events'
import { StringOutputParser } from '@langchain/core/output_parsers'
import { visualizeInterview } from '../services/visualize.js';
setMaxListeners(30)
const Persona = z.object({
  name: z.string().describe('persona name'),
  background: z.string().describe('persona background')
})
const Personas = z.object({
  personas: z.array(Persona).describe('list of persona')
})

const Interview = z.object({
  persona: Persona.describe('the target that you interview'),
  question: z.string().describe('the question that you ask to the persona'),
  answer: z.string().describe('the answer that you get from the persona')
})
const EvaluationInterviewResult = z.object({
  reason: z.string().describe('the evaluation of your interview result'),
  isSufficient: z.boolean().describe('interview result is enough or not')
})
const ResponseFormatter = z.object({
  title: z.array(z.string()).describe('title of the article')
})

const NoteResponse = z.object({
  note: z.array(z.string()).describe('content in the article')
})

// Annotationを使用した状態定義
const GraphState = Annotation.Root({
  topic: Annotation<string>(),
  personas: Annotation<z.infer<typeof Persona>[]>,
  interviews: Annotation<z.infer<typeof Interview>[]>,
  interviewResult: Annotation<z.infer<typeof EvaluationInterviewResult>>,
  history: Annotation<HistoryEntry[]>(),
  usedTitles: Annotation<string[]>(),
  titleSuggestions: Annotation<string[]>(),
  chosenTitle: Annotation<string>(),
  notes: Annotation<string[]>(),
  completed: Annotation<boolean>()
})

export type ArticleState = typeof GraphState.State

const today = new Date().toISOString().split('T')[0]
const model = new ChatOpenAI({
  temperature: 0,
  modelName: 'gpt-4o'
})

const animateMessage = (baseMessage: string) => {
  let dotCount = 0;
  const interval = setInterval(() => {
    const dots = ".".repeat(dotCount % 4); // 最大3個まで
    readline.clearLine(process.stdout, 0);
    readline.cursorTo(process.stdout, 0);
    process.stdout.write(`${baseMessage}${dots}`);
    dotCount++;
  }, 500);
  return interval;
};
const stopAnimationWithMessage = (
  intervalId: NodeJS.Timeout,
  message: string
) => {
  clearInterval(intervalId);
  readline.clearLine(process.stdout, 0);
  readline.cursorTo(process.stdout, 0);
  console.log(message);
};
// 各ノードの実装
const getTopicNode = async (_: ArticleState) => {
  const history = await getHisotry()
  const usedTitles = history.map(v => v.title)

  const { topic } = await inquirer.prompt({
    type: 'input',
    name: 'topic',
    message: 'What do you want to lean about?'
  })

  return {
    topic,
    history,
    usedTitles
  }
}

const generatePersona = async (state: ArticleState) => {
  const { goodAreas } = await inquirer.prompt([
    {
      type: "checkbox",
      name: "goodAreas",
      message: "what technical area do you interested in",
      choices: [
        new inquirer.Separator(" === Application Types === "),
        { name: "Web Application", value: "web_app" },
        { name: "Mobile App", value: "mobile_app" },
        { name: "Desktop App", value: "desktop_app" },
        { name: "Game Development", value: "game_dev" },
        { name: "Embedded / IoT", value: "embedded_iot" },
        { name: "CLI / Batch Tools", value: "cli_batch" },
        { name: "API / Microservices", value: "api_microservices" },
        { name: "Static/Dynamic Websites (CMS)", value: "cms" },

        new inquirer.Separator(" === Technical Skills === "),
        { name: "Frontend Development", value: "frontend" },
        { name: "Backend Development", value: "backend" },
        { name: "Cloud / Infrastructure", value: "cloud_infra" },
        { name: "DevOps / SRE", value: "devops" },
        { name: "Security", value: "security" },
        { name: "AI / Machine Learning", value: "ai_ml" },
        { name: "Data Science", value: "data_science" },
        { name: "Blockchain / Web3", value: "blockchain" },
        { name: "Testing / QA", value: "testing" },
      ]
    }
  ])
  const { badAreas } = await inquirer.prompt([
    {
      type: "checkbox",
      name: "badAreas",
      message: "what technical area do you not interested in",
      choices: [
        new inquirer.Separator(" === Application Types === "),
        { name: "Web Application", value: "web_app" },
        { name: "Mobile App", value: "mobile_app" },
        { name: "Desktop App", value: "desktop_app" },
        { name: "Game Development", value: "game_dev" },
        { name: "Embedded / IoT", value: "embedded_iot" },
        { name: "CLI / Batch Tools", value: "cli_batch" },
        { name: "API / Microservices", value: "api_microservices" },
        { name: "Static/Dynamic Websites (CMS)", value: "cms" },

        new inquirer.Separator(" === Technical Skills === "),
        { name: "Frontend Development", value: "frontend" },
        { name: "Backend Development", value: "backend" },
        { name: "Cloud / Infrastructure", value: "cloud_infra" },
        { name: "DevOps / SRE", value: "devops" },
        { name: "Security", value: "security" },
        { name: "AI / Machine Learning", value: "ai_ml" },
        { name: "Data Science", value: "data_science" },
        { name: "Blockchain / Web3", value: "blockchain" },
        { name: "Testing / QA", value: "testing" },
        { name: "Architecture", value: "architecture" }
      ]
    }
  ])

  const baseMessage = "ペルソナ作成中";
  const animation = animateMessage(baseMessage);
  const prompt = ChatPromptTemplate.fromMessages(
    [
      [
        "system",
        "あなたはユーザインタビュー用の多様なペルソナを作成する専門家です。"
      ],
      [
        "human",
        `
以下のユーザリクエストに関数インタビュー用に、5人の多様なペルソナを作成してください。\n\n
ユーザリクエスト:{topic}\n\n
各ペルソナには名前と簡単な背景を含めてください。年齢、技術的専門知識において多様性を確保してください。\n
ただし、各ペルソナはITに関連する事柄のプロフェッショナルであることにしてください。\n
返信は日本語でお願いします.\n
以下のペルソナは含めないようにしてください\n
除外するペルソナ: \n
{excludePersona}\n
ペルソナを作成する際は以下の技術的専門知識や職域のものを生成してください。\n
{goodAreas}\n
ペルソナを作成する際は以下の技術的専門知識や職域のものは生成しないでください。\n
{badAreas}\n
`
      ]
    ]
  )
  const chain = prompt.pipe(model.withStructuredOutput(Personas))
  const result = await chain.invoke({
    topic: state.topic!,
    excludePersona: (!state.chosenTitle || state.chosenTitle !== "再度記事タイトルを生成する") ? "除外するペルソナはありません" : state.personas!.map(v => { return `name: ${v.name} - background: ${v.background}` }).join('\n'),
    goodAreas: goodAreas.length === 0 ? "指定はありません" : goodAreas.map((v: string) => { return `${v}` }).join('\n'),
    badAreas: badAreas.length === 0 ? "指定はありません" : badAreas.map((v: string) => { return `${v}` }).join('\n'),
  })
  stopAnimationWithMessage(animation, "✅ペルソナ作成完了")
  return {
    personas: result.personas,
  }
}

const conductInterview = async (state: ArticleState) => {
  let baseMessage = "各ペルソナへの質問を生成中";
  let animation = animateMessage(baseMessage);

  const questionPrompt = ChatPromptTemplate.fromMessages([
    [
      "system",
      "あなたはユーザ要件に基づいて適切な質問を生成する専門家です。"
    ],
    [
      "human",
      `
以下のペルソナに関連するユーザリクエストについて、1つの質問を生成してください。\n\n
ユーザリクエスト: {userRequest}に関連する記事を読んだ際に、表面的ではなく深い洞察が得られると思える記事の内容について\n
ペルソナ:{personaName} - {personaBackground}\n\n
質問は具体的で、このペルソナの視点から重要な情報を引き出すように設計してください。
`
    ]
  ])
  const questionChain = questionPrompt.pipe(model).pipe(new StringOutputParser())
  const quesitonQueries = state.personas.map(v => { return { userRequest: state.topic, personaName: v.name, personaBackground: v.background } })
  const questionResult = await questionChain.batch(quesitonQueries)
  stopAnimationWithMessage(animation, "✅質問作成完了")

  // 回答生成
  baseMessage = "各ペルソナが回答中";
  animation = animateMessage(baseMessage);
  const answerPrompt = ChatPromptTemplate.fromMessages(
    [
      [
        "system",
        "あなたは以下のペルソナとして回答しています: {personaName} - {personaBackground}"
      ],
      [
        "human",
        "質問:{question}"
      ]
    ]
  )
  const answerChain = answerPrompt.pipe(model).pipe(new StringOutputParser())
  const answerQueries = questionResult.map((v, i) => { return { personaName: state.personas[i].name, personaBackground: state.personas[i].background, question: v } })
  const answerResult = await answerChain.batch(answerQueries)

  const interviewResult = []
  for (let i = 0; i < state.personas.length; i++) {
    interviewResult.push({
      persona: state.personas[i],
      question: questionResult[i],
      answer: answerResult[i],
    })
  }

  stopAnimationWithMessage(animation, "✅ペルソナへのインタビュー完了")
  return {
    interviews: interviewResult
  }
}

const evaluatInterview = async (state: ArticleState) => {
  const baseMessage = "ペルソナへのインタビュー結果を評価中"
  const animation = animateMessage(baseMessage)
  const prompt = ChatPromptTemplate.fromMessages([
    [
      "system",
      "あなたは技術ブログの記事のタイトルを作成するための情報の十分性を評価する専門家です。"
    ],
    [
      "human",
      `
以下のユーザリクエストとインタビュー結果に基づいて、
書くべき記事のタイトル候補を提案するのに十分な情報が集まったかどうかを判断してください\n\n
返信は日本で返してください。
ユーザリクエスト: {userRequest}\n\n
インタビュー記事:\n{interviewResults}`
    ]
  ])
  const chain = prompt.pipe(model.withStructuredOutput(EvaluationInterviewResult))
  const result = await chain.invoke({
    userRequest: state.topic,
    interviewResults: state.interviews!.map(v => { return `${v.persona.name} - ${v.persona.background}\n質問: ${v.question}\n回答: ${v.answer}` }).join('\n')
  })
  stopAnimationWithMessage(animation, "✅インタビュー評価完了")
  return {
    interviewResult: result
  }
}

const generateTitlesNode = async (state: ArticleState) => {
  const baseMessage = "記事タイトルを作成中"
  const animation = animateMessage(baseMessage)

  const chatPromt = ChatPromptTemplate.fromMessages([
    ["system", `
あなたは収集した情報に基づいて記事タイトルを提案する専門家です。
`],
    ['human', `
以下のユーザリクエストと複数のペルソナからのインタビュー結果に基づいて、私が成長するために今一番書くべき記事のタイトルを5つ提案してください。\n\n
返答は日本語でお願いします。\n\n
ユーザリクエスト: {userRequest}に関連する事項を調査して記事にしたいです。その過程で自分の技術者として成長することと読者に深い洞察を得る機会を提供するのが目的です\n\n
インタビュー結果: \n{interviewResults}\n
提案する5つの記事タイトルは可能な限りユニークで多様性を持たせてください。\n
提案する5つの記事タイトルは表面的ではなく、読者が深い洞察を得られ、作者の自分自身がそれを書くことで技術的に成長できるものにしてください\n
提案する5つの記事タイトルは可能な限り特集のようなものではなく一つの技術概念を深堀するようなものにしてください\n
また、私は以下の記事を既に記載しているため、こちらの記事とは重複しないようにしてください\n
{history}\n
また、以下の記事タイトルはユーザが却下しているため、これらの記事とは類似性が低い記事タイトルを生成してください。\n
{excludeTitle}
`]
  ])

  const chain = chatPromt.pipe(model.withStructuredOutput(ResponseFormatter))
  const result = await chain.invoke({
    history: state.history.map(v => { return v.title }),
    userRequest: state.topic,
    interviewResults: state.interviews!.map(v => { return `${v.persona.name} - ${v.persona.background}\n質問: ${v.question}\n回答: ${v.answer}` }).join('\n'),
    excludeTitle: !state.titleSuggestions || state.chosenTitle !== "再度記事タイトルを生成する" ? "除外する記事タイトルはありません" : state.titleSuggestions!.map(v => { return `・「${v}` }).join('\n')
  })

  stopAnimationWithMessage(animation, "✅記事タイトル作成完了")
  return {
    titleSuggestions: result.title
  }
}

const selectTitleNode = async (state: ArticleState) => {
  const { chosen } = await inquirer.prompt({
    type: 'list',
    name: 'chosen',
    message: 'どのタイトルで記事を書きますか?',
    choices: [...state.titleSuggestions, "再度記事タイトルを生成する"]
  })

  return {
    chosenTitle: chosen
  }
}

const generateNotesNode = async (state: ArticleState) => {
  const baseMessage = "記事を書く際に意識するとよい点を作成中"
  const animation = animateMessage(baseMessage)
  const notePrompt = ChatPromptTemplate.fromMessages([
    ["system", `
あなたは{topic}についての高い専門知識を有する専門家です。
中級エンジニアのユーザはあなたにこれからユーザが記載する記事のタイトルを送ります。
あなたは記事のタイトルをもとに記事の中で書くべき具体的な内容を日本語で5つあげてください。
初心者用の記事ではなく深い洞察が得られる記事にする必要があるため、その点を考慮してください。
記事タイトル: {title}
`],
    ["human", "{topic}"]
  ])

  const chain = notePrompt.pipe(model.withStructuredOutput(NoteResponse))
  const result = await chain.invoke({
    topic: state.topic,
    title: state.chosenTitle
  })

  stopAnimationWithMessage(animation, "✅記事を書く際に意識すると良い点の作成完了")

  return {
    notes: result.note
  }
}

const saveResultNode = async (state: ArticleState) => {
  await addHistory({
    data: today,
    topic: state.topic!,
    title: state.chosenTitle!,
    notes: state.notes!
  })

  console.log(`✅ 登録完了: 「${state.chosenTitle}をテーマに記事を書いていきましょう!」\n 記事を書くときは以下を意識すると勉強になりますよ!\n${state.notes!.map(note => `${note}`).join('\n')}`)

  visualizeInterview({
    topic: state.topic,
    personas: state.personas,
    interviews: state.interviews,
    interviewEvaluation: state.interviewResult,
    titleSuggestions: state.titleSuggestions,
    chosenTitle: state.chosenTitle,
    notes: state.notes
  })
  return {
    completed: true
  }
}

const checkEvaluationCondition = (state: ArticleState) => {
  console.log(state.interviewResult.isSufficient ? "✅インタビュー結果が問題ないと評価されました" : "✖インタビュー結果に問題がありました、再度ペルソナを作成後、インタビューを再実施します")
  return state.interviewResult.isSufficient ? "generateTitles" : "generatePersona"
}

const checkChosenTitle = (state: ArticleState) => {
  return state.chosenTitle === "再度記事タイトルを生成する" ? "generatePersona" : "generateNotes"
}
// LangGraphワークフローの構築
const workflow = new StateGraph(GraphState)
  .addNode("getTopic", getTopicNode)
  .addNode("generatePersona", generatePersona)
  .addNode("conductInterview", conductInterview)
  .addNode("evaluationInterview", evaluatInterview)
  .addNode("generateTitles", generateTitlesNode)
  .addNode("selectTitle", selectTitleNode)
  .addNode("generateNotes", generateNotesNode)
  .addNode("saveResult", saveResultNode)
  .addEdge(START, "getTopic")
  .addEdge("getTopic", "generatePersona")
  .addEdge("generatePersona", "conductInterview")
  .addEdge("conductInterview", "evaluationInterview")
  .addEdge("generateTitles", "selectTitle")
  .addEdge("generateNotes", "saveResult")
  .addEdge("saveResult", END)
  .addConditionalEdges("evaluationInterview", checkEvaluationCondition)
  .addConditionalEdges("selectTitle", checkChosenTitle)

const app = workflow.compile()

export async function suggestCommand() {
  const result = await app.invoke({})
  return result
}

すみません...ソースコードを全部貼り付けたら邪魔だったのでアコーディオンでたたみました。。

workflowの登録

// LangGraphワークフローの構築
const workflow = new StateGraph(GraphState)
  .addNode("getTopic", getTopicNode)
  .addNode("generatePersona", generatePersona)
  .addNode("conductInterview", conductInterview)
  .addNode("evaluationInterview", evaluatInterview)
  .addNode("generateTitles", generateTitlesNode)
  .addNode("selectTitle", selectTitleNode)
  .addNode("generateNotes", generateNotesNode)
  .addNode("saveResult", saveResultNode)
  .addEdge(START, "getTopic")
  .addEdge("getTopic", "generatePersona")
  .addEdge("generatePersona", "conductInterview")
  .addEdge("conductInterview", "evaluationInterview")
  .addEdge("generateTitles", "selectTitle")
  .addEdge("generateNotes", "saveResult")
  .addEdge("saveResult", END)
  .addConditionalEdges("evaluationInterview", checkEvaluationCondition)
  .addConditionalEdges("selectTitle", checkChosenTitle)

const app = workflow.compile()

export async function suggestCommand() {
  const result = await app.invoke({})
  return result
}

LangGraphはLLM同士を協調させて、上から下に一方通行に流すのではなく、相互に行き来できるようにするためのライブラリです。
そして、その流れを定義しているのがworkflowと呼ばれるものです
つまり、LLMから来た返信をまた別のLLMに渡してさらに返信が来たら、今度はユーザに確認してもらって、却下されたらまた最初のLLMからやり直して...
このような複雑なフローを組めるようになるという事ですね!
便利!!

ということで早速ソースコードを見てみましょう!
suggestCommand関数はこのファイルにおけるエントリー関数です。すべてはここから始まります
とはいってもやってることはworkflowを発火しているだけです
あっちいけ!

LangGraphを全く知らないと。うげ!と声を出してしまうかもしれませんが、
addXX形式で沢山のメソッドが並んでいます
これは実際にworkflowを定義している箇所です

StateGraph関数の引数にはワークフローで状態を一貫して保存するためのオブジェクトを渡しています
今後は全てのノードでこの状態を参照しながら今どんな状態なのか何が実行されているのかなどを判断します

addNodeaddEdgeという関数がありますが、これは名前の通りなので何となく予想がつくかもしれませんね
Nodeは電車でいうとこの駅で、Edgeは線路です
つまり、駅を登録して駅同士がどのようにつながるのかを定義しているのという事です
addNode(function)とすることで関数を登録
addEdge(srcNodeName,distNodeName)とすることで、ノードの実行順序を登録できます
addConditionalEdgesというのは、いわゆるIF文です。第一引数にIF文に入る前のNodeを、第二引数には宛先のNodeNameを返却する関数を渡します。つまり、関数内で条件式を組み立てて自由に次の移動先を制御できるようになっています。

ちなみにコーディングしてて何か既視感があるなと思ったら。AWS CDKでStep Functionsのフローを定義する書き方とそっくりでした。。。
結局、フローを定義する。というお題目だと似ちゃうんですね

ちなみに、このワークフローで動かすとこんな感じでLangSmith上でも流れが確認できます!

それでは、ここからは各Nodeで何をしているのかを見ていきましょう!
説明順がそのままフロー順になっていますので、流れをイメージして読めるとおみます!

TOPIC取得

TOPICはユーザが最初に入力する自分が書きたい技術の名前です
例えばTypeScriptの記事が書きたければTypeScriptと入力したり、さらに非同期処理に焦点を当てたいならTypeSciptの非同期処理と入力します。
やはり、LLMの強みはこういった人間が自由に入力してもある程度想定通りに動くとこですよね!!

const getTopicNode = async (_: ArticleState) => {
  const history = await getHisotry()
  const usedTitles = history.map(v => v.title)

  const { topic } = await inquirer.prompt({
    type: 'input',
    name: 'topic',
    message: 'What do you want to lean about?'
  })

  return {
    topic,
    history,
    usedTitles
  }
}

まずはgetHistory関数で今までの記事タイトル履歴を取得をします。
これはLLMで記事タイトルを提案してもらう際に重複を避けるために使用されます

そして、ユーザに入力を促して、最終的に取得した結果を返却します

ちなみに、以降は色々な値をreturnしていますが、これはワークフロー定義の際にStateGraph()関数に渡したオブジェクトに代入されます
代入先は同名のプロパティになり、既に値がある場合は上書きされます

ちなみに、今回状態を保存するために使用されるオブジェクトはこちらを使っています

const Persona = z.object({
  name: z.string().describe('persona name'),
  background: z.string().describe('persona background')
})
const Personas = z.object({
  personas: z.array(Persona).describe('list of persona')
})

const Interview = z.object({
  persona: Persona.describe('the target that you interview'),
  question: z.string().describe('the question that you ask to the persona'),
  answer: z.string().describe('the answer that you get from the persona')
})
const EvaluationInterviewResult = z.object({
  reason: z.string().describe('the evaluation of your interview result'),
  isSufficient: z.boolean().describe('interview result is enough or not')
})
const ResponseFormatter = z.object({
  title: z.array(z.string()).describe('title of the article')
})

const NoteResponse = z.object({
  note: z.array(z.string()).describe('content in the article')
})

// Annotationを使用した状態定義
const GraphState = Annotation.Root({
  topic: Annotation<string>(),
  personas: Annotation<z.infer<typeof Persona>[]>,
  interviews: Annotation<z.infer<typeof Interview>[]>,
  interviewResult: Annotation<z.infer<typeof EvaluationInterviewResult>>,
  history: Annotation<HistoryEntry[]>(),
  usedTitles: Annotation<string[]>(),
  titleSuggestions: Annotation<string[]>(),
  chosenTitle: Annotation<string>(),
  notes: Annotation<string[]>(),
  completed: Annotation<boolean>()
})

ペルソナ作成

TOPICの取得が完了したら、次はペルソナ作成です。

const generatePersona = async (state: ArticleState) => {
  const { goodAreas } = await inquirer.prompt([
    {
      type: "checkbox",
      name: "goodAreas",
      message: "what technical area do you interested in",
      choices: [
        new inquirer.Separator(" === Application Types === "),
        { name: "Web Application", value: "web_app" },
        { name: "Mobile App", value: "mobile_app" },
        { name: "Desktop App", value: "desktop_app" },
        { name: "Game Development", value: "game_dev" },
        { name: "Embedded / IoT", value: "embedded_iot" },
        { name: "CLI / Batch Tools", value: "cli_batch" },
        { name: "API / Microservices", value: "api_microservices" },
        { name: "Static/Dynamic Websites (CMS)", value: "cms" },

        new inquirer.Separator(" === Technical Skills === "),
        { name: "Frontend Development", value: "frontend" },
        { name: "Backend Development", value: "backend" },
        { name: "Cloud / Infrastructure", value: "cloud_infra" },
        { name: "DevOps / SRE", value: "devops" },
        { name: "Security", value: "security" },
        { name: "AI / Machine Learning", value: "ai_ml" },
        { name: "Data Science", value: "data_science" },
        { name: "Blockchain / Web3", value: "blockchain" },
        { name: "Testing / QA", value: "testing" },
      ]
    }
  ])
  const { badAreas } = await inquirer.prompt([
    {
      type: "checkbox",
      name: "badAreas",
      message: "what technical area do you not interested in",
      choices: [
        new inquirer.Separator(" === Application Types === "),
        { name: "Web Application", value: "web_app" },
        { name: "Mobile App", value: "mobile_app" },
        { name: "Desktop App", value: "desktop_app" },
        { name: "Game Development", value: "game_dev" },
        { name: "Embedded / IoT", value: "embedded_iot" },
        { name: "CLI / Batch Tools", value: "cli_batch" },
        { name: "API / Microservices", value: "api_microservices" },
        { name: "Static/Dynamic Websites (CMS)", value: "cms" },

        new inquirer.Separator(" === Technical Skills === "),
        { name: "Frontend Development", value: "frontend" },
        { name: "Backend Development", value: "backend" },
        { name: "Cloud / Infrastructure", value: "cloud_infra" },
        { name: "DevOps / SRE", value: "devops" },
        { name: "Security", value: "security" },
        { name: "AI / Machine Learning", value: "ai_ml" },
        { name: "Data Science", value: "data_science" },
        { name: "Blockchain / Web3", value: "blockchain" },
        { name: "Testing / QA", value: "testing" },
        { name: "Architecture", value: "architecture" }
      ]
    }
  ])

  const baseMessage = "ペルソナ作成中";
  const animation = animateMessage(baseMessage);
  const prompt = ChatPromptTemplate.fromMessages(
    [
      [
        "system",
        "あなたはユーザインタビュー用の多様なペルソナを作成する専門家です。"
      ],
      [
        "human",
        `
以下のユーザリクエストに関数インタビュー用に、5人の多様なペルソナを作成してください。\n\n
ユーザリクエスト:{topic}\n\n
各ペルソナには名前と簡単な背景を含めてください。年齢、技術的専門知識において多様性を確保してください。\n
ただし、各ペルソナはITに関連する事柄のプロフェッショナルであることにしてください。\n
返信は日本語でお願いします.\n
以下のペルソナは含めないようにしてください\n
除外するペルソナ: \n
{excludePersona}\n
ペルソナを作成する際は以下の技術的専門知識や職域のものを生成してください。\n
{goodAreas}\n
ペルソナを作成する際は以下の技術的専門知識や職域のものは生成しないでください。\n
{badAreas}\n
`
      ]
    ]
  )
  const chain = prompt.pipe(model.withStructuredOutput(Personas))
  const result = await chain.invoke({
    topic: state.topic!,
    excludePersona: (!state.chosenTitle || state.chosenTitle !== "再度記事タイトルを生成する") ? "除外するペルソナはありません" : state.personas!.map(v => { return `name: ${v.name} - background: ${v.background}` }).join('\n'),
    goodAreas: goodAreas.length === 0 ? "指定はありません" : goodAreas.map((v: string) => { return `${v}` }).join('\n'),
    badAreas: badAreas.length === 0 ? "指定はありません" : badAreas.map((v: string) => { return `${v}` }).join('\n'),
  })
  stopAnimationWithMessage(animation, "✅ペルソナ作成完了")
  return {
    personas: result.personas,
  }
}

まずはユーザが興味ある and ない技術エリアを選択してもらっています。
これは自分の勉強したいと思っている領域に方向性を合わせるために入れています
例えば、私がゲーム開発に興味なくてペルソナにゲーム開発者が含まれてしまうと、記事タイトルの提案の際にゲーム開発でよく使われる技術が考慮されて表示される可能性があります。
そのため、ペルソナ作成の段階で絞るようにしています

選択が完了すると、次はLLMとのやり取りをする準備をします。
ChatPromptTemplate.fromMessages()では実際にOpenAIに投げるプロンプトやロールを渡しています。
プロンプトの中に{xx}のような記載が見受けられると思いますが、これは最終的に置換される箇所になります。
chain.invoke()にてLLMに問い合わせる際に値を指定する事で自動で置換されます

もう一つ説明しておく必要がありそうなのはprompt.pipe(model.withStructuredOutput(Personas))です
この構文はLangGraphではなくLangCahinの構文になります。
pythonだとpipeの部分が|を使用しますが、LLMを呼び出すために使用するオブジェクトなどをpipeメソッドで鎖のようにつなげていく事でシンプルに記載する事が出来ます!
もしなかったら、行を分けて引数で渡して...ぞっとしますね
鎖のように、だからチェーンという事ですか?
※なんかHaskellとか純粋関数型言語ってこういう記載の仕方得意ですよね

ちなみに、withStructuredOutputメソッドは驚くほど便利です。
何をしているかというと、LLMからの返答を引数で渡したオブジェクトにマッピングしてくれます。
LLMアプリを作ったことある人は経験あると思いますが、Jsonで返却してください。って伝えたのに、
「わかりましたJsonファイルですね!以下がJsonになります。」みたいにJson構造のみで返却されず自然言語の箇所が残っているなんてことありますよね。
従来だと、正規表現とかで何とか無理矢理対応しますが、先ほどのメソッドを使うと勝手にLLM返答をオブジェクトにマッピングしてくれるので追加の処理が不要です!

こちらのNodeを実行した際のLangSmithが以下です。

しっかり、Input文字が置換されていて、
Outputはこちらが指定したオブジェクトに紐づいていますね!
ちなみにOutputにはLLMが作成したTypeScriptに関連するペルソナ5人が作成されていますね

インタビュー実施

それでは、作成した5人のペルソナにさっそくインタビューをしていきましょう!

const conductInterview = async (state: ArticleState) => {
  let baseMessage = "各ペルソナへの質問を生成中";
  let animation = animateMessage(baseMessage);

  const questionPrompt = ChatPromptTemplate.fromMessages([
    [
      "system",
      "あなたはユーザ要件に基づいて適切な質問を生成する専門家です。"
    ],
    [
      "human",
      `
以下のペルソナに関連するユーザリクエストについて、1つの質問を生成してください。\n\n
ユーザリクエスト: {userRequest}に関連する記事を読んだ際に、表面的ではなく深い洞察が得られると思える記事の内容について\n
ペルソナ:{personaName} - {personaBackground}\n\n
質問は具体的で、このペルソナの視点から重要な情報を引き出すように設計してください。
`
    ]
  ])
  const questionChain = questionPrompt.pipe(model).pipe(new StringOutputParser())
  const quesitonQueries = state.personas.map(v => { return { userRequest: state.topic, personaName: v.name, personaBackground: v.background } })
  const questionResult = await questionChain.batch(quesitonQueries)
  stopAnimationWithMessage(animation, "✅質問作成完了")

  // 回答生成
  baseMessage = "各ペルソナが回答中";
  animation = animateMessage(baseMessage);
  const answerPrompt = ChatPromptTemplate.fromMessages(
    [
      [
        "system",
        "あなたは以下のペルソナとして回答しています: {personaName} - {personaBackground}"
      ],
      [
        "human",
        "質問:{question}"
      ]
    ]
  )
  const answerChain = answerPrompt.pipe(model).pipe(new StringOutputParser())
  const answerQueries = questionResult.map((v, i) => { return { personaName: state.personas[i].name, personaBackground: state.personas[i].background, question: v } })
  const answerResult = await answerChain.batch(answerQueries)

  const interviewResult = []
  for (let i = 0; i < state.personas.length; i++) {
    interviewResult.push({
      persona: state.personas[i],
      question: questionResult[i],
      answer: answerResult[i],
    })
  }

  stopAnimationWithMessage(animation, "✅ペルソナへのインタビュー完了")
  return {
    interviews: interviewResult
  }
}

まずは、ペルソナに質問する際に必要になる質問文です!
各ペルソナは多様性を担保しているので固定文言で統一された質問では深い回答が得られません。
そのため、ここではそれぞれのペルソナにあった質問をそれぞれ生成します

それぞれの質問を生成したら、今度はペルソナと質問文をプロンプトに含めてLLMに解答するように依頼します。
そして、完了したらこの関数も終了です

薄々皆さんも気づいていると思いますが、
今回のアプリは各関数が同じようなメソッドと手法でLLMと通信して、それをEdgeでつないでAI Agentを構成しています。
そのため、コード自体の面白さは進むごとにつまらなくなると思いますが、ご容赦ください。。

こちらのNodeを実行した際のLangSmithが以下です。

インタビュー結果の評価

ペルソナへのインタビューが終わると次は結果の評価です
評価と言ってもここでしているのはシンプルです
結果をまだLLMに投げて十分かどうかを聞いているだけです

const evaluatInterview = async (state: ArticleState) => {
  const baseMessage = "ペルソナへのインタビュー結果を評価中"
  const animation = animateMessage(baseMessage)
  const prompt = ChatPromptTemplate.fromMessages([
    [
      "system",
      "あなたは技術ブログの記事のタイトルを作成するための情報の十分性。評価する専門家です。"
    ],
    [
      "human",
      `
以下のユーザリクエストとインタビュー結果に基づいて、
書くべき記事のタイトル候補を提案するのに十分な情報が集まったかどうかを判断してください\n\n
返信は日本で返してください。
ユーザリクエスト: {userRequest}\n\n
インタビュー記事:\n{interviewResults}`
    ]
  ])
  const chain = prompt.pipe(model.withStructuredOutput(EvaluationInterviewResult))
  const result = await chain.invoke({
    userRequest: state.topic,
    interviewResults: state.interviews!.map(v => { return `${v.persona.name} - ${v.persona.background}\n質問: ${v.question}\n回答: ${v.answer}` }).join('\n')
  })
  stopAnimationWithMessage(animation, "✅インタビュー評価完了")
  return {
    interviewResult: result
  }
}

このプログラムを作る際に参考にした書籍には、
AI Agentの普及に向けては評価の手法がまだ確立されていないため、評価手法の確立が重要。との記載がありましたが、
皆さんだったら、どんな評価手法でLLMの結果を評価しますか?

ちなみに、このNodeでは評価結果がTrue/Falseで出力されます
Trueの場合はタイトル候補作成に進みますが、Falseの場合はペルソナ作成から再スタートします
※鬼畜仕様です

こちらのNodeを実行した際のLangSmithが以下です。

タイトル候補作成

そして、いよいよ記事タイトル候補を作成です

const generateTitlesNode = async (state: ArticleState) => {
  const baseMessage = "記事タイトルを作成中"
  const animation = animateMessage(baseMessage)

  const chatPromt = ChatPromptTemplate.fromMessages([
    ["system", `
あなたは収集した情報に基づいて記事タイトルを提案する専門家です。
`],
    ['human', `
以下のユーザリクエストと複数のペルソナからのインタビュー結果に基づいて、私が成長するために今一番書くべき記事のタイトルを5つ提案してください。\n\n
返答は日本語でお願いします。\n\n
ユーザリクエスト: {userRequest}に関連する事項を調査して記事にしたいです。その過程で自分の技術者として成長することと読者に深い洞察を得る機会を提供するのが目的です\n\n
インタビュー結果: \n{interviewResults}\n
提案する5つの記事タイトルは可能な限りユニークで多様性を持たせてください。\n
提案する5つの記事タイトルは表面的ではなく、読者が深い洞察を得られ、作者の自分自身がそれを書くことで技術的に成長できるものにしてください\n
提案する5つの記事タイトルは可能な限り特集のようなものではなく一つの技術概念を深堀するようなものにしてください\n
また、私は以下の記事を既に記載しているため、こちらの記事とは重複しないようにしてください\n
{history}\n
また、以下の記事タイトルはユーザが却下しているため、これらの記事とは類似性が低い記事タイトルを生成してください。\n
{excludeTitle}
`]
  ])

  const chain = chatPromt.pipe(model.withStructuredOutput(ResponseFormatter))
  const result = await chain.invoke({
    history: state.history.map(v => { return v.title }),
    userRequest: state.topic,
    interviewResults: state.interviews!.map(v => { return `${v.persona.name} - ${v.persona.background}\n質問: ${v.question}\n回答: ${v.answer}` }).join('\n'),
    excludeTitle: !state.titleSuggestions || state.chosenTitle !== "再度記事タイトルを生成する" ? "除外する記事タイトルはありません" : state.titleSuggestions!.map(v => { return `・「${v}` }).join('\n')
  })

  stopAnimationWithMessage(animation, "✅記事タイトル作成完了")
  return {
    titleSuggestions: result.title
  }
}

伝えることが減ってきていて心苦しいですが、
こんな感じです。
雑!!!!!!

こちらのNodeを実行した際のLangSmithが以下です。

タイトル候補から選択

次は生成した5つのタイトル候補から一つ興味あるものを選んでもらいます

const selectTitleNode = async (state: ArticleState) => {
  const { chosen } = await inquirer.prompt({
    type: 'list',
    name: 'chosen',
    message: 'どのタイトルで記事を書きますか?',
    choices: [...state.titleSuggestions, "再度記事タイトルを生成する"]
  })

  return {
    chosenTitle: chosen
  }
}

ちなみに、再度記事タイトルを生成するを押すと、再度ペルソナ作成から始まります
※鬼畜仕様リターン

記事を書く際にポイント生成

タイトル候補から一つを選ぶと、その記事タイトルを基にその記事でどういった事を記載すれば、勉強になるのかを生成してもらいます

const generateNotesNode = async (state: ArticleState) => {
  const baseMessage = "記事を書く際に意識するとよい点を作成中"
  const animation = animateMessage(baseMessage)
  const notePrompt = ChatPromptTemplate.fromMessages([
    ["system", `
あなたは{topic}についての高い専門知識を有する専門家です。
中級エンジニアのユーザはあなたにこれからユーザが記載する記事のタイトルを送ります。
あなたは記事のタイトルをもとに記事の中で書くべき具体的な内容を日本語で5つあげてください。
初心者用の記事ではなく深い洞察が得られる記事にする必要があるため、その点を考慮してください。
記事タイトル: {title}
`],
    ["human", "{topic}"]
  ])

  const chain = notePrompt.pipe(model.withStructuredOutput(NoteResponse))
  const result = await chain.invoke({
    topic: state.topic,
    title: state.chosenTitle
  })

  stopAnimationWithMessage(animation, "✅記事を書く際に意識すると良い点の作成完了")

  return {
    notes: result.note
  }
}

こちらのNodeを実行した際のLangSmithが以下です。

記事タイトル保存

最後に今後利用する際に同じ類の記事タイトルが生成されないようにDBにタイトルを保存します。

const saveResultNode = async (state: ArticleState) => {
  await addHistory({
    data: today,
    topic: state.topic!,
    title: state.chosenTitle!,
    notes: state.notes!
  })

  console.log(`✅ 登録完了: 「${state.chosenTitle}をテーマに記事を書いていきましょう!」\n 記事を書くときは以下を意識すると勉強になりますよ!\n${state.notes!.map(note => `${note}`).join('\n')}`)

  visualizeInterview({
    topic: state.topic,
    personas: state.personas,
    interviews: state.interviews,
    interviewEvaluation: state.interviewResult,
    titleSuggestions: state.titleSuggestions,
    chosenTitle: state.chosenTitle,
    notes: state.notes
  })
  return {
    completed: true
  }
}

保存が完了したら、最後にビジュアル化する関数を呼び出しで終了です!

visualize.ts

中身を見てもらった方が早いと思いますので、まずはご覧ください

ソースコード全量
import { writeFileSync } from "fs"
import { resolve } from "path";

interface Persona {
  name: string
  background: string
}

interface Interview {
  persona: Persona
  question: string
  answer: string
}

interface InterviewEvaluation {
  reason: string
  isSufficient: boolean
}

export interface InterviewResult {
  topic: string
  personas: Persona[]
  interviews: Interview[]
  interviewEvaluation: InterviewEvaluation
  titleSuggestions: string[]
  chosenTitle: string
  notes: string[]
}

export function visualizeInterview(interviewInfo: InterviewResult) {
  // ペルソナカードのHTML生成
  const personasHtml = interviewInfo.personas.map(persona =>
    `<div class="persona-card">
      <div class="persona-name">${escapeHtml(persona.name)}</div>
      <div class="persona-background">${escapeHtml(persona.background)}</div>
    </div>`
  ).join('');

  // インタビューコンテンツのHTML生成
  const interviewsHtml = interviewInfo.interviews.map(interview =>
    `<div class="interview-item">
      <div class="interview-target">対象:${escapeHtml(interview.persona.name)} </div>
      <div class="interview-question">Q: ${escapeHtml(interview.question)}</div>
      <div class="interview-answer">${formatAnswer(interview.answer)}</div>
    </div>`
  ).join('');

  // タイトル提案のHTML生成
  const titleSuggestionsHtml = interviewInfo.titleSuggestions.map(title =>
    `<div class="title-suggestion">${escapeHtml(title)}</div>`
  ).join('');

  // ノートリストのHTML生成
  const notesHtml = interviewInfo.notes.map(note =>
    `<li>${escapeHtml(note)}</li>`
  ).join('');

  // 結果ステータスの判定
  const resultStatusClass = interviewInfo.interviewEvaluation.isSufficient ? 'result-sufficient' : 'result-insufficient';
  const resultStatusText = interviewInfo.interviewEvaluation.isSufficient ? '十分' : '不十分';

  const replacedHtml = htmlTemplate
    .replaceAll("{{TOPIC}}", escapeHtml(interviewInfo.topic))
    .replace("{{PERSONAS_COUNT}}", interviewInfo.personas.length.toString())
    .replace("{{INTERVIEWS_COUNT}}", interviewInfo.interviews.length.toString())
    .replace("{{TITLE_SUGGESTIONS_COUNT}}", interviewInfo.titleSuggestions.length.toString())
    .replace("{{PERSONAS_CARDS}}", personasHtml)
    .replace("{{INTERVIEWS_CONTENT}}", interviewsHtml)
    .replace("{{RESULT_STATUS_CLASS}}", resultStatusClass)
    .replace("{{RESULT_STATUS_TEXT}}", resultStatusText)
    .replace("{{RESULT_REASON}}", escapeHtml(interviewInfo.interviewEvaluation.reason))
    .replace("{{TITLE_SUGGESTIONS}}", titleSuggestionsHtml)
    .replace("{{CHOSEN_TITLE}}", escapeHtml(interviewInfo.chosenTitle))
    .replace("{{NOTES_LIST}}", notesHtml);

  const outputPath = resolve(process.cwd(), "interviewResult.html");
  writeFileSync(outputPath, replacedHtml, 'utf8');
  console.log("HTMLファイルが出力されました:", outputPath);
}

// HTMLエスケープ関数
function escapeHtml(text: string): string {
  const map: { [key: string]: string } = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#039;'
  };
  return text.replace(/[&<>"']/g, (m) => map[m]);
}

// 回答テキストのフォーマット(改行を<br>に変換、段落を適切に処理)
function formatAnswer(answer: string): string {
  return escapeHtml(answer)
    .replace(/\n\n/g, '</p><p>')
    .replace(/\n/g, '<br>')
    .replace(/^/, '<p>')
    .replace(/$/, '</p>')
    // 複数の<p></p>タグを適切に処理
    .replace(/<p><\/p>/g, '')
    .replace(/<p><br>/g, '<p>')
    .replace(/<br><\/p>/g, '</p>');
}

const htmlTemplate = `
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{TOPIC}} - 調査結果レポート</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        
        body {
            font-family: 'Hiragino Sans', 'Yu Gothic', 'Meiryo', sans-serif;
            line-height: 1.6;
            color: #333;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
        }
        
        .container {
            max-width: 1200px;
            margin: 0 auto;
            padding: 20px;
        }
        
        .header {
            text-align: center;
            color: white;
            margin-bottom: 40px;
            padding: 40px 0;
        }
        
        .header h1 {
            font-size: 2.5em;
            margin-bottom: 10px;
            text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
        }
        
        .header .subtitle {
            font-size: 1.2em;
            opacity: 0.9;
        }
        
        .section {
            background: white;
            border-radius: 15px;
            box-shadow: 0 10px 30px rgba(0,0,0,0.1);
            margin-bottom: 30px;
            overflow: hidden;
            transition: transform 0.3s ease;
        }
        
        .section:hover {
            transform: translateY(-5px);
        }
        
        .section-header {
            background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
            color: white;
            padding: 20px 30px;
            font-size: 1.4em;
            font-weight: bold;
        }
        
        .section-content {
            padding: 30px;
        }
        
        .personas-grid {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
            gap: 20px;
            margin-top: 20px;
        }
        
        .persona-card {
            background: #f8f9fa;
            border-radius: 10px;
            padding: 20px;
            border-left: 4px solid #4facfe;
            transition: transform 0.2s ease;
        }
        
        .persona-card:hover {
            transform: scale(1.02);
        }
        
        .persona-name {
            font-size: 1.2em;
            font-weight: bold;
            color: #2c3e50;
            margin-bottom: 10px;
        }
        
        .persona-background {
            color: #555;
            line-height: 1.7;
        }
        
        .interview-item {
            background: #f8f9fa;
            border-radius: 10px;
            padding: 25px;
            margin-bottom: 20px;
            border-left: 4px solid #28a745;
        }

        .interview-target {
            font-weight: bold;
            color: #555;
            line-height: 1.8;
        }
        
        .interview-question {
            font-weight: bold;
            color: #2c3e50;
            margin-bottom: 15px;
            font-size: 1.1em;
        }
        
        .interview-answer {
            color: #555;
            line-height: 1.8;
        }
        
        .interview-answer h3 {
            color: #2c3e50;
            margin: 20px 0 10px 0;
        }
        
        .interview-answer ul {
            margin-left: 20px;
        }
        
        .interview-answer li {
            margin-bottom: 8px;
        }
        
        .result-status {
            display: inline-block;
            padding: 8px 16px;
            border-radius: 20px;
            font-weight: bold;
            margin-bottom: 15px;
        }
        
        .result-sufficient {
            background: #d4edda;
            color: #155724;
            border: 1px solid #c3e6cb;
        }
        
        .result-insufficient {
            background: #f8d7da;
            color: #721c24;
            border: 1px solid #f5c6cb;
        }
        
        .titles-grid {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
            gap: 15px;
            margin-top: 20px;
        }
        
        .title-suggestion {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            padding: 20px;
            border-radius: 10px;
            text-align: center;
            font-weight: bold;
            transition: transform 0.2s ease;
            cursor: pointer;
        }
        
        .title-suggestion:hover {
            transform: scale(1.05);
        }
        
        .chosen-title {
            background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);
            color: #8b4513;
            border: 3px solid #ff6b35;
            transform: scale(1.1);
        }
        
        .notes-list {
            list-style: none;
        }
        
        .notes-list li {
            background: #e3f2fd;
            margin-bottom: 15px;
            padding: 20px;
            border-radius: 8px;
            border-left: 4px solid #2196f3;
            position: relative;
        }
        
        .notes-list li::before {
            content: "📝";
            position: absolute;
            left: -10px;
            top: 15px;
            background: white;
            padding: 5px;
            border-radius: 50%;
        }
        
        .stats {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
            gap: 20px;
            margin-bottom: 30px;
        }
        
        .stat-card {
            background: white;
            padding: 20px;
            border-radius: 10px;
            text-align: center;
            box-shadow: 0 5px 15px rgba(0,0,0,0.1);
        }
        
        .stat-number {
            font-size: 2.5em;
            font-weight: bold;
            color: #4facfe;
            margin-bottom: 10px;
        }
        
        .stat-label {
            color: #666;
            font-size: 0.9em;
        }
        
        @media (max-width: 768px) {
            .container {
                padding: 10px;
            }
            
            .header h1 {
                font-size: 2em;
            }
            
            .section-content {
                padding: 20px;
            }
            
            .personas-grid {
                grid-template-columns: 1fr;
            }
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="header">
            <h1>{{TOPIC}}</h1>
            <div class="subtitle">インタビュー調査結果レポート</div>
        </div>
        
        <div class="stats">
            <div class="stat-card">
                <div class="stat-number">{{PERSONAS_COUNT}}</div>
                <div class="stat-label">インタビュー対象者</div>
            </div>
            <div class="stat-card">
                <div class="stat-number">{{INTERVIEWS_COUNT}}</div>
                <div class="stat-label">実施インタビュー数</div>
            </div>
            <div class="stat-card">
                <div class="stat-number">{{TITLE_SUGGESTIONS_COUNT}}</div>
                <div class="stat-label">タイトル提案数</div>
            </div>
        </div>
        
        <div class="section">
            <div class="section-header">
                👥 インタビュー対象者
            </div>
            <div class="section-content">
                <div class="personas-grid">
                    {{PERSONAS_CARDS}}
                </div>
            </div>
        </div>
        
        <div class="section">
            <div class="section-header">
                💬 インタビュー結果
            </div>
            <div class="section-content">
                {{INTERVIEWS_CONTENT}}
            </div>
        </div>
        
        <div class="section">
            <div class="section-header">
                📊 調査結果の評価
            </div>
            <div class="section-content">
                <div class="result-status {{RESULT_STATUS_CLASS}}">
                    {{RESULT_STATUS_TEXT}}
                </div>
                <p>{{RESULT_REASON}}</p>
            </div>
        </div>
        
        <div class="section">
            <div class="section-header">
                💡 提案タイトル
            </div>
            <div class="section-content">
                <div class="titles-grid">
                    {{TITLE_SUGGESTIONS}}
                </div>
            </div>
        </div>
        
        <div class="section">
            <div class="section-header">
                🎯 選択されたタイトル
            </div>
            <div class="section-content">
                <div class="title-suggestion chosen-title">
                    {{CHOSEN_TITLE}}
                </div>
            </div>
        </div>
        
        <div class="section">
            <div class="section-header">
                📝 記事構成ノート
            </div>
            <div class="section-content">
                <ul class="notes-list">
                    {{NOTES_LIST}}
                </ul>
            </div>
        </div>
    </div>
</body>
</html>
`

例にもれなく全量は邪魔なので畳みました
見てもらうとわかりますが、HTMLのテンプレートをテキストで保持して、一部がプレースホルダ―になっているので、その個所をAI Agentで出力された値で置換してるのみです
※HTMLをAIに作成してもらいましたが、やっぱりこういうどうも気乗りしない作業を一瞬で終わらせてくれるのはうれしいですね!!

ちなみにHTMLはこんな感じで出力されます


終わりに

後半は説明する事がなく、同じことの繰り返しで逃げるように「終わりに」に来ました。

上司を作るなんて言うふざけた出発点でしたが、TypeScriptでLangGraphを利用してAI Agentを作るという試みは稚拙ながら達成できたでしょうか...
このAIアプリは大前提として自己学習の補助としての使用する事が想定されていますので、AIアプリはあなたを持ち上げて無理やり知らない世界に置いていき、あとは自分で頑張れStyleです。
なかなかの鬼畜生AIになってしまいましたが、自己学習にはこれぐらいじゃないと満足できませんよね?

ちなみに、このアプリを使うとAPI料金で一回11円かかります。
という事で私の職場に、会話する時に必ず11円を要求するモンスター上司が生まれました。
わーい。

最後になりますが、この記事は以下の書籍を参考にさせて頂きました。
特に要件定義書を作成するAI Agentを作成する章ではとても興味深い手法が使われており、このプログラムも強く影響を受けています。
LangChainやLangGraph、RAGを勉強したい方は一読する事をお勧めいたします!
書籍内ではPythonで記載されているため、その点だけはご留意ください
https://gihyo.jp/book/2024/978-4-297-14530-9

Discussion