TitleOS commited on
Commit
390cebe
·
verified ·
1 Parent(s): 080472b

Upload 9 files

Browse files
Files changed (8) hide show
  1. README.md +15 -9
  2. app.py +6 -3
  3. field_mapper.py +55 -0
  4. hf_dataset_loader.py +33 -0
  5. hf_inspect.py +89 -0
  6. hf_publish.py +28 -0
  7. models.py +47 -0
  8. schema_detect.py +174 -0
README.md CHANGED
@@ -40,14 +40,20 @@ server-side between sessions.
40
 
41
  ## Project layout
42
 
 
 
 
43
  - `app.py` - Gradio UI and event wiring.
44
- - `core/` - pure logic: schema detection (`schema_detect.py`) and row mapping
45
- (`field_mapper.py`). No network calls in here, which makes it the easy part
46
- to unit test on its own - see the inline examples in each module's tests
47
- if you add a `tests/` folder later.
48
- - `services/` - everything that talks to Hugging Face: peeking at a dataset's
49
- shape (`hf_inspect.py`), pulling the real rows (`hf_dataset_loader.py`), and
50
- pushing/exporting the result (`hf_publish.py`).
 
 
 
51
 
52
  ## Known limitations
53
 
@@ -57,13 +63,13 @@ server-side between sessions.
57
  Anything else routes to manual mapping on purpose - a near-miss column name
58
  (e.g. `question`/`chosen` instead of `prompt`/`chosen`) won't get silently
59
  mis-mapped. The known-pairs list is a plain Python list at the top of
60
- `core/schema_detect.py` if you want to extend it for patterns you hit a lot.
61
  - Datasets that bundle extra per-row enrichment beyond a simple triplet (for
62
  example, appending a vulnerability note to an answer, or synthesizing a
63
  prompt from a code snippet plus a language tag) won't be replicated by the
64
  generic mapper - it pulls the two fields you point it at, verbatim. If you
65
  need that kind of enrichment again, it's a small addition to
66
- `core/field_mapper.py`.
67
  - Gated datasets need the signed-in user's token to actually have read
68
  access; there's no separate "request access" flow built in here.
69
  - The `gr.OAuthToken` / `gr.OAuthProfile` injection is wired to the documented
 
40
 
41
  ## Project layout
42
 
43
+ Everything lives flat in the repo root (no subfolders, so it uploads fine
44
+ through the Spaces web UI one file at a time):
45
+
46
  - `app.py` - Gradio UI and event wiring.
47
+ - `models.py` - the `DatasetEntry` / `FieldMapping` dataclasses shared by
48
+ everything else.
49
+ - `schema_detect.py` - pure schema-detection logic. No network calls in
50
+ here, which makes it the easy part to unit test on its own.
51
+ - `field_mapper.py` - pure row-to-triplet extraction logic, also network-free.
52
+ - `hf_inspect.py` - peeks at a dataset's shape via the lightweight
53
+ `datasets-server` API (with a streaming fallback) without downloading it.
54
+ - `hf_dataset_loader.py` - pulls the real rows, up to the per-dataset limit.
55
+ - `hf_publish.py` - pushes the combined dataset to the Hub, or writes it
56
+ out as JSONL.
57
 
58
  ## Known limitations
59
 
 
63
  Anything else routes to manual mapping on purpose - a near-miss column name
64
  (e.g. `question`/`chosen` instead of `prompt`/`chosen`) won't get silently
65
  mis-mapped. The known-pairs list is a plain Python list at the top of
66
+ `schema_detect.py` if you want to extend it for patterns you hit a lot.
67
  - Datasets that bundle extra per-row enrichment beyond a simple triplet (for
68
  example, appending a vulnerability note to an answer, or synthesizing a
69
  prompt from a code snippet plus a language tag) won't be replicated by the
70
  generic mapper - it pulls the two fields you point it at, verbatim. If you
71
  need that kind of enrichment again, it's a small addition to
72
+ `field_mapper.py`.
73
  - Gated datasets need the signed-in user's token to actually have read
74
  access; there's no separate "request access" flow built in here.
75
  - The `gr.OAuthToken` / `gr.OAuthProfile` injection is wired to the documented
app.py CHANGED
@@ -24,9 +24,12 @@ import tempfile
24
 
25
  import gradio as gr
26
 
