diff --git a/CHANGELOG.md b/CHANGELOG.md index b06da89..ce738ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to semantic versioning. +## [Unreleased] + +### Added +- `granola` adapter for Granola public-API transcripts (`GET /v1/notes/{id}?include=transcript`). + Splits turns by audio source (`microphone`→rep, `speaker`/`system`→prospect) instead of + diarized names, rebases absolute ISO timestamps to call offsets, and accepts the bare array + or wrapped `{"transcript": [...]}` note object. 10 adapters total. + ## [0.1.0] - 2026-05-31 ### Added diff --git a/docs/adapters.md b/docs/adapters.md index 5abe67a..1f91875 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -45,7 +45,7 @@ The `side` field on participants and turns is critical for coaching: only ## Built-in adapters -There are **9** built-in adapters: +There are **10** built-in adapters: | Adapter ID | Input format | Notes | |---|---|---| @@ -56,6 +56,7 @@ There are **9** built-in adapters: | `otter` | Otter.ai export (JSON or `.txt`) | Best-effort/unverified (API is Enterprise-gated). Handles `utterances`/`transcripts`/`speeches`, `speaker_id`→top-level `speakers[]`, `start_offset` in milliseconds. Prefer the SRT/TXT export when possible. | | `recall` | Recall.ai export (JSON) | JSON array of turns `{participant, words[]}`; joins words into a turn, `start_timestamp.relative` in seconds, maps `is_host`→rep (others→prospect; override with `--participants`). | | `grain` | Grain export (JSON) | JSON array `[{start,end,text,speaker,participant_id}]`; `start`/`end` in milliseconds (ms→seconds). | +| `granola` | Granola public API (`GET /v1/notes/{id}?include=transcript`) | JSON array of segments `{text, start_time, end_time, speaker:{source}}`. Splits by **audio source** nested under `speaker` (`microphone`→rep, `speaker`→prospect) rather than diarized names, so `side` comes from the channel. Absolute ISO `start_time`/`end_time` rebased to call offsets. Accepts a bare array or the wrapped `{"transcript": [...]}` note object. | | `json-generic` | Any JSON with a `turns` or `utterances` array | Configurable field mapping. | | `plaintext` | Plain `.txt` file with speaker-prefixed lines | Final fallback; sniffs for `Speaker: text` lines. | @@ -72,7 +73,7 @@ like this: wins. The priority order is: ``` - VTT → SRT → Gong → Fireflies → Otter → Recall → Grain → JSONGeneric → Plaintext + VTT → SRT → Gong → Fireflies → Otter → Recall → Grain → Granola → JSONGeneric → Plaintext ``` Most-specific recorders are tried first; the generic JSON adapter and the diff --git a/src/gtmsi/adapters/__init__.py b/src/gtmsi/adapters/__init__.py index 93c1540..3b64e20 100644 --- a/src/gtmsi/adapters/__init__.py +++ b/src/gtmsi/adapters/__init__.py @@ -13,6 +13,7 @@ from .fireflies import FirefliesAdapter from .gong import GongAdapter from .grain import GrainAdapter +from .granola import GranolaAdapter from .json_generic import JSONGenericAdapter from .otter import OtterAdapter from .plaintext import PlaintextAdapter @@ -31,6 +32,7 @@ OtterAdapter(), RecallAdapter(), GrainAdapter(), + GranolaAdapter(), JSONGenericAdapter(), PlaintextAdapter(), ] diff --git a/src/gtmsi/adapters/granola.py b/src/gtmsi/adapters/granola.py new file mode 100644 index 0000000..d535390 --- /dev/null +++ b/src/gtmsi/adapters/granola.py @@ -0,0 +1,153 @@ +"""Granola adapter. + +Granola records the local meeting audio and splits the transcript by **audio +source** rather than by diarized speaker. In the public API +(`GET /v1/notes/{id}?include=transcript`) each segment carries a `speaker` object +whose `source` is: + +- ``"microphone"`` — the Granola user's own mic (the person running the call: + typically the rep / CSM). +- ``"speaker"`` — the other side, captured from the system/speaker output (the + prospect or customer). (Older/desktop payloads call this ``"system"``.) + +That source split is a gift for coaching: ``side`` comes straight from the audio +channel, with no name heuristic needed to find the rep. We map +``microphone -> rep`` and ``speaker``/``system`` ``-> prospect``. On post-sales +calls the other channel is really the customer/partner — override with +``--participants`` / transcript ``metadata`` when that matters; scoring only +evaluates rep-side turns either way. + +Verified against the live public API — a flat array of segments with ISO-8601 +**absolute** timestamps (we convert them to offsets from the first segment): + + [ + {"text": "Walk me through how you do this today.", + "start_time": "2026-05-28T23:04:09.803Z", + "end_time": "2026-05-28T23:04:10.123Z", + "speaker": {"source": "microphone"}}, + {"text": "Sure, it's mostly spreadsheets…", + "start_time": "2026-05-28T23:04:11.936Z", + "end_time": "2026-05-28T23:04:13.856Z", + "speaker": {"source": "speaker"}}, + ... + ] + +The array may arrive bare, wrapped as ``{"transcript": [...]}`` (the full note +object from the API), or under ``segments``. Granola attributes no per-person +names, so the display name is the channel ("You" / "Participant") unless a +``speaker.name`` (or a string ``speaker``) is present, which we prefer. Granola +also exports plain text, which the plaintext adapter covers. +""" +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Any + +from ..models import Transcript, Turn +from .base import build_participants, guess_side, title_from_path + +# Audio-source values, mapped to a transcript side. "speaker" is the OTHER side in +# the public API (the computer's speaker output); "system" is the older alias. +_MIC_SOURCES = {"microphone", "mic", "me"} +_OTHER_SOURCES = {"speaker", "system", "them"} +# Where the segment array may live when the export is an object, not a bare list. +_ARRAY_KEYS = ("transcript", "segments", "entries") + + +class GranolaAdapter: + name = "granola" + + def sniff(self, path: str, text: str) -> bool: + if not path.lower().endswith(".json"): + return False + try: + data = json.loads(text) + except ValueError: + return False + rows = self._rows(data) + if not rows: + return False + # Distinctive Granola signature: text + a microphone/speaker audio source, + # whether the source sits on the segment or nested under `speaker`. + return any( + isinstance(r, dict) and "text" in r and self._source(r) in (_MIC_SOURCES | _OTHER_SOURCES) + for r in rows[:5] + ) + + def parse(self, path: str, text: str) -> Transcript: + rows = [r for r in self._rows(json.loads(text)) if isinstance(r, dict)] + + # Pass 1: parse absolute timestamps so we can rebase to call-relative offsets. + starts = [self._ts(r.get("start_time") or r.get("start_timestamp") or r.get("start")) for r in rows] + base = min((s for s in starts if s is not None), default=None) + + def offset(ts: datetime | None) -> float | None: + return (ts - base).total_seconds() if ts is not None and base is not None else None + + turns: list[Turn] = [] + for r, start in zip(rows, starts, strict=True): + txt = (r.get("text") or "").strip() + if not txt: + continue + src = self._source(r) + side = "rep" if src in _MIC_SOURCES else "prospect" if src in _OTHER_SOURCES else None + name = self._name(r) or ("You" if side == "rep" else "Participant") + if side is None: # unknown source — fall back to the name heuristic + side = guess_side(str(name)) + end = self._ts(r.get("end_time") or r.get("end_timestamp") or r.get("end")) + turns.append( + Turn( + speaker=str(name), + side=side, + text=txt, + start_seconds=offset(start), + end_seconds=offset(end), + ) + ) + + return Transcript( + title=title_from_path(path), + started_at=base.isoformat() if base else None, + source={"recorder": "granola", "adapter": self.name}, + participants=build_participants(turns), + turns=turns, + ) + + def _rows(self, data: Any) -> list: + if isinstance(data, list): + return data + if isinstance(data, dict): + for k in _ARRAY_KEYS: + if isinstance(data.get(k), list): + return data[k] + return [] + + def _source(self, r: dict) -> str: + """Audio source, whether on the segment (`source`) or nested (`speaker.source`).""" + sp = r.get("speaker") + if isinstance(sp, dict) and sp.get("source"): + return str(sp["source"]).lower() + return str(r.get("source", "")).lower() + + def _name(self, r: dict) -> str | None: + """Explicit speaker name when Granola provides one (rare — usually unnamed).""" + sp = r.get("speaker") + if isinstance(sp, str): + return sp + if isinstance(sp, dict) and sp.get("name"): + return str(sp["name"]) + return None + + def _ts(self, v: Any) -> datetime | None: + """Parse an ISO-8601 string (or epoch number) to an aware UTC datetime.""" + if v is None: + return None + if isinstance(v, (int, float)): + secs = v / 1000.0 if v > 1e11 else float(v) # ms vs s heuristic + return datetime.fromtimestamp(secs, tz=timezone.utc) + try: + dt = datetime.fromisoformat(str(v).strip().replace("Z", "+00:00")) + except ValueError: + return None + return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 4f7f767..64af457 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -248,6 +248,54 @@ def test_otter_real_shape(tmp_path): assert t.turns[0].start_seconds == 4.5 # 4500 ms -> 4.5 s +def test_granola_audio_source_sides(tmp_path): + # Real public-API shape (GET /v1/notes/{id}?include=transcript): the audio source + # is nested under `speaker` (microphone == rep, speaker == the other side), and + # timestamps are absolute ISO `start_time`/`end_time` rebased to call offsets. + doc = [ + {"text": "Walk me through how you do this today.", + "start_time": "2026-05-28T23:04:04.000Z", "end_time": "2026-05-28T23:04:10.000Z", + "speaker": {"source": "microphone"}}, + {"text": "It's mostly spreadsheets.", + "start_time": "2026-05-28T23:04:15.500Z", "end_time": "2026-05-28T23:04:19.000Z", + "speaker": {"source": "speaker"}}, + ] + p = tmp_path / "call.granola.json" + p.write_text(json.dumps(doc)) + t = load_transcript(str(p)) + assert t.source["recorder"] == "granola" + assert t.turns[0].side == "rep" # microphone -> rep + assert t.turns[0].speaker == "You" # unnamed mic channel + assert t.turns[0].start_seconds == 0.0 # rebased to first segment + assert t.turns[1].side == "prospect" # speaker (other side) -> prospect + assert t.turns[1].speaker == "Participant" + assert t.turns[1].start_seconds == 11.5 # 23:04:15.5 - 23:04:04 + assert t.started_at == "2026-05-28T23:04:04+00:00" + + +def test_granola_wrapped_note_and_legacy_source(tmp_path): + # Accept the full note object {"transcript": [...]} and the legacy top-level + # `source: system` desktop shape with `start_timestamp`. + doc = {"transcript": [ + {"source": "system", "text": "Legacy other-side line.", + "start_timestamp": "2026-05-28T23:04:04.000Z"}, + ]} + p = tmp_path / "note.json" + p.write_text(json.dumps(doc)) + t = load_transcript(str(p)) + assert t.source["recorder"] == "granola" + assert t.turns[0].side == "prospect" + + +def test_granola_does_not_steal_generic_json(tmp_path): + # A generic {"turns": [...]} export has no microphone/system source -> json-generic. + doc = {"turns": [{"speaker": "Rep", "text": "Hi", "side": "rep"}]} + p = tmp_path / "generic.json" + p.write_text(json.dumps(doc)) + t = load_transcript(str(p)) + assert t.source["recorder"] != "granola" + + def test_force_adapter(tmp_path): p = tmp_path / "ambiguous.txt" p.write_text(PLAINTEXT)