Discord通知を1行に集約する方法──固定ルールで状態確認を減らす

パステル系を描いたアイキャッチ画像

Discordの通知や状態タイルを確認し、会話へ貼り付ける作業は、件数が少なくても繰り返すほど視線を奪います。某所ラボでは、その状態のハンドキャリーを固定ルールの1行ダイジェストへ渡しました。

2026年8月19日、所長は「ディスコードの通知とか、スタジオのタイルのステータスを確認して、[AIとの対話]に貼り付けるのがめんどくさい。全体ステータスの確認がめんどくさい。を解消したい」と話しました。今回の到達点は、セッション冒頭で異常の有無を目視パースなしに把握できる状態です。

通知の意味を推測するAIは使っていません。固定語彙で分類し、数えるべきものだけを数えたところ、必要な入口として十分に動きました。

目次

貼り付け作業が必要だったのは、状態が散っていたからです

前期には、誰にどの仕事を任せるかを実測で決める道具を作りました。誰にどの仕事をやらせるかを、実測で決める道具を作った記録の次に残ったのは、任せた後の状態をどう見るかでした。

今回の問題は通知そのものではありません。通知ミラー、解決済みの台帳、レーンの鮮度、実行枠、タスク列などを別々に開き、必要な断片を会話へ移すことでした。作業者が欲しいのは全ログの再掲ではなく、「今、対応が要るものはあるか」という入口です。

固定ルールとLLM呼び出しを比べると、今回は前者で足りました

選択肢 この用途で得るもの 今回の判断
LLMで通知を要約する 文脈を含む言い換え 採用しませんでした。異常の入口を作るだけなら推測は不要でした。
固定キーワード規則で分類する 判定根拠を追いやすく、オフラインで動く 採用しました。「停止」「fail」「失敗」などの固定語彙だけで分類します。
人が各画面を開いて会話へ貼る 個別の細部まで読める 詳細確認が必要な時だけ戻る手段として残し、常用の入口から外しました。

ここで扱う分類器は、何が起きたかを理解したふりはしません。READMEにも、キーワードによるdanger/warning判定のみで、判断・推測をしないと明記されています。今回は、その不器用さが都合よく働きました。

8種類の読み取り元を、壊れても止まらない入口へ束ねました

図解: 8ソースを3出力へ集約(読み取り専用の8ソース / 固定プログラム / 全体ダイジェスト / --line / --json / セッション起動フック)
図解: 8ソースを3出力へ集約(AI生成)

集約元はすべて読み取り専用で、環境変数による差し替えができます。監視に入れる部品と鳴らす条件の考え方は、既存の監視の部品表にもつながっています。

構成は、8種類の状態を固定プログラムへ渡し、全体表示・1行表示・JSON出力へ分けるだけです。

図1: 構成図・8種類の読み取り元を3つの出力モードへ集約する
実画面・構成図 読み取る対象 出力先
読み取り専用の8ソース 通知ミラー、解決台帳、レーン鮮度、quota 4口、タスクキュー、アプリ進捗、判断待ち札、未処置キュー 固定プログラム
固定プログラム 固定語彙によるdanger/warning判定 全体ダイジェスト、--line--json
セッション起動フック --lineの1行出力 セッション冒頭のバナー

図1で重要なのは、どれか1つのソースが読めなくても、ほかの表示を止めない点です。取得できなかったものは末尾に[取得失敗]とファイル名を残します。バナー側も失敗を飲み込み、ダイジェストが壊れてもセッション起動自体は止めません。

レーン鮮度の常設監視は、対象一覧を自分で固定しない設計です。そこはレーンの番犬は、監視対象の一覧を自分で持たないで扱った方向を、そのまま集約側へ持ち込んでいます。

最小実物は、否定された失敗を数えずに1行へまとめます

図解: 1行への固定ルール(alerts / 否定された失敗 / 固定語彙 / 件数 / quota最低値 / 1行)
図解: 1行への固定ルール(AI生成)

製品版は8ソースの読取り、解決台帳との照合、セッション起動フックまで含みます。ただし公開用の最小実物は、固定語彙で異常を分類し、件数とquota最低値を1行にする中心部分だけを再現します。Python 3と標準ライブラリだけで動作し、外部パッケージとネットワークは使いません。

実行前には、次の内容をe0-status-digest.pyとして保存してください。

#!/usr/bin/env python3
"""Offline minimum of status_digest.py's fixed-rule one-line aggregation."""

from __future__ import annotations

import argparse
import json
import re
import sys
from typing import Any


NEGATED_FAILURE = re.compile(
    r"(?:失敗|エラー|fail(?:ure|ed)?s?|errors?)\s*(?:units?)?\s*"
    r"(?:=|:)?\s*(?:ゼロ|0\b|なし|zero|none)",
    re.IGNORECASE,
)


def compact_space(value: Any) -> str:
    return re.sub(r"\s+", " ", str(value or "")).strip()