27
- from core import field_mapper, schema_detect
28
- from core.models import DatasetEntry, FieldMapping
29
- from services import hf_dataset_loader, hf_inspect, hf_publish
 
 
 
30
 
31
  _STATUS_LABELS = {
32
  "empty": "Not detected yet.",
 
24
 
25
  import gradio as gr
26
 
27
+ import field_mapper
28
+ import schema_detect
29
+ import hf_dataset_loader
30
+ import hf_inspect
31
+ import hf_publish
32
+ from models import DatasetEntry, FieldMapping
33
 
34
  _STATUS_LABELS = {
35
  "empty": "Not detected yet.",
field_mapper.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Turn one raw dataset row into a normalized chat-format record, given a
2
+ FieldMapping. Pure function - no network, no HF imports, easy to test.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ from typing import Optional
7
+
8
+ from models import FieldMapping
9
+
10
+
11
+ def _stringify(value) -> Optional[str]:
12
+ if value is None:
13
+ return None
14
+ if isinstance(value, str):
15
+ return value if value.strip() else None
16
+ return str(value)
17
+
18
+
19
+ def extract_triplet(row: dict, mapping: FieldMapping, system_prompt: str) -> Optional[dict]:
20
+ """Returns {"messages": [...]} in OpenAI/ShareGPT chat format, or None
21
+ if this row didn't have usable user/assistant text.
22
+ """
23
+ user_text: Optional[str] = None
24
+ asst_text: Optional[str] = None
25
+
26
+ if mapping.kind == "conversation_list":
27
+ items = row.get(mapping.config["list_field"]) or []
28
+ role_key = mapping.config["role_key"]
29
+ content_key = mapping.config["content_key"]
30
+ human_tag = mapping.config["human_tag"]
31
+ gpt_tag = mapping.config["gpt_tag"]
32
+ user_text = _stringify(
33
+ next((item.get(content_key) for item in items if item.get(role_key) == human_tag), None)
34
+ )
35
+ asst_text = _stringify(
36
+ next((item.get(content_key) for item in items if item.get(role_key) == gpt_tag), None)
37
+ )
38
+
39
+ elif mapping.kind == "flat_pair":
40
+ user_text = _stringify(row.get(mapping.config["user_field"]))
41
+ asst_text = _stringify(row.get(mapping.config["assistant_field"]))
42
+
43
+ else:
44
+ return None
45
+
46
+ if not user_text or not asst_text:
47
+ return None
48
+
49
+ return {
50
+ "messages": [
51
+ {"role": "system", "content": system_prompt or ""},
52
+ {"role": "user", "content": user_text},
53
+ {"role": "assistant", "content": asst_text},
54
+ ]
55
+ }
hf_dataset_loader.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Service layer for pulling up to N rows out of a HF dataset, for real
2
+ (not just a peek). Streams where the dataset supports it so we never
3
+ download more than was asked for.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from typing import Optional
8
+
9
+ from datasets import load_dataset
10
+
11
+
12
+ def load_limited(
13
+ repo_id: str,
14
+ subset: str,
15
+ split: str,
16
+ limit: int,
17
+ token: Optional[str] = None,
18
+ ) -> list:
19
+ try:
20
+ ds = load_dataset(repo_id, subset or None, split=split, streaming=True, token=token)
21
+ rows = []
22
+ for i, row in enumerate(ds):
23
+ if i >= limit:
24
+ break
25
+ rows.append(dict(row))
26
+ return rows
27
+ except Exception:
28
+ # Some datasets (mostly older script-based ones) don't support
29
+ # streaming. Fall back to a full load and slice - slower, but
30
+ # correct, and rare enough not to special-case earlier.
31
+ ds = load_dataset(repo_id, subset or None, split=split, token=token)
32
+ n = min(limit, len(ds))
33
+ return [dict(ds[i]) for i in range(n)]
hf_inspect.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Service layer for peeking at a HF dataset's shape without pulling the
2
+ whole thing down. Tries the lightweight datasets-server API first; falls
3
+ back to a short streaming pull for datasets that API doesn't cover
4
+ (gated, script-based, or just not indexed yet).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Optional
9
+
10
+ import requests
11
+ from datasets import load_dataset
12
+
13
+ _ROWS_URL = "https://datasets-server.huggingface.co/rows"
14
+ _SPLITS_URL = "https://datasets-server.huggingface.co/splits"
15
+ _REQUEST_TIMEOUT_SECONDS = 15
16
+
17
+
18
+ class DatasetInspectError(Exception):
19
+ """Raised when we genuinely can't get a peek at a dataset, after
20
+ trying both the fast path and the streaming fallback."""
21
+
22
+
23
+ def list_splits(repo_id: str, token: Optional[str] = None) -> list:
24
+ """Returns [{"config": ..., "split": ...}, ...] for the dataset."""
25
+ headers = {"Authorization": f"Bearer {token}"} if token else {}
26
+ resp = requests.get(
27
+ _SPLITS_URL, params={"dataset": repo_id}, headers=headers, timeout=_REQUEST_TIMEOUT_SECONDS
28
+ )
29
+ if resp.status_code != 200:
30
+ raise DatasetInspectError(
31
+ f"Couldn't list splits for '{repo_id}' (HTTP {resp.status_code}): {resp.text[:300]}"
32
+ )
33
+ data = resp.json()
34
+ return [{"config": s["config"], "split": s["split"]} for s in data.get("splits", [])]
35
+
36
+
37
+ def peek_rows(
38
+ repo_id: str,
39
+ subset: str,
40
+ split: str,
41
+ sample_size: int = 8,
42
+ token: Optional[str] = None,
43
+ ) -> list:
44
+ """Returns up to `sample_size` raw rows as plain dicts."""
45
+ if not repo_id.strip():
46
+ raise DatasetInspectError("No dataset repo id given.")
47
+ try:
48
+ return _peek_via_datasets_server(repo_id, subset, split, sample_size, token)
49
+ except DatasetInspectError:
50
+ return _peek_via_streaming(repo_id, subset, split, sample_size, token)
51
+
52
+
53
+ def _peek_via_datasets_server(
54
+ repo_id: str, subset: str, split: str, sample_size: int, token: Optional[str]
55
+ ) -> list:
56
+ headers = {"Authorization": f"Bearer {token}"} if token else {}
57
+ params = {
58
+ "dataset": repo_id,
59
+ "config": subset or "default",
60
+ "split": split,
61
+ "offset": 0,
62
+ "length": sample_size,
63
+ }
64
+ resp = requests.get(_ROWS_URL, params=params, headers=headers, timeout=_REQUEST_TIMEOUT_SECONDS)
65
+ if resp.status_code != 200:
66
+ raise DatasetInspectError(f"datasets-server returned HTTP {resp.status_code} for '{repo_id}'")
67
+ data = resp.json()
68
+ rows = data.get("rows", [])
69
+ if not rows:
70
+ raise DatasetInspectError(f"datasets-server returned no rows for '{repo_id}'")
71
+ return [r["row"] for r in rows]
72
+
73
+
74
+ def _peek_via_streaming(
75
+ repo_id: str, subset: str, split: str, sample_size: int, token: Optional[str]
76
+ ) -> list:
77
+ try:
78
+ ds = load_dataset(repo_id, subset or None, split=split, streaming=True, token=token)
79
+ except Exception as exc:
80
+ raise DatasetInspectError(f"Couldn't load '{repo_id}': {exc}") from exc
81
+
82
+ rows = []
83
+ for i, row in enumerate(ds):
84
+ if i >= sample_size:
85
+ break
86
+ rows.append(dict(row))
87
+ if not rows:
88
+ raise DatasetInspectError(f"'{repo_id}' (config={subset or 'default'}, split={split}) has no rows")
89
+ return rows
hf_publish.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Service layer for getting the combined dataset out to the world -
2
+ either pushed to a HF Hub repo, or written out as a local JSONL file.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+
9
+ from datasets import Dataset
10
+
11
+
12
+ def push_dataset(records: list, repo_id: str, private: bool, token: str) -> str:
13
+ if not token:
14
+ raise ValueError("No HF token available - sign in first.")
15
+ if not repo_id or "/" not in repo_id:
16
+ raise ValueError("repo_id must look like 'username/dataset-name'.")
17
+
18
+ ds = Dataset.from_list(records)
19
+ ds.push_to_hub(repo_id, private=private, token=token)
20
+ return f"https://huggingface.co/datasets/{repo_id}"
21
+
22
+
23
+ def write_jsonl(records: list, output_path: str) -> str:
24
+ os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
25
+ with open(output_path, "w", encoding="utf-8") as f:
26
+ for record in records:
27
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
28
+ return output_path
models.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Data models for dataset entries and field mappings.
2
+
3
+ These are plain dataclasses so they can live inside a gr.State without any
4
+ serialization step - Gradio keeps server-side state as real Python objects
5
+ per session, so we just mutate them in place.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import uuid
10
+ from dataclasses import dataclass, field
11
+ from typing import Literal, Optional
12
+
13
+ MappingKind = Literal["conversation_list", "flat_pair", "unmapped"]
14
+ EntryStatus = Literal["empty", "detecting", "needs_mapping", "ready", "error"]
15
+
16
+
17
+ @dataclass
18
+ class FieldMapping:
19
+ """How to pull a (system, user, assistant) triplet out of one raw row.
20
+
21
+ `config` holds whatever the given `kind` needs:
22
+ - conversation_list: list_field, role_key, content_key, human_tag, gpt_tag
23
+ - flat_pair: user_field, assistant_field
24
+ """
25
+
26
+ kind: MappingKind
27
+ config: dict = field(default_factory=dict)
28
+
29
+
30
+ @dataclass
31
+ class DatasetEntry:
32
+ """One row in the dataset-builder list."""
33
+
34
+ uid: str = field(default_factory=lambda: uuid.uuid4().hex[:8])
35
+ repo_id: str = ""
36
+ subset: str = ""
37
+ split: str = "train"
38
+ limit: int = 1000
39
+ system_prompt: str = ""
40
+
41
+ mapping: Optional[FieldMapping] = None
42
+ detected_columns: list = field(default_factory=list)
43
+ detected_list_info: Optional[dict] = None
44
+ sample_rows: list = field(default_factory=list)
45
+
46
+ status: EntryStatus = "empty"
47
+ error_message: str = ""
schema_detect.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure functions for guessing how a raw HF dataset row maps onto a
2
+ (system, user, assistant) triplet, based on a handful of sample rows.
3
+
4
+ Nothing here touches the network - callers fetch sample rows separately
5
+ (see hf_inspect.py) and hand them in. That split is deliberate:
6
+ this logic is the part worth unit-testing on its own, against fixed
7
+ sample data, without spinning up HTTP calls every time.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Optional
12
+
13
+ from models import FieldMapping
14
+
15
+ # Known flat-column name pairs, checked case-insensitively against the
16
+ # dataset's actual column names. Ordered roughly by how common they are
17
+ # across instruction-tuning datasets on the Hub.
18
+ _KNOWN_FLAT_PAIRS = [
19
+ ("instruction", "output"),
20
+ ("instruction", "response"),
21
+ ("prompt", "chosen"),
22
+ ("prompt", "completion"),
23
+ ("query", "answer"),
24
+ ("query", "answers"),
25
+ ("question", "solution"),
26
+ ("question", "answer"),
27
+ ("input", "output"),
28
+ ]
29
+
30
+ # Known (human_tag, assistant_tag) pairs for the role key inside a
31
+ # conversation-list column (e.g. ShareGPT's "from", OpenAI's "role").
32
+ _KNOWN_TAG_PAIRS = [
33
+ ("human", "gpt"),
34
+ ("user", "assistant"),
35
+ ("user", "model"),
36
+ ]
37
+
38
+
39
+ def _is_list_of_dicts_column(values: list) -> bool:
40
+ for v in values:
41
+ if not isinstance(v, list) or not v:
42
+ return False
43
+ if not all(isinstance(item, dict) for item in v):
44
+ return False
45
+ return True
46
+
47
+
48
+ def detect_flat_pair(columns: list) -> Optional[FieldMapping]:
49
+ """Match the dataset's column names against known flat pairs."""
50
+ lower_to_actual = {c.lower(): c for c in columns}
51
+ for user_name, asst_name in _KNOWN_FLAT_PAIRS:
52
+ if user_name in lower_to_actual and asst_name in lower_to_actual:
53
+ return FieldMapping(
54
+ kind="flat_pair",
55
+ config={
56
+ "user_field": lower_to_actual[user_name],
57
+ "assistant_field": lower_to_actual[asst_name],
58
+ },
59
+ )
60
+ return None
61
+
62
+
63
+ def _guess_role_and_content_key(values: list, keys: set):
64
+ """Pick which key behaves like a role tag (short, low-cardinality
65
+ strings) and which behaves like free-text content (longer, varied).
66
+ Returns (role_key, content_key), or (None, None) if it can't tell.
67
+ """
68
+ candidates = list(keys)
69
+ if len(candidates) < 2:
70
+ return None, None
71
+
72
+ avg_lengths = {}
73
+ distinct_counts = {}
74
+ for key in candidates:
75
+ all_values = [item.get(key) for value_list in values for item in value_list if key in item]
76
+ string_values = [v for v in all_values if isinstance(v, str)]
77
+ if not string_values:
78
+ avg_lengths[key] = float("inf")
79
+ distinct_counts[key] = len(set(map(str, all_values)))
80
+ continue
81
+ avg_lengths[key] = sum(len(v) for v in string_values) / len(string_values)
82
+ distinct_counts[key] = len(set(string_values))
83
+
84
+ role_key = min(candidates, key=lambda k: (distinct_counts[k], avg_lengths[k]))
85
+ remaining = [k for k in candidates if k != role_key]
86
+ content_key = max(remaining, key=lambda k: avg_lengths[k])
87
+ return role_key, content_key
88
+
89
+
90
+ def detect_list_column(sample_rows: list) -> Optional[dict]:
91
+ """Find a column whose values look like a conversation list (ShareGPT's
92
+ `conversations`, OpenAI's `messages`, or anything shaped like them) and
93
+ figure out which sub-key is the role and which is the content.
94
+
95
+ Returns a dict describing what was found - used both to try full
96
+ auto-detection and to pre-fill the manual-mapping UI when auto-detect
97
+ isn't confident. Returns None if nothing list-shaped turned up.
98
+ """
99
+ if not sample_rows:
100
+ return None
101
+
102
+ columns = list(sample_rows[0].keys())
103
+ for col in columns:
104
+ values = [row.get(col) for row in sample_rows]
105
+ if not _is_list_of_dicts_column(values):
106
+ continue
107
+
108
+ common_keys = None
109
+ for value_list in values:
110
+ for item in value_list:
111
+ keys = set(item.keys())
112
+ common_keys = keys if common_keys is None else (common_keys & keys)
113
+ if not common_keys or len(common_keys) < 2:
114
+ continue
115
+
116
+ role_key, content_key = _guess_role_and_content_key(values, common_keys)
117
+ if role_key is None:
118
+ continue
119
+
120
+ tag_values = sorted(
121
+ {
122
+ item.get(role_key)
123
+ for value_list in values
124
+ for item in value_list
125
+ if role_key in item and item.get(role_key) is not None
126
+ }
127
+ )
128
+
129
+ return {
130
+ "list_field": col,
131
+ "role_key": role_key,
132
+ "content_key": content_key,
133
+ "tag_values": tag_values,
134
+ }
135
+
136
+ return None
137
+
138
+
139
+ def detect_conversation_list(sample_rows: list) -> Optional[FieldMapping]:
140
+ """Full auto-detect for conversation-list columns. Only returns a
141
+ mapping when both the human and assistant tags are recognized -
142
+ anything less certain falls through to manual mapping on purpose.
143
+ """
144
+ found = detect_list_column(sample_rows)
145
+ if not found:
146
+ return None
147
+
148
+ tags_lower = {str(t).lower(): t for t in found["tag_values"] if t is not None}
149
+ for human_tag, gpt_tag in _KNOWN_TAG_PAIRS:
150
+ if human_tag in tags_lower and gpt_tag in tags_lower:
151
+ return FieldMapping(
152
+ kind="conversation_list",
153
+ config={
154
+ "list_field": found["list_field"],
155
+ "role_key": found["role_key"],
156
+ "content_key": found["content_key"],
157
+ "human_tag": tags_lower[human_tag],
158
+ "gpt_tag": tags_lower[gpt_tag],
159
+ },
160
+ )
161
+ return None
162
+
163
+
164
+ def auto_detect(sample_rows: list) -> Optional[FieldMapping]:
165
+ """Try every detector in order of confidence. Returns None if nothing
166
+ lands cleanly - caller should fall back to manual mapping.
167
+ """
168
+ if not sample_rows:
169
+ return None
170
+ mapping = detect_conversation_list(sample_rows)
171
+ if mapping:
172
+ return mapping
173
+ columns = list(sample_rows[0].keys())
174
+ return detect_flat_pair(columns)