設計書: News Reminder + メールサマリー リニューアル

Issue #126 | 2026-03-16 | architect

1. 概要

News Reminder(金融ニュース)とメールダイジェストの Slack 投稿を以下の観点でリニューアルする。

  1. 日付スレッド方式: 日付で親メッセージ → 詳細はスレッド内に返信
  2. Block Kit フォーマット: 表・リンク・絵文字で視認性向上
  3. News Reminder 頻度調整: 毎朝 → 平日のみに変更
  4. メールダイジェストのスケジューラ組み込み
  5. ニュースのカテゴリ明確化: 「金融ニュース」と明示

2. 日付スレッド方式の設計

2.1 仕組み

親メッセージ(日付ヘッダ)をチャンネルに投稿し、そのスレッドに詳細を返信する。同日に複数回実行された場合は既存スレッドに追記する。

項目News Reminderメールダイジェスト
親メッセージ📊 2026-03-16 金融ニュースダイジェスト📬 2026-03-16 メールダイジェスト
投稿先#news-reminder#mail-yuki, #mail-info
スレッド内容銘柄ごとのニュース要約(Block Kit)カテゴリ別メール要約(Block Kit)
同日再実行既存スレッドに追記既存スレッドに追記

2.2 親メッセージ ts の管理

各チャンネル・日付ごとの親メッセージ ts を永続化し、同日再実行時に再利用する。

保存先

パイプラインファイル
News Reminderops/news_reminder/data/thread_ts.json
メールダイジェストops/mail_filing/data/digest_thread_ts.json

フォーマット

{
  "2026-03-16": {
    "channel_id": "C0123ABC",
    "ts": "1710561234.000100"
  }
}

古いエントリは30日超で自動削除(冪等性維持 + ファイル膨張防止)。

2.3 処理フロー

1. 今日の日付キーで thread_ts.json を検索
2. 見つかった → そのスレッドに返信(thread_ts 指定)
3. 見つからない →
   a. 親メッセージを投稿(Block Kit header)
   b. 返された ts を thread_ts.json に保存
   c. スレッドに詳細を返信

3. Block Kit フォーマット設計

3.1 News Reminder — 親メッセージ

📊 金融ニュースダイジェスト
2026-03-16(月)| 3銘柄 / 7件のニュース

Block Kit 構成:

[
  { "type": "header", "text": "📊 金融ニュースダイジェスト" },
  { "type": "section", "text": "2026-03-16(月)| 3銘柄 / 7件のニュース" }
]

3.2 News Reminder — スレッド返信(銘柄ごと)

S&P 500(VOO)
⚠️ FRBが利下げ見送りを示唆、市場に慎重ムード — Reuters
→ 🔴 ネガティブ: 利下げ期待後退でインデックスに下押し圧力
米テック株が週間で3%上昇、AI需要が牽引 — Bloomberg
→ 🟢 ポジティブ: AI関連銘柄の組入比率が高いVOOに追い風
💡 総合: FRB動向に注意しつつ、テック主導の上昇基調は継続見込み

Block Kit 構成(1銘柄 = 1返信メッセージ):

[
  { "type": "header", "text": "{銘柄名}({ティッカー})" },
  { "type": "divider" },
  // ニュース1件ごとに section
  {
    "type": "section",
    "text": "⚠️ *<{url}|{要約}>* — {出典}\n→ 🔴 ネガティブ: {影響分析}"
  },
  // ... 最大3件
  { "type": "divider" },
  { "type": "context", "elements": [{ "type": "mrkdwn", "text": "💡 {銘柄別コメント}" }] }
]
50ブロック制限対策: 銘柄ごとに別メッセージとしてスレッド返信するため、1メッセージあたりのブロック数は最大10程度に収まる。

3.3 News Reminder — 総合コメント(最後のスレッド返信)

💡 総合コメント
FRBの利下げ見送り示唆は短期的にはネガティブだが、テック主導の上昇基調は崩れていない。暗号資産市場はETF関連のニュースに敏感に反応中。当面は様子見が妥当。

3.4 メールダイジェスト — 親メッセージ

📬 メールダイジェスト: yuki.uchiyama(個人)
2026-03-16(月)| 12件の未読

3.5 メールダイジェスト — スレッド返信(カテゴリごと)

🔴 要対応(3件)
田中太郎 — 契約書の確認依頼
  3月末までに署名が必要。添付PDFを確認のこと
