mcpField
V
voice-debrief

voice-debrief

github.com/realwooolf生産性1642 回インストール16 週間前に更新
リポジトリ

voice-debrief について

录音复盘は、面接や会議などの会話を整理するスキルです。音声・動画の文字起こしや既存のPDFテキストから、会話記録や構造化された要約を作り、MarkdownファイルまたはFeishuのドキュメントや多次元テーブルに保存できます。

主な機能

  1. 01音声・動画ファイルの文字起こし
  2. 02複数の文字起こし結果の統合と順序整理
  3. 03会話記録、Q&A整理、会議議事録の作成
  4. 04結果のMarkdownファイルへの保存
  5. 05Feishuドキュメントと多次元テーブルへの記録

活用シーン

  1. 01面接録音からQ&Aと総評を作成
  2. 02会議録音から議事録とタスクを整理
  3. 03複数の録音をまとめて順序付け
  4. 04振り返り結果の保存と追加質問の追記

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/realwooolf/voice-debrief --skill voice-debrief

インストール後、Claude Code で/voice-debriefを入力して読み込まれたか確認します。

同梱ファイルREADME.md

詳細

録音の振り返り(面接/会議)

渡されたファイルの種類に基づいて、実行する段階を自動判定します。

  • 音声ファイル(.wav / .m4a / .mp3)または動画ファイル(.mp4)が渡された場合 → 段階一:ローカルで文字起こし
  • PDF ファイル(文字起こし済みテキストがある場合)が渡された場合 → 段階二:整理して結果を出力

注意: Claude Code は音声/動画ファイルの直接アップロードに対応していません。ファイルパスを貼り付けるか、ファイルの場所がわかるスクリーンショットを添付してください(Finder ウィンドウの下部などに完全なパスが表示されます)。例: 帮我整理这场面试 /Users/xxx/Desktop/某公司二面.mp3

複数ファイルの処理: ユーザーから複数の音声/動画ファイルが渡された場合、段階一の完了後に段階零(複数ファイルの統合と並べ替え)を自動実行し、その後、段階二に進みます。ファイルが1つの場合は、統合をスキップして直接処理します。


事前準備

使用前に、以下のツールがインストールされていることを確認してください。

# 转录(二选一,按平台选)
pip install mlx-whisper          # Apple Silicon Mac 用,GPU 加速,推荐
pip install faster-whisper        # 其他平台 fallback,比 openai-whisper 快 2-4 倍

# 音视频处理(whisper 内部依赖 ffmpeg 解码音频)
# macOS
brew install ffmpeg
# Ubuntu / Debian
sudo apt install ffmpeg
# Windows
# 前往 https://ffmpeg.org/download.html 下载,或用 winget install ffmpeg

プラットフォームに関する説明:

  • Apple Silicon Mac(M1/M2/M3/M4)→ mlx-whisper を使用します。速度は5~10倍です
  • Linux / Windows + NVIDIA GPU → faster-whisper + CUDA を使用し、自動検出します
  • Intel Mac / GPU なしの Linux / Windows → faster-whisper + CPU int8 を使用します。openai-whisper より2~4倍高速です

飛書の設定

飛書への保存を選択する場合は、以下の初回のみ必要な設定を事前に完了してください。ローカル保存を選択する場合は、このセクションをスキップできます。

手順一:飛書のカスタムアプリを作成する
  1. open.feishu.cn を開き、飛書アカウントにログインします
  2. 右上の「開発者コンソール」→「アプリを作成」→「カスタムアプリ」を選択します
  3. アプリ名(interview-debrief など、任意)と説明を入力して作成します
手順二:権限を有効にする
  1. アプリを開き、左側のメニューから「権限管理」をクリックします
  2. 「権限の一括インポート/エクスポート」→「インポート」をクリックし、以下の JSON を貼り付けて、「次へ:追加する権限を確認」をクリックします:
    {
      "scopes": {
        "user": [
          "docx:document",
          "bitable:app",
          "docs:doc",
          "docs:doc:readonly",
          "docs:document:import",
          "drive:drive",
          "drive:file",
          "wiki:wiki"
        ]
      }
    }
    
  3. 確認すると、すべての権限が有効になります(テスト用テナント環境では審査不要です)
手順三:コールバック URL を追加する
  1. 左側のメニューから「セキュリティ設定」をクリックします
  2. 「リダイレクト URL」を見つけて、「追加」をクリックし、以下を入力します:
    http://localhost:9998/callback
    
  3. 保存します
手順四:アプリを公開する
  1. 左側のメニューから「バージョン管理と公開」→「バージョンを作成」をクリックします
  2. バージョン番号に 1.0.0 を入力し、更新内容には任意の内容を入力します
  3. 利用可能範囲はデフォルトのままにします(一部のメンバーのみ、自分だけに表示)
  4. 「保存」をクリックします。飛書の個人版では審査不要のため、保存後すぐに有効になります
手順五:認証情報を取得して設定ファイルを作成する
  1. 左側のメニューから「認証情報と基本情報」をクリックし、App ID と App Secret をコピーします

  2. ターミナルで以下のコマンドを実行して設定ファイルを作成します(実際の値に置き換えてください):

    cat > ~/.claude/feishu_config.json << 'EOF'
    {
      "app_id": "你的 App ID",
      "app_secret": "你的 App Secret",
      "interview_bitable_app_token": "",
      "interview_bitable_table_id": "",
      "meeting_bitable_app_token": "",
      "meeting_bitable_table_id": ""
    }
    EOF
    
  3. 多次元テーブルの設定(面接記録テーブルと会議記録テーブルはそれぞれ独立しています。どちらかを選択してください):

    • テーブルがない場合:対応する2つのフィールドを空欄にしておくと、初回実行時に skill が種類に応じたテーブルを自動作成して書き込みます
    • 既存のテーブルがある場合:飛書の多次元テーブルを開きます。URL の形式は https://xxx.feishu.cn/base/ClG9xxxxxxx?table=tblxxxxxxx です。ClG9xxxxxxx を対応する _bitable_app_token に、tblxxxxxxx を対応する _bitable_table_id に入力します

面接アプリ(app_id/app_secret)と認可は共通で、多次元テーブルのみが互いに影響しない2つのテーブルに分かれています。

設定が完了した後、初回使用時にブラウザーが自動的に開き、飛書の OAuth 認可が行われます。以降は token が自動的にキャッシュされるため、再操作は不要です。


段階一:音声/動画の処理

実行条件: ファイル拡張子が .wav、.m4a、.mp3、.mp4 の場合

既存の文字起こしファイルを確認する

音声/動画ファイルのパスを受け取ったら、まず同じディレクトリに同名の .txt ファイルがあるか確認します(例:interview.m4a → interview.txt を確認):

  • ある場合:.txt の内容を直接読み込み、文字起こしをスキップして、段階二の情報収集に進みます
  • ない場合:文字起こしを開始します(以下を参照)
文字起こし

まずプラットフォームを検出し、対応する文字起こし方法を選択します。文字起こしスクリプトは run_in_background=True で実行してください。文字起こしはバックグラウンドで実行され、その間に Claude は段階二で必要な情報(保存方法、日付、会社名など)をチャットで尋ねます。文字起こし完了の通知を受け取ったら、収集済みの情報を使って、複数ファイルの場合は段階零、単一ファイルの場合は段階二に進みます。

import platform, subprocess, os

def is_apple_silicon():
    return platform.system() == "Darwin" and platform.machine() == "arm64"

def transcribe(files):
    for f in files:
        out = f.rsplit('.', 1)[0] + '.txt'
        if is_apple_silicon():
            # mlx-whisper:Apple Silicon GPU 加速,41 分钟音频约 3-5 分钟
            import mlx_whisper
            result = mlx_whisper.transcribe(
                f, path_or_hf_repo="mlx-community/whisper-large-v3-turbo", language="zh",
                initial_prompt="以下是普通话的转录内容:"
            )
        else:
            # faster-whisper:CPU int8 量化(2-4x vs openai-whisper),有 NVIDIA GPU 自动用 CUDA
            from faster_whisper import WhisperModel
            import torch
            device = "cuda" if torch.cuda.is_available() else "cpu"
            compute_type = "float16" if device == "cuda" else "int8"
            model = WhisperModel("small", device=device, compute_type=compute_type)
            segments, _ = model.transcribe(f, language="zh", initial_prompt="以下是普通话的转录内容:")
            result = {"segments": [{"text": s.text} for s in segments]}
        open(out, 'w', encoding='utf-8').write('\n'.join(s['text'].strip() for s in result['segments']))
        print('完成:', out)

注意:

  • mlx-whisper は初回実行時にモデル(約800MB)を自動ダウンロードし、その後はローカルにキャッシュします
  • faster-whisper は初回実行時にモデル(small は約244MB)を自動ダウンロードし、その後はローカルにキャッシュします
  • 文字起こしスクリプトは必ず run_in_background=True で起動してください。そうしないと Claude がブロックされ、情報を収集できません

段階零:複数ファイルの統合と並べ替え(自動実行)

実行条件: 段階一で2つ以上の .txt 文字起こしファイルが生成された場合、自動実行されます。ユーザーの操作は不要です。

実行手順:

  1. すべての .txt ファイルの内容を読み込みます
  2. ファイル名のタイムスタンプと内容の意味に基づいて順序を判断します:
    • ファイル名にタイムスタンプ(例:1733、215639)が含まれる場合 → 時刻の昇順に並べます
    • 内容に冒頭の挨拶(自己紹介、こんにちは)が含まれる場合 → 先頭に並べます
    • 内容に別れの挨拶(バイバイ、ありがとうございます)が含まれる場合 → 最後に並べます
    • 内容に明確な文脈上のつながりがある場合 → 意味の連続性に基づいて判断します
  3. 並べ替え結果をユーザーに提示して確認します(ファイルの順序と判断根拠を列挙)。確認後、段階二に進みます
  4. 順序が不確かな場合は、ユーザーに尋ねます

段階二:整理 → 結果の出力

実行条件: ファイル拡張子が .pdf の場合、または段階一/段階零の完了後の文字起こしテキストの場合

開始前に、2段階に分けて情報を収集します。

1つ目の質問(単独で尋ね、1つのメッセージにまとめる):

  • 保存方法:ローカルファイルまたは飛書ドキュメント?
  • 種類:面接/業務会議/その他(不明な場合は面接をデフォルトにします。内容が明らかに会議の議論である場合は会議をデフォルトにします)

回答を受け取った後、飛書を選択している場合は、~/.claude/feishu_config.json が存在するか、また app_id / app_secret がどちらも空ではなく、プレースホルダーではないか(「あなたの App ID」と等しくないか)を確認します。未設定または設定が不完全な場合は、まず飛書の設定を案内し(「飛書の設定」セクションを参照)、設定完了後に続行します。

