AI 工具

用 Fireflies 生成会议发言时长报告:占比、提问与独白统计

会议是不是总被一个人主导?有没有人一直没发言?Fireflies 的 analytics.speakers 给出了每个人的发言时长、提问数和最长独白。 这篇做 talktime.py:汇总多场会议,生成 Markdown 报告:每人的发言时长、占比、提问数、最长独白和语速,并按人汇总提醒(例如“在 5/5 场会议中发言超过 50%”)。

BINBHO · 图文教程

会议是不是总被一个人主导?有没有人一直没发言?Fireflies 的 analytics.speakers 给出了每个人的发言时长、提问数和最长独白。 这篇做 talk_time.py:汇总多场会议,生成 Markdown 报告:每人的发言时长、占比、提问数、最长独白和语速,并按人汇总提醒(例如“在 5/5 场会议中发言超过 50%”)。

先看清成品

  • 替身上 5 场会议生成报告:王敏发言占 56%,在 5/5 场会议中超过 50%。
  • 核对官方文档时发现:duration_pct 是“占整场会议时长”的百分比,各人相加不等于 100;发言占比改用 duration 计算。
  • 发现并修复:提醒每场一条太啰嗦;自己算的语速与接口的 words_per_minute 不一致。pytest 3 项通过。

合规提醒:会议录音和转写涉及所有参会者的个人信息。录音前必须告知并取得同意(不同地区法律要求不同);只导出你有权访问的会议,导出的文件妥善保管,分享前确认参会者知情。发言时长报告只用于改进会议,不要用来考核或监控个人。本文只使用 Fireflies 官方 GraphQL API(https://api.fireflies.ai/graphql),key 在 Fireflies 的 Integrations → Fireflies API 中获取,放在 FIREFLIES_API_KEY。

运行说明:box 上没有 Fireflies 账号和 key。我用无效 key 真实调用了 GraphQL 接口(2026-10-21 20:35 和 20:53,UTC+8),得到 HTTP 500 和 {"errors":[{"friendly":true,"code":"auth_failed","message":"An error occurred while authenticating your request. Please recheck your API key and try again. …"}]}。其余响应来自我写的本地替身:字段名按官方文档,57 场会议的内容全是编的(每天 3 场,时间都是 08:00)。

做完之后你会得到什么?

成果 说明
talk_time.py 汇总和报告
ff_export.py / fake_fireflies.py 复用 A 篇
talk_time.md 结果
test_talk.py 3 项测试

需要准备哪些环境?

项目 说明
Python 3.9 以上,pip install requests pytest
Fireflies key Integrations → Fireflies API 复制,export FIREFLIES_API_KEY=…

关键事实

事实 来源
AnalyticsSpeaker:duration(秒)、word_count、longest_monologue(秒)、monologues_count、filler_words、questions、duration_pct、words_per_minute 官方 MeetingAnalytics schema
duration_pct 是该发言人说话时间占整场会议时长的百分比 同上
限流:Free 每分钟 30 次、每天 500 次;Pro 每分钟 60 次、每天 5000 次;每日额度按 UTC 零点(北京时间 08:00)重置 官方 Limits

文件结构

79-fireflies-C-src/
├── talk_time.py
├── ff_export.py
├── fake_fireflies.py
├── demo_local.py
└── test_talk.py

第 1 步:脚本

"""会议发言时长报告:汇总多场会议 analytics.speakers,看谁说得最多、最长独白、提问数。
单位(官方 MeetingAnalytics 文档):duration、longest_monologue 是秒;duration_pct 是“占整场会议时长”的百分比,
各人相加不等于 100(有沉默),所以“发言占比”用 duration 自己算。"""
import argparse, os, sys
from collections import defaultdict
from ff_export import gql, local, FFError, PAGE

Q = """query Transcripts($limit: Int, $skip: Int, $fromDate: DateTime) {
  transcripts(limit: $limit, skip: $skip, fromDate: $fromDate) { id title date duration
    analytics { speakers { name duration word_count longest_monologue questions duration_pct words_per_minute } } } }"""

def fetch(key, since=None, **kw):
    out, skip = [], 0
    while True:
        v = {"limit": PAGE, "skip": skip}
        if since: v["fromDate"] = since
        page = gql(key, Q, v, **kw)["transcripts"]; out += page
        if len(page) < PAGE: return out
        skip += PAGE

def report(meetings, dominate=0.5, monologue_s=180):
    tot = defaultdict(lambda: {"sec": 0.0, "wpm_x_sec": 0.0, "questions": 0, "longest": 0.0, "meetings": 0, "dominated": [], "long": []})
    flags = []
    for m in meetings:
        sp = (m.get("analytics") or {}).get("speakers") or []
        talk = sum(s["duration"] for s in sp)
        if not sp or talk == 0: flags.append(f"{local(m['date'])} {m['title']}:没有发言分析(可能还在处理或会议太短)"); continue
        for s in sp:
            t = tot[s["name"]]; t["sec"] += s["duration"]; t["questions"] += s["questions"]
            t["wpm_x_sec"] += (s.get("words_per_minute") or 0) * s["duration"]  # 用接口给的语速,按发言时长加权
            t["longest"] = max(t["longest"], s["longest_monologue"]); t["meetings"] += 1
            if s["duration"] / talk > dominate: t["dominated"].append(s["duration"] / talk)
            if s["longest_monologue"] > monologue_s: t["long"].append(m["title"])
    for n, t in tot.items():  # 按人汇总提醒,而不是每场一条
        if t["dominated"]:
            flags.append(f"{n} 在 {len(t['dominated'])}/{t['meetings']} 场会议中发言超过 {dominate:.0%}(最高 {max(t['dominated']):.0%})")
        if t["long"]:
            flags.append(f"{n} 在 {len(t['long'])} 场会议中有超过 {monologue_s / 60:g} 分钟的独白:{'、'.join(t['long'][:3])}")
    all_sec = sum(t["sec"] for t in tot.values()) or 1
    rows = sorted(({"name": n, **t, "share": t["sec"] / all_sec} for n, t in tot.items()), key=lambda r: -r["sec"])
    return rows, flags

def to_md(rows, flags, n):
    out = [f"# 发言时长报告({n} 场会议)", "", "| 参会者 | 参加场次 | 发言时长(分钟) | 发言占比 | 提问 | 最长独白(分钟) | 每分钟词数 |",
           "| :--- | ---: | ---: | ---: | ---: | ---: | ---: |"]
    for r in rows:
        wpm = r["wpm_x_sec"] / r["sec"] if r["sec"] else 0
        out.append(f"| {r['name']} | {r['meetings']} | {r['sec'] / 60:.1f} | {r['share']:.0%} | {r['questions']} | {r['longest'] / 60:.1f} | {wpm:.0f} |")
    if flags: out += ["", "## 值得留意", ""] + [f"- {f}" for f in flags]
    return "\n".join(out) + "\n"

def main(argv=None):
    ap = argparse.ArgumentParser(); ap.add_argument("--since"); ap.add_argument("-o", default="talk_time.md")
    ap.add_argument("--dominate", type=float, default=0.5); a = ap.parse_args(argv)
    key = os.environ.get("FIREFLIES_API_KEY") or sys.exit("请先 export FIREFLIES_API_KEY=…")
    try: ms = fetch(key, a.since)
    except FFError as e: sys.exit(f"错误:{e}")
    rows, flags = report(ms, a.dominate)
    md = to_md(rows, flags, len(ms)); open(a.o, "w", encoding="utf-8").write(md); print(md)

if __name__ == "__main__":
    main()

第 2 步:演示脚本

import os, sys
import fake_fireflies
srv = fake_fireflies.start(); os.environ["FIREFLIES_URL"] = f"http://127.0.0.1:{srv.server_port}/graphql"
import importlib, ff_export; importlib.reload(ff_export)
import talk_time; importlib.reload(talk_time); talk_time.main(sys.argv[1:])

第 3 步:测试

import talk_time as m

def meet(title, speakers, date=1792540800000):
    return {"id": title, "title": title, "date": date, "duration": 30,
            "analytics": {"speakers": [{"name": n, "duration": d, "word_count": 0, "longest_monologue": lm, "questions": q,
                                        "duration_pct": d / 1800 * 100, "words_per_minute": w} for n, d, lm, q, w in speakers]}}

def test_share_from_duration_not_pct():
    rows, _ = m.report([meet("a", [("甲", 600, 60, 1, 150), ("乙", 200, 30, 0, 100)])])
    assert abs(rows[0]["share"] - 0.75) < 1e-9     # duration_pct 只有 33%,但发言占比是 75%

def test_flags_aggregated_per_person():
    ms = [meet(f"m{i}", [("甲", 600, 200, 0, 150), ("乙", 100, 30, 0, 120)]) for i in range(3)]
    _, flags = m.report(ms)
    assert len(flags) == 2 and "3/3" in flags[0] and "独白" in flags[1]

def test_weighted_wpm_and_empty():
    rows, flags = m.report([meet("a", [("甲", 60, 10, 0, 100)]), meet("b", [("甲", 180, 10, 0, 200)]), {"id": "c", "title": "c", "date": 1792540800000, "analytics": None}])
    md = m.to_md(rows, flags, 3)
    assert "| 175 |" in md and any("没有发言分析" in f for f in flags)

第 4 步:运行

$ FIREFLIES_API_KEY=demo python demo_local.py --since 2026-10-20T00:00:00+08:00
# 发言时长报告(5 场会议)

| 参会者 | 参加场次 | 发言时长(分钟) | 发言占比 | 提问 | 最长独白(分钟) | 每分钟词数 |
| :--- | ---: | ---: | ---: | ---: | ---: | ---: |
| 王敏 | 5 | 52.0 | 56% | 30 | 2.0 | 150 |
| 李强 | 5 | 25.0 | 27% | 5 | 1.0 | 140 |
| 赵雪 | 5 | 16.7 | 18% | 0 | 0.7 | 145 |

## 值得留意

- 王敏 在 5/5 场会议中发言超过 50%(最高 56%)
$ python -m pytest -q
3 passed

怎么检查结果?

  • 发言占比 = 个人 duration ÷ 所有人 duration 之和,不受沉默时间影响。
  • 主持人发言多、提问多通常正常;报告只是提示,不下结论。
  • 发言人名字来自转写的说话人识别,可能把两个人识别成一个,先抽查。

调试示例

1. 我的替身一开始就理解错了 duration_pct。 我最初让替身里三个人的 duration_pct 加起来正好 100(55 + 27 + 18)。写报告时去查官方 MeetingAnalytics 文档,发现它是“占整场会议时长”的百分比:30 分钟的会,说了 600 秒就是 33%,有沉默时各人加起来不到 100。我改了替身(现在第一场三人合计 59.2%),报告里的“发言占比”改用 duration 自己算。

2. 提醒太多。 第一版每场会议、每个人各报一条:

- 2026-10-21 08:00 周会 #20:王敏 占发言 55%
- 2026-10-21 08:00 周会 #40:王敏 占发言 56%
- 2026-10-20 08:00 周会 #1:王敏 占发言 55%
…(共 5 条)

改为按人汇总:“王敏 在 5/5 场会议中发言超过 50%(最高 56%)”。

3. 语速对不上。 第一版用 word_count ÷ 发言分钟数自己算语速,王敏得到 86,而接口给的 words_per_minute 是 150。原因是我替身里编的 word_count 和 words_per_minute 本身不一致;但这也说明不该自己重算接口已经给出的指标(中文怎么算“词”也不好说)。修复:直接用 words_per_minute,按发言时长加权平均。

常见问题

现象 原因 处理
“没有发言分析” 会议还在处理或太短 稍后再跑
占比和网页上不同 网页可能用 duration_pct 两种口径不同,报告里说明
同一人出现两次 说话人名字不一致 在 Fireflies 中修正说话人

模板规则

  • 先查清字段的单位和口径。
  • 能用接口给的指标就不自己重算。
  • 提醒按人汇总。
  • 报告用于改进会议,不用于监控个人。

项目资源

用途 地址
Fireflies API 文档(官方) https://docs.fireflies.ai
transcripts 查询(官方) https://docs.fireflies.ai/graphql-api/query/transcripts
限额与限流(官方) https://docs.fireflies.ai/fundamentals/limits
错误码(官方) https://docs.fireflies.ai/miscellaneous/error-codes
MeetingAnalytics 字段说明(官方) https://docs.fireflies.ai/schema/meeting-analytics
Fireflies 官网 https://fireflies.ai

数据只是镜子,怎么开好会还得大家商量。