teich 0.2.3__tar.gz → 0.2.4__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 (67) hide show
  1. {teich-0.2.3 → teich-0.2.4}/PKG-INFO +1 -1
  2. {teich-0.2.3 → teich-0.2.4}/docs/studio.md +37 -0
  3. {teich-0.2.3 → teich-0.2.4}/pyproject.toml +1 -1
  4. {teich-0.2.3 → teich-0.2.4}/src/teich/__init__.py +1 -1
  5. {teich-0.2.3 → teich-0.2.4}/src/teich/formatter.py +105 -78
  6. teich-0.2.4/src/teich/studio/dataset_preview.py +274 -0
  7. teich-0.2.4/src/teich/studio/extraction.py +219 -0
  8. {teich-0.2.3 → teich-0.2.4}/src/teich/studio/server.py +117 -1
  9. {teich-0.2.3 → teich-0.2.4}/src/teich/studio/static/app.js +502 -9
  10. {teich-0.2.3 → teich-0.2.4}/src/teich/studio/static/index.html +93 -0
  11. {teich-0.2.3 → teich-0.2.4}/src/teich/studio/static/style.css +151 -0
  12. {teich-0.2.3 → teich-0.2.4}/tests/test_formatter.py +209 -35
  13. {teich-0.2.3 → teich-0.2.4}/tests/test_studio.py +110 -0
  14. {teich-0.2.3 → teich-0.2.4}/uv.lock +1 -1
  15. {teich-0.2.3 → teich-0.2.4}/.github/workflows/test.yml +0 -0
  16. {teich-0.2.3 → teich-0.2.4}/.gitignore +0 -0
  17. {teich-0.2.3 → teich-0.2.4}/DOCS.md +0 -0
  18. {teich-0.2.3 → teich-0.2.4}/LICENSE +0 -0
  19. {teich-0.2.3 → teich-0.2.4}/README.md +0 -0
  20. {teich-0.2.3 → teich-0.2.4}/ROADMAP.md +0 -0
  21. {teich-0.2.3 → teich-0.2.4}/config.example.yaml +0 -0
  22. {teich-0.2.3 → teich-0.2.4}/docker/codex-runtime.Dockerfile +0 -0
  23. {teich-0.2.3 → teich-0.2.4}/docs/cli.md +0 -0
  24. {teich-0.2.3 → teich-0.2.4}/docs/data-format.md +0 -0
  25. {teich-0.2.3 → teich-0.2.4}/docs/generation.md +0 -0
  26. {teich-0.2.3 → teich-0.2.4}/docs/index.md +0 -0
  27. {teich-0.2.3 → teich-0.2.4}/docs/pipeline.md +0 -0
  28. {teich-0.2.3 → teich-0.2.4}/docs/prepare-data.md +0 -0
  29. {teich-0.2.3 → teich-0.2.4}/docs/python-api.md +0 -0
  30. {teich-0.2.3 → teich-0.2.4}/docs/training.md +0 -0
  31. {teich-0.2.3 → teich-0.2.4}/gemma-template.jinja +0 -0
  32. {teich-0.2.3 → teich-0.2.4}/gemma4_example.py +0 -0
  33. {teich-0.2.3 → teich-0.2.4}/src/teich/__main__.py +0 -0
  34. {teich-0.2.3 → teich-0.2.4}/src/teich/anonymize.py +0 -0
  35. {teich-0.2.3 → teich-0.2.4}/src/teich/audit.py +0 -0
  36. {teich-0.2.3 → teich-0.2.4}/src/teich/cli.py +0 -0
  37. {teich-0.2.3 → teich-0.2.4}/src/teich/config.py +0 -0
  38. {teich-0.2.3 → teich-0.2.4}/src/teich/converter.py +0 -0
  39. {teich-0.2.3 → teich-0.2.4}/src/teich/extract.py +0 -0
  40. {teich-0.2.3 → teich-0.2.4}/src/teich/loader.py +0 -0
  41. {teich-0.2.3 → teich-0.2.4}/src/teich/prepare.py +0 -0
  42. {teich-0.2.3 → teich-0.2.4}/src/teich/runner.py +0 -0
  43. {teich-0.2.3 → teich-0.2.4}/src/teich/studio/__init__.py +0 -0
  44. {teich-0.2.3 → teich-0.2.4}/src/teich/studio/events.py +0 -0
  45. {teich-0.2.3 → teich-0.2.4}/src/teich/studio/generation.py +0 -0
  46. {teich-0.2.3 → teich-0.2.4}/src/teich/studio/interactive.py +0 -0
  47. {teich-0.2.3 → teich-0.2.4}/src/teich/studio/project.py +0 -0
  48. {teich-0.2.3 → teich-0.2.4}/src/teich/studio/static/teich.svg +0 -0
  49. {teich-0.2.3 → teich-0.2.4}/src/teich/studio/static/vendor/xterm-addon-fit.js +0 -0
  50. {teich-0.2.3 → teich-0.2.4}/src/teich/studio/static/vendor/xterm.css +0 -0
  51. {teich-0.2.3 → teich-0.2.4}/src/teich/studio/static/vendor/xterm.js +0 -0
  52. {teich-0.2.3 → teich-0.2.4}/src/teich/tool_schema.py +0 -0
  53. {teich-0.2.3 → teich-0.2.4}/src/teich/trace_readme.py +0 -0
  54. {teich-0.2.3 → teich-0.2.4}/teich_example.py +0 -0
  55. {teich-0.2.3 → teich-0.2.4}/tests/conftest.py +0 -0
  56. {teich-0.2.3 → teich-0.2.4}/tests/test_audit.py +0 -0
  57. {teich-0.2.3 → teich-0.2.4}/tests/test_cli.py +0 -0
  58. {teich-0.2.3 → teich-0.2.4}/tests/test_config.py +0 -0
  59. {teich-0.2.3 → teich-0.2.4}/tests/test_converter.py +0 -0
  60. {teich-0.2.3 → teich-0.2.4}/tests/test_extract_anonymize_cli.py +0 -0
  61. {teich-0.2.3 → teich-0.2.4}/tests/test_integration.py +0 -0
  62. {teich-0.2.3 → teich-0.2.4}/tests/test_loader.py +0 -0
  63. {teich-0.2.3 → teich-0.2.4}/tests/test_prepare.py +0 -0
  64. {teich-0.2.3 → teich-0.2.4}/tests/test_runner.py +0 -0
  65. {teich-0.2.3 → teich-0.2.4}/tests/test_tokenizer_smoke.py +0 -0
  66. {teich-0.2.3 → teich-0.2.4}/tests/test_tool_schema.py +0 -0
  67. {teich-0.2.3 → teich-0.2.4}/tests/test_trace_readme.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: teich
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: Turn coding agent traces into auditable supervised fine-tuning data
5
5
  License: Apache-2.0
6
6
  License-File: LICENSE
@@ -43,6 +43,7 @@ Studio gives you browser controls for the same files and runners used by the CLI
43
43
  - edit `config.yaml`
44
44
  - add, edit, import, and validate `prompts.jsonl`
45
45
  - start and resume batch generation
46
+ - extract existing local agent sessions from browser-selected source paths
46
47
  - watch generation progress and provider/model usage
47
48
  - inspect generated traces
48
49
  - open interactive agent sessions
@@ -81,6 +82,42 @@ teich generate -c config.yaml
81
82
 
82
83
  Use it to start a batch, monitor status, and resume a run without leaving the browser.
83
84
 
85
+ ## Browser Extraction
86
+
87
+ The Extract tab is a UI wrapper over:
88
+
89
+ ```bash
90
+ teich extract PROVIDER --sessions-dir PATH --out ./output
91
+ ```
92
+
93
+ Use it to stage existing local sessions from `claude`, `codex`, `pi`, or `hermes` without leaving the browser. The source box accepts either the provider home folder or the provider's direct data path:
94
+
95
+ - Claude: `.claude` or `.claude/projects`
96
+ - Codex: `.codex` or `.codex/sessions`
97
+ - Pi: `.pi`, `.pi/agent/sessions`, or `.pi/sessions`
98
+ - Hermes: `.hermes` or `.hermes/state.db`
99
+
100
+ Studio can fill in detected default paths for the selected provider. Extraction writes into the configured output folder, generates a dataset `README.md`, and anonymizes staged traces by default. Check **Skip anonymization** only when you are intentionally keeping raw local values.
101
+
102
+ ## Dataset Preview
103
+
104
+ The Dataset Preview tab lets you inspect the configured output folder before upload. It shows:
105
+
106
+ - JSONL files and row counts
107
+ - Hugging Face-style features for the converted training rows
108
+ - searchable row previews
109
+ - row JSON details
110
+ - local session/trace previews with messages, reasoning, tool calls, and tool results
111
+ - the generated dataset card preview when `README.md` is present
112
+
113
+ If `publish.repo_id` is set in `config.yaml`, Studio also exposes the official Hugging Face embedded Dataset Viewer URL:
114
+
115
+ ```text
116
+ https://huggingface.co/datasets/<owner>/<dataset>/embed/viewer
117
+ ```
118
+
119
+ That official embed works for datasets already available on the Hub. For unpublished local output, Studio uses Teich's local converter to approximate the parts of the viewer that matter before upload. The full Hugging Face viewer backend is hosted by Hugging Face and adds Parquet-backed row serving, search, filtering, SQL, and statistics after the dataset is uploaded and processed.
120
+
84
121
  ## Requirements
