Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .idea/claudeCodeEditorTabs.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 19 additions & 10 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,12 @@ pytest -v --tb=long

| File | Coverage area |
|------|---------------|
| `test_batch_creation.py` | JSONL batch generation — prompt construction, schema building, `forbid_additional_props`, non-ASCII text, edge cases |
| `test_batch_parsing.py` | Result parsing (`_safe_parse_model_text`), OpenAI client creation, `get_batch_results` with mocked API (success, auth failure, rate limit, timeout, malformed output, fenced JSON) |
| `test_task.py` | The Task engine — prompt/list rendering (all list formats), dynamic output-model/schema building per field type, `validate_task`, `Task.to_dict`/`from_dict` round-trip, `forbid_additional_props` |
| `test_presets.py` | The three built-in presets are valid, editable `Task`s |
| `test_batch_creation.py` | JSONL batch generation from a `Task` — prompt rendering reaching the request body, system message handling, strict schema attached, non-ASCII text, edge cases |
| `test_batch_parsing.py` | Result parsing (`_safe_parse_model_text`), OpenAI client creation, `send_batch` validation + sidecar writing, `get_batch_results` with mocked API (success, auth failure, rate limit, timeout, malformed output, fenced JSON, sidecar rejoin) |
| `test_data_conversion.py` | `make_str_enum`, `to_long_df` (including multi-label explode), `join_datasets` |
| `test_data_import.py` | Delimiter sniffing, CSV/TSV/Excel reading, non-ASCII content, empty files, realistic fixture |
| `test_data_import.py` | Delimiter sniffing, CSV/TSV/Excel reading, non-ASCII content, empty files, realistic fixture, full-table `load_table` |
| `test_settings.py` | User config load/save/roundtrip, `get_setting` precedence |
| `test_reliability.py` | Cohen's kappa — perfect agreement, known value, edge cases (empty, mismatched lengths, single label, unicode labels) |

Expand Down Expand Up @@ -126,21 +128,25 @@ CodebookAI/
├── requirements-dev.txt Test / development dependencies
├── pytest.ini Pytest configuration
├── core/ The Task engine (pure, fully testable)
│ ├── task.py Task/TaskList/OutputField, prompt rendering,
│ │ dynamic Pydantic model + strict schema builder
│ └── presets.py Built-in Task presets (single/multi/keyword)
├── batch_processing/ OpenAI Batch API workflow
│ ├── batch_creation.py JSONL generation (pure, fully testable)
│ ├── batch_method.py Batch submission / retrieval / result parsing
│ ├── batch_creation.py JSONL generation from a Task (pure, fully testable)
│ ├── batch_method.py Batch submission / retrieval / sidecar rejoin
│ └── batch_error_handling.py Error reporting UI
├── live_processing/ Real-time classification
│ ├── single_label_live.py Single-label pipeline
│ ├── multi_label_live.py Multi-label pipeline
├── live_processing/ Real-time (synchronous) execution + analysis tools
│ ├── task_live.py Runs any Task row-by-row via the live API
│ ├── reliability_calculator.py Cohen's kappa (pure, fully testable)
│ ├── keyword_extraction_live.py
│ ├── correlogram.py
│ └── sampler.py
├── file_handling/ File I/O (mostly pure, fully testable)
│ ├── data_import.py CSV / Excel reader + import-wizard GUI
│ ├── data_import.py CSV / Excel reader; single-column wizard (for
│ │ Lists) + full-table loader (for the Task Builder)
│ └── data_conversion.py make_str_enum, to_long_df, join_datasets
├── settings/ Configuration
Expand All @@ -150,13 +156,16 @@ CodebookAI/
│ └── models_registry.py OpenAI model list cache
├── ui/ Tkinter GUI components
│ └── task_builder.py The Task Builder -- the app's central window
├── tests/ Automated test suite
│ ├── conftest.py Shared fixtures; in-memory keyring setup
│ ├── fixtures/ Static data files used by tests
│ │ ├── sample_labels.csv
│ │ ├── sample_quotes.csv
│ │ └── realistic_dataset.csv
│ ├── test_task.py
│ ├── test_presets.py
│ ├── test_batch_creation.py
│ ├── test_batch_parsing.py
│ ├── test_data_conversion.py
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@

![CodebookAI Logo](./assets/BannerNarrow.png)

CodebookAI is a tool designed to assist qualitative researchers in processing large datasets through OpenAI's GPT models (e.g., 4o, 5, o3, etc.). It enables batch processing of text snippets against a set of labels, significantly reducing the cost and time associated with manual coding, as well as a variety of other tools aimed at qualitative data preparation and analysis.
CodebookAI is a tool designed to assist qualitative researchers in processing large datasets through OpenAI's GPT models (e.g., 4o, 5, o3, etc.). Its **Task Builder** lets you write your own prompt against an imported table, define named label Lists that constrain the model's response to on-list values only (no hallucinated or reworded labels to clean up), and choose exactly which columns -- including existing IDs -- carry through to your results. Run a task live for quick results or submit it as a batch for large, cost-effective jobs. The app also includes a variety of other tools aimed at qualitative data preparation and analysis.

## Getting Started

Expand Down
198 changes: 33 additions & 165 deletions batch_processing/batch_creation.py
Original file line number Diff line number Diff line change
@@ -1,194 +1,62 @@
"""
JSON schema handling and batch file generation for OpenAI text classification.
JSONL batch file generation for OpenAI Structured Outputs requests.

This module provides utilities for creating JSON schemas that enforce structured
responses from OpenAI models, and for generating JSONL batch files for processing
multiple text classification requests efficiently.
This used to be three near-identical functions (generate_single_label_batch,
generate_multi_label_batch, generate_keyword_extraction_batch), each with its
own hardcoded prompt and Pydantic model. They're now one function driven by a
core.task.Task -- the prompt, the constant/per-row split, and the output
schema all come from the task instead of being baked in here.
"""

from __future__ import annotations
import io, json
from enum import Enum
from io import BytesIO
from typing import Optional, List
from pydantic import BaseModel, ConfigDict, Field

from file_handling.data_conversion import make_str_enum
from file_handling.data_import import import_data
from settings.config import model


def forbid_additional_props(schema: dict) -> dict:
"""Recursively set additionalProperties:false on every object schema."""
if not isinstance(schema, dict):
return schema

# If this schema node is an object, set additionalProperties: false
if schema.get("type") == "object":
schema.setdefault("properties", {})
schema["additionalProperties"] = False

# Recurse into each property
for prop_schema in schema.get("properties", {}).values():
forbid_additional_props(prop_schema)

# Recurse into patternProperties (rare but safe)
for prop_schema in schema.get("patternProperties", {}).values():
forbid_additional_props(prop_schema)

# If it's an array, recurse into "items"
if schema.get("type") == "array" and "items" in schema:
forbid_additional_props(schema["items"])

# oneOf/anyOf/allOf branches
for key in ("oneOf", "anyOf", "allOf"):
if key in schema and isinstance(schema[key], list):
for s in schema[key]:
forbid_additional_props(s)
import io
import json
from io import BytesIO

return schema
from core.task import Task, build_request_params, build_strict_schema, render_prompt


def generate_single_label_batch(labels, quotes) -> BytesIO | None:
def generate_batch(task: Task, rows: list[dict]) -> BytesIO:
"""
Create a JSONL batch as bytes, in memory (no disk writes).
Returns a BytesIO whose .name is set to 'batchinput.jsonl'.
"""
labels_enum = make_str_enum("Label", labels)

class LabeledQuote(BaseModel):
label: labels_enum # STRICT: must be one of labels
model_config = ConfigDict(use_enum_values=True, extra='forbid')

SCHEMA = LabeledQuote.model_json_schema()
STRICT_SCHEMA = forbid_additional_props(SCHEMA)

buf = io.BytesIO()
# Give the buffer a filename so the API knows its type
buf.name = "batchinput.jsonl"

