Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions docs/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|---|
Expand All @@ -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. |

Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/gtmsi/adapters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,6 +32,7 @@
OtterAdapter(),
RecallAdapter(),
GrainAdapter(),
GranolaAdapter(),
JSONGenericAdapter(),
PlaintextAdapter(),
]
Expand Down
153 changes: 153 additions & 0 deletions src/gtmsi/adapters/granola.py
Original file line number Diff line number Diff line change
@@ -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)
48 changes: 48 additions & 0 deletions tests/test_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading