React+FastAPI+OpenAIでヘルプレポートサイト作成

ユーザが任意入力されたデータを要約してランキング形式で表示させるサイトをOpenAIを組み合わせて作成してみました。ランキングはscikit-learnのTfidfVectorizerとKMeansを使用して、DBに保存されたテキストをクラスタリングし、類似テキストをグループ化することで件数をカウント。ラインキング表示させる形になっています。

# 前提
OS：CentOS7.9
Docker： 23.0.1, build a5ee5b1
docker-compose： 1.28.2, build 67630359
Python：3.9.2
node： v19.9.0
npm: 9.6.3
APIキー：OpenAIにて事前作成しておく

# 1. コンテナ作成
任意のディレクトリ上で、Dockerfile,docker-compose.ymlを作成してください。

```dockerfile:Dockerfile
FROM node:19-slim

RUN apt-get update && apt-get install -y vim curl git python3 python3-dev python3-pip procps sqlite3

RUN npm install -g create-react-app
RUN npm install -g npm@9.6.3
RUN pip3 install uvicorn fastapi openai==0.28 scikit-learn
```

```yaml:docker-compose.yml
version: '3'
services:
  api:
    image: rankings-api:0.1
    restart: always
    build: .
    container_name: 'rankings-api'
    tty: true
    volumes:
      - ./work:/opt
    ports:
      - '4001:4001'
      - '4002:4002'
```

続いて、workディレクトリ作成後のcomposeを使ってコンテナを起動します。
```bash
mkdir work
docker-compose up -d
```

# 2. FastAPI準備
コンテナ起動後に、FastAPIを利用したAPIを作成します。以下の順にコマンドを実行します。
```bash
mkdir work/fastapi
cd work/fastapi
```

エディタ(vim)などでmain.pyファイルを作成します。openai.api_keyは自分で取得したAPIキーを入力してください。React側からは文字入力を受け付けるエンドポイント(submit)と要約されたデータを返却するエンドポイント(rankings)を作成しています。

```python:main.py
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import sqlite3
import openai
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.cluster import KMeans
import numpy as np

openai.api_key = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXX'   #自身のAPIキー

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=['*'],
    allow_credentials=True,
    allow_methods=['*'],
    allow_headers=['*'],
)

conn = sqlite3.connect('ranking.db', check_same_thread=False)
c = conn.cursor()
c.execute('''CREATE TABLE IF NOT EXISTS entries (id INTEGER PRIMARY KEY, text TEXT)''')
conn.commit()

class InputData(BaseModel):
    text: str

@app.post("/submit/")
async def submit(data: InputData):
    c.execute("INSERT INTO entries (text) VALUES (?)", (data.text,))
    conn.commit()
    return {"message": "Successfully"}

def group_similar_texts(texts, num_clusters):
    try:
        vectorizer = TfidfVectorizer().fit_transform(texts)
        vectors = vectorizer.toarray()

        kmeans = KMeans(n_clusters=num_clusters, random_state=0).fit(vectors)

        clusters = {}
        for idx, label in enumerate(kmeans.labels_):
            if label not in clusters:
                clusters[label] = []
            clusters[label].append(texts[idx])

        return list(clusters.values())

    except Exception as e:
        print(f"Error in group_similar_texts: {e}")
        return []

@app.get("/rankings/")
async def get_rankings():
    c.execute("SELECT text FROM entries")
    rows = c.fetchall()
    texts = [row[0] for row in rows]

    if not texts:
        return {"rankings": []}

    grouped_texts = group_similar_texts(texts, num_clusters=2)

    summaries = []
    for group in grouped_texts:
        combined_text = " ".join(group)
        response = openai.ChatCompletion.create(
            model="gpt-4",
            messages=[
                {"role": "system", "content": "You are a helpful assistant."},
                {"role": "user", "content": f"次の文章を要約して下さい: {combined_text}"}
            ],
            max_tokens=100,
            temperature=0.0
        )
        summary = response.choices[0].message['content'].strip()
        summaries.append((combined_text, summary, len(group)))

    sorted_summaries = sorted(summaries, key=lambda x: x[2], reverse=True)

    rankings = [{"combined_text": s[0], "summary": s[1], "count": s[2]} for s in sorted_summaries]

    return {"rankings": rankings}
```
類似事象としてグループ化させるために、sklearnのTfidfVectorizer＋KMeansを使用しています。TfidfVectorizerとKMeansは以下のサイトを参考にさせていただきました。

**TfidfVectorizer**

https://scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.TfidfVectorizer.html

**KMeans**

https://scikit-learn.org/stable/modules/generated/sklearn.cluster.KMeans.html


# 3. FastAPI起動
以下コマンドを実行してuvicornからFastAPIを起動します。
```bash
docker exec -it rankings-api /bin/bash
cd /opt/fastapi
uvicorn main:app --host 0.0.0.0 --port 4002
```

