transcript-viewer 0.8.0__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/PKG-INFO +2 -2
  2. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/pyproject.toml +2 -2
  3. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/src/transcript_viewer/cli.py +6 -1
  4. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/src/transcript_viewer/corpus.py +31 -0
  5. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/src/transcript_viewer/fetch.py +2 -1
  6. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/src/transcript_viewer/page.html +3 -0
  7. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/src/transcript_viewer/viewer.py +13 -2
  8. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/tests/test_cli.py +5 -1
  9. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/tests/test_corpus.py +33 -0
  10. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/tests/test_fetch.py +28 -6
  11. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/uv.lock +5 -5
  12. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/.coverage +0 -0
  13. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/.github/workflows/publish.yml +0 -0
  14. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/.github/workflows/test.yml +0 -0
  15. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/.gitignore +0 -0
  16. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/README.md +0 -0
  17. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/src/transcript_viewer/__init__.py +0 -0
  18. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/src/transcript_viewer/ai.py +0 -0
  19. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/src/transcript_viewer/config.py +0 -0
  20. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/src/transcript_viewer/library.py +0 -0
  21. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/src/transcript_viewer/store.py +0 -0
  22. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/tests/fixtures/big-command.json +0 -0
  23. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/tests/page.test.js +0 -0
  24. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/tests/test_ai.py +0 -0
  25. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/tests/test_config.py +0 -0
  26. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/tests/test_library.py +0 -0
  27. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/tests/test_page.py +0 -0
  28. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/tests/test_readme.py +0 -0
  29. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/tests/test_store.py +0 -0
  30. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/tests/test_stream.py +0 -0
  31. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/tests/test_style.py +0 -0
  32. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/tests/test_tidiness.py +0 -0
  33. {transcript_viewer-0.8.0 → transcript_viewer-0.9.0}/tests/test_viewer.py +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: transcript-viewer
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: Browse agent transcripts in a local, dependency-free web viewer.
5
5
  License: MIT
6
6
  Requires-Python: >=3.12
7
- Requires-Dist: atif-make>=0.5.0
7
+ Requires-Dist: atif-make>=0.6.0
8
8
  Provides-Extra: ai
9
9
  Requires-Dist: anthropic>=0.40; extra == 'ai'
10
10
  Provides-Extra: parquet
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "transcript-viewer"
3
- version = "0.8.0"
3
+ version = "0.9.0"
4
4
  description = "Browse agent transcripts in a local, dependency-free web viewer."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -10,7 +10,7 @@ license = { text = "MIT" }
10
10
  # The floor is not politeness: this viewer imports atif_make.container, which
11
11
  # is how a benchmark published as one JSON array of a thousand runs becomes a
12
12
  # thousand sessions. An older atif-make fails to import at all.
13
- dependencies = ["atif-make>=0.5.0"]
13
+ dependencies = ["atif-make>=0.6.0"]
14
14
 
15
15
  [project.optional-dependencies]
16
16
  # Claude-backed explanations are opt-in, so the default install stays
@@ -49,7 +49,12 @@ def cmd_view(args: argparse.Namespace) -> int:
49
49
  # An explicit path that holds nothing is a mistake worth reporting; an
50
50
  # empty library is not, so this only guards the argument.
51
51
  if not entries:
52
- print(f"transcript-viewer: nothing convertible in {path}", file=sys.stderr)
52
+ print(
53
+ f"transcript-viewer: no transcript in {path}. "
54
+ f"{corpus.describe_contents(path)}, and none of it is a log "
55
+ f"this can read.",
56
+ file=sys.stderr,
57
+ )
53
58
  return 1
54
59
  else:
55
60
  # Only what has been added deliberately. Scanning a machine because the
@@ -9,6 +9,7 @@ something actually asks for that trajectory.
9
9
  from __future__ import annotations
10
10
 
11
11
  import hashlib
12
+ from collections import Counter
12
13
  import re
13
14
  import json
14
15
  from dataclasses import asdict, dataclass
@@ -311,6 +312,7 @@ def scan(roots: list[Path] | None = None, origin: str = "scanned") -> list[Entry
311
312
  sorted(where.rglob("*.jsonl"))
312
313
  + sorted(where.rglob("*.har"))
313
314
  + sorted(where.rglob("*.json"))
315
+ + sorted(where.rglob("*.md"))
314
316
  # Not a log, but a container of them, opened above.
315
317
  + sorted(where.rglob("*.parquet"))
316
318
  )
@@ -367,3 +369,32 @@ def load(path: Path | None = None) -> list[Entry]:
367
369
  except TypeError:
368
370
  continue
369
371
  return entries
