File size: 19,156 Bytes
42da440
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
from tqdm import asyncio as tqdmasyncio
import json
import logging
import os
import re
import signal
import sys
import time
import traceback
import asyncio
from augmentoolkit.generation_functions.generation_step_class import GenerationStep

from augmentoolkit.utils.random import noop

try:
    import pyfiglet

    def _print_banner(text):  # Use underscore to indicate internal helper
        print(pyfiglet.figlet_format(text, font="standard"))

except ImportError:
    print(
        "Warning: pyfiglet not installed. Install with 'pip install pyfiglet' for formatted banners."
    )

    def _print_banner(text):
        print(f"\n{'='*10} {text} {'='*10}\n")


def load_dataset_func(input_dict, output_path):
    if os.path.exists(output_path):
        # this dataset exists
        with open(output_path, "r", encoding="utf-8") as f:
            try:
                print(f"Loading file at {output_path}")
                file_contents = json.load(f)
                print("Loaded!")
                # print(file_contents)
            except json.JSONDecodeError:
                # Print a prominent warning about the JSON decode error
                print("\n" + "!" * 80)
                print(f"WARNING: JSON DECODE ERROR in file {output_path}")
                print(
                    "The file exists but contains invalid JSON. Creating an empty dictionary instead."
                )
                print(
                    "This may indicate data corruption or an incomplete write operation."
                )
                print("!" * 80 + "\n")
                file_contents = input_dict

        # assert file_contents is a dict
        assert isinstance(
            file_contents, dict
        ), f"Expected file_contents to be a dictionary, but got {type(file_contents).__name__} instead. This may indicate data corruption in {output_path}."

        # Mutate input_dict to contain file_contents
        input_dict.update(file_contents)


def save_dataset(input_dict, output_path):
    interrupt_count = 0
    max_interrupts = 5
    original_sigint_handler = signal.getsignal(signal.SIGINT)

    def _robust_save_interrupt_handler(sig, frame):
        nonlocal interrupt_count
        interrupt_count += 1
        if interrupt_count == 1:
            _print_banner("SAVE WARNING")
            print(
                f"\nWARNING: Interrupt detected during critical dataset save operation! Unless you really want to lose the in-progress dataset generation for the {output_path} step, please wait a few seconds!",
                file=sys.stderr,
            )
            print(
                f"Press Ctrl+C {max_interrupts - interrupt_count} more times consecutively to force exit.",
                file=sys.stderr,
            )
        elif interrupt_count < max_interrupts:
            print(
                f"\nInterrupt {interrupt_count}/{max_interrupts}. Press Ctrl+C {max_interrupts - interrupt_count} more times to force exit.",
                file=sys.stderr,
            )
        else:
            print(
                f"\n{max_interrupts} consecutive interrupts detected during save. Forcing exit.",
                file=sys.stderr,
            )
            # Restore original handler *before* exiting
            signal.signal(signal.SIGINT, original_sigint_handler)
            sys.exit(130)  # Exit code 130 for SIGINT

    # Outer try...finally ensures original handler is always restored
    save_successful = False
    try:
        # Install custom handler ONLY for the duration of save_dataset
        signal.signal(signal.SIGINT, _robust_save_interrupt_handler)

        # --- Perform the critical operation ---
        print("\nAttempting to save dataset...")  # Indicate save start
        # Create empty structure if file doesn't exist
        if not os.path.exists(output_path):
            # Ensure directory exists before creating file
            os.makedirs(os.path.dirname(output_path), exist_ok=True)
            with open(output_path, "w", encoding="utf-8") as f:
                json.dump({}, f)

        # Create a temporary file with a similar name
        temp_output_path = f"{output_path}.temp"

        try:
            # Write to the temporary file first
            with open(temp_output_path, "w", encoding="utf-8") as f:
                json.dump(input_dict, f)

            # Replace the original file with the temporary file
            # This ensures atomic write operation
            if os.path.exists(output_path):
                os.replace(temp_output_path, output_path)
            else:
                os.rename(temp_output_path, output_path)
        finally:
            # If something goes wrong, try to clean up the temp file
            if os.path.exists(temp_output_path):
                try:
                    os.remove(temp_output_path)
                except:
                    pass
        # --- Critical operation finished ---
        save_successful = True
    except Exception as e:
        print(f"\nAn error occurred during dataset save: {e}", file=sys.stderr)
        traceback.print_exc()
        # The finally block below will still run to restore the handler.
        # Re-raise the exception if you want the pipeline execution to fail clearly
        raise e
    finally:
        # ALWAYS restore the original handler
        signal.signal(signal.SIGINT, original_sigint_handler)
        if save_successful:
            print(
                f"Dataset saved successfully to {output_path}"
            )  # Optional success message
        elif (
            interrupt_count < max_interrupts
        ):  # Only print if not exited due to interrupts
            print(
                "Dataset save may be incomplete due to an error or interruption (but not forced exit).",
                file=sys.stderr,
            )
        # Reset counter (mostly for clarity, as it's reset next time execute_pipeline runs)
        interrupt_count = 0


