|
| 1 | +import datetime |
| 2 | +import json |
| 3 | +import os |
| 4 | + |
| 5 | +import pytest |
| 6 | +from datasets import config as datasets_config |
| 7 | + |
| 8 | +from bespokelabs.curator.llm.prompt_formatter import PromptFormatter |
| 9 | +from bespokelabs.curator.request_processor.config import OnlineRequestProcessorConfig |
| 10 | +from bespokelabs.curator.request_processor.online.openai_online_request_processor import OpenAIOnlineRequestProcessor |
| 11 | +from bespokelabs.curator.types.generic_request import GenericRequest |
| 12 | +from bespokelabs.curator.types.generic_response import GenericResponse |
| 13 | + |
| 14 | +# The dataset writer flushes rows to disk in batches of this size. Failing after the first batch |
| 15 | +# means part of the dataset is already in the file when the build stops. |
| 16 | +_BATCH = datasets_config.DEFAULT_MAX_BATCH_SIZE |
| 17 | +_N_ROWS = _BATCH + 50 |
| 18 | +_FAIL_AT_ROW = _BATCH + 10 |
| 19 | +_PARSE_FUNC_HASH = "parse_func_hash" |
| 20 | + |
| 21 | + |
| 22 | +def _write_cached_run(working_dir, corrupt_row=None): |
| 23 | + """Write the request and response files a finished online run leaves in its cache directory.""" |
| 24 | + now = datetime.datetime.now() |
| 25 | + with open(os.path.join(working_dir, "requests_0.jsonl"), "w") as requests_file, open(os.path.join(working_dir, "responses_0.jsonl"), "w") as responses_file: |
| 26 | + for idx in range(_N_ROWS): |
| 27 | + request = GenericRequest( |
| 28 | + model="gpt-4o-mini", |
| 29 | + messages=[{"role": "user", "content": f"question {idx}"}], |
| 30 | + original_row={"question": f"question {idx}"}, |
| 31 | + original_row_idx=idx, |
| 32 | + ) |
| 33 | + requests_file.write(json.dumps(request.model_dump(), default=str) + "\n") |
| 34 | + if idx == corrupt_row: |
| 35 | + responses_file.write('{"response_message": "unterminated\n') |
| 36 | + continue |
| 37 | + response = GenericResponse( |
| 38 | + response_message=f"answer {idx}", |
| 39 | + raw_response={}, |
| 40 | + generic_request=request, |
| 41 | + created_at=now, |
| 42 | + finished_at=now, |
| 43 | + ) |
| 44 | + responses_file.write(json.dumps(response.model_dump(), default=str) + "\n") |
| 45 | + with open(os.path.join(working_dir, "metadata_0.json"), "w") as f: |
| 46 | + json.dump({"num_jobs": _N_ROWS}, f) |
| 47 | + |
| 48 | + |
| 49 | +def _processor(working_dir, parse_func): |
| 50 | + # create_dataset_files only needs the config, working directory and prompt formatter, so skip |
| 51 | + # __init__, which makes a network call to read rate limits. |
| 52 | + processor = object.__new__(OpenAIOnlineRequestProcessor) |
| 53 | + processor.config = OnlineRequestProcessorConfig(model="gpt-4o-mini") |
| 54 | + processor.working_dir = str(working_dir) |
| 55 | + processor.prompt_formatter = PromptFormatter(model_name="gpt-4o-mini", prompt_func=lambda row: row["question"], parse_func=parse_func) |
| 56 | + return processor |
| 57 | + |
| 58 | + |
| 59 | +def _parse(row, response): |
| 60 | + return {"question": row["question"], "answer": response} |
| 61 | + |
| 62 | + |
| 63 | +def _interrupting_parse(): |
| 64 | + calls = {"n": 0} |
| 65 | + |
| 66 | + def parse(row, response): |
| 67 | + calls["n"] += 1 |
| 68 | + if calls["n"] == _FAIL_AT_ROW: |
| 69 | + raise KeyboardInterrupt |
| 70 | + return _parse(row, response) |
| 71 | + |
| 72 | + return parse |
| 73 | + |
| 74 | + |
| 75 | +@pytest.mark.parametrize( |
| 76 | + "corrupt_row, parse_func, error", |
| 77 | + [ |
| 78 | + pytest.param(None, _interrupting_parse, KeyboardInterrupt, id="keyboard_interrupt"), |
| 79 | + pytest.param(_FAIL_AT_ROW, lambda: _parse, ValueError, id="unreadable_response_line"), |
| 80 | + ], |
| 81 | +) |
| 82 | +def test_interrupted_dataset_build_is_not_loaded_as_cached_dataset(tmp_path, corrupt_row, parse_func, error): |
| 83 | + """A dataset build that stops partway must not leave a dataset file that the next run loads from the cache.""" |
| 84 | + _write_cached_run(tmp_path, corrupt_row=corrupt_row) |
| 85 | + |
| 86 | + with pytest.raises(error): |
| 87 | + _processor(tmp_path, parse_func()).create_dataset_files(_PARSE_FUNC_HASH) |
| 88 | + |
| 89 | + assert _processor(tmp_path, _parse).attempt_loading_cached_dataset(_PARSE_FUNC_HASH) is None |
| 90 | + # No partial dataset or temporary file is left in the cache directory. |
| 91 | + assert sorted(os.listdir(tmp_path)) == ["metadata_0.json", "requests_0.jsonl", "responses_0.jsonl"] |
| 92 | + |
| 93 | + |
| 94 | +def test_dataset_is_rebuilt_from_cached_responses_after_interrupted_build(tmp_path): |
| 95 | + _write_cached_run(tmp_path) |
| 96 | + with pytest.raises(KeyboardInterrupt): |
| 97 | + _processor(tmp_path, _interrupting_parse()).create_dataset_files(_PARSE_FUNC_HASH) |
| 98 | + |
| 99 | + dataset = _processor(tmp_path, _parse).create_dataset_files(_PARSE_FUNC_HASH) |
| 100 | + |
| 101 | + assert len(dataset) == _N_ROWS |
| 102 | + cached = _processor(tmp_path, _parse).attempt_loading_cached_dataset(_PARSE_FUNC_HASH) |
| 103 | + assert cached["answer"] == [f"answer {idx}" for idx in range(_N_ROWS)] |
| 104 | + assert sorted(os.listdir(tmp_path)) == sorted( |
| 105 | + [f"{_PARSE_FUNC_HASH}.arrow", "failed_requests.jsonl", "metadata_0.json", "requests_0.jsonl", "responses_0.jsonl"] |
| 106 | + ) |
0 commit comments