Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
a597500
fix(cli): give every conversation in a batch its own file and label t…
airdropia Oct 24, 2026
a51fea9
test(cli): pin the batch collision and the transcript label failures …
airdropia Oct 24, 2026
3dbef8a
docs(failure-classes): record the batch export collision (#23485)
airdropia Oct 24, 2026
308798d
Address review feedback
airdropia Oct 24, 2026
626aa62
Address review feedback
airdropia Oct 24, 2026
33e80c7
Address review feedback
airdropia Oct 24, 2026
2c0d0cf
Address review feedback
airdropia Oct 24, 2026
09167ca
Address review feedback
airdropia Oct 24, 2026
79ed8a8
Address review feedback
airdropia Oct 24, 2026
da4dc2f
Address review feedback
airdropia Oct 24, 2026
01ee430
Address review feedback
airdropia Oct 25, 2026
8bd9ff0
Address review feedback
airdropia Oct 25, 2026
ffb3ac8
Address review feedback
airdropia Oct 25, 2026
1b417d9
Address review feedback
airdropia Oct 25, 2026
fca622b
Address review feedback
airdropia Oct 25, 2026
f225d67
Address review feedback
airdropia Oct 25, 2026
e20eac4
style(cli): restore the trailing newline at end of file
airdropia Oct 25, 2026
92fc549
style(cli): restore the trailing newline at end of file
airdropia Oct 25, 2026
9d6c4a2
style(cli): restore the trailing newline at end of file
airdropia Oct 25, 2026
7808892
Address review feedback
airdropia Oct 25, 2026
6e096ec
Address review feedback
airdropia Oct 25, 2026
509d249
Address review feedback
airdropia Oct 25, 2026
2dddbd7
Address review feedback
airdropia Oct 26, 2026
ed9f728
chore: add trailing newline at EOF (closes the last review nit)
airdropia Oct 27, 2026
1705ed1
chore: add trailing newline at EOF (closes the last review nit)
airdropia Oct 27, 2026
2f1f0ab
chore: add trailing newline at EOF (closes the last review nit)
airdropia Oct 27, 2026
1748d25
Address review feedback
airdropia Oct 27, 2026
0837651
Address review feedback
airdropia Oct 27, 2026
b4583b7
Address review feedback
airdropia Oct 27, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"schema_version": 1,
"id": "FC-batch-export-overwrites-a-same-batch-sibling",
"violated_contract": "A batch exporter that is allowed to replace existing output must still give every item in the batch its own output path. `export_conversations(overwrite=True)` in `sdks/python-cli/examples/conversations_to_markdown.py` recorded the resolved path without checking it against the paths the active batch had already written, so two conversations whose date, title slug and 8-character id prefix agreed resolved to the same file: the second write replaced the first conversation, the function returned that path twice, and the first conversation was lost with no error and no warning. The same batch collision was already guarded on the `overwrite=False` path, so the two branches disagreed about what `overwrite` may discard. Reported as #23485 on 2026-10-24.",
"canonical_prevention": "Disambiguate the output path against the set of paths the active batch has already claimed before writing, on the overwrite path as well as the exclusive-create path, so `overwrite` keeps replacing files left behind by earlier runs while a name already claimed inside the current batch is never reused. Keep a batch-collision regression test that exports two conversations resolving to the same base name and asserts two distinct files with both bodies intact.",
"canonical_prevention_artifact": [
"sdks/python-cli/tests/test_conversations_to_markdown_filename_collisions.py"
],
"evidence_prs": [],
"scope_hints": [
"sdks/python-cli/examples/conversations_to_markdown.py",
"sdks/python-cli/tests/test_conversations_to_markdown_filename_collisions.py"
],
"status": "open"
}
60 changes: 50 additions & 10 deletions sdks/python-cli/examples/conversations_to_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,40 @@ def slugify(text: str) -> str:
return re.sub(r"[-\s]+", "_", text)[:50] or "conversation"


def flatten_line(text: str) -> str:
"""Collapse internal newlines so a value stays on one Markdown line.

A transcript segment or a note field may carry a multi-line string. Emitting
it unchanged after a ``>`` prefix lets the later lines escape the blockquote
(they render as ordinary paragraphs), so every run of newlines is collapsed
to a single space.
"""
return re.sub(r"\s*[\r\n]+\s*", " ", text).strip()


