File size: 5,963 Bytes
414b4fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
"""Portable PaDoc tree records and JSON/JSONL data loading."""

from __future__ import annotations

import json
from collections.abc import Iterator
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any


@dataclass
class Node:
    prefix: str
    children: list[Node] = field(default_factory=list)


@dataclass
class DataPiece:
    query: str
    response: Node
    images: list[str] = field(default_factory=list)


@dataclass(frozen=True)
class TrainingRecord:
    """One raw record plus the directory used for relative image paths."""

    value: dict[str, Any]
    image_dir: Path


@dataclass(frozen=True)
class DataConfig:
    records: list[TrainingRecord]
    seed: int = 42


def parse_node(value: dict[str, Any]) -> Node:
    if not isinstance(value, dict) or not isinstance(value.get("prefix"), str):
        raise ValueError("Every response node requires a string 'prefix'.")
    children = value.get("children", [])
    if not isinstance(children, list):
        raise ValueError("Node 'children' must be a list.")
    return Node(prefix=value["prefix"], children=[parse_node(child) for child in children])


def parse_data_piece(value: dict[str, Any]) -> DataPiece:
    if not isinstance(value, dict) or not isinstance(value.get("query"), str):
        raise ValueError("Every training record requires a string 'query'.")
    images = value.get("images", [])
    if not isinstance(images, list) or not all(isinstance(item, str) for item in images):
        raise ValueError("Record 'images' must be a list of paths.")
    return DataPiece(
        query=value["query"],
        response=parse_node(value["response"]),
        images=list(images),
    )


def _iter_file(path: Path) -> Iterator[dict[str, Any]]:
    if path.suffix.lower() == ".jsonl":
        with path.open(encoding="utf-8") as handle:
            for line_number, line in enumerate(handle, 1):
                if not line.strip():
                    continue
                try:
                    value = json.loads(line)
                except json.JSONDecodeError as exc:
                    raise ValueError(f"{path}:{line_number}: invalid JSON: {exc}") from exc
                if not isinstance(value, dict):
                    raise ValueError(f"{path}:{line_number}: expected a JSON object")
                yield value
        return

    with path.open(encoding="utf-8") as handle:
        values = json.load(handle)
    if not isinstance(values, list):
        raise ValueError(f"{path}: expected a top-level JSON array")
    for index, value in enumerate(values):
        if not isinstance(value, dict):
            raise ValueError(f"{path}: record {index} is not an object")
        yield value


def load_records(path: str | Path) -> list[dict[str, Any]]:
    """Load records from one JSON/JSONL file or a directory of such files."""

    path = Path(path).expanduser().resolve()
    if path.is_file():
        return list(_iter_file(path))
    if not path.is_dir():
        raise FileNotFoundError(f"Data path does not exist: {path}")
    files = sorted([*path.glob("*.json"), *path.glob("*.jsonl")])
    if not files:
        raise FileNotFoundError(f"No JSON or JSONL files found under {path}")
    records: list[dict[str, Any]] = []
    for file_path in files:
        records.extend(_iter_file(file_path))
    return records


def records_from_path(
    data_path: str | Path,
    *,
    image_dir: str | Path = ".",
) -> list[TrainingRecord]:
    root = Path(image_dir).expanduser().resolve()
    return [TrainingRecord(value, root) for value in load_records(data_path)]


def load_data_config(path: str | Path) -> DataConfig:
    """Load a portable YAML mix whose paths are relative to the YAML file.

    Schema::

        seed: 42
        sources:
          - path: ../examples/train.jsonl
            image_dir: ../examples/images
            repeat: 1
    """

    try:
        import yaml
    except ImportError as exc:  # pragma: no cover - declared dependency
        raise RuntimeError("PyYAML is required for --data-config") from exc

    config_path = Path(path).expanduser().resolve()
    with config_path.open(encoding="utf-8") as handle:
        raw = yaml.safe_load(handle)
    if not isinstance(raw, dict):
        raise ValueError(f"{config_path}: top-level YAML must be a mapping")
    unknown = set(raw) - {"seed", "sources"}
    if unknown:
        raise ValueError(f"{config_path}: unknown keys: {sorted(unknown)}")
    sources = raw.get("sources")
    if not isinstance(sources, list) or not sources:
        raise ValueError(f"{config_path}: 'sources' must be a non-empty list")

    records: list[TrainingRecord] = []
    for index, source in enumerate(sources):
        if not isinstance(source, dict):
            raise ValueError(f"{config_path}: sources[{index}] must be a mapping")
        source_unknown = set(source) - {"path", "image_dir", "repeat"}
        if source_unknown:
            raise ValueError(
                f"{config_path}: sources[{index}] unknown keys: {sorted(source_unknown)}"
            )
        if not isinstance(source.get("path"), str):
            raise ValueError(f"{config_path}: sources[{index}] requires string 'path'")
        repeat = source.get("repeat", 1)
        if not isinstance(repeat, int) or repeat < 1:
            raise ValueError(f"{config_path}: sources[{index}].repeat must be >= 1")

        source_path = (config_path.parent / source["path"]).resolve()
        image_value = source.get("image_dir")
        image_dir = (
            (config_path.parent / image_value).resolve()
            if isinstance(image_value, str)
            else (source_path.parent if source_path.is_file() else source_path)
        )
        loaded = [TrainingRecord(value, image_dir) for value in load_records(source_path)]
        for _ in range(repeat):
            records.extend(loaded)

    return DataConfig(records=records, seed=int(raw.get("seed", 42)))