0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Streamlitを「もう一つのチャット」にしない:AI返信の承認・有人引き継ぎキューをPythonで作る

0
Last updated at Posted at 2026-08-31

AIチャットUIは短いコードで作れます。しかし実運用で難しいのは、入力欄や吹き出しではありません。

  • ユーザーは既存チャットとAI画面のどちらへ投稿すべきか
  • AIが誤ったとき、誰が返信を引き取るのか
  • グループ内の発言を、どこまでAIへ渡してよいのか
  • 担当者が確認していないAI回答を、誰が送信したことにするのか

ここを決めずにStreamlit製チャットを追加すると、便利なデモが「誰も所有していない新しい窓口」になりがちです。

本稿では発想を反転し、ユーザー向けチャットはTencent RTC Social Messaging側に残し、Streamlitは運営者専用の承認キューにする構成を作ります。

結論:AIには返信案を作らせ、送信権と案件所有権は人に残す

今回の実装では、次のルールを固定します。

状況 AIへ渡す範囲 最終送信者
通常メッセージ 渡さない なし
/ai 質問 現在の発言だけ 担当者
/ai+ 質問 現在の発言+同じ送信者の直近発言 担当者
/human 相談 渡さない 担当者
秘密情報らしき文字列を含む 渡さない 担当者
画像などテキスト以外 渡さない 担当者

AIが実証できるのは、渡された文脈から返信案を生成できることまでです。回答の正しさ、投稿者の意図、組織として実行してよい操作までは保証しません。

そのため、LLMの性能評価より先に以下を実装します。

  1. ユーザーによる明示的なAI利用
  2. AIへ渡す文脈の制限
  3. 秘密情報の簡易ゲート
  4. 重複イベントの排除
  5. 担当者による案件の取得
  6. 編集・承認後だけ送信する経路

前提:ユーザーの入口と運営画面を分離する

Tencent RTCのSocial Messagingソリューションは、1対1チャット、グループ、コミュニティ、リッチメディア、ライブ配信ルーム内チャットなどのシナリオを扱います。

本稿では製品固有の未確認API名を仮定せず、Social Messagingを利用するアプリケーションサーバーから、受信イベントを次の共通形式へ正規化する境界を置きます。

{
  "event_id": "evt-001",
  "conversation_id": "group-42",
  "sender_id": "user-7",
  "content_type": "text",
  "text": "/ai 配送先を変更できますか"
}

実装する全体フローは次のとおりです。

Social Messagingのチャット
        ↓ 受信イベントを正規化
FastAPIゲートウェイ
        ├─ 通常投稿 → AI処理なし
        ├─ 秘密情報・非テキスト → 有人キュー
        └─ /ai、/ai+ → AI返信案を生成
                         ↓
                  SQLite承認キュー
                         ↓
                 Streamlit運営画面
                         ↓ 取得・編集・承認
                  送信アダプター
                         ↓
Social Messagingの元の会話へ返信

Streamlitは新しい問い合わせ窓口ではなく、既存チャットの裏側にある運営ツールです。

手順1:検証環境を作る

Python 3.11以降を前提にします。

mkdir chat-review-queue
cd chat-review-queue
python -m venv .venv
source .venv/bin/activate

pip install fastapi uvicorn streamlit pydantic

作成するファイルは2つです。

chat-review-queue/
├── app.py       # 受信、ポリシー判定、返信案生成
└── console.py   # 担当者向け承認キュー

コード1:チャット受信とAI返信案を分離する

app.pyを作成します。

from datetime import datetime, timezone
import json
import sqlite3
import uuid

from fastapi import FastAPI
from pydantic import BaseModel

DB = "chat.db"
app = FastAPI()


def now() -> str:
    return datetime.now(timezone.utc).isoformat()


def connect() -> sqlite3.Connection:
    con = sqlite3.connect(DB)
    con.row_factory = sqlite3.Row
    return con


def init_db() -> None:
    with connect() as con:
        con.executescript("""
        CREATE TABLE IF NOT EXISTS messages (
          seq INTEGER PRIMARY KEY AUTOINCREMENT,
          event_id TEXT NOT NULL UNIQUE,
          conversation_id TEXT NOT NULL,
          sender_id TEXT NOT NULL,
          content_type TEXT NOT NULL,
          text TEXT NOT NULL,
          created_at TEXT NOT NULL
        );

        CREATE TABLE IF NOT EXISTS cases (
          id TEXT PRIMARY KEY,
          event_id TEXT NOT NULL UNIQUE,
          conversation_id TEXT NOT NULL,
          status TEXT NOT NULL,
          reason TEXT NOT NULL,
          owner TEXT,
          context_json TEXT NOT NULL,
          draft TEXT,
          created_at TEXT NOT NULL
        );

        CREATE TABLE IF NOT EXISTS outbox (
          id TEXT PRIMARY KEY,
          conversation_id TEXT NOT NULL,
          body TEXT NOT NULL,
          state TEXT NOT NULL,
          created_at TEXT NOT NULL
        );
        """)