大野裕希 — 月次レポートのレビュー
  3月分の売上データに修正あり。確認後フィードバック
Amazon Web Services — 請求金額の確認
  今月の請求が前月比+30%。利用状況を確認推奨

Block Kit 構成(カテゴリごと = 1返信メッセージ。カテゴリ: 🔴 要対応 / 🟡 情報共有 / ⚪ 無視可能):

[
  { "type": "header", "text": "🔴 要対応({N}件)" },
  { "type": "divider" },
  // メール1件ごとに section
  {
    "type": "section",
    "text": "• *{送信者}* — {件名}\n  {要点}"
  },
  // ... 件数分
]
件数0のカテゴリは省略(現行仕様を維持)。

3.6 Claude CLI へのプロンプト変更

summarize.py と digest.py の要約プロンプトを変更し、JSON 形式で出力させる。Block Kit 組み立てはPython側で行う。

News Reminder — 新出力フォーマット

{
  "holdings": [
    {
      "ticker": "VOO",
      "name": "S&P 500",
      "articles": [
        {
          "summary": "FRBが利下げ見送りを示唆、市場に慎重ムード",
          "source": "Reuters",
          "url": "https://...",
          "impact": "negative",
          "impact_detail": "利下げ期待後退でインデックスに下押し圧力",
          "alert": true
        }
      ],
      "comment": "銘柄別の所感"
    }
  ],
  "overall_comment": "全体の総合コメント"
}

メールダイジェスト — 新出力フォーマット

{
  "categories": [
    {
      "level": "action_required",
      "emoji": "🔴",
      "label": "要対応",
      "items": [
        {
          "sender": "田中太郎",
          "subject": "契約書の確認依頼",
          "summary": "3月末までに署名が必要。添付PDFを確認のこと"
        }
      ]
    }
  ]
}

4. 変更対象ファイル一覧

ファイル変更内容
ops/news_reminder/pipeline/steps/summarize.py 変更
  • プロンプトをJSON出力形式に変更
  • 返り値を strdict に変更
ops/news_reminder/pipeline/steps/notify.py 変更
  • 日付スレッド方式に変更(親メッセージ + スレッド返信)
  • Block Kit フォーマットでの投稿
  • thread_ts.json の読み書き
  • 入力を strdict に変更
ops/mail_filing/pipeline/steps/digest.py 変更
  • 要約プロンプトをJSON出力形式に変更
  • 日付スレッド方式に変更
  • Block Kit フォーマットでの投稿
  • _post_to_slack()slack_sdk.WebClient に統一
  • digest_thread_ts.json の読み書き
bot/scheduler.py 変更
  • News Reminder: 平日のみ実行に変更
  • メールダイジェスト: digest コマンドをスケジューラに追加(8:00実行)
ops/news_reminder/app/config.py 変更 slack_channel のデフォルト値確認(変更不要の可能性あり)

5. 頻度調整の設計

5.1 News Reminder: 平日のみ(推奨案)

頻度メリットデメリット
A. 平日のみ(推奨) 月〜金 7:00 金融市場は平日のみ。土日のノイズ除去。週5→5回で変わらないが休日の通知がなくなる 土日に重大ニュースがあった場合、月曜まで遅れる
B. 週3回 月・水・金 7:00 通知頻度が半減 火・木のニュースが翌日まで遅れる
C. 週1回 月 7:00 週次レビュー的な使い方 タイムリー性が大幅に低下

推奨: 案A(平日のみ)。金融ニュースという性質上、市場が動く平日のみで十分。土日は市場が休場で新着が少なく、月曜に週末分もまとめて配信される。

5.2 実装方法

# scheduler.py の news_scan 判定に曜日チェックを追加
import jpholiday  # 既にops依存にあり

def _is_business_day(d: date) -> bool:
    """平日(月〜金)かつ祝日でないか判定"""
    return d.weekday() < 5 and not jpholiday.is_holiday(d)
jpholiday は既に ops の依存に含まれている。祝日も除外することで、本当に市場が動く日だけ通知する。

5.3 メールダイジェスト: 毎朝 8:00(現状維持 + スケジューラ追加)

メールは毎日届くため、頻度調整は不要。現在手動実行のみだが、スケジューラに追加して毎朝 8:00 に自動実行する。