2つ目の質問(種類ごとに尋ね、1つのメッセージにまとめる):

面接:

  • 日付(形式:YYYY-MM-DD)
  • 会社名、職種名、面接の回数(一回目、二回目、最終面接など)
  • 面接官の氏名(任意)、面接時間(任意。例:45min)

業務会議:

  • 日付(形式:YYYY-MM-DD)
  • 会議のテーマ、プロジェクト/カテゴリ(多次元テーブルでの絞り込みに使用。例:関連するプロジェクト名)
  • 参加者(任意)、会議時間(任意。例:45min)

その他:

  • 日付
  • 相手の立場、テーマ (多次元テーブルの更新はスキップします)

種類に応じて、対応するドキュメントのタイトルと情報ヘッダーを作成します。

面接:

{公司名} 完整对话
{公司名} 问答整理
{公司名} 面试总评

信息头:公司 / 岗位 / 轮次 / 日期

業務会議:

{会议主题} 完整对话
{会议主题} 会议纪要(内含:要点整理 / 结论 / 待办事项)

信息头:会议主题 / 项目类别 / 参会人 / 日期

その他:

{对方身份} 完整对话
{对方身份} 问答整理
{对方身份} 总评

信息头:对方身份 / 主题 / 日期
1. ローカル保存(ローカルを選択した場合に実行)

整理結果を録音ファイルと同じディレクトリの .md ファイルに書き込みます。面接/その他の場合は3つのファイル(会話全文/質問と回答の整理/総評)、業務会議の場合は2つのファイル(会話全文/議事録)を作成します。

import os

def save_local(source_path, title, parts):
    # source_path 可以是音频文件或 PDF 文件路径
    # parts: [(后缀, 内容), ...],面试/其他传三项,会议传两项(完整对话、会议纪要)
    base_dir = os.path.dirname(os.path.abspath(source_path))
    for suffix, content in parts:
        out = os.path.join(base_dir, f"{title}-{suffix}.md")
        with open(out, "w", encoding="utf-8") as f:
            f.write(content)
        print(f"已保存:{out}")

保存完了後、総評/議事録の内容をチャットに直接出力します。以降の飛書関連の手順はすべてスキップします。


2. 飛書の user_access_token を取得する(飛書を選択した場合に実行)

設定から APP_ID / APP_SECRET を読み込み、TOKEN_CACHE のパスでは ~ を展開します。

import json, time, os, subprocess, tempfile
import http.server, threading, webbrowser, urllib.parse

# 从本地配置文件读取(~/.claude/feishu_config.json),不写入 skill 避免泄露
_cfg = json.load(open(os.path.expanduser("~/.claude/feishu_config.json")))
APP_ID = _cfg["app_id"]
APP_SECRET = _cfg["app_secret"]
TOKEN_CACHE = os.path.expanduser("~/.claude/feishu_token.json")
REDIRECT_URI = "http://localhost:9998/callback"

def load_token():
    try:
        with open(TOKEN_CACHE) as f:
            return json.load(f)
    except:
        return None

def save_token(data):
    os.makedirs(os.path.dirname(TOKEN_CACHE), exist_ok=True)
    with open(TOKEN_CACHE, "w") as f:
        json.dump(data, f)

def curl_post_auth(url, data, headers=None):
    payload = json.dumps(data, ensure_ascii=False)
    with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False, encoding='utf-8') as f:
        f.write(payload)
        fname = f.name
    cmd = ["curl", "-sk", "-X", "POST", url, "--noproxy", "*",
           "-H", "Content-Type: application/json"]
    if headers:
        for h in headers:
            cmd += ["-H", h]
    cmd += ["--data", f"@{fname}"]
    try:
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
    finally:
        os.unlink(fname)
    if not result.stdout.strip():
        return {}
    return json.loads(result.stdout)

def get_app_token():
    d = curl_post_auth(
        "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
        {"app_id": APP_ID, "app_secret": APP_SECRET})
    return d.get("tenant_access_token")

def oauth_flow():
    # 先释放端口,防止上次未完成进程占用
    subprocess.run("lsof -ti:9998 | xargs kill -9 2>/dev/null", shell=True)
    app_token = get_app_token()
    code_holder = {}
    class Handler(http.server.BaseHTTPRequestHandler):
        def do_GET(self):
            parsed = urllib.parse.urlparse(self.path)
            params = urllib.parse.parse_qs(parsed.query)
            code_holder["code"] = params.get("code", [None])[0]
            self.send_response(200)
            self.end_headers()
            self.wfile.write(b"<html><body>Done! You can close this tab.</body></html>")
        def log_message(self, *args): pass
    server = http.server.HTTPServer(("localhost", 9998), Handler)
    t = threading.Thread(target=server.handle_request)
    t.start()
    auth_url = (f"https://open.feishu.cn/open-apis/authen/v1/authorize"
                f"?app_id={APP_ID}&redirect_uri={urllib.parse.quote(REDIRECT_URI)}"
                f"&scope=docx%3Adocument%20bitable%3Aapp%20docs%3Adoc%20docs%3Adoc%3Areadonly"
                f"%20docs%3Adocument%3Aimport%20drive%3Adrive%20drive%3Afile%20wiki%3Awiki"
                f"&response_type=code")
    print("正在打开飞书授权页面...")
    webbrowser.open(auth_url)
    t.join(timeout=120)
    code = code_holder.get("code")
    if not code:
        raise Exception("未获取到授权码,请重试")
    d = curl_post_auth(
        "https://open.feishu.cn/open-apis/authen/v1/access_token",
        {"grant_type": "authorization_code", "code": code},
        headers=[f"Authorization: Bearer {app_token}"])
    d = d.get("data", {})
    if not d.get("access_token"):
        raise Exception(f"获取 access_token 失败,请检查飞书应用配置:{d}")
    token_data = {
        "access_token": d["access_token"],
        "refresh_token": d.get("refresh_token", ""),
        "expires_at": int(time.time()) + d.get("expires_in", 7200) - 300
    }
    save_token(token_data)
    return token_data["access_token"]