init_db()


class IncomingMessage(BaseModel):
    event_id: str
    conversation_id: str
    sender_id: str
    content_type: str
    text: str = ""


# 本番のモデレーション製品ではなく、AIへ送らないための最小ゲート。
SENSITIVE_MARKERS = (
    "パスワード",
    "秘密鍵",
    "クレジットカード",
    "access_token",
    "BEGIN PRIVATE KEY",
)


def has_sensitive_marker(text: str) -> bool:
    lowered = text.lower()
    return any(marker.lower() in lowered for marker in SENSITIVE_MARKERS)


def create_draft(context: list[dict]) -> str:
    """再現用の決定的なダミー。ここだけを任意のLLM呼び出しに交換する。"""
    question = context[-1]["text"]
    return (
        f"【AI返信案】「{question}」について確認しました。"
        "確定情報が不足しているため、担当者が条件を確認して回答します。"
    )


def enqueue_notice(con: sqlite3.Connection, conversation_id: str, body: str) -> None:
    con.execute(
        "INSERT INTO outbox VALUES (?, ?, ?, 'pending', ?)",
        (str(uuid.uuid4()), conversation_id, body, now()),
    )


@app.post("/events/message")
def receive(msg: IncomingMessage):
    with connect() as con:
        inserted = con.execute(
            """
            INSERT OR IGNORE INTO messages
              (event_id, conversation_id, sender_id, content_type, text, created_at)
            VALUES (?, ?, ?, ?, ?, ?)
            """,
            (
                msg.event_id,
                msg.conversation_id,
                msg.sender_id,
                msg.content_type,
                msg.text,
                now(),
            ),
        )

        if inserted.rowcount == 0:
            return {"result": "duplicate"}

        current_seq = con.execute(
            "SELECT seq FROM messages WHERE event_id = ?",
            (msg.event_id,),
        ).fetchone()["seq"]

        if msg.content_type != "text":
            mode, question, reason = "human", "", "non_text"
        elif msg.text.startswith("/human "):
            mode, question, reason = "human", msg.text[7:].strip(), "requested_human"
        elif msg.text.startswith("/ai+ "):
            mode, question, reason = "ai_context", msg.text[5:].strip(), "ai_opt_in_context"
        elif msg.text.startswith("/ai "):
            mode, question, reason = "ai_single", msg.text[4:].strip(), "ai_opt_in_single"
        else:
            return {"result": "ignored"}

        if has_sensitive_marker(question):
            mode, reason = "human", "sensitive_marker"

        context: list[dict] = []
        if mode == "ai_context":
            # グループ内の他ユーザーの発言は含めない。
            rows = con.execute(
                """
                SELECT sender_id, text
                FROM messages
                WHERE conversation_id = ?
                  AND sender_id = ?
                  AND content_type = 'text'
                  AND seq < ?
                ORDER BY seq DESC
                LIMIT 6
                """,
                (msg.conversation_id, msg.sender_id, current_seq),
            ).fetchall()
            context.extend(
                {"sender_id": row["sender_id"], "text": row["text"]}
                for row in reversed(rows)
            )

        if question:
            context.append({"sender_id": msg.sender_id, "text": question})

        draft = create_draft(context) if mode.startswith("ai") and context else None
        status = "awaiting_review" if draft else "needs_human"
        case_id = str(uuid.uuid4())

        con.execute(
            """
            INSERT INTO cases
              (id, event_id, conversation_id, status, reason, owner,
               context_json, draft, created_at)
            VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)
            """,
            (
                case_id,
                msg.event_id,
                msg.conversation_id,
                status,
                reason,
                json.dumps(context, ensure_ascii=False),
                draft,
                now(),
            ),
        )

        enqueue_notice(
            con,
            msg.conversation_id,
            "担当者が内容を確認します。AIの返信案は、承認されるまで送信されません。",
        )

        return {"result": "queued", "case_id": case_id, "status": status}


@app.get("/outbox")
def list_outbox():
    with connect() as con:
        rows = con.execute(
            "SELECT * FROM outbox ORDER BY created_at"
        ).fetchall()
        return [dict(row) for row in rows]