def speaker_label(segment: Dict[str, Any]) -> str:
"""Resolve the display label for one transcript segment's speaker.

Exports name the speaker in different ways, so all of them are accepted:
a name string under ``speaker`` or ``speaker_name``, or a bare speaker
number under ``speaker`` or ``speaker_id``. Speaker ``0`` is a real speaker
and must not be treated as missing. ``bool`` is excluded from the number
case because ``isinstance(False, int)`` is true, which would otherwise
render a ``speaker: False`` segment as "Speaker False".
"""
for key in ("speaker", "speaker_name"):
value = segment.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
for key in ("speaker", "speaker_id"):
value = segment.get(key)
if isinstance(value, bool):
continue
if isinstance(value, int):
return f"Speaker {value}"
return "Speaker"


def conversation_to_markdown(conv: Dict[str, Any]) -> str:
"""Convert a single Omi conversation dictionary into formatted Markdown."""
conv_id = conv.get("id", "unknown")
Expand All @@ -84,8 +118,8 @@ def conversation_to_markdown(conv: Dict[str, Any]) -> str:
overview = structured.get("overview") or ""
action_items = structured.get("action_items") or []

# Transcript segments
transcript_segments = conv.get("transcript_segments") or []
# Transcript segments. An export may carry them under ``segments`` instead.
transcript_segments = conv.get("transcript_segments") or conv.get("segments") or []

# Clean date representation normalized to UTC
date_str = ""
Expand All @@ -100,7 +134,7 @@ def conversation_to_markdown(conv: Dict[str, Any]) -> str:
except Exception:
date_str = started_at

category_tag = strip_surrogates(category).lower().replace(" ", "-")
category_tag = strip_surrogates(str(category)).lower().replace(" ", "-")