def get_token():
    cached = load_token()
    if cached and time.time() < cached.get("expires_at", 0):
        return cached["access_token"]
    return oauth_flow()

初回実行時にブラウザーが開き、認可が行われます。以降は token が自動的にキャッシュされて再利用されるため、再操作は不要です。

重要: token を取得したら、同じスクリプト内ですぐに後続のすべての操作(ドキュメントの作成、bitable への書き込み)を続けて実行してください。中断してユーザーの確認を待たないでください。待機中に token の有効期限が切れる可能性があります。

ポート競合の処理: OAuth サーバーを起動する前にポートを解放し、前回未完了のプロセスがポートを占有するのを防ぎます:

import subprocess
subprocess.run("lsof -ti:9998 | xargs kill -9 2>/dev/null", shell=True)
3. PDF を読み込む

Read ツールを使って PDF 全文を読み込みます。

4. 発言者を特定する

面接/その他: 会話の内容に基づいて判断します:

  • 自己紹介、プロジェクトの紹介、質問への回答をするのは私です
  • 質問、追加質問、フィードバックをするのは面接官です

業務会議: 参加者の氏名/役割に基づいて発言者を区別します(複数人いる場合)。具体的な氏名を特定できない場合は、「発言者A」「発言者B」などと表記し、全体を通して表記を統一します。

5. Feishu ドキュメントの作成(面接/その他は3つ、会議は2つのドキュメント)

重要な注意事項(デバッグで確認済み):

  • subprocess curl を使って Feishu API を呼び出し、Python の SSL スタックを回避してください(Python 3.14 とローカルプロキシの組み合わせでは SSL ハンドシェイクが失敗します)
  • text_color パラメーターは使用しないでください(99992402 field validation failed が発生します)
  • 一括書き込みは1回につき20ブロック以下にしてください。超えるとタイムアウトしやすくなります
  • トークンの取得方法:まず app_id/app_secret で tenant_access_token を取得し、それを使って user_access_token を取得します
import json, time, subprocess, tempfile, os

def curl_post(url, data, token):
    payload = json.dumps(data, ensure_ascii=False)
    with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False, encoding='utf-8') as f:
        f.write(payload)
        fname = f.name
    try:
        result = subprocess.run(
            ["curl", "-sk", "-X", "POST", url, "--noproxy", "*",
             "-H", "Content-Type: application/json",
             "-H", f"Authorization: Bearer {token}",
             "--data", f"@{fname}"],
            capture_output=True, text=True, timeout=60)
    finally:
        os.unlink(fname)
    if not result.stdout.strip():
        return {}
    return json.loads(result.stdout)

def create_doc(token, title):
    d = curl_post("https://open.feishu.cn/open-apis/docx/v1/documents", {"title": title}, token)
    if d.get("code") != 0:
        print(f"create_doc failed: {d}")
        return None
    return d["data"]["document"]["document_id"]

def write_blocks(token, doc_id, blocks):
    for i in range(0, len(blocks), 20):
        batch = blocks[i:i+20]
        for attempt in range(3):
            r = curl_post(
                f"https://open.feishu.cn/open-apis/docx/v1/documents/{doc_id}/blocks/{doc_id}/children",
                {"children": batch, "index": -1}, token)
            if r.get("code") == 0:
                break
            print(f"  batch {i} attempt {attempt+1} failed: {r.get('code')}, retrying...")
            time.sleep(1.5)
        time.sleep(0.5)

def p(text):
    return {"block_type": 2, "text": {"elements": [{"text_run": {"content": text}}], "style": {}}}

def bold(text):
    return {"block_type": 2, "text": {"elements": [{"text_run": {"content": text, "text_element_style": {"bold": True}}}], "style": {}}}

def mixed_p(parts):
    # parts = [("文字", is_bold), ...],用于发言人加粗+正文不加粗的混排段落
    elements = []
    for text, is_bold in parts:
        el = {"text_run": {"content": text}}
        if is_bold:
            el["text_run"]["text_element_style"] = {"bold": True}
        elements.append(el)
    return {"block_type": 2, "text": {"elements": elements, "style": {}}}

def h1(text):
    return {"block_type": 3, "heading1": {"elements": [{"text_run": {"content": text}}], "style": {}}}

def h2(text):
    return {"block_type": 4, "heading2": {"elements": [{"text_run": {"content": text}}], "style": {}}}

def divider():
    return {"block_type": 22, "divider": {}}
各ドキュメントの冒頭に基本情報ブロックを書き込む

面接/その他:

公司:{公司名}
岗位:{岗位名}
轮次:{面试轮次}
日期:{面试日期}
────────────────────────

業務会議:

会议主题:{会议主题}
项目/类别:{项目类别}
参会人:{参会人}
日期:{会议日期}
────────────────────────

ドキュメント1:会話全文
  • 会話全体を時系列順に並べ、発言者を太字にします(面接官:/私:、または会議参加者の氏名/役割)
  • 内容を省略せず、すべて残します