# 4. Reactコード作成
次に、React側の準備に取り掛かります。別ターミナルを立ち上げて、以下のコマンドを順に実行しましょう。
```bash
docker exec -it rankings-api /bin/bash
cd /opt/
npx create-react-app app
cd /opt/app
npm install axios @mui/material @emotion/react @emotion/styled @mui/icons-material
```

src/App.jsを以下のように変更します。
```react:src/App.js
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import {
  Container,
  TextField,
  Button,
  List,
  Typography,
  Paper,
  Box,
  Card,
  CardContent,
  CardActions,
  IconButton,
  CssBaseline
} from '@mui/material';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import SendIcon from '@mui/icons-material/Send';
import Brightness4Icon from '@mui/icons-material/Brightness4';
import Brightness7Icon from '@mui/icons-material/Brightness7';

function App() {
  const [input, setInput] = useState('');
  const [rankings, setRankings] = useState([]);
  const [themeMode, setThemeMode] = useState(false);

  const theme = createTheme({
    palette: {
      mode: themeMode ? 'light' : 'dark',
    },
  });

  const handleSubmit = async () => {
    await axios.post('http://x.x.x.x:4002/submit/', { text: input });  // IPはDockerホストのIP
    setInput('');
  };

  useEffect(() => {
    const fetchRankings = async () => {
      const response = await axios.get('http://x.x.x.x:4002/rankings/');   // IPはDockerホストのIP
      setRankings(response.data.rankings);
    };

    // 1分ごとにリフレッシュ
    const intervalId = setInterval(fetchRankings, 60000);

    // インターバルのクリーンアップ
    return () => clearInterval(intervalId);
  }, []);

  return (
    <ThemeProvider theme={theme}>
      <CssBaseline />
      <Container maxWidth="md">
        <Paper elevation={3} style={{ padding: '2rem', marginTop: '2rem' }}>
          <Box display="flex" justifyContent="space-between" alignItems="center">
            <Typography variant="h4" component="h1" gutterBottom>
              トラブル報告
            </Typography>
            <IconButton onClick={() => setThemeMode(!themeMode)}>
              {themeMode ? <Brightness7Icon /> : <Brightness4Icon />}
            </IconButton>
          </Box>
          <Box component="form" noValidate autoComplete="off" onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
            <TextField
              label="お困りごとを入力してください。"
              variant="outlined"
              fullWidth
              value={input}
              onChange={(e) => setInput(e.target.value)}
              style={{ marginBottom: '1rem' }}
            />
          <Button
            variant="contained"
            color="primary"
            fullWidth
            onClick={handleSubmit}
            endIcon={<SendIcon />}
          >
            送信
          </Button>
        </Box>
      </Paper>
      <Typography variant="h5" component="h2" style={{ marginTop: '2rem' }}>
        トラブルダイジェスト(ランキング順)
      </Typography>
      <List>
        {rankings.map((ranking, index) => (
          <Card key={index} style={{ marginBottom: '1rem' }}>
            <CardContent>
              <Typography color="textSecondary" gutterBottom>
                トラブル事象(要約):
              </Typography>
              <Typography variant="body2" component="p">
                {ranking.summary}
              </Typography>
            </CardContent>
            <CardActions>
              <Typography variant="caption" color="textSecondary" style={{ marginLeft: 'auto' }}>
                件数: {ranking.count}
              </Typography>
            </CardActions>
          </Card>
        ))}
      </List>
    </Container>
  </ThemeProvider>
  );
}

export default App;
```
これで準備完了です。

# 5. Reactアプリ起動
Sourceも変更できたので、これでアプリを起動できます。起動前に.envファイルを作成して起動するポートを4001番に変更します。
```python:.env
PORT=4001
```

では下記コマンドで起動しましょう。
```bash
npm start
```

# 6. アプリケーション確認

ブラウザでアクセスします。*http://x.x.x.x:4001* でアクセスしましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2335933/4634bd18-2b44-68a0-56db-3a617f3c1804.png)


Inputboxにいくつかデータを登録すると以下のように表示されます！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2335933/02622d47-4658-a8d8-cee5-2ca3e8355117.png)

分類を細かくしたい場合、num_clusters=2の数値を大きくしてください。(num_clusters=5の例)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2335933/2f603db9-9611-e40b-cb1b-445eff9e7451.png)

分類が思うようにいかない場合は、Janomeなどの形態素解析ライブラリを利用した上でクラスタリング化するとよいかもしれません。

**Janome**

https://mocobeta.github.io/janome/

また、OpenAIへのリクエストは60秒間隔で実行されるようになっています。間隔を調整する場合、App.jsのintervalIdを調整してください。

*App.js*
```react
const intervalId = setInterval(fetchRankings, 60000);
```


# 7. その他
機械学習ライブラリ＋OpenAIなどを組み合わせることで、様々な効果を持つサイトができると思いました。記事が誰かの役に立てば幸いです。