既に実装済み: scheduler.py_run_mail_filing が 8:00 に mail_filing.pipeline run を実行しているが、これは添付ファイル振り分け用。ダイジェストは別コマンドのため追加が必要。

# scheduler.py に追加
MAIL_DIGEST_HOUR = 8  # mail_filing と同時刻でOK(非同期で順次実行)

# run() ループ内に追加
if (
    now.hour == MAIL_DIGEST_HOUR
    and self._last_runs.get("mail_digest") != today
):
    self._last_runs["mail_digest"] = today
    self._save_last_runs()
    await self._run_mail_digest()

async def _run_mail_digest(self) -> None:
    """メールダイジェストを subprocess で実行する。"""
    proc = await asyncio.create_subprocess_exec(
        sys.executable, "-m", "mail_filing.pipeline", "digest",
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
        cwd=str(PROJECT_ROOT),
    )
    # ... タイムアウト・エラーハンドリングは _run_mail_filing と同様

6. Slack 送信方式の統一

現状3つの方式が混在している。

ファイル現状変更後
notify.pyslack_sdk.WebClient(同期)slack_sdk.WebClient(維持)
digest.pyhttpx 直書きslack_sdk.WebClient(統一)
scheduler.pyslack_sdk.AsyncWebClient維持(Bot本体から受け取るため)

理由: Block Kit の blocks パラメータを正しく送信するには slack_sdk が確実。httpx 直書きは blocks の JSON シリアライズでミスが起きやすい。

7. 関数シグネチャ定義

7.1 notify.py(News Reminder)

from __future__ import annotations
from typing import TypedDict

class ArticleSummary(TypedDict):
    summary: str
    source: str
    url: str
    impact: str          # "positive" | "negative" | "neutral"
    impact_detail: str
    alert: bool          # ⚠️ マーク対象

class HoldingSummary(TypedDict):
    ticker: str
    name: str
    articles: list[ArticleSummary]
    comment: str

class NewsSummaryResult(TypedDict):
    holdings: list[HoldingSummary]
    overall_comment: str

# ts管理
def _load_thread_ts(data_dir: Path) -> dict[str, dict]:
    """thread_ts.json を読み込む。30日超のエントリは削除。"""

def _save_thread_ts(data_dir: Path, ts_data: dict[str, dict]) -> None:
    """thread_ts.json を保存する。"""

def _get_or_create_thread(
    client: WebClient,
    channel: str,
    date_str: str,
    data_dir: Path,
) -> str:
    """日付スレッドの ts を取得。なければ親メッセージを投稿して作成。"""

# メイン
def run(cfg: PipelineConfig, summary: NewsSummaryResult) -> None:
    """要約結果を日付スレッド方式・Block Kit で Slack に投稿する。"""

7.2 summarize.py(News Reminder)

# 返り値の変更
async def run(cfg: PipelineConfig) -> NewsSummaryResult | None:
    """取得済みニュースを Claude CLI で要約する。構造化された辞書を返す。"""

# プロンプトにJSON出力指示を追加
# Claude CLI の出力を json.loads() でパース
# パース失敗時は最大1回リトライ(プロンプトに「JSONのみ出力せよ」を強調)

7.3 digest.py(メールダイジェスト)

from __future__ import annotations
from typing import TypedDict

class DigestItem(TypedDict):
    sender: str
    subject: str
    summary: str

class DigestCategory(TypedDict):
    level: str       # "action_required" | "info" | "ignorable"
    emoji: str       # "🔴" | "🟡" | "⚪"
    label: str       # "要対応" | "情報共有" | "無視可能"
    items: list[DigestItem]

class DigestResult(TypedDict):
    categories: list[DigestCategory]

# ts管理(notify.py と同じパターン)
def _load_thread_ts() -> dict[str, dict]: ...
def _save_thread_ts(ts_data: dict[str, dict]) -> None: ...
def _get_or_create_thread(
    client: WebClient,
    channel: str,
    date_str: str,
    mailbox_name: str,
) -> str: ...

# 要約結果のパース
def _parse_summary(raw: str) -> DigestResult: ...

# Block Kit 組み立て
def _build_category_blocks(cat: DigestCategory) -> list[dict]: ...

# Slack投稿(httpx → slack_sdk.WebClient に変更)
def _post_to_slack(
    client: WebClient,
    channel: str,
    thread_ts: str,
    blocks: list[dict],
    text: str,
) -> None: ...