372
+
373
+
374
+ def describe_contents(root: Path, limit: int = 4000) -> str:
375
+ """What is inside a path, said in a sentence.
376
+
377
+ Reached only when nothing in it converted. "Nothing convertible" on its own
378
+ invites the reader to doubt the tool rather than the file — one real archive
379
+ held 1,952 files and an audit that referenced a transcript which had not
380
+ been shipped with it, and the answer gave no way to tell that from a bug.
381
+ """
382
+ if root.is_file():
383
+ return f"{root.name} ({root.stat().st_size:,} bytes)"
384
+
385
+ kinds: Counter[str] = Counter()
386
+ total = 0
387
+ for item in root.rglob("*"):
388
+ if not item.is_file():
389
+ continue
390
+ total += 1
391
+ if total > limit:
392
+ break
393
+ kinds[item.suffix.lower() or "no extension"] += 1
394
+
395
+ if not total:
396
+ return "it is empty"
397
+
398
+ common = ", ".join(f"{n:,} {ext}" for ext, n in kinds.most_common(4))
399
+ more = "+" if total > limit else ""
400
+ return f"{total:,}{more} files inside — {common}"
@@ -58,7 +58,8 @@ HOSTS = {
58
58
  # in: a bucket of agent transcripts is far more likely to hold one zip per
59
59
  # session than loose JSONL, so excluding archives made S3 useless for exactly
60
60
  # the case it was added for.
61
- LOGS = (".jsonl", ".json", ".har")
61
+ # A transcript is not always JSON: some corpora ship the readable rendering.
62
+ LOGS = (".jsonl", ".json", ".har", ".md", ".markdown")
62
63
  # A published dataset is a container of runs rather than a log, but it is still
63
64
  # a thing worth fetching: METR's transcripts come as Parquet shards.
64
65
  DATASETS = (".parquet",)
@@ -1728,6 +1728,9 @@ const EXAMPLES=[
1728
1728
  {name:"Redwood agent transcripts",
1729
1729
  url:"s3://rr-agent-transcripts",
1730
1730
  note:"needs an AWS profile set in Settings"},
1731
+ {name:"Redwood sabotage trajectories",
1732
+ url:"s3://rr-strajs",
1733
+ note:"136 transcripts, honest and attack in pairs — needs an AWS profile"},
1731
1734
  ];
1732
1735
 
1733
1736
  function drawExamples(){
@@ -27,6 +27,7 @@ from urllib.parse import parse_qs, unquote, urlparse
27
27
  from atif_make.atif import ContentPart, Trajectory
28
28
  from . import corpus
29
29
  from atif_make.container import is_container, open_container
30
+ from atif_make.detect import FORMATS
30
31
  from atif_make.convert import convert
31
32
  from transcript_viewer.corpus import Entry, scan
32
33
 
@@ -615,9 +616,19 @@ class _Handler(BaseHTTPRequestHandler):
615
616
  self._json({"error": str(exc)}, 400)
616
617
  return
617
618
  if not found:
618
- # Nothing usable in it — do not keep the copy around.
619
+ # Nothing usable in it — do not keep the copy around. Say what was
620
+ # in it on the way out: an answer of "nothing convertible" with
621
+ # nothing behind it reads as the tool failing rather than the file
622
+ # not holding what was hoped.
623
+ inside = corpus.describe_contents(target)
619
624
  shutil.rmtree(home, ignore_errors=True)
620
- self._json({"error": f"nothing convertible in {name}"}, 415)
625
+ self._json(
626
+ {
627
+ "error": f"No transcript in {name}. {inside}, and none of it "
628
+ f"is a log this can read ({', '.join(FORMATS)})."
629
+ },
630
+ 415,
631
+ )
621
632
  return
622
633
 
623
634
  with self.lock:
@@ -65,7 +65,11 @@ def test_a_path_with_nothing_convertible_reports_rather_than_serving(
65
65
  ):
66
66
  (tmp_path / "notes.txt").write_text("nothing here")
67
67
  assert cli.main([str(tmp_path)]) == 1
68
- assert "nothing convertible" in capsys.readouterr().err
68
+ said = capsys.readouterr().err
69
+ assert "no transcript" in said
70
+ # It must also say what it looked at: a bare refusal reads as the tool
71
+ # failing rather than the path not holding what was hoped for.
72
+ assert "1 file" in said and ".txt" in said
69
73
  assert not served
70
74
 
71
75
 
@@ -496,3 +496,36 @@ def test_pieces_unpacked_beside_a_download_are_used_as_they_are(tmp_path):
496
496
  entries = corpus.scan([tmp_path])
497
497
  assert len(entries) == 3
498
498
  assert all(str(beside) in e.path for e in entries)
499
+
500
+
501
+ def test_a_path_that_converts_to_nothing_still_says_what_was_in_it(tmp_path):
502
+ """"Nothing convertible" alone reads as the tool failing, not the file."""
503
+ (tmp_path / "a").mkdir()
504
+ (tmp_path / "a" / "notes.md").write_text("# hello")
505
+ (tmp_path / "a" / "data.json").write_text('{"not": "a transcript"}')
506
+ (tmp_path / "a" / "run.py").write_text("print(1)")
507
+
508
+ said = corpus.describe_contents(tmp_path)
509
+
510
+ assert "3 files" in said
511
+ for ext in (".md", ".json", ".py"):
512
+ assert ext in said, f"{ext} should be named"
513
+
514
+
515
+ def test_describing_a_single_file_names_it(tmp_path):
516
+ path = tmp_path / "one.jsonl"
517
+ path.write_text("{}\n")
518
+ said = corpus.describe_contents(path)
519
+ assert "one.jsonl" in said and "bytes" in said
520
+
521
+
522
+ def test_describing_an_empty_directory_says_so(tmp_path):
523
+ assert corpus.describe_contents(tmp_path) == "it is empty"
524
+
525
+
526
+ def test_describing_a_huge_tree_does_not_read_all_of_it(tmp_path):
527
+ """A 600 MB archive should not be walked twice to explain itself."""
528
+ for i in range(60):
529
+ (tmp_path / f"f{i}.bin").write_text("x")
530
+ said = corpus.describe_contents(tmp_path, limit=10)
531
+ assert "+" in said, "it should say the count is a floor, not a total"
@@ -215,7 +215,11 @@ def test_a_dataset_url_lists_its_convertible_files(monkeypatch):
215
215
  )
216
216
  assert service == "hf"
217
217
  assert label == "owner--name"
218
- assert [f.name for f in files] == ["a/transcript.jsonl", "b/metadata.json"]
218
+ assert [f.name for f in files] == [
219
+ "a/transcript.jsonl",
220
+ "a/description.md",
221
+ "b/metadata.json",
222
+ ]
219
223
  assert all("/resolve/main/" in f.url for f in files)
220
224
  assert seen["auth"] == ["tok"], "the token was not sent"
221
225
 
@@ -310,11 +314,23 @@ def test_a_blob_url_is_rewritten_to_raw(monkeypatch):
310
314
 
311
315
 
312
316
  def test_nothing_convertible_is_said_plainly(monkeypatch):
313
- _stub(monkeypatch, {"api/datasets": [{"type": "file", "path": "readme.md"}]})
317
+ # Markdown is fetchable some corpora publish transcripts as nothing else —
318
+ # so the refusal is tested with something that could not be one.
319
+ _stub(monkeypatch, {"api/datasets": [{"type": "file", "path": "diagram.png"}]})
314
320
  with pytest.raises(fetch.FetchError, match="Nothing convertible"):
315
321
  fetch.plan("https://huggingface.co/datasets/owner/name", {})
316
322
 
317
323
 
324
+ def test_a_transcript_published_as_markdown_is_worth_fetching(monkeypatch):
325
+ """A bucket of 136 of these was unreachable while .md was filtered out."""
326
+ _stub(
327
+ monkeypatch,
328
+ {"api/datasets": [{"type": "file", "path": "run/transcript_attack.md"}]},
329
+ )
330
+ plan = fetch.plan("https://huggingface.co/datasets/owner/name", {})
331
+ assert [f.name for f in plan.files] == ["run/transcript_attack.md"]
332
+
333
+
318
334
  def test_too_many_files_is_refused_before_downloading(monkeypatch):
319
335
  _stub(
320
336
  monkeypatch,
@@ -489,7 +505,7 @@ def test_an_s3_url_lists_what_is_convertible(monkeypatch):
489
505
  plan = fetch.plan("s3://rr-agent-transcripts/runs", {"aws": "rw-eng"})
490
506
  assert plan.service == "s3"
491
507
  assert plan.label == "rr-agent-transcripts"
492
- assert [f.name for f in plan.files] == ["a.jsonl", "deep/b.jsonl"]
508
+ assert [f.name for f in plan.files] == ["a.jsonl", "notes.md", "deep/b.jsonl"]
493
509
  assert plan.web == "s3://rr-agent-transcripts/runs"
494
510
 
495
511
 
@@ -604,7 +620,10 @@ def test_an_archive_counts_as_something_worth_fetching(monkeypatch):
604
620
  ]
605
621
  }))