面接/その他 — ドキュメント2:質疑応答の整理
  • 質疑応答の各組を個別のブロックに分け、二次見出し(Q1、Q2……)を付けます
  • 見出しには、その質疑応答の中心的な話題をまとめます
  • 候補者からの逆質問も含めます
  • 各質疑応答の間には空行を入れます
面接/その他 — ドキュメント3:面接総評

会話の内容に応じて構成を柔軟に調整します。基本の枠組みは次のとおりです。

全体的な印象(全体を一言で表す)

良かった点(具体例を挙げ、原文を引用)

明確な問題点(具体例を挙げ、なぜ問題なのかを説明)

改善の提案(実行可能で、今回の面接の具体的な状況に即した内容)

特別な状況(面接官からの明確なフィードバック、圧迫質問、明らかなミスなど)があれば、個別のセクションを追加して説明します。


業務会議 — ドキュメント2:議事録

1つのドキュメント内に、二次見出しで区切って次の3つのセクションを順に含めます。

要点整理:議題ごとに分け、各議題にテーマをまとめた二次見出しを付けます。本文には議論の要点をまとめます(逐語録ではありません)。

結論/決定事項:今回の会議で得られた結論や決定事項を、項目ごとに列挙します。

ToDo:項目ごとに「事項 —— 担当者(言及があれば)—— 期限(言及があれば)」の形式で列挙します。担当者または期限が言及されていない場合は、事項のみを記載し、作り上げないでください。

6. 総評/議事録をチャットにも出力

ドキュメントの作成後、必ず面接総評(面接/その他)または議事録(業務会議)の内容をチャットに全文出力してください。省略してはいけません。この手順は必須であり、スキップできません。

7. 多次元テーブルの更新(面接タイプ → 面接記録テーブル、業務会議タイプ → monee会議記録テーブル、その他タイプはスキップ)

ドキュメントの作成後、~/.claude/feishu_config.json で設定されている対応する多次元テーブルを自動更新します。面接と会議は完全に独立した2つのテーブルで、互いに影響しません。それぞれ個別の _bitable_app_token / _bitable_table_id を持ちます。

面接タイプ

まず、多次元テーブルを確認し、未設定の場合は自動作成します。

def create_bitable_if_needed(token):
    cfg_path = os.path.expanduser("~/.claude/feishu_config.json")
    cfg = json.load(open(cfg_path))
    if cfg.get("interview_bitable_app_token") and cfg.get("interview_bitable_table_id"):
        return  # 已配置,跳过

    print("未检测到多维表格,正在自动创建面试记录表...")

    # 创建多维表格应用
    r = curl_post("https://open.feishu.cn/open-apis/bitable/v1/apps",
                  {"name": "面试记录"}, token)
    if r.get("code") != 0:
        print(f"创建多维表格失败: {r}")
        return
    app_token = r["data"]["app"]["app_token"]

    # 获取默认表格 ID
    r2 = subprocess.run(
        ["curl", "-sk", f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables",
         "--noproxy", "*", "-H", f"Authorization: Bearer {token}"],
        capture_output=True, text=True, timeout=30)
    table_id = json.loads(r2.stdout)["data"]["items"][0]["table_id"]

    fields_url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
    records_url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"

    # 删除默认空行(飞书新建表格自带 10 条空记录,逐条删除,batch_delete 会报 RecordIdNotFound)
    recs_r = subprocess.run(
        ["curl", "-sk", f"{records_url}?page_size=100", "--noproxy", "*",
         "-H", f"Authorization: Bearer {token}"],
        capture_output=True, text=True, timeout=30)
    for rec in json.loads(recs_r.stdout).get("data", {}).get("items", []):
        if not rec.get("fields"):
            subprocess.run(
                ["curl", "-sk", "-X", "DELETE", f"{records_url}/{rec['record_id']}",
                 "--noproxy", "*", "-H", f"Authorization: Bearer {token}"],
                capture_output=True, text=True, timeout=15)

    # 删除默认多余列(单选、日期、附件),保留主字段
    fields_r = subprocess.run(
        ["curl", "-sk", f"{fields_url}?page_size=100", "--noproxy", "*",
         "-H", f"Authorization: Bearer {token}"],
        capture_output=True, text=True, timeout=30)
    keep_names = {"岗位","面试轮次","面试时间","面试时长","面试官","面试整理版","面试问答整理","面试总评","备注"}
    for f in json.loads(fields_r.stdout).get("data", {}).get("items", []):
        if not f.get("is_primary") and f["field_name"] not in keep_names:
            subprocess.run(
                ["curl", "-sk", "-X", "DELETE", f"{fields_url}/{f['field_id']}",
                 "--noproxy", "*", "-H", f"Authorization: Bearer {token}"],
                capture_output=True, text=True, timeout=15)

    # 主字段改名为「公司」(主字段不可删,直接 PUT 改名)
    fields_r2 = subprocess.run(
        ["curl", "-sk", f"{fields_url}?page_size=100", "--noproxy", "*",
         "-H", f"Authorization: Bearer {token}"],
        capture_output=True, text=True, timeout=30)
    for f in json.loads(fields_r2.stdout).get("data", {}).get("items", []):
        if f.get("is_primary"):
            import tempfile as _tf
            _payload = json.dumps({"field_name": "公司", "type": 1}, ensure_ascii=False)
            with _tf.NamedTemporaryFile(mode='w', suffix='.json', delete=False, encoding='utf-8') as _ff:
                _ff.write(_payload); _ffname = _ff.name
            subprocess.run(
                ["curl", "-sk", "-X", "PUT", f"{fields_url}/{f['field_id']}", "--noproxy", "*",
                 "-H", "Content-Type: application/json", "-H", f"Authorization: Bearer {token}",
                 "--data", f"@{_ffname}"],
                capture_output=True, text=True, timeout=15)
            import os as _os; _os.unlink(_ffname)
            break

    # 新建业务字段(不含「公司」,它已是主字段)
    for field in [
        {"field_name": "岗位",         "type": 1},
        {"field_name": "面试轮次",     "type": 1},
        {"field_name": "面试时间",     "type": 5},
        {"field_name": "面试时长",     "type": 1},
        {"field_name": "面试官",       "type": 1},
        {"field_name": "面试整理版",   "type": 15},
        {"field_name": "面试问答整理", "type": 15},
        {"field_name": "面试总评",     "type": 15},
        {"field_name": "备注",         "type": 1},
    ]:
        curl_post(fields_url, field, token)

    # 写回配置文件
    cfg["interview_bitable_app_token"] = app_token
    cfg["interview_bitable_table_id"] = table_id
    with open(cfg_path, "w", encoding="utf-8") as f:
        json.dump(cfg, f, ensure_ascii=False, indent=2)

    print(f"✅ 多维表格已创建:https://feishu.cn/base/{app_token}")