lines = []
for i, q in enumerate(quotes, 1):
lines.append({
"custom_id": f"quote-{i:05d}",
"method": "POST",
"url": "/v1/responses",
"body": {
"model": model,
"input": [
{"role": "user",
"content": f"Label this quote with exactly one label from the allowed set.\n"
f"Allowed: {', '.join(labels)}\nQuote: {q}"}
],
"text": {
"format": {
"type": "json_schema",
"name": "LabeledQuote",
"schema": STRICT_SCHEMA,
"strict": True
}
},
"metadata": {"quote": q}
}
})

for line in lines:
buf.write((json.dumps(line, ensure_ascii=False) + "\n").encode("utf-8"))

buf.seek(0)
return buf
One row -> one request, custom_id = "row-00001", "row-00002", ...
(1-based, matching the row order of `rows`).


def generate_multi_label_batch(labels, quotes) -> BytesIO | None:
"""
Create a JSONL batch as bytes, in memory (no disk writes).
Returns a BytesIO whose .name is set to 'batchinput.jsonl'.
Row data itself is NOT sent as request metadata -- OpenAI's metadata has
a ~512 char/value limit that arbitrary carried columns could exceed.
Carried columns are rejoined locally by row position after download
(see batch_processing.batch_method).
"""
labels_enum = make_str_enum("Label", labels)

class LabeledQuoteMulti(BaseModel):
label: List[labels_enum] = Field(..., min_length=1)
model_config = ConfigDict(use_enum_values=True, extra='forbid')

SCHEMA = LabeledQuoteMulti.model_json_schema()
STRICT_SCHEMA = forbid_additional_props(SCHEMA)
schema = build_strict_schema(task)
request_params = build_request_params(task)

buf = io.BytesIO()
# Give the buffer a filename so the API knows its type
buf.name = "batchinput.jsonl"

lines = []
for i, q in enumerate(quotes, 1):
lines.append({
"custom_id": f"quote-{i:05d}",
"method": "POST",
"url": "/v1/responses",
"body": {
"model": model,
"input": [
{"role": "user",
"content": "Label this quote with labels from the allowed set only. \n"
f"Allowed: {', '.join(labels)}\nQuote: {q}"}
],
"text": {
"format": {
"type": "json_schema",
"name": "LabeledQuoteMulti",
"schema": STRICT_SCHEMA,
"strict": True
}
},
"metadata": {"quote": q}
}
})

for line in lines:
buf.write((json.dumps(line, ensure_ascii=False) + "\n").encode("utf-8"))

buf.seek(0)
return buf


def generate_keyword_extraction_batch(texts) -> BytesIO | None:
"""
Create a JSONL batch as bytes, in memory (no disk writes).
Returns a BytesIO whose .name is set to 'batchinput.jsonl'.
"""
class KeywordExtraction(BaseModel):
keywords: list[str] = Field(..., min_length=1)
model_config = ConfigDict(extra='forbid')
for i, row in enumerate(rows, 1):
messages = []
if task.system:
messages.append({"role": "system", "content": render_prompt(task, row, text=task.system)})
messages.append({"role": "user", "content": render_prompt(task, row)})

SCHEMA = KeywordExtraction.model_json_schema()
STRICT_SCHEMA = forbid_additional_props(SCHEMA)

buf = io.BytesIO()
# Give the buffer a filename so the API knows its type
buf.name = "batchinput.jsonl"

lines = []
for i, txt in enumerate(texts, 1):
lines.append({
"custom_id": f"text-{i:05d}",
line = {
"custom_id": f"row-{i:05d}",
"method": "POST",
"url": "/v1/responses",
"body": {
"model": model,
"input": [{"role": "system", "content": "You are an expert at structured data extraction."},
{"role": "user", "content": f"Extract the keywords from this text: {txt}"}
],
"input": messages,
"text": {
"format": {
"type": "json_schema",
"name": "KeywordExtraction",
"schema": STRICT_SCHEMA,
"strict": True
"name": "TaskOutput",
"schema": schema,
"strict": True,
}
},
"metadata": {"quote": txt}
}
})

for line in lines:
**request_params,
},
}
buf.write((json.dumps(line, ensure_ascii=False) + "\n").encode("utf-8"))

buf.seek(0)
Expand Down
Loading
Loading