8. データフロー

8.1 News Reminder

fetch (既存)
  → fetched.json(中間データ)
    → summarize.py(Claude CLI)
      → NewsSummaryResult(構造化dict)
        → notify.py
          → 1. thread_ts.json 確認/作成
          → 2. 親メッセージ投稿(or 既存スレッド取得)
          → 3. 銘柄ごとにスレッド返信(Block Kit)
          → 4. 総合コメントをスレッド返信
          → 5. 通知済み記録

8.2 メールダイジェスト

Graph API(未読メール取得)
  → _build_mail_text()
    → Claude CLI(要約)
      → DigestResult(構造化dict)
        → 1. digest_thread_ts.json 確認/作成
        → 2. 親メッセージ投稿(or 既存スレッド取得)
        → 3. カテゴリごとにスレッド返信(Block Kit)
        → 4. 処理済み記録

9. エラーハンドリング

エラーケース対処
Claude CLI の出力が JSON パース不可1回リトライ(プロンプトに「JSONのみ」を強調)。2回目も失敗 → 従来のプレーンテキスト投稿にフォールバック
Slack API エラー(親メッセージ投稿失敗)ログ出力して終了。スレッド返信は行わない
Slack API エラー(スレッド返信失敗)ログ出力して次の返信に続行(1件の失敗で全体を止めない)
thread_ts.json 破損空の状態として扱う(新規スレッド作成)
同日のスレッドtsが無効(チャンネル削除等)Slack APIが thread_not_found を返す → tsを削除して新規作成

10. scheduler.py 変更詳細

10.1 News Reminder: 平日判定の追加

# run() ループ内の news_scan 条件を変更
if (
    now.hour == self.scan_hour
    and self._last_runs.get("news_scan") != today
    and _is_business_day(today)  # ← 追加
):
    ...

10.2 メールダイジェストのスケジューラ追加

# 定数追加
MAIL_DIGEST_HOUR = 8

# run() ループ内に追加
if (
    now.hour == MAIL_DIGEST_HOUR
    and self._last_runs.get("mail_digest") != today
):
    self._last_runs["mail_digest"] = today
    self._save_last_runs()
    await self._run_mail_digest()

_run_mail_digest()_run_mail_filing() と同じパターンで python -m mail_filing.pipeline digest を subprocess 実行する。

10.3 ログ出力の改善

# 起動ログにmail_digestも追加
log.info(
    "DailyScheduler 起動: news_scan=%02d:00(平日), mail_filing=%02d:00, mail_digest=%02d:00",
    self.scan_hour, MAIL_FILING_HOUR, MAIL_DIGEST_HOUR,
)

11. AI News スキャン(scheduler.py 内)への影響

scheduler.py_post_summary()_ask_if_to_evaluate() は AI News スキャン結果(#ai-ops チャンネル)用であり、今回の金融ニュース(#news-reminder)とは別系統。変更不要

整理: 2つのニュース系統を混同しないこと。
#news-reminder: 金融ニュース(News Reminder パイプライン)← 今回のリニューアル対象
#ai-ops: AIニュース(scheduler.py の _run_daily)← 変更なし

12. 前提条件・制約

13. トレードオフ・検討した代替案

13.1 Claude CLI の出力形式

採用理由
JSON出力 + Pythonで Block Kit 組み立て ✅ 採用 Block Kit のブロック構造を正確に制御可能。レイアウト変更がPythonコード側で完結
Claude CLI に直接 Block Kit JSON を生成させる Block Kit の仕様は複雑で、LLM が正しい JSON を出力する保証がない。デバッグも困難
従来のプレーンテキスト維持 + Block Kit ラッパー プレーンテキストからの構造化パースは不安定。最初から JSON で出力させる方が確実

13.2 thread_ts の管理方法

採用理由
JSONファイル(data/thread_ts.json) ✅ 採用 既存の data/ パターンと一貫。シンプルで冪等
Slack API で当日の投稿を検索 API呼び出し増加。conversations.history の権限追加が必要になる可能性

13.3 Slack送信方式の統一範囲

採用理由
digest.py の httpx → slack_sdk に変更のみ ✅ 採用 最小変更。notify.py は既に slack_sdk 使用中
共通 Slack ヘルパーモジュールを作成 2箇所の使用では過剰な共通化。パイプラインが独立している設計を壊す