def filter_out_nonpresent_keys(item_dict, key_to_check="judgement"):
    keys_to_remove = []
    for key, value in item_dict.items():
        if not key_to_check in value:
            keys_to_remove.append(key)

    for key in keys_to_remove:
        del item_dict[key]


# just realized that I could've done process input dta as higher order func smh


class PipelineStep:
    def __init__(
        self,
        method_overrides=None,
        prompt_path=None,
        sampling_params=None,
        output_file=None,
        output_processor=lambda x: x,
        input_processor=lambda x: (x, {}),
        logging_level=logging.INFO,
        result_key="placeholder_result_key",  # this is the key that the result will be saved under in the output dictionary.
        details_key="placeholder_key_details",
        regex=re.compile(r".*", re.DOTALL),
        validation_function=lambda x, y: {"result": True, "message": "default message"},
        max_retries=3,
        log_full_outputs=False,
        **kwargs,  # Anything run time gets passed into .run() instead of the class at initialization. The only other thing I may have to add to that list are the static arguments.
    ):  # things that are args here are things that would be in the code. Some of these will be live-tweakable.
        self.prompt_path = prompt_path
        self.sampling_params = sampling_params
        self.output_processor = output_processor
        self.logging_level = logging_level
        self.result_key = result_key
        self.regex = regex
        self.output_file = output_file
        self.validation_function = validation_function
        self.max_retries = max_retries
        self.log_full_outputs = log_full_outputs
        self.static_arguments = kwargs  # any additional arguments are passed in during generation time. Fits the role of stuff read from the config, like special instructions.
        self.details_key = details_key
        self.input_processor = input_processor

        # Handle method overrides
        if method_overrides:
            for method_name, method in method_overrides.items():
                setattr(
                    self, method_name, method.__get__(self)
                )  # Bind methods to instance

    def process_input_data(self, input_data):
        return self.input_processor(
            input_data
        )  # this should be a dictionary with the keys being the same as the interpolation spots in the prompt. This function in particular will basically always be overridden in subclasses.

    def make_output_path(self, output_dir):
        """Returns full path to the consolidated JSON file"""
        return os.path.join(output_dir, f"{self.output_file}.json")

    def read_previous_output(self, key, output_dict):
        entry = output_dict.get(str(key), {})
        # Check if result key exists in existing entry
        if self.result_key in entry:
            # print(f"Found key {self.result_key}!")
            # print(entry[self.result_key])
            # output_dict[key] = entry
            # print("successfully loaded item")
            # print(output_dict[key])
            return True
        return False

    async def generate_data(
        self,
        processed_data,
        engine_wrapper,
        prompt_path,
        prompt_folder,
        default_prompt_folder,
        completion_mode,
        use_stop,
        **kwargs,
    ):
        try:

            generator = GenerationStep(
                prompt_path=prompt_path,
                default_prompt_folder=default_prompt_folder,
                sampling_params=self.sampling_params,
                completion_mode=completion_mode,
                engine_wrapper=engine_wrapper,
                output_processor=self.output_processor,
                retries=1,
                logging_level=self.logging_level,
                use_stop=use_stop,
                prompt_folder=prompt_folder,
                regex=self.regex,
            )

            # print(processed_data)

            result, full_output, full_response, full_input = await generator.generate(
                **processed_data, **self.static_arguments, **kwargs
            )

            return result, full_output, full_response, full_input
        except Exception as e:
            print(e)
            traceback.print_exc()

    def save(
        self,
        result=None,
        key=None,
        input_dict=None,
        input_data=None,
        full_response=None,
        include_details=False,
        full_input=None,
        completion_mode=False,
    ):

        input_data[self.result_key] = result
        if include_details:
            input_data[self.details_key] = [
                {
                    "full_response": full_response,
                    "full_input": full_input,
                    "completion_mode": completion_mode,  # needed to determine whether full input is a list of messages or a string
                    # did it pass validation is determined by, well, our config and the whole-dataset postprocessors that check for things missing keys. what keys were used is just whatever keys are in the input data since they should not change, they should not be mutated.
                    # the prompt and input are essentially inseparable?
                }
            ]

        input_dict[str(key)] = input_data
        return input_data

    async def run(
        self,
        key=None,
        input_data=None,
        engine_wrapper=None,
        input_dict=None,
        default_prompt_folder=None,
        prompt_folder=None,
        completion_mode=False,
        use_stop=True,
        include_details=False,
        **kwargs,
    ):  # things that are args here are produced during inference time. Including config settings.
        full_prompt_path = (
            self.prompt_path + ".yaml"
            if not completion_mode
            else self.prompt_path + ".txt"
        )

        # Check existing entries
        if self.read_previous_output(key, input_dict):
            return

        processed_data, additional_args_dict = self.process_input_data(
            input_data
        )  # additional args dict is a dict that will go away and die, but is present information for the generation

        error_message = ""  # TODO reserved for when we

        complete = False
        max_retries = self.max_retries
        while not complete and max_retries > 0:
            try:
                result, full_output, full_response, full_input = (
                    await self.generate_data(
                        processed_data,
                        engine_wrapper,
                        full_prompt_path,
                        prompt_folder,
                        default_prompt_folder,
                        completion_mode,
                        use_stop,
                        error_message=error_message,
                        **kwargs,
                        **additional_args_dict,
                    )
                )
                validation_result = self.validation_function(
                    result, input_data
                )  # What I should do here -- have it return a string as well. That string gets passed in as error message t the prompt, allowing for the model to self-correct over repeat generations basd on failed generations. If it excepts then the exception is turned into the error string, handling output processor errors too. It would all work. I just have to change the usage throughout all the pipelines. This would be great and immensely useful.
                if validation_result["result"]:
                    complete = True
                else:
                    error_message = validation_result["message"]
            except Exception as e:
                print(e)
                error_message = str(e)
                traceback.print_exc()
            max_retries -= 1
        if not complete:  # consider raising here and catching in the actual pipeline.
            return

        return self.save(
            result=result,
            key=key,
            input_dict=input_dict,
            input_data=input_data,
            full_response=full_response,
            include_details=include_details,
            completion_mode=completion_mode,
            full_input=full_input,
        )

    def load_dataset(
        self, input_dict, output_dir
    ):  # NOTE footgun here where if the output file path
        output_path = self.make_output_path(output_dir)
        load_dataset_func(input_dict=input_dict, output_path=output_path)

    def save_dataset(self, input_dict, output_dir):
        output_path = self.make_output_path(output_dir=output_dir)
        save_dataset(input_dict=input_dict, output_path=output_path)

        job_id = os.environ.get("JOB_ID") or os.environ.get("TASK_ID")
        work_repo = os.environ.get("HF_WORK_REPO")
        if job_id and work_repo:
            try:
                from augmentoolkit.hf_persistence.checkpoint_manager import CheckpointManager
                ckpt_mgr = CheckpointManager()
                manifest = ckpt_mgr.load_remote_manifest(job_id=job_id, work_repo=work_repo)
                if manifest:
                    shard_id = getattr(self, "output_file", "step_output")
                    ckpt_mgr.save_checkpoint(
                        manifest=manifest,
                        shard_id=shard_id,
                        checkpoint_data=input_dict,
                        stage=getattr(self, "result_key", "generation"),
                        sync_remote=True
                    )
            except Exception as e:
                print(f"ERROR: Fail-closed remote checkpoint sync failed for step {getattr(self, 'output_file', '')}: {e}")
                raise e

    async def execute_pipeline(
        self,
        input_dict={},
        engine_wrapper=None,
        rtwl=None,
        default_prompt_folder=None,
        prompt_folder=None,
        output_dir=None,
        completion_mode=None,
        use_stop=None,
        include_details=False,
        loop_callback=noop,
        **kwargs,
    ):
        self.load_dataset(
            input_dict=input_dict, output_dir=output_dir
        )  # if it is at the start and end of every pipeline execution, we get the same behavior as before, past thing will load and save just fine, without constant reads/writes.
        
        task_completed_successfully = False
        try:
            data_generations_tasks = [
                self.run(
                    input_data=value,
                    engine_wrapper=engine_wrapper,
                    key=key,
                    input_dict=input_dict,
                    default_prompt_folder=default_prompt_folder,
                    prompt_folder=prompt_folder,
                    completion_mode=completion_mode,
                    use_stop=use_stop,
                    include_details=include_details,
                    **kwargs,
                )
                for key, value in input_dict.items()
            ]
            coroutines = [rtwl(task) for task in data_generations_tasks]
            TASK_TIMEOUT_SECONDS = 600  # 10 minutes timeout
            processed_count = 0  # Keep track for logging
            total_tasks = len(coroutines)
            for future in tqdmasyncio.tqdm.as_completed(coroutines):
                processed_count += 1
                loop_callback(input_dict=input_dict, **kwargs)
                try:
                    await asyncio.wait_for(future, timeout=TASK_TIMEOUT_SECONDS)
                except asyncio.TimeoutError:
                    print(
                        f"\nWARNING: Task {processed_count}/{total_tasks} timed out after {TASK_TIMEOUT_SECONDS} seconds.",
                        file=sys.stderr,
                    )
                    # Task is cancelled by wait_for, loop continues
                except Exception as e_inner:  # Catch exceptions from the future itself
                    print(
                        f"\nError processing task {processed_count}/{total_tasks}: {e_inner}"
                    )
                    traceback.print_exc()  # Optionally log traceback
            task_completed_successfully = True
        except Exception as e:
            print(f"Exception occurred during task execution: {e}")
            traceback.print_exc()
            raise e
        finally:
            # Robust save with interrupt handling
            if task_completed_successfully:
                filter_out_nonpresent_keys(input_dict, key_to_check=self.result_key)
            self.save_dataset(input_dict=input_dict, output_dir=output_dir)


# we don't want failed items biting us later. Mind, this may not be a complete solution, since other things reading from the file might screw us. Hmm there is clearly a bug case, where on the next step as it iterates through keys it will also iterate through keys that failed and supply things with insufficient context. And you know what the solution is? A filter out failed items dict baked into every execute pipeline. Since if the output key does not exist in the item at this key, then it failed, and it should be dropped.