この例のcreate_draft()は、ワークフローを再現するためのダミーです。LangChainや任意のLLM SDKへ差し替える場合も、モデルにチャット送信権を渡さず、文字列の返信案を返すだけの関数として閉じ込めます。

また、SENSITIVE_MARKERSは本格的なモデレーションではありません。秘密情報らしき入力をLLMへ送らないための、保守的な検証用ゲートです。

コード2:Streamlitを担当者の承認キューにする

console.pyを作成します。

import os
import sqlite3
import uuid
from datetime import datetime, timezone

import streamlit as st

DB = "chat.db"
OPERATOR = os.getenv("OPERATOR", "operator@example.com")


def now() -> str:
    return datetime.now(timezone.utc).isoformat()


def connect() -> sqlite3.Connection:
    con = sqlite3.connect(DB)
    con.row_factory = sqlite3.Row
    return con


st.set_page_config(page_title="Chat review queue", layout="wide")
st.title("チャット返信・有人引き継ぎキュー")
st.caption(f"担当者: {OPERATOR}")

with connect() as con:
    cases = con.execute(
        """
        SELECT * FROM cases
        WHERE status IN ('awaiting_review', 'needs_human', 'claimed')
        ORDER BY created_at
        """
    ).fetchall()

if not cases:
    st.success("未処理案件はありません")

for case in cases:
    with st.container(border=True):
        st.write({
            "case_id": case["id"],
            "conversation_id": case["conversation_id"],
            "status": case["status"],
            "reason": case["reason"],
            "owner": case["owner"],
        })
        st.code(case["context_json"], language="json")

        if case["owner"] is None:
            if st.button("この案件を取得", key=f"claim-{case['id']}"):
                with connect() as con:
                    con.execute(
                        """
                        UPDATE cases
                        SET owner = ?, status = 'claimed'
                        WHERE id = ? AND owner IS NULL
                        """,
                        (OPERATOR, case["id"]),
                    )
                st.rerun()
            continue

        if case["owner"] != OPERATOR:
            st.info(f"{case['owner']} が対応中です")
            continue

        reply = st.text_area(
            "送信内容",
            value=case["draft"] or "",
            key=f"reply-{case['id']}",
        )

        col1, col2 = st.columns(2)
        with col1:
            if st.button("承認して送信待ちへ", key=f"send-{case['id']}"):
                if not reply.strip():
                    st.error("返信本文が空です")
                else:
                    with connect() as con:
                        con.execute(
                            """
                            INSERT INTO outbox
                              (id, conversation_id, body, state, created_at)
                            VALUES (?, ?, ?, 'pending', ?)
                            """,
                            (str(uuid.uuid4()), case["conversation_id"], reply, now()),
                        )
                        con.execute(
                            "UPDATE cases SET status = 'approved' WHERE id = ?",
                            (case["id"],),
                        )
                    st.rerun()

        with col2:
            if st.button("回答せず終了", key=f"close-{case['id']}"):
                with connect() as con:
                    con.execute(
                        "UPDATE cases SET status = 'closed' WHERE id = ?",
                        (case["id"],),
                    )
                st.rerun()

起動します。

uvicorn app:app --reload --port 8000

別のターミナルで運営画面を起動します。

source .venv/bin/activate
OPERATOR=alice@example.com streamlit run console.py

手順3:Tencent RTCとの接続境界を決める

検証コードでは、送信予定メッセージをoutboxへ保存しています。本番では次の2か所だけをSocial Messagingのアプリケーション統合へ置き換えます。

  1. 受信イベントをIncomingMessage形式へ正規化し、/events/messageへ渡す
  2. outbox.state = 'pending'のレコードを、元のconversation_idへ送信する

認証情報や送信処理をStreamlitへ置かないことが重要です。Streamlitは取得・編集・承認だけを担当し、実際の送信はサーバー側アダプターに限定します。

送信に成功したらoutbox.stateをsentへ、失敗したらfailedへ更新します。再試行時はoutbox.idを冪等キーとして扱い、同じ返信を二重送信しない設計にします。

確認方法:5つの入力で制御経路を検証する

1. 通常発言はAIへ渡らない

curl -s http://localhost:8000/events/message \
  -H 'content-type: application/json' \
  -d '{
    "event_id":"evt-001",
    "conversation_id":"group-42",
    "sender_id":"user-7",
    "content_type":"text",
    "text":"こんにちは"
  }'

期待結果はignoredです。AIを会話へ追加したからといって、全発言を自動的に収集しません。

2. 明示された1件だけAI返信案になる

curl -s http://localhost:8000/events/message \
  -H 'content-type: application/json' \
  -d '{
    "event_id":"evt-002",
    "conversation_id":"group-42",
    "sender_id":"user-7",
    "content_type":"text",
    "text":"/ai 配送先を変更できますか"
  }'

Streamlitにawaiting_reviewの案件が表示されます。返信案はまだユーザーへ送られていません。

3. 同一イベントを再送しても案件を増やさない

同じevt-002をもう一度POSTします。期待結果は次です。

{"result":"duplicate"}

チャット基盤やアプリケーションサーバーの再送を、AIへの二重依頼や二重返信に変換しないための確認です。

4. 秘密情報らしき入力はAIへ渡さない

curl -s http://localhost:8000/events/message \
  -H 'content-type: application/json' \
  -d '{
    "event_id":"evt-003",
    "conversation_id":"group-42",
    "sender_id":"user-7",
    "content_type":"text",
    "text":"/ai access_tokenを貼ったので調べて"
  }'

statusはneeds_human、reasonはsensitive_markerとなり、draftは生成されません。

5. 案件を取得した担当者だけが承認する

Streamlitで案件を取得し、返信文を編集して「承認して送信待ちへ」を押します。その後、次を確認します。

curl -s http://localhost:8000/outbox

担当者が確定した本文だけがpendingとして追加されていれば成功です。

翻訳を入れるなら、AI文脈とは別の操作にする

多言語チャットでは、翻訳文をそのままAIの入力へ使いたくなります。しかし、自動翻訳とAI回答を一体化すると、誤訳なのか回答ミスなのかを切り分けにくくなります。

TUIChatにはテキストメッセージをオンデマンド翻訳する方法が用意されています。対応するコンテンツ形式、言語、エディション上の条件は、実装時点の公式ドキュメントを確認してください。

運用上は次の分離が安全です。

  • ユーザー向け翻訳:TUIChat上で明示操作する
  • AIへの入力:原文を保存し、必要なら翻訳文を別フィールドにする
  • 担当者画面:原文と翻訳文を併記する
  • 最終返信:担当者が送信言語を確認する

翻訳文だけを保存すると、後から判断根拠を追跡できません。

注意点とトレードオフ

Streamlit自体を認証境界にしない

サンプルでは環境変数で担当者名を指定していますが、本番の本人確認にはなりません。社内SSO、リバースプロキシ、アクセス制御を組み合わせ、運営画面を一般公開しないでください。

人の承認は回答速度と引き換えになる

全件承認は安全側ですが、担当者数を超えるとキューが滞留します。自動送信へ急いで移行するのではなく、まず以下を計測します。

  • 未取得案件の件数と最古作成時刻
  • AI返信案がそのまま承認された割合
  • 編集された項目
  • 有人へ切り替えた理由
  • 送信失敗と再試行回数

十分に限定された質問だけを後から自動化候補にする方が、最初から万能ボットを目指すより判断しやすくなります。

簡易キーワード判定を安全機能と呼ばない

表記揺れ、画像内文字列、難読化、文脈依存の攻撃は検出できません。本番では利用目的に合ったモデレーション、添付ファイル処理、監査、保持期間、削除手続きを別途設計してください。

/ai+でもグループ全体を渡さない

今回のコードは同じ送信者の直近6件だけを取得します。他の参加者がAI利用へ同意したとは限らないためです。グループ全体の文脈が必要なら、参加者への表示、同意単位、保持期間、退出後の扱いを先に決めます。

最終チェックリスト

  • AIを使う操作がユーザーから見える
  • 通常メッセージを無条件でLLMへ送っていない
  • 非テキストと秘密情報候補を有人経路へ送れる
  • 同じ受信イベントから案件が重複生成されない
  • AIモデルは返信案しか返せない
  • 案件に担当者が記録される
  • 承認前の返信案がチャットへ送信されない
  • 送信処理がStreamlitの外にある
  • ユーザーに有人確認中であることが見える
  • 翻訳文と原文を区別して追跡できる

短いAIチャットUIは、アイデアを触るためには有効です。一方、実運用へ進める際に必要なのは画面の豪華さではなく、誰がAIを呼び、何を渡し、誰が案件を所有し、誰が送信を確定したかを追跡できることです。

Streamlitをユーザー向けの新しい窓口ではなく、人が制御を取り戻すための運営キューとして使うと、デモから運用への境界を明確にできます。


関係性の開示: 筆者はTencent RTCのコンテンツ制作に関係しています。本稿の製品情報と実装上の境界確認には、Tencent RTCの公式ドキュメントを参照しました。

0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?