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.
- {teich-0.2.3 → teich-0.2.4}/PKG-INFO +1 -1
- {teich-0.2.3 → teich-0.2.4}/docs/studio.md +37 -0
- {teich-0.2.3 → teich-0.2.4}/pyproject.toml +1 -1
- {teich-0.2.3 → teich-0.2.4}/src/teich/__init__.py +1 -1
- {teich-0.2.3 → teich-0.2.4}/src/teich/formatter.py +105 -78
- teich-0.2.4/src/teich/studio/dataset_preview.py +274 -0
- teich-0.2.4/src/teich/studio/extraction.py +219 -0
- {teich-0.2.3 → teich-0.2.4}/src/teich/studio/server.py +117 -1
- {teich-0.2.3 → teich-0.2.4}/src/teich/studio/static/app.js +502 -9
- {teich-0.2.3 → teich-0.2.4}/src/teich/studio/static/index.html +93 -0
- {teich-0.2.3 → teich-0.2.4}/src/teich/studio/static/style.css +151 -0
- {teich-0.2.3 → teich-0.2.4}/tests/test_formatter.py +209 -35
- {teich-0.2.3 → teich-0.2.4}/tests/test_studio.py +110 -0
- {teich-0.2.3 → teich-0.2.4}/uv.lock +1 -1
- {teich-0.2.3 → teich-0.2.4}/.github/workflows/test.yml +0 -0
- {teich-0.2.3 → teich-0.2.4}/.gitignore +0 -0
- {teich-0.2.3 → teich-0.2.4}/DOCS.md +0 -0
- {teich-0.2.3 → teich-0.2.4}/LICENSE +0 -0
- {teich-0.2.3 → teich-0.2.4}/README.md +0 -0
- {teich-0.2.3 → teich-0.2.4}/ROADMAP.md +0 -0
- {teich-0.2.3 → teich-0.2.4}/config.example.yaml +0 -0
- {teich-0.2.3 → teich-0.2.4}/docker/codex-runtime.Dockerfile +0 -0
- {teich-0.2.3 → teich-0.2.4}/docs/cli.md +0 -0
- {teich-0.2.3 → teich-0.2.4}/docs/data-format.md +0 -0
- {teich-0.2.3 → teich-0.2.4}/docs/generation.md +0 -0
- {teich-0.2.3 → teich-0.2.4}/docs/index.md +0 -0
- {teich-0.2.3 → teich-0.2.4}/docs/pipeline.md +0 -0
- {teich-0.2.3 → teich-0.2.4}/docs/prepare-data.md +0 -0
- {teich-0.2.3 → teich-0.2.4}/docs/python-api.md +0 -0
- {teich-0.2.3 → teich-0.2.4}/docs/training.md +0 -0
- {teich-0.2.3 → teich-0.2.4}/gemma-template.jinja +0 -0
- {teich-0.2.3 → teich-0.2.4}/gemma4_example.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/src/teich/__main__.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/src/teich/anonymize.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/src/teich/audit.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/src/teich/cli.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/src/teich/config.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/src/teich/converter.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/src/teich/extract.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/src/teich/loader.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/src/teich/prepare.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/src/teich/runner.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/src/teich/studio/__init__.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/src/teich/studio/events.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/src/teich/studio/generation.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/src/teich/studio/interactive.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/src/teich/studio/project.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/src/teich/studio/static/teich.svg +0 -0
- {teich-0.2.3 → teich-0.2.4}/src/teich/studio/static/vendor/xterm-addon-fit.js +0 -0
- {teich-0.2.3 → teich-0.2.4}/src/teich/studio/static/vendor/xterm.css +0 -0
- {teich-0.2.3 → teich-0.2.4}/src/teich/studio/static/vendor/xterm.js +0 -0
- {teich-0.2.3 → teich-0.2.4}/src/teich/tool_schema.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/src/teich/trace_readme.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/teich_example.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/tests/conftest.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/tests/test_audit.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/tests/test_cli.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/tests/test_config.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/tests/test_converter.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/tests/test_extract_anonymize_cli.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/tests/test_integration.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/tests/test_loader.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/tests/test_prepare.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/tests/test_runner.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/tests/test_tokenizer_smoke.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/tests/test_tool_schema.py +0 -0
- {teich-0.2.3 → teich-0.2.4}/tests/test_trace_readme.py +0 -0
|
@@ -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,10 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from copy import deepcopy
|
|
4
|
-
from dataclasses import dataclass, field
|
|
5
|
-
import
|
|
6
|
-
import
|
|
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
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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[
|
|
725
|
-
else:
|
|
726
|
-
|
|
727
|
-
if
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
746
|
-
|
|
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
|
|
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
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
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
|
+
}
|