606
622
  plan = fetch.plan("s3://rr-agent-transcripts/chippy/abc", {"aws": "rw-eng"})
607
- assert [f.name for f in plan.files] == ["transcripts.zip"]
623
+ # The Markdown comes too: a corpus that publishes transcripts as Markdown
624
+ # and nothing else was unreachable while it was filtered out. One that is
625
+ # not a transcript simply does not index.
626
+ assert [f.name for f in plan.files] == ["transcripts.zip", "notes.md"]
608
627
 
609
628
 
610
629
  def test_the_listing_is_capped_rather_than_exhaustive(monkeypatch):
@@ -650,7 +669,8 @@ def test_s3_browsing_returns_folders_and_files(monkeypatch):
650
669
  nodes = fetch.browse("s3://bucket", "chippy", {"aws": "rw-eng"})
651
670
  assert "--delimiter" in seen["command"]
652
671
  assert [(n.kind, n.name) for n in nodes] == [
653
- ("folder", "abc"), ("folder", "def"), ("file", "loose.zip"),
672
+ ("folder", "abc"), ("folder", "def"),
673
+ ("file", "loose.zip"), ("file", "readme.md"),
654
674
  ]
655
675
  assert [n.path for n in nodes][0] == "chippy/abc"
656
676
 
@@ -667,7 +687,9 @@ def test_github_browsing_reads_one_level(monkeypatch):
667
687
  {"type": "file", "path": "logs/notes.md", "size": 3},
668
688
  ]})
669
689
  nodes = fetch.browse("https://github.com/owner/name/tree/main/logs", "", {})
670
- assert [(n.kind, n.name) for n in nodes] == [("folder", "inner"), ("file", "a.jsonl")]
690
+ assert [(n.kind, n.name) for n in nodes] == [
691
+ ("folder", "inner"), ("file", "a.jsonl"), ("file", "notes.md"),
692
+ ]
671
693
 
672
694
 
673
695
  def test_hf_browsing_reads_one_level(monkeypatch):
@@ -44,11 +44,11 @@ wheels = [
44
44
 
45
45
  [[package]]
46
46
  name = "atif-make"
47
- version = "0.5.0"
47
+ version = "0.6.0"
48
48
  source = { registry = "https://pypi.org/simple" }
49
- sdist = { url = "https://files.pythonhosted.org/packages/60/2c/9e5d1b1ba278fde5135b8066487e029dd0620161b6a220f5cb2a118a0333/atif_make-0.5.0.tar.gz", hash = "sha256:d4c3122a63b553099f0843d5d1c666136b0bccb1c11e8ebd2b4f9c4a9200cb5e", size = 215842, upload-time = "2026-08-23T05:15:04.439Z" }
49
+ sdist = { url = "https://files.pythonhosted.org/packages/d3/2b/b8cf83efc63bc09a546eb15b4a13a6013413f14b02ce2cc8e491c7884c21/atif_make-0.6.0.tar.gz", hash = "sha256:1aabe7af93cb07cbe23f39dd6f28aab5aa42f96074eb6f0d8312400447493f77", size = 240892, upload-time = "2026-08-24T21:29:42.84Z" }
50
50
  wheels = [
51
- { url = "https://files.pythonhosted.org/packages/00/47/f9a09d10b3d53600c43fa1b22c1d01cb731e628b3572c2e40109de195cfb/atif_make-0.5.0-py3-none-any.whl", hash = "sha256:b52fac5489f5cbc34f947f5d8b21b51a6a04276420d908a96f938e1bf7443791", size = 49769, upload-time = "2026-08-23T05:15:03.265Z" },
51
+ { url = "https://files.pythonhosted.org/packages/f9/b2/41bc96961d27d0d8794844ff2c716345f4593f6d1c0d7378039d3ce02b19/atif_make-0.6.0-py3-none-any.whl", hash = "sha256:add29b4bfb41682840b2e1bfe1564ac3b97c6eebe3f894c321b30d7d95485373", size = 58826, upload-time = "2026-08-24T21:29:41.798Z" },
52
52
  ]
53
53
 
54
54
  [package.optional-dependencies]
@@ -388,7 +388,7 @@ wheels = [
388
388
 
389
389
  [[package]]
390
390
  name = "transcript-viewer"
391
- version = "0.8.0"
391
+ version = "0.9.0"
392
392
  source = { editable = "." }
393
393
  dependencies = [
394
394
  { name = "atif-make" },
@@ -410,7 +410,7 @@ dev = [
410
410
  [package.metadata]
411
411
  requires-dist = [
412
412
  { name = "anthropic", marker = "extra == 'ai'", specifier = ">=0.40" },
413
- { name = "atif-make", specifier = ">=0.5.0" },
413
+ { name = "atif-make", specifier = ">=0.6.0" },
414
414
  { name = "atif-make", extras = ["parquet"], marker = "extra == 'parquet'", specifier = ">=0.5.0" },
415
415
  ]
416
416
  provides-extras = ["parquet", "ai"]