実行順序:最初に create_bitable_if_needed(token) を呼び出してテーブルが存在することを確認し、次に update_bitable を呼び出してレコードを書き込みます。

ロジック:

  1. 毎回、必ず新しいレコードを作成します。既存のレコードは検索も上書きもしません
  2. 次のフィールドを書き込みます:会社、職種、面接ラウンド、面接日時、面接時間、面接官、3つのドキュメントリンク
  3. 面接官と面接時間は任意項目で、ユーザーから提供されていない場合は省略します

会社フィールドの命名規則:

  • 新規作成前にテーブルを検索し、同じ会社名のレコードがあるか確認します
  • 同じ会社名のレコードがない場合:会社 フィールドには会社名のみを記入します(例:「某社」)
  • 既存のレコードが1件あり、そのレコードの会社フィールドが会社名のみの場合(以前は一次面接のみだったことを示します):
    • まず、その古いレコードの 会社 フィールドを「会社名+一次面接」に更新します(例:「某社一次面接」)
    • 新しいレコードの 会社 フィールドには「会社名+今回のラウンド」を記入します(例:「某社二次面接」「某社人事面接」)
  • 既存のレコードが2件以上ある場合:新しいレコードには「会社名+今回のラウンド」を記入し、既存のレコードは変更しません

ラウンドの接尾辞には、収集した面接ラウンドのフィールドをそのまま使います(例:「一次面接」「二次面接」「人事面接」「最終面接」)。

import json, subprocess, os, tempfile

def curl_put_bitable(url, data, token):
    payload = json.dumps(data, ensure_ascii=False)
    with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False, encoding='utf-8') as f:
        f.write(payload)
        fname = f.name
    try:
        result = subprocess.run(
            ["curl", "-sk", "-X", "PUT", url, "--noproxy", "*",
             "-H", "Content-Type: application/json",
             "-H", f"Authorization: Bearer {token}",
             "--data", f"@{fname}"],
            capture_output=True, text=True, timeout=30)
    finally:
        os.unlink(fname)
    if not result.stdout.strip():
        return {}
    decoder = json.JSONDecoder()
    obj, _ = decoder.raw_decode(result.stdout.strip())
    return obj

def curl_post_bitable(url, data, token):
    payload = json.dumps(data, ensure_ascii=False)
    with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False, encoding='utf-8') as f:
        f.write(payload)
        fname = f.name
    try:
        result = subprocess.run(
            ["curl", "-sk", "-X", "POST", url, "--noproxy", "*",
             "-H", "Content-Type: application/json",
             "-H", f"Authorization: Bearer {token}",
             "--data", f"@{fname}"],
            capture_output=True, text=True, timeout=30)
    finally:
        os.unlink(fname)
    if not result.stdout.strip():
        return {}
    decoder = json.JSONDecoder()
    obj, _ = decoder.raw_decode(result.stdout.strip())
    return obj

