transcript-viewer 0.5.0__py3-none-any.whl
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.
- transcript_viewer/__init__.py +5 -0
- transcript_viewer/ai.py +374 -0
- transcript_viewer/cli.py +87 -0
- transcript_viewer/config.py +193 -0
- transcript_viewer/corpus.py +340 -0
- transcript_viewer/fetch.py +771 -0
- transcript_viewer/library.py +169 -0
- transcript_viewer/page.html +2248 -0
- transcript_viewer/store.py +83 -0
- transcript_viewer/viewer.py +1000 -0
- transcript_viewer-0.5.0.dist-info/METADATA +440 -0
- transcript_viewer-0.5.0.dist-info/RECORD +14 -0
- transcript_viewer-0.5.0.dist-info/WHEEL +4 -0
- transcript_viewer-0.5.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""What you decide about a transcript, as opposed to what it is.
|
|
2
|
+
|
|
3
|
+
Titles, tags and stars live here; paths, sizes and formats stay in
|
|
4
|
+
atif-make's index. Keeping them apart means re-indexing can never destroy an
|
|
5
|
+
annotation, and an annotation survives its file being re-scanned from somewhere
|
|
6
|
+
new — records are keyed by content, so a file that moves keeps everything.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import threading
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from . import store
|
|
17
|
+
|
|
18
|
+
LIBRARY_PATH = store.ROOT / "library.json"
|
|
19
|
+
VERSION = 1
|
|
20
|
+
|
|
21
|
+
# Two viewers can be open at once; serialise our own writes and let the atomic
|
|
22
|
+
# replace settle the rest.
|
|
23
|
+
_lock = threading.Lock()
|
|
24
|
+
|
|
25
|
+
_DEFAULTS: dict[str, Any] = {
|
|
26
|
+
"title": "",
|
|
27
|
+
# Where a fetched session came from. This is what places it in the tree, what
|
|
28
|
+
# the tree links back to, and what a refresh asks again.
|
|
29
|
+
"source": "",
|
|
30
|
+
# The folder that fetch was told to download into, so a refresh puts new
|
|
31
|
+
# files beside the old ones rather than somewhere else.
|
|
32
|
+
"into": "",
|
|
33
|
+
"tags": [],
|
|
34
|
+
"starred": False,
|
|
35
|
+
"note": "",
|
|
36
|
+
# Individual steps starred inside a transcript. Held as the viewer's step
|
|
37
|
+
# keys — "12" at the top level, "<trajectory>-3" inside a subagent, whose
|
|
38
|
+
# ids restart at 1 — so a star cannot land on the wrong step.
|
|
39
|
+
"starred_steps": [],
|
|
40
|
+
# Summaries the model has already produced, by tool-call id. Cached here so
|
|
41
|
+
# a call is paid for once, and so they survive a restart like any other note.
|
|
42
|
+
"summaries": {},
|
|
43
|
+
# Whether this transcript may be sent to the model at all. On by default,
|
|
44
|
+
# but a single switch turns every AI control off for one session — useful
|
|
45
|
+
# when a transcript holds something that should not leave the machine.
|
|
46
|
+
"ai": True,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _now() -> str:
|
|
51
|
+
return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load(path: Path | None = None) -> dict[str, dict]:
|
|
55
|
+
"""Every annotation, by key. A missing or damaged file reads as empty."""
|
|
56
|
+
data = store.read_json(path or LIBRARY_PATH)
|
|
57
|
+
entries = data.get("entries")
|
|
58
|
+
if not isinstance(entries, dict):
|
|
59
|
+
return {}
|
|
60
|
+
return {k: v for k, v in entries.items() if isinstance(v, dict)}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def save(entries: dict[str, dict], path: Path | None = None) -> None:
|
|
64
|
+
"""Write the whole library atomically."""
|
|
65
|
+
store.write_json(path or LIBRARY_PATH, {"version": VERSION, "entries": entries})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get(key: str, path: Path | None = None) -> dict:
|
|
69
|
+
"""One record, with defaults filled in, whether or not it is annotated."""
|
|
70
|
+
path = path or LIBRARY_PATH
|
|
71
|
+
return {**_DEFAULTS, **load(path).get(key, {})}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _clean_tags(value: Any) -> list[str]:
|
|
75
|
+
"""Tags are short, unique, lower-case labels — order preserved."""
|
|
76
|
+
if not isinstance(value, list):
|
|
77
|
+
return []
|
|
78
|
+
seen: set[str] = set()
|
|
79
|
+
out: list[str] = []
|
|
80
|
+
for tag in value:
|
|
81
|
+
if not isinstance(tag, str):
|
|
82
|
+
continue
|
|
83
|
+
tag = tag.strip().lower()[:40]
|
|
84
|
+
if tag and tag not in seen:
|
|
85
|
+
seen.add(tag)
|
|
86
|
+
out.append(tag)
|
|
87
|
+
return out[:20]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _clean_steps(value: Any) -> list[str]:
|
|
91
|
+
"""Step keys, unique and order-preserved. Kept as strings because a
|
|
92
|
+
subagent's key is not a number."""
|
|
93
|
+
if not isinstance(value, list):
|
|
94
|
+
return []
|
|
95
|
+
seen: set[str] = set()
|
|
96
|
+
out: list[str] = []
|
|
97
|
+
for item in value:
|
|
98
|
+
key = str(item).strip()[:120]
|
|
99
|
+
if key and key not in seen:
|
|
100
|
+
seen.add(key)
|
|
101
|
+
out.append(key)
|
|
102
|
+
return out[:2000]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def update(key: str, path: Path | None = None, **fields: Any) -> dict:
|
|
106
|
+
"""Merge fields into one record and persist. Returns the merged record."""
|
|
107
|
+
if not key:
|
|
108
|
+
raise ValueError("a key is required")
|
|
109
|
+
path = path or LIBRARY_PATH
|
|
110
|
+
with _lock:
|
|
111
|
+
entries = load(path)
|
|
112
|
+
record = {**_DEFAULTS, **entries.get(key, {})}
|
|
113
|
+
for name, value in fields.items():
|
|
114
|
+
if name not in _DEFAULTS:
|
|
115
|
+
continue
|
|
116
|
+
if name == "tags":
|
|
117
|
+
record["tags"] = _clean_tags(value)
|
|
118
|
+
elif name == "starred_steps":
|
|
119
|
+
record["starred_steps"] = _clean_steps(value)
|
|
120
|
+
elif name == "summaries":
|
|
121
|
+
record["summaries"] = {
|
|
122
|
+
str(k)[:120]: str(v)[:4000]
|
|
123
|
+
for k, v in (value or {}).items()
|
|
124
|
+
if isinstance(k, str) and isinstance(v, str)
|
|
125
|
+
} if isinstance(value, dict) else {}
|
|
126
|
+
elif name in ("starred", "ai"):
|
|
127
|
+
record[name] = bool(value)
|
|
128
|
+
else:
|
|
129
|
+
record[name] = str(value).strip()[:500] if value is not None else ""
|
|
130
|
+
record.setdefault("added", _now())
|
|
131
|
+
# An entry annotated back to its defaults is not worth keeping. Compared
|
|
132
|
+
# against the defaults rather than tested for falsiness, because a
|
|
133
|
+
# default can be True: "ai" is, so `any()` would keep every entry alive.
|
|
134
|
+
if {k: v for k, v in record.items() if k != "added"} != _DEFAULTS:
|
|
135
|
+
entries[key] = record
|
|
136
|
+
else:
|
|
137
|
+
entries.pop(key, None)
|
|
138
|
+
save(entries, path)
|
|
139
|
+
return record
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def remove(key: str, path: Path | None = None) -> bool:
|
|
143
|
+
"""Forget one record. True when there was something to forget."""
|
|
144
|
+
path = path or LIBRARY_PATH
|
|
145
|
+
with _lock:
|
|
146
|
+
entries = load(path)
|
|
147
|
+
existed = entries.pop(key, None) is not None
|
|
148
|
+
if existed:
|
|
149
|
+
save(entries, path)
|
|
150
|
+
return existed
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def tags(path: Path | None = None) -> list[tuple[str, int]]:
|
|
154
|
+
"""Every tag with a count, most used first."""
|
|
155
|
+
path = path or LIBRARY_PATH
|
|
156
|
+
counts: dict[str, int] = {}
|
|
157
|
+
for record in load(path).values():
|
|
158
|
+
for tag in record.get("tags") or []:
|
|
159
|
+
counts[tag] = counts.get(tag, 0) + 1
|
|
160
|
+
return sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def decorate(rows: list[dict], path: Path | None = None) -> list[dict]:
|
|
164
|
+
"""Fold annotations into index rows so a client gets one object per session."""
|
|
165
|
+
path = path or LIBRARY_PATH
|
|
166
|
+
entries = load(path)
|
|
167
|
+
for row in rows:
|
|
168
|
+
row.update({**_DEFAULTS, **entries.get(row.get("key", ""), {})})
|
|
169
|
+
return rows
|