# Serialize YAML frontmatter scalars safely using json.dumps to prevent injection and Python 3.10/3.11 SyntaxError
lines: List[str] = [
Expand Down Expand Up @@ -158,16 +192,12 @@ def conversation_to_markdown(conv: Dict[str, Any]) -> str:
for seg in transcript_segments:
if not isinstance(seg, dict):
continue
speaker = seg.get("speaker", "Speaker")
if isinstance(speaker, int):
speaker_label = f"Speaker {speaker}"
else:
speaker_label = str(speaker) if speaker else "Speaker"
speaker_label_text = speaker_label(seg)

start_time = format_timestamp(seg.get("start"))
text = (seg.get("text") or "").strip()
text = flatten_line(seg.get("text") or "")
if text:
lines.append(f"> **[{start_time}] {speaker_label}:** {text}")
lines.append(f"> **[{start_time}] {speaker_label_text}:** {text}")
lines.append(">")
if lines[-1] == ">":
lines.pop()
Expand Down Expand Up @@ -209,6 +239,16 @@ def export_conversations(
md_content = conversation_to_markdown(conv)

if overwrite:
# ``overwrite`` replaces files that already exist on disk from an
# earlier run, but a name this batch has already written belongs to
# a different conversation: reusing it would silently discard that
# conversation and return the same path twice. Only names claimed
# by this batch are disambiguated, so a pre-existing file is still
# replaced as documented.
counter = 1
while filepath in used_paths:
counter += 1
filepath = output_dir / f"{base_name}_{counter}.md"
used_paths.add(filepath)
filepath.write_text(md_content, encoding="utf-8")
else:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
"""Regression tests for the conversations to markdown exporter (#23485).

Pins the four failure paths reported in #23485:

* ``export_conversations(overwrite=True)`` silently discarded a conversation
when two conversations in the same batch resolved to the same file name,
because the batch-collision guard existed only on the ``overwrite=False``
path.
* A non-string ``structured.category`` raised ``AttributeError`` while the
neighbouring fields already coerced with ``str(...)``.
* A boolean speaker rendered as "Speaker False" (``isinstance(False, int)`` is
true), and an integer speaker under ``speaker_id``, including speaker ``0``,
was ignored.
* A multi-line transcript segment escaped its Markdown blockquote.

The exporter script is loaded dynamically, the same way the sibling example
tests load theirs, so this suite is hermetic: no network, no real API.
"""

from __future__ import annotations

import importlib.util
from pathlib import Path
import tempfile
import unittest

script_path = Path(__file__).resolve().parent.parent / "examples" / "conversations_to_markdown.py"
spec = importlib.util.spec_from_file_location("conversations_to_markdown", script_path)
c2m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(c2m)


def conversation(conv_id: str, title: str, **overrides):
"""A minimal conversation that shares date, title and id prefix with any
other conversation built the same way, so the base file name collides."""
conv = {
"id": conv_id,
"started_at": "2026-05-04T10:00:00Z",
"structured": {"title": title, "category": "general"},
}
conv.update(overrides)
return conv


class TestOverwriteBatchCollisions(unittest.TestCase):
"""#23485 item 1: overwrite=True must not clobber a same-batch sibling."""

def test_two_colliding_conversations_get_distinct_files_and_contents(self):
first = conversation("12345678-1111-4abc-8def-000000000001", "Same Title")
second = conversation("12345678-2222-4abc-8def-000000000002", "Same Title")
first["structured"]["overview"] = "first conversation body"
second["structured"]["overview"] = "second conversation body"

with tempfile.TemporaryDirectory() as tmp:
out = Path(tmp)
paths = c2m.export_conversations([first, second], out, overwrite=True)

self.assertEqual(len(paths), 2, "both conversations must be exported")
self.assertNotEqual(paths[0], paths[1], "colliding names must be disambiguated")
self.assertEqual(len(set(paths)), 2)

first_text = paths[0].read_text(encoding="utf-8")
second_text = paths[1].read_text(encoding="utf-8")
# Neither conversation may be lost.
self.assertIn("first conversation body", first_text)
self.assertIn("second conversation body", second_text)
self.assertNotIn("second conversation body", first_text)
self.assertNotIn("first conversation body", second_text)

def test_overwrite_still_replaces_a_pre_existing_file(self):
"""Disambiguation applies to this batch only, so a file left by an
earlier run is still replaced at its own name."""
conv = conversation("12345678-3333-4abc-8def-000000000003", "Same Title")
conv["structured"]["overview"] = "fresh body"

with tempfile.TemporaryDirectory() as tmp:
out = Path(tmp)
base = out / f"2026-05-04_same_title_12345678.md"
out.mkdir(parents=True, exist_ok=True)
base.write_text("stale body", encoding="utf-8")

paths = c2m.export_conversations([conv], out, overwrite=True)

self.assertEqual(paths, [base], "an earlier run's file must be replaced in place")
self.assertIn("fresh body", base.read_text(encoding="utf-8"))
self.assertNotIn("stale body", base.read_text(encoding="utf-8"))

def test_non_overwrite_still_disambiguates(self):
"""The pre-existing overwrite=False behaviour must not regress."""
first = conversation("12345678-4444-4abc-8def-000000000004", "Same Title")
second = conversation("12345678-5555-4abc-8def-000000000005", "Same Title")

with tempfile.TemporaryDirectory() as tmp:
out = Path(tmp)
paths = c2m.export_conversations([first, second], out, overwrite=False)
self.assertEqual(len(set(paths)), 2)
for path in paths:
self.assertTrue(path.exists())


class TestNonStringCategory(unittest.TestCase):
"""#23485 item 2: a non-string category must not raise AttributeError."""

def test_category_as_list_does_not_raise(self):
conv = conversation("abc-1", "T", structured={"title": "T", "category": ["work", "family"]})
text = c2m.conversation_to_markdown(conv)
self.assertIn("category:", text)

def test_category_as_int_does_not_raise(self):
conv = conversation("abc-2", "T", structured={"title": "T", "category": 7})
text = c2m.conversation_to_markdown(conv)
self.assertIn("7", text)

def test_category_as_dict_does_not_raise(self):
conv = conversation("abc-3", "T", structured={"title": "T", "category": {"name": "work"}})
text = c2m.conversation_to_markdown(conv)
self.assertIn("category:", text)

def test_string_category_is_slugged_as_before(self):
conv = conversation(
"abc-4", "T", structured={"title": "T", "category": "Team Meeting"}
)
text = c2m.conversation_to_markdown(conv)
self.assertIn("team-meeting", text)


class TestSpeakerLabels(unittest.TestCase):
"""#23485 item 3: boolean, integer and named speakers all resolve."""

def label(self, segment):
return c2m.speaker_label(segment)

def test_boolean_speaker_is_not_rendered_as_a_number(self):
self.assertEqual(self.label({"speaker": False}), "Speaker")
self.assertEqual(self.label({"speaker": True}), "Speaker")

def test_integer_speaker_is_numbered(self):
self.assertEqual(self.label({"speaker": 3}), "Speaker 3")

def test_speaker_zero_is_a_real_speaker(self):
self.assertEqual(self.label({"speaker_id": 0}), "Speaker 0")
self.assertEqual(self.label({"speaker": 0}), "Speaker 0")

def test_named_speaker_wins(self):
self.assertEqual(self.label({"speaker": "Alice"}), "Alice")
self.assertEqual(self.label({"speaker_name": "Bob"}), "Bob")

def test_named_speaker_beats_a_bare_number(self):
self.assertEqual(self.label({"speaker": "Alice", "speaker_id": 2}), "Alice")

def test_empty_and_blank_names_fall_through(self):
self.assertEqual(self.label({"speaker": "", "speaker_id": 1}), "Speaker 1")
self.assertEqual(self.label({"speaker": " ", "speaker_id": 1}), "Speaker 1")
self.assertEqual(self.label({}), "Speaker")

def test_false_speaker_falls_back_to_speaker_id(self):
self.assertEqual(self.label({"speaker": False, "speaker_id": 4}), "Speaker 4")

def test_rendered_transcript_uses_the_resolved_label(self):
conv = conversation(
"abc-5",
"T",
transcript_segments=[{"speaker_id": 0, "start": 0, "text": "hello"}],
)
text = c2m.conversation_to_markdown(conv)
self.assertIn("Speaker 0", text)
self.assertNotIn("Speaker False", text)


class TestMultilineTranscriptText(unittest.TestCase):
"""#23485 item 4: a multi-line segment must stay inside the blockquote."""

def test_every_transcript_line_is_quoted(self):
conv = conversation(
"abc-6",
"T",
transcript_segments=[
{"speaker": "Alice", "start": 0, "text": "first line\nsecond line"}
],
)
text = c2m.conversation_to_markdown(conv)
blockquote_lines = [line for line in text.splitlines() if line.startswith(">")]
self.assertTrue(blockquote_lines, "the transcript must be emitted as a blockquote")
for line in text.splitlines():
if "first line" in line or "second line" in line:
self.assertTrue(
line.startswith(">"),
f"transcript text escaped the blockquote: {line!r}",
)

def test_windows_newlines_are_flattened(self):
conv = conversation(
"abc-7",
"T",
transcript_segments=[{"speaker": "A", "start": 0, "text": "one\r\ntwo\r\nthree"}],
)
text = c2m.conversation_to_markdown(conv)
for fragment in ("one", "two", "three"):
line = next(line for line in text.splitlines() if fragment in line)
self.assertTrue(line.startswith(">"), line)

def test_blank_line_runs_do_not_survive(self):
conv = conversation(
"abc-8",
"T",
transcript_segments=[{"speaker": "A", "start": 0, "text": "alpha\n\n\nbeta"}],
)
text = c2m.conversation_to_markdown(conv)
line = next(line for line in text.splitlines() if "alpha" in line)
self.assertIn("beta", line)
self.assertNotIn("\n", line)


class TestSegmentsKeyFallback(unittest.TestCase):
"""#23485: an export may carry the transcript under ``segments``."""

def test_segments_key_is_used_when_transcript_segments_is_absent(self):
conv = conversation(
"abc-9",
"T",
segments=[{"speaker": "Alice", "start": 5, "text": "from the segments key"}],
)
conv.pop("transcript_segments", None)
text = c2m.conversation_to_markdown(conv)
self.assertIn("from the segments key", text)
self.assertIn("00:05", text)

def test_transcript_segments_still_wins_when_both_are_present(self):
conv = conversation(
"abc-10",
"T",
transcript_segments=[{"speaker": "A", "start": 0, "text": "preferred"}],
segments=[{"speaker": "B", "start": 0, "text": "ignored"}],
)
text = c2m.conversation_to_markdown(conv)
self.assertIn("preferred", text)
self.assertNotIn("ignored", text)


if __name__ == "__main__":
unittest.main()