def update_bitable(token, company, round_name, date_str, position, interviewer, duration,
                   doc1_id, doc2_id, doc3_id):
    cfg = json.load(open(os.path.expanduser("~/.claude/feishu_config.json")))
    app_token = cfg.get("interview_bitable_app_token")
    table_id = cfg.get("interview_bitable_table_id")
    if not app_token or not table_id:
        print("bitable 未配置,跳过")
        return

    base_url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}"

    # 查询该公司已有的所有记录
    records_r = subprocess.run(
        ["curl", "-sk", f"{base_url}/records?page_size=100", "--noproxy", "*",
         "-H", f"Authorization: Bearer {token}"],
        capture_output=True, text=True, timeout=30)
    records_data = json.loads(records_r.stdout)
    existing = [
        rec for rec in records_data.get("data", {}).get("items", [])
        if rec["fields"].get("公司", "").startswith(company)
    ]

    # 决定新记录的公司字段名
    if len(existing) == 0:
        # 第一条记录,直接写公司名
        company_label = company
    else:
        # 已有记录,新记录加轮次后缀
        company_label = f"{company}{round_name}"
        # 如果只有 1 条老记录且它的公司字段等于纯公司名,补上「一面」后缀
        if len(existing) == 1 and existing[0]["fields"].get("公司", "") == company:
            old_id = existing[0]["record_id"]
            curl_put_bitable(
                f"{base_url}/records/{old_id}",
                {"fields": {"公司": f"{company}一面"}},
                token)
            print(f"已将旧记录公司名更新为「{company}一面」")

    # 日期转 Unix 毫秒时间戳(飞书日期字段要求此格式,不能传字符串)
    import datetime
    y, m, d = map(int, date_str.split("-"))
    date_ts_ms = int(datetime.datetime(y, m, d).timestamp() * 1000)

    fields = {
        "公司": company_label,   # 主字段,直接按名称写入
        "岗位": position,
        "面试轮次": round_name,
        "面试时间": date_ts_ms,
        "面试整理版": {
            "text": f"{company_label}-面试整理版",
            "link": f"https://feishu.cn/docx/{doc1_id}"
        },
        "面试问答整理": {
            "text": f"{company_label}-面试问答整理",
            "link": f"https://feishu.cn/docx/{doc2_id}"
        },
        "面试总评": {
            "text": f"{company_label}-面试总评",
            "link": f"https://feishu.cn/docx/{doc3_id}"
        }
    }
    if interviewer:
        fields["面试官"] = interviewer
    if duration:
        fields["面试时长"] = duration

    r = curl_post_bitable(f"{base_url}/records", {"fields": fields}, token)
    print("bitable 新建:", "成功" if r.get("code") == 0 else r)

呼び出し時に、収集した情報を渡します:

update_bitable(token, company=公司名, round_name=面试轮次,
               date_str=面试日期, position=岗位名,
               interviewer=面试官(可为None), duration=时长(可为None),
               doc1_id=doc1_id, doc2_id=doc2_id, doc3_id=doc3_id)
業務会議タイプ

会議記録テーブルと面接記録テーブルは独立した別々の多次元テーブルであり、共用も統合もしません。テーブル名は「monee会議記録」に固定します。

ロジック: 会議ごとに必ず新しいレコードを作成します。面接テーブルのような、同名レコードの統合やラウンドの接尾辞付与は行いません(会議のテーマは通常毎回異なるため、統合する必要はありません)。

def create_meeting_bitable_if_needed(token):
    cfg_path = os.path.expanduser("~/.claude/feishu_config.json")
    cfg = json.load(open(cfg_path))
    if cfg.get("meeting_bitable_app_token") and cfg.get("meeting_bitable_table_id"):
        return  # 已配置,跳过

    print("未检测到会议记录表,正在自动创建...")

    r = curl_post("https://open.feishu.cn/open-apis/bitable/v1/apps",
                  {"name": "monee会议记录"}, token)
    if r.get("code") != 0:
        print(f"创建多维表格失败: {r}")
        return
    app_token = r["data"]["app"]["app_token"]

    r2 = subprocess.run(
        ["curl", "-sk", f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables",
         "--noproxy", "*", "-H", f"Authorization: Bearer {token}"],
        capture_output=True, text=True, timeout=30)
    table_id = json.loads(r2.stdout)["data"]["items"][0]["table_id"]

    fields_url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
    records_url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"

    # 删除默认空行
    recs_r = subprocess.run(
        ["curl", "-sk", f"{records_url}?page_size=100", "--noproxy", "*",
         "-H", f"Authorization: Bearer {token}"],
        capture_output=True, text=True, timeout=30)
    for rec in json.loads(recs_r.stdout).get("data", {}).get("items", []):
        if not rec.get("fields"):
            subprocess.run(
                ["curl", "-sk", "-X", "DELETE", f"{records_url}/{rec['record_id']}",
                 "--noproxy", "*", "-H", f"Authorization: Bearer {token}"],
                capture_output=True, text=True, timeout=15)

    # 删除默认多余列,保留主字段
    fields_r = subprocess.run(
        ["curl", "-sk", f"{fields_url}?page_size=100", "--noproxy", "*",
         "-H", f"Authorization: Bearer {token}"],
        capture_output=True, text=True, timeout=30)
    keep_names = {"项目类别","日期","时长","参会人","完整对话","会议纪要","备注"}
    for f in json.loads(fields_r.stdout).get("data", {}).get("items", []):
        if not f.get("is_primary") and f["field_name"] not in keep_names:
            subprocess.run(
                ["curl", "-sk", "-X", "DELETE", f"{fields_url}/{f['field_id']}",
                 "--noproxy", "*", "-H", f"Authorization: Bearer {token}"],
                capture_output=True, text=True, timeout=15)

    # 主字段改名为「会议主题」
    fields_r2 = subprocess.run(
        ["curl", "-sk", f"{fields_url}?page_size=100", "--noproxy", "*",
         "-H", f"Authorization: Bearer {token}"],
        capture_output=True, text=True, timeout=30)
    for f in json.loads(fields_r2.stdout).get("data", {}).get("items", []):
        if f.get("is_primary"):
            import tempfile as _tf
            _payload = json.dumps({"field_name": "会议主题", "type": 1}, ensure_ascii=False)
            with _tf.NamedTemporaryFile(mode='w', suffix='.json', delete=False, encoding='utf-8') as _ff:
                _ff.write(_payload); _ffname = _ff.name
            subprocess.run(
                ["curl", "-sk", "-X", "PUT", f"{fields_url}/{f['field_id']}", "--noproxy", "*",
                 "-H", "Content-Type: application/json", "-H", f"Authorization: Bearer {token}",
                 "--data", f"@{_ffname}"],
                capture_output=True, text=True, timeout=15)
            import os as _os; _os.unlink(_ffname)
            break

    # 新建业务字段(不含「会议主题」,它已是主字段)
    for field in [
        {"field_name": "项目类别", "type": 1},
        {"field_name": "日期",     "type": 5},
        {"field_name": "时长",     "type": 1},
        {"field_name": "参会人",   "type": 1},
        {"field_name": "完整对话", "type": 15},
        {"field_name": "会议纪要", "type": 15},
        {"field_name": "备注",     "type": 1},
    ]:
        curl_post(fields_url, field, token)

    cfg["meeting_bitable_app_token"] = app_token
    cfg["meeting_bitable_table_id"] = table_id
    with open(cfg_path, "w", encoding="utf-8") as f:
        json.dump(cfg, f, ensure_ascii=False, indent=2)

    print(f"✅ monee会议记录表已创建:https://feishu.cn/base/{app_token}")