85
122
 
86
123
  For agent providers:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "teich"
3
- version = "0.2.3"
3
+ version = "0.2.4"
4
4
  description = "Turn coding agent traces into auditable supervised fine-tuning data"
5
5
  readme = "README.md"
6
6
  license = {text = "Apache-2.0"}
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.2.3"
3
+ __version__ = "0.2.4"
4
4
 
5
5
  from .audit import SFTAuditReport, audit_sft_dataset
6
6
  from .config import Config, load_config
@@ -1,10 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
- from copy import deepcopy
4
- from dataclasses import dataclass, field
5
- import difflib
6
- import json
7
- import re
3
+ from copy import deepcopy
4
+ from dataclasses import dataclass, field
5
+ import json
6
+ import re
8
7
  from collections.abc import Mapping, Sequence
9
8
  from typing import Any
10
9
 
@@ -623,11 +622,11 @@ def _wrap_with_markers(value: Any, start_marker: str, end_marker: str) -> tuple[
623
622
  return updated_value, True
624
623
 
625
624
 
626
- def _mark_supervised_messages(
627
- messages: list[dict[str, Any]],
628
- *,
629
- include_context_spans: bool = True,
630
- ) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
625
+ def _mark_supervised_messages(
626
+ messages: list[dict[str, Any]],
627
+ *,
628
+ include_context_spans: bool = True,
629
+ ) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
631
630
  marked_messages = deepcopy(messages)
632
631
  markers: list[dict[str, str]] = []
633
632
  marker_index = 0
@@ -692,24 +691,31 @@ def _mark_supervised_messages(
692
691
  updated_content, changed = mark_value(content, kind=kind, role=role)
693
692
  if changed:
694
693
  message["content"] = updated_content
695
- return marked_messages, markers
696
-
697
-
698
- def _strip_markers_and_collect_spans(text: str, markers: list[dict[str, str]]) -> tuple[str, list[dict[str, Any]]] | None:
699
- if not markers:
700
- return text, []
701
- marker_lookup: dict[str, tuple[str, int]] = {}
702
- pattern_parts: list[str] = []
703
- for index, marker in enumerate(markers):
704
- start_marker = marker["start_marker"]
705
- end_marker = marker["end_marker"]
706
- marker_lookup[start_marker] = ("start", index)
707
- marker_lookup[end_marker] = ("end", index)
708
- pattern_parts.append(re.escape(start_marker))
709
- pattern_parts.append(re.escape(end_marker))
694
+ return marked_messages, markers
695
+
696
+
697
+ def _marker_text_variants(marker: str) -> tuple[str, ...]:
698
+ escaped = json.dumps(marker)[1:-1]
699
+ return (marker, escaped) if escaped != marker else (marker,)
700
+
701
+
702
+ def _strip_markers_and_collect_spans(text: str, markers: list[dict[str, str]]) -> tuple[str, list[dict[str, Any]]] | None:
703
+ if not markers:
704
+ return text, []
705
+ marker_lookup: dict[str, tuple[str, int]] = {}
706
+ pattern_parts: list[str] = []
707
+ for index, marker in enumerate(markers):
708
+ start_marker = marker["start_marker"]
709
+ end_marker = marker["end_marker"]
710
+ for marker_variant in _marker_text_variants(start_marker):
711
+ marker_lookup[marker_variant] = ("start", index)
712
+ pattern_parts.append(re.escape(marker_variant))
713
+ for marker_variant in _marker_text_variants(end_marker):
714
+ marker_lookup[marker_variant] = ("end", index)
715
+ pattern_parts.append(re.escape(marker_variant))
710
716
  pattern = re.compile("|".join(pattern_parts))
711
717
  cleaned_parts: list[str] = []
712
- active_starts: dict[int, int] = {}
718
+ active_starts: dict[int, list[int]] = {}
713
719
  spans: list[dict[str, Any]] = []
714
720
  cursor = 0
715
721
  cleaned_length = 0
@@ -720,15 +726,19 @@ def _strip_markers_and_collect_spans(text: str, markers: list[dict[str, str]]) -
720
726
  cleaned_length += len(chunk)
721
727
  marker = match.group(0)
722
728
  kind, index = marker_lookup[marker]
723
- if kind == "start":
724
- active_starts[index] = cleaned_length
725
- else:
726
- start = active_starts.pop(index, None)
727
- if start is None:
728
- return None
729
- if start < cleaned_length:
730
- marker = markers[index]
731
- spans.append(
729
+ if kind == "start":
730
+ active_starts.setdefault(index, []).append(cleaned_length)
731
+ else:
732
+ starts = active_starts.get(index)
733
+ if not starts:
734
+ cursor = match.end()
735
+ continue
736
+ start = starts.pop()
737
+ if not starts:
738
+ active_starts.pop(index, None)
739
+ if start < cleaned_length:
740
+ marker = markers[index]
741
+ spans.append(
732
742
  {
733
743
  "start": start,
734
744
  "end": cleaned_length,
@@ -742,21 +752,19 @@ def _strip_markers_and_collect_spans(text: str, markers: list[dict[str, str]]) -
742
752
  tail = text[cursor:]
743
753
  if tail:
744
754
  cleaned_parts.append(tail)
745
- if active_starts:
746
- return None
747
- cleaned_text = "".join(cleaned_parts)
748
- return cleaned_text, spans
755
+ cleaned_text = "".join(cleaned_parts)
756
+ return cleaned_text, spans
749
757
 
750
758
 
751
759
  def _range_touches_span_boundary(start: int, end: int, spans: list[dict[str, Any]]) -> bool:
752
760
  for span in spans:
753
- span_start = span["start"]
754
- span_end = span["end"]
755
- if start == end and (start == span_start or start == span_end):
756
- return True
757
- if span_start <= start and end <= span_end and (start == span_start or end == span_end):
758
- return True
759
- return False
761
+ span_start = span["start"]
762
+ span_end = span["end"]
763
+ if start == end and (start == span_start or start == span_end):
764
+ return True
765
+ if span_start <= start and end <= span_end:
766
+ return True
767
+ return False
760
768
 
761
769
 
762
770
  def _shift_span_for_whitespace_edit(
@@ -806,33 +814,49 @@ def _shift_span_for_whitespace_edit(
806
814
  return updated
807
815
 
808
816
 
809
- def _reconcile_marker_boundary_whitespace(
810
- text: str,
811
- spans: list[dict[str, Any]],
812
- target_text: str,
813
- ) -> tuple[str, list[dict[str, Any]]] | None:
814
- if text == target_text:
815
- return text, spans
816
- matcher = difflib.SequenceMatcher(a=text, b=target_text, autojunk=False)
817
- edits: list[tuple[int, int, str]] = []
818
- for tag, start_a, end_a, start_b, end_b in matcher.get_opcodes():
819
- if tag == "equal":
820
- continue
821
- if tag not in {"delete", "insert", "replace"}:
822
- return None
823
- removed = text[start_a:end_a]
824
- inserted = target_text[start_b:end_b]
825
- if removed and not removed.isspace():
826
- return None
827
- if inserted and not inserted.isspace():
828
- return None
829
- if not removed and not inserted:
830
- return None
831
- if not _range_touches_span_boundary(start_a, end_a, spans):
832
- return None
833
- edits.append((start_a, end_a, inserted))
834
- if not edits:
835
- return None
817
+ def _reconcile_marker_boundary_whitespace(
818
+ text: str,
819
+ spans: list[dict[str, Any]],
820
+ target_text: str,
821
+ ) -> tuple[str, list[dict[str, Any]]] | None:
822
+ if text == target_text:
823
+ return text, spans
824
+ edits: list[tuple[int, int, str]] = []
825
+
826
+ text_index = 0
827
+ target_index = 0
828
+ while text_index < len(text) or target_index < len(target_text):
829
+ text_space_start = text_index
830
+ while text_index < len(text) and text[text_index].isspace():
831
+ text_index += 1
832
+ target_space_start = target_index
833
+ while target_index < len(target_text) and target_text[target_index].isspace():
834
+ target_index += 1
835
+
836
+ removed = text[text_space_start:text_index]
837
+ inserted = target_text[target_space_start:target_index]
838
+ if removed != inserted:
839
+ if not _range_touches_span_boundary(text_space_start, text_index, spans):
840
+ return None
841
+ edits.append((text_space_start, text_index, inserted))
842
+
843
+ while (
844
+ text_index < len(text)
845
+ and target_index < len(target_text)
846
+ and not text[text_index].isspace()
847
+ and not target_text[target_index].isspace()
848
+ ):
849
+ if text[text_index] != target_text[target_index]:
850
+ return None
851
+ text_index += 1
852
+ target_index += 1
853
+
854
+ if text_index >= len(text) and target_index < len(target_text) and not target_text[target_index].isspace():
855
+ return None
856
+ if target_index >= len(target_text) and text_index < len(text) and not text[text_index].isspace():
857
+ return None
858
+ if not edits:
859
+ return None
836
860
  adjusted_text = text
837
861
  adjusted_spans = [dict(span) for span in spans]
838
862
  for start, end, inserted in reversed(edits):
@@ -1345,11 +1369,14 @@ def _supervised_text_and_spans(
1345
1369
  )
1346
1370
  if inferred_spans:
1347
1371
  return formatted_text, _span_dicts(inferred_spans)
1348
- gemma_spans = _gemma_like_supervised_spans(formatted_text)
1349
- if gemma_spans:
1350
- supervised_spans.extend(
1351
- {
1352
- "start": start,
1372
+ gemma_spans = _gemma_like_supervised_spans(formatted_text)
1373
+ if gemma_spans:
1374
+ gemma_spans = _subtract_spans(gemma_spans, _tool_call_spans(formatted_text))
1375
+ gemma_spans = _subtract_spans(gemma_spans, _tool_response_spans(formatted_text))
1376
+ gemma_spans = _subtract_spans(gemma_spans, _reasoning_spans(formatted_text))
1377
+ supervised_spans.extend(
1378
+ {
1379
+ "start": start,
1353
1380
  "end": end,
1354
1381
  "source_start": start,
1355
1382
  "source_end": end,
@@ -0,0 +1,274 @@
1
+ """Local Hugging Face-style dataset previews for Teich Studio."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from ..converter import convert_traces_to_training_data
10
+ from ..loader import trace_is_complete
11
+ from .events import summarize_chat_row, summarize_trace_events
12
+
13
+ MAX_README_CHARS = 24_000
14
+ MAX_TRACE_FILES = 8
15
+ MAX_TRACE_EVENTS = 5_000
16
+ MAX_TRACE_DISPLAY = 80
17
+
18
+
19
+ def _jsonl_files(root: Path) -> list[Path]:
20
+ if root.is_file() and root.suffix.lower() == ".jsonl":
21
+ return [root]
22
+ if not root.is_dir():
23
+ return []
24
+ files: list[Path] = []
25
+ for path in sorted(root.rglob("*.jsonl")):
26
+ if not path.is_file():
27
+ continue
28
+ relative_parts = path.relative_to(root).parts
29
+ if any(part in {"failures", "partials"} for part in relative_parts):
30
+ continue
31
+ files.append(path)
32
+ return files
33
+
34
+
35
+ def _line_count(path: Path) -> int:
36
+ try:
37
+ with path.open("r", encoding="utf-8") as handle:
38
+ return sum(1 for line in handle if line.strip())
39
+ except OSError:
40
+ return 0
41
+
42
+
43
+ def _feature_type(value: Any) -> dict[str, Any]:
44
+ if isinstance(value, str):
45
+ return {"dtype": "string", "_type": "Value"}
46
+ if isinstance(value, bool):
47
+ return {"dtype": "bool", "_type": "Value"}
48
+ if isinstance(value, int):
49
+ return {"dtype": "int64", "_type": "Value"}
50
+ if isinstance(value, float):
51
+ return {"dtype": "float64", "_type": "Value"}
52
+ if isinstance(value, list):
53
+ nested = _feature_type(value[0]) if value else {"dtype": "null", "_type": "Value"}
54
+ return {"feature": nested, "_type": "Sequence"}
55
+ if isinstance(value, dict):
56
+ return {key: _feature_type(item) for key, item in sorted(value.items())}
57
+ if value is None:
58
+ return {"dtype": "null", "_type": "Value"}
59
+ return {"dtype": type(value).__name__, "_type": "Value"}
60
+
61
+
62
+ def _features(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
63
+ columns: dict[str, Any] = {}
64
+ for row in rows:
65
+ for key, value in row.items():
66
+ columns.setdefault(key, value)
67
+ return [
68
+ {"feature_idx": index, "name": name, "type": _feature_type(value)}
69
+ for index, (name, value) in enumerate(sorted(columns.items()))
70
+ ]
71
+
72
+
73
+ def _stringify_for_search(row: dict[str, Any]) -> str:
74
+ return json.dumps(row, ensure_ascii=False, sort_keys=True).casefold()
75
+
76
+
77
+ def _row_preview(row: dict[str, Any]) -> dict[str, Any]:
78
+ messages = row.get("messages") if isinstance(row.get("messages"), list) else []
79
+ tools = row.get("tools") if isinstance(row.get("tools"), list) else []
80
+ metadata = row.get("metadata") if isinstance(row.get("metadata"), dict) else {}
81
+ return {
82
+ "prompt": row.get("prompt") or _first_message_text(messages, "user"),
83
+ "response": row.get("response") or _last_message_text(messages, "assistant"),
84
+ "model": row.get("model") or metadata.get("model"),
85
+ "message_count": len(messages),
86
+ "tool_count": len(tools),
87
+ "trace_type": metadata.get("trace_type"),
88
+ "complete": trace_is_complete(row),
89
+ }
90
+
91
+
92
+ def _first_message_text(messages: list[Any], role: str) -> str | None:
93
+ for message in messages:
94
+ if isinstance(message, dict) and message.get("role") == role:
95
+ content = message.get("content")
96
+ return content if isinstance(content, str) else None
97
+ return None
98
+
99
+
100
+ def _last_message_text(messages: list[Any], role: str) -> str | None:
101
+ for message in reversed(messages):
102
+ if isinstance(message, dict) and message.get("role") == role:
103
+ content = message.get("content")
104
+ return content if isinstance(content, str) else None
105
+ return None
106
+
107
+
108
+ def _column_statistics(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
109
+ columns = sorted({key for row in rows for key in row.keys()})
110
+ stats: list[dict[str, Any]] = []
111
+ for column in columns:
112
+ values = [row.get(column) for row in rows if column in row]
113
+ scalar_counts: dict[str, int] = {}
114
+ for value in values:
115
+ if isinstance(value, str | int | float | bool) or value is None:
116
+ label = "null" if value is None else str(value)
117
+ scalar_counts[label] = scalar_counts.get(label, 0) + 1
118
+ top_values = sorted(scalar_counts.items(), key=lambda item: (-item[1], item[0]))[:5]
119
+ stats.append(
120
+ {
121
+ "name": column,
122
+ "present": len(values),
123
+ "missing": max(len(rows) - len(values), 0),
124
+ "type": _feature_type(values[0]) if values else {"dtype": "null", "_type": "Value"},
125
+ "top_values": [{"value": value, "count": count} for value, count in top_values],
126
+ }
127
+ )
128
+ return stats
129
+
130
+
131
+ def _detect_trace_provider(events: list[dict[str, Any]]) -> str:
132
+ for event in events[:5]:
133
+ event_type = event.get("type")
134
+ if event_type == "session_meta":
135
+ return "codex"
136
+ if event_type == "session":
137
+ return "pi"
138
+ if event_type == "external_session_meta":
139
+ return "hermes"
140
+ if event_type in {"response_item", "event_msg"}:
141
+ return "codex"
142
+ if event_type == "message" and isinstance(event.get("message"), dict):
143
+ return "pi"
144
+ if "sessionId" in event or event_type == "queue-operation":
145
+ return "claude-code"
146
+ if isinstance(event.get("messages"), list):
147
+ return "chat"
148
+ return "unknown"
149
+
150
+
151
+ def _read_trace_events(path: Path) -> list[dict[str, Any]]:
152
+ events: list[dict[str, Any]] = []
153
+ try:
154
+ with path.open("r", encoding="utf-8-sig") as handle:
155
+ for line in handle:
156
+ line = line.strip()
157
+ if not line:
158
+ continue
159
+ try:
160
+ event = json.loads(line)
161
+ except json.JSONDecodeError:
162
+ continue
163
+ if isinstance(event, dict):
164
+ events.append(event)
165
+ if len(events) >= MAX_TRACE_EVENTS:
166
+ break
167
+ except OSError:
168
+ return []
169
+ return events
170
+
171
+
172
+ def _trace_preview(root: Path, path: Path) -> dict[str, Any]:
173
+ events = _read_trace_events(path)
174
+ provider = _detect_trace_provider(events)
175
+ if provider == "chat":
176
+ display: list[dict[str, Any]] = []
177
+ for row in events:
178
+ display.extend(summarize_chat_row(row))
179
+ else:
180
+ display = summarize_trace_events(provider, events)
181
+ try:
182
+ name = path.relative_to(root).as_posix()
183
+ except ValueError:
184
+ name = path.name
185
+ return {
186
+ "name": name,
187
+ "provider": provider,
188
+ "event_count": len(events),
189
+ "display": display[:MAX_TRACE_DISPLAY],
190
+ "truncated": len(display) > MAX_TRACE_DISPLAY,
191
+ }
192
+
193
+
194
+ def _readme_text(root: Path) -> str | None:
195
+ readme = root / "README.md" if root.is_dir() else root.parent / "README.md"
196
+ if not readme.exists():
197
+ return None
198
+ try:
199
+ text = readme.read_text(encoding="utf-8")
200
+ except OSError:
201
+ return None
202
+ return text[:MAX_README_CHARS]
203
+
204
+
205
+ def build_dataset_preview(
206
+ root: Path,
207
+ *,
208
+ repo_id: str | None = None,
209
+ offset: int = 0,
210
+ limit: int = 100,
211
+ search: str | None = None,
212
+ ) -> dict[str, Any]:
213
+ root = root.expanduser().resolve()
214
+ if not root.exists():
215
+ raise FileNotFoundError(f"Dataset output path not found: {root}")
216
+
217
+ offset = max(offset, 0)
218
+ limit = max(1, min(limit, 100))
219
+ trace_files = _jsonl_files(root)
220
+ file_rows = [
221
+ {
222
+ "name": path.relative_to(root).as_posix() if root.is_dir() else path.name,
223
+ "size_bytes": path.stat().st_size,
224
+ "rows": _line_count(path),
225
+ }
226
+ for path in trace_files
227
+ ]
228
+
229
+ errors: list[str] = []
230
+ try:
231
+ rows = convert_traces_to_training_data(root)
232
+ except Exception as exc:
233
+ rows = []
234
+ errors.append(str(exc))
235
+
236
+ query = (search or "").strip().casefold()
237
+ indexed_rows = list(enumerate(rows))
238
+ if query:
239
+ indexed_rows = [(index, row) for index, row in indexed_rows if query in _stringify_for_search(row)]
240
+ page = indexed_rows[offset:offset + limit]
241
+ page_rows = [
242
+ {"row_idx": index, "row": row, "preview": _row_preview(row)}
243
+ for index, row in page
244
+ ]
245
+ selected_rows = [row for _, row in indexed_rows]
246
+ complete_rows = sum(1 for row in selected_rows if trace_is_complete(row))
247
+
248
+ return {
249
+ "root": str(root),
250
+ "repo_id": repo_id,
251
+ "hf_embed_url": f"https://huggingface.co/datasets/{repo_id}/embed/viewer" if repo_id else None,
252
+ "splits": [{"config": "default", "split": "train"}],
253
+ "files": file_rows,
254
+ "readme": _readme_text(root),
255
+ "dataset": {
256
+ "config": "default",
257
+ "split": "train",
258
+ "num_rows": len(selected_rows),
259
+ "total_rows": len(rows),
260
+ "offset": offset,
261
+ "length": len(page_rows),
262
+ "features": _features(selected_rows),
263
+ "rows": page_rows,
264
+ "search": search or "",
265
+ "complete_rows": complete_rows,
266
+ "incomplete_rows": max(len(selected_rows) - complete_rows, 0),
267
+ },
268
+ "statistics": _column_statistics(selected_rows),
269
+ "trace_previews": [_trace_preview(root, path) for path in trace_files[:MAX_TRACE_FILES]],
270
+ "errors": errors,
271
+ "notes": [
272
+ "Local preview uses Teich conversion directly. Hugging Face's hosted viewer adds Parquet-backed search, filtering, and SQL after upload."
273
+ ],
274
+ }