def alert_severity(message: str) -> str:
    """Classify the first line with the production digest's fixed vocabulary."""
    headline = compact_space(message.splitlines()[0] if message else "")
    lowered = NEGATED_FAILURE.sub("", headline.lower())
    if any(word in lowered for word in ("停止", "fail", "失敗", "critical", "veto", "blocker")):
        return "danger"
    if any(word in lowered for word in ("提案", "warn", "要確認", "更新", "advisory")) \
            or re.search(r"\breview\b", lowered):
        return "warning"
    return "info"


def render_line(payload: dict[str, Any]) -> str:
    alerts = payload.get("alerts") if isinstance(payload.get("alerts"), list) else []
    action_count = sum(
        alert_severity(str(message)) in {"danger", "warning"}
        for message in alerts
    )
    automatic = int(payload.get("automatic_jobs") or 0)
    lane_anomalies = payload.get("lane_anomalies")
    lane_count = len(lane_anomalies) if isinstance(lane_anomalies, list) else 0
    quota = payload.get("quota_remaining")
    quota_rows = quota if isinstance(quota, dict) else {}

    parts: list[str] = []
    if action_count == 0 and lane_count == 0:
        parts.append("異常なし")
    if action_count:
        parts.append(f"オーナー対応{action_count}件")
    if automatic:
        parts.append(f"broker自動処理中{automatic}件")
    if lane_count:
        parts.append(f"レーン異常{lane_count}件")
    if quota_rows:
        ordered = sorted((float(value), str(name)) for name, value in quota_rows.items())
        remaining, label = ordered[0]
        parts.append(f"quota最低 {label}{remaining:.0f}%")
    return " / ".join(parts)


SAMPLE = {
    "alerts": [
        "lane停止: freshness exceeded",
        "daily run: 失敗 0",
        "inventory complete",
    ],
    "automatic_jobs": 3,
    "lane_anomalies": ["freshness", "unit"],
    "quota_remaining": {"Claude": 81, "Codex": 63},
}


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--sample",
        action="store_true",
        help="run the embedded reproducible fixture",
    )
    args = parser.parse_args()
    payload = SAMPLE if args.sample else json.load(sys.stdin)
    if not isinstance(payload, dict):
        raise SystemExit("input must be a JSON object")
    print(render_line(payload))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

保存後の実行コマンドは1つです。

$ PYTHONDONTWRITEBYTECODE=1 python3 e0-status-digest.py --sample

2026年8月23日、オフラインの埋め込みfixtureで終了コード0を得ました。

オーナー対応1件 / broker自動処理中3件 / レーン異常2件 / quota最低 Codex63%

この結果では、daily run: 失敗 0は否定された失敗として除外されます。一方、lane停止: freshness exceededは「停止」に一致するため、オーナー対応1件に数えられます。quotaは小さい順に選ぶため、Claude 81%ではなくCodex 63%が表示されました。

検証日 主要コンポーネント 確認した値
2026年8月23日 Python 3.14.4、Python標準ライブラリのみ 終了コード0、1行出力に対応1件・自動処理3件・レーン異常2件・quota最低63%

製品版の8ソース読取りやセッション起動フックまでは、この最小実物に含めていません。--sampleを外す場合は、alertsautomatic_jobslane_anomaliesquota_remainingを持つJSONオブジェクトを標準入力で渡します。

文字を毎回貼る案は撤回し、監視を実際に動かす方へ寄りました

--runningは、後から追加した補助コマンドです。当初は報告末尾へ実行中の便を示す文面を添えるつもりでした。しかし2026年8月20日、所長から「文字の出力が欲しいわけではない。タスクとしてウォッチドッグされてれば良い」と指摘がありました。

そこで、毎回貼る文面としての役割は撤回しました。実測の--running出力は、実行中の便1件と、常設監視3件──レーン鮮度の番犬、便の統合出口、便の配車──を示します。タスクIDは出しません。利用者に必要なのは内部構造ではなく、監視が実際に置かれているかどうかだからです。

今は1行で入口を作り、次は長時間の便を監視下へ寄せます

現在は、セッション冒頭に1行ダイジェストが自動表示され、異常の有無を先に読める状態です。すべての事情を要約してくれる仕組みではありませんが、画面を巡回してから会話を始める必要は減りました。

次に寄せたいのは、長時間走る便をセッションの背景タスクとして実際に監視下へ置くことです。まずは自分の通知を、固定語彙で「対応が必要」「それ以外」に分けられるか数えてください。分けられない情報は、まだ1行へ集約する段階ではありません。

この検証を回している環境

この検証は、自宅の常設ラボ(使い捨てVM/LXCを回す母艦+GPU+VLAN分離ネットワーク)で動かしています。使っている機材と選定理由、全体構成は1本にまとめています。

ラボ構成のまとめを見る →
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

目次