def update_meeting_bitable(token, topic, category, date_str, attendees, duration,
                            doc1_id, doc2_id):
    cfg = json.load(open(os.path.expanduser("~/.claude/feishu_config.json")))
    app_token = cfg.get("meeting_bitable_app_token")
    table_id = cfg.get("meeting_bitable_table_id")
    if not app_token or not table_id:
        print("会议记录表未配置,跳过")
        return

    base_url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}"

    import datetime
    y, m, d = map(int, date_str.split("-"))
    date_ts_ms = int(datetime.datetime(y, m, d).timestamp() * 1000)

    fields = {
        "会议主题": topic,   # 主字段
        "项目类别": category,
        "日期": date_ts_ms,
        "完整对话": {
            "text": f"{topic}-完整对话",
            "link": f"https://feishu.cn/docx/{doc1_id}"
        },
        "会议纪要": {
            "text": f"{topic}-会议纪要",
            "link": f"https://feishu.cn/docx/{doc2_id}"
        }
    }
    if attendees:
        fields["参会人"] = attendees
    if duration:
        fields["时长"] = duration

    r = curl_post_bitable(f"{base_url}/records", {"fields": fields}, token)
    print("会议记录表新建:", "成功" if r.get("code") == 0 else r)

呼び出し時に、収集した情報を渡します:

create_meeting_bitable_if_needed(token)
update_meeting_bitable(token, topic=会议主题, category=项目类别,
                       date_str=会议日期, attendees=参会人(可为None),
                       duration=时长(可为None),
                       doc1_id=doc1_id, doc2_id=doc2_id)
8. 完了通知と振り返り質問モードの確認

面接/その他タイプで最後に作成するドキュメントは「総評」、業務会議タイプで最後に作成するドキュメントは「議事録」です。以下、これらをまとめて「最終ドキュメント」と呼びます。

チャットには次の順序で出力します。複数のメッセージに分けてはいけません。

  1. Feishu ドキュメントが作成されたこと(面接/その他は3つ、会議は2つ)と、対応する多次元テーブルが更新されたことを伝えます
  2. 各ドキュメントのリンクを出力します(形式:https://feishu.cn/docx/{document_id})
  3. 最終ドキュメントの内容(総評または議事録)を全文出力します
  4. AskUserQuestion を使って、振り返り質問モードを有効にするか尋ねます

振り返り質問モードの確認:

AskUserQuestion を使い、次の選択肢を提示します。

質問: これ以降の質問と回答を最終ドキュメントに保存しますか? 選択肢 A(有効にする): 以降の質問で「保存」と言ったときに、内容を整理して最終ドキュメントに追記します 選択肢 B(不要): ドキュメントを読むだけにし、質問と回答は保存しません

ユーザーが B を選んだ場合、フローは終了します。

ユーザーが A を選んだ場合、次のように返信します。

📌 振り返り質問モードが有効になりました。 「保存」と言うと、この質問と回答を整理して最終ドキュメントの「振り返り質問」セクションに追記します。

収集範囲: ユーザーが有効化を選択してから、トリガーワードを発するまでの、本面接/会議に関連するすべての質疑応答。

トリガーワード: ユーザーが「保存」「保存して」「保存する」「Feishu に保存」のいずれかを言うと、保存を実行します。

保存ロジック:

  1. 今回の会話を振り返り、質問モードを有効にしてからのすべての質疑応答を確認します
  2. 質疑応答形式に整理します。各組は Q: で始め、ユーザーの質問を書き、本文に回答を記載します
  3. h2 形式でグループ分けし、質問ごとに1つの h2 見出しを付けます
  4. Feishu API を呼び出し、対応する最終ドキュメントの末尾に divider + h1("振り返り質問") + 各質疑応答ブロックを追記します
  5. 最終ドキュメントにすでに「振り返り質問」セクションがある場合(初回の保存ではない場合)、新しい内容のみを追記し、見出しを重複して書きません
  6. 保存完了後、「ドキュメントに保存しました」と伝え、質問モードを継続します。ユーザーは引き続き質問でき、次回トリガーワードを言ったときに新しい内容を追記します

複数の場面がある場合: 今回のスキルで複数の面接/会議を処理した場合、返信で「『虾皮を保存』『追觅を保存』と言うと、それぞれ対応するドキュメントに保存できます。または『すべて保存』と言うと、まとめて保存できます」と説明します。

ローカル保存モードの場合: この手順をスキップし、質問しません。

コメント