import argparse import json import os import sys import time from concurrent.futures import ThreadPoolExecutor from typing import Any, Dict, Iterable, List, Optional, Tuple from tqdm.auto import tqdm try: from openai import OpenAI # type: ignore except ImportError: OpenAI = None # type: ignore BatchItem = Dict[str, Any] ModelResult = Dict[str, Any] def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description=( "Infer original source artists and song titles for mashups using " "the OpenAI API in batched requests." ) ) parser.add_argument( "--input", required=False, help="Path to input JSONL file containing mashup metadata.", ) parser.add_argument( "--output", required=False, help="Path to output JSONL file with enriched metadata.", ) parser.add_argument( "--model", default="gpt-4.1-mini", help="OpenAI model name to use (default: gpt-4.1).", ) parser.add_argument( "--batch-size", type=int, default=50, help="Number of rows to send per OpenAI request (default: 10).", ) parser.add_argument( "--max-rows", type=int, default=None, help="Optional maximum number of rows to process (for testing).", ) parser.add_argument( "--rpm", type=float, default=None, help="Optional max requests per minute (simple rate limiting).", ) parser.add_argument( "--max-retries", type=int, default=3, help="Maximum number of retries for a failed OpenAI request.", ) parser.add_argument( "--num-workers", type=int, default=1, help="Number of concurrent OpenAI batch requests to run (default: 1).", ) parser.add_argument( "--dry-run", action="store_true", help=( "Run a small in-memory example batch (including the provided " "Ariana Grande x Arca mashup) and print results instead of " "reading/writing files." ), ) return parser.parse_args() def get_openai_api_key() -> str: """ Fetch the OpenAI API key. By default, this reads from the OPENAI_API_KEY environment variable. You can also hard-code a fallback here if you prefer, e.g.: api_key = os.environ.get("OPENAI_API_KEY") or "sk-..." For security, it's recommended to keep using the environment variable. """ api_key = os.environ.get("OPENAI_API_KEY", "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") if not api_key: raise RuntimeError( "OPENAI_API_KEY environment variable is not set. " "Please export it before running this script." ) return api_key def ensure_openai_initialized(api_key: str) -> None: if OpenAI is None: raise RuntimeError( "The 'openai' Python package is not installed. " "Install it with `pip install openai`." ) def iter_jsonl( path: str, max_rows: Optional[int] = None ) -> Iterable[Tuple[int, Dict[str, Any]]]: with open(path, "r", encoding="utf-8") as f: for idx, line in enumerate(f): if max_rows is not None and idx >= max_rows: break line = line.strip() if not line: continue try: obj = json.loads(line) except json.JSONDecodeError as e: print( f"Skipping malformed JSON on line {idx + 1}: {e}", file=sys.stderr, ) continue yield idx, obj def write_jsonl(path: str, rows: Iterable[Dict[str, Any]]) -> None: with open(path, "w", encoding="utf-8") as f: for row in rows: f.write(json.dumps(row, ensure_ascii=False) + "\n") def build_batch_items( rows: List[Tuple[int, Dict[str, Any]]] ) -> List[BatchItem]: batch: List[BatchItem] = [] for global_idx, row in rows: item: BatchItem = {"id": global_idx} # If there is no YouTube match, send a minimal but useful set of textual # metadata fields to help infer the component songs. if row.get("yt_match_score") is None: item.update( { "song_name": row.get("song_name"), "artists": row.get("artists"), "album_name": row.get("album_name"), "label": row.get("label"), "genre": row.get("genre"), "delimiter_found": row.get("delimiter_found"), "mashup_keyword": row.get("mashup_keyword"), "data_source": row.get("data_source"), "split_parts": row.get("split_parts"), "metadata": row.get("metadata"), } ) # If we do have YouTube info, focus on title/artist plus key textual hints. else: item.update( { "song_name": row.get("song_name"), "artists": row.get("artists"), "album_name": row.get("album_name"), "label": row.get("label"), "genre": row.get("genre"), "delimiter_found": row.get("delimiter_found"), "mashup_keyword": row.get("mashup_keyword"), "data_source": row.get("data_source"), "split_parts": row.get("split_parts"), "yt_video_title": row.get("yt_video_title"), "yt_channel_title": row.get("yt_channel_title"), "yt_match_score": row.get("yt_match_score"), } ) batch.append(item) return batch def build_system_prompt() -> str: return ( "You are an expert at parsing noisy YouTube mashup metadata and at " "recognizing songs and artists from incomplete or corrupted information. " "Given a batch of mashup video metadata items, you must infer the " "original songs and artists being mashed together.\n\n" "Rules:\n" "- For each item, identify the original source songs and their artists. " "Assume there are at least two distinct source songs in every mashup.\n" "- Most items are mashups of exactly 2 songs; prefer 2 source songs when " "that is most plausible, but allow more if clearly indicated.\n" "- Some items include YouTube metadata (yt_* fields); others do not and " "only have fields like song_name, split_parts, album_name, etc. Use " "whatever fields are present for each item.\n" "- Use your knowledge of real-world music (including underground, indie, " "and DJ/producer scenes) to infer likely source songs and artists even when " "titles or names are missing, truncated, translated, or misspelled. " "Correct obvious typos and normalize aliases and stylizations to canonical " "artist and track names when possible.\n" "- Prefer artist–song combinations that you know (or strongly expect) " "actually exist over unlikely or inconsistent ones, and avoid inventing " "obviously fake artists or titles. When multiple songs could match, choose " "the most musically plausible option given genre, era, and context, and " "briefly note your reasoning in parsing_notes.\n" "- Do not automatically assume that the mashup artist is one of the source " "artists; mashups are often created by third-party producers or DJs. " "However, in the case where the the mashup artist (indicated by the artists field)" "performs one of the source songs, be sure to label them as a source artist.\n" "- Always return source_artists and source_titles as arrays of the same " "length. When you know a title but not the artist (or vice versa), fill " "the unknown entry with the string \"unknown\". Do not copy the exact same " "string into both source_artists and source_titles; if you cannot tell " "whether a token is an artist or a title, choose the most likely role, " "use \"unknown\" for the other, and reflect the uncertainty in " "parsing_confidence and parsing_notes. If you cannot confidently identify " "two full song–artist pairs, still provide your best guesses for at least " "two sources (using \"unknown\" where needed) and set parsing_confidence " "to a low value.\n" "- Respond ONLY with valid JSON. Do not include natural-language text, " "backticks, comments, or any content outside the JSON.\n" "- The entire response MUST be a single JSON array with one object per " "input item, matching the specified schema exactly.\n" "- Each object must have keys: id, source_artists, source_titles, " "parsing_confidence, parsing_notes.\n" "- id must exactly match the id field of the corresponding input item.\n" "- parsing_confidence is a float between 0 and 1 representing your overall " "confidence in the correctness of the inferred sources.\n" "- source_artists and source_titles should usually each have length 2.\n" ) def build_user_prompt(batch_items: List[BatchItem]) -> str: return json.dumps( { "description": ( "Input is a list of mashup metadata items. For each item, infer " "the original source songs and artists being mashed up. " "Return a JSON array of the same length as the input list, with " "one object per item following the specified schema." ), "items": batch_items, "expected_output_schema": { "type": "array", "items": { "type": "object", "properties": { "id": {"type": "integer"}, "source_artists": { "type": "array", "items": {"type": "string"}, }, "source_titles": { "type": "array", "items": {"type": "string"}, }, "parsing_confidence": { "type": "number", "description": ( "Float in [0, 1] representing overall confidence " "in the correctness of the inferred sources." ), }, "parsing_notes": { "type": "string", "description": ( "Free-text explanation of how the sources were " "inferred, including any uncertainties or " "assumptions." ), }, }, "required": [ "id", "source_artists", "source_titles", "parsing_confidence", "parsing_notes", ], }, }, }, ensure_ascii=False, ) def call_openai_for_batch( batch_items: List[BatchItem], model: str, client: Any, max_retries: int = 3, rpm: Optional[float] = None, last_request_ts: Optional[float] = None, ) -> Tuple[List[ModelResult], float]: if not batch_items: return [], last_request_ts or time.time() system_prompt = build_system_prompt() user_prompt = build_user_prompt(batch_items) attempt = 0 while True: attempt += 1 # Simple rate limiting based on requests-per-minute. if rpm is not None and last_request_ts is not None: min_interval = 60.0 / max(rpm, 1e-6) elapsed = time.time() - last_request_ts if elapsed < min_interval: time.sleep(min_interval - elapsed) try: completion = client.chat.completions.create( model=model, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], temperature=0, ) content = completion.choices[0].message.content or "" try: parsed = json.loads(content) except json.JSONDecodeError: # Sometimes the model may include extra text around the JSON. # Try to recover by extracting the first JSON array substring. stripped = content.strip() start = stripped.find("[") end = stripped.rfind("]") if start != -1 and end != -1 and end > start: candidate = stripped[start : end + 1] parsed = json.loads(candidate) else: raise if not isinstance(parsed, list): raise ValueError("Model response is not a JSON array.") results: List[ModelResult] = [] for item in parsed: if not isinstance(item, dict): continue results.append(item) return results, time.time() except Exception as e: # noqa: BLE001 print( f"OpenAI call failed on attempt {attempt}/{max_retries}: {e}", file=sys.stderr, ) if attempt >= max_retries: # Fallback: generate default low-confidence results for each item. fallback_results: List[ModelResult] = [] for bi in batch_items: fallback_results.append( { "id": bi.get("id"), "source_artists": [], "source_titles": [], "parsing_confidence": 0.0, "parsing_notes": ( "Failed to parse via OpenAI API after retries; " "no structured data extracted." ), } ) return fallback_results, time.time() # Exponential backoff before retrying. sleep_secs = min(2**attempt, 30) time.sleep(sleep_secs) def merge_results_into_rows( batch_rows: List[Tuple[int, Dict[str, Any]]], results: List[ModelResult], ) -> List[Dict[str, Any]]: result_by_id: Dict[Any, ModelResult] = {r.get("id"): r for r in results} enriched_rows: List[Dict[str, Any]] = [] for global_idx, row in batch_rows: r = result_by_id.get(global_idx) if r is None: # Default when model did not return anything for this id. row["source_artists"] = [] row["source_titles"] = [] row["parsing_confidence"] = 0.0 row["parsing_notes"] = "No result returned for this row id." else: row["source_artists"] = r.get("source_artists") or [] row["source_titles"] = r.get("source_titles") or [] # Ensure the confidence is a float in [0, 1] if possible. conf = r.get("parsing_confidence") try: conf_f = float(conf) except (TypeError, ValueError): conf_f = 0.0 if conf_f < 0.0: conf_f = 0.0 if conf_f > 1.0: conf_f = 1.0 notes = (r.get("parsing_notes") or "").strip() # If the model has clearly confused artists with titles by making the # arrays effectively identical, treat this as very low confidence. artists_norm = [str(a).strip().lower() for a in row["source_artists"]] titles_norm = [str(t).strip().lower() for t in row["source_titles"]] if artists_norm and artists_norm == titles_norm: if conf_f > 0.1: conf_f = 0.1 extra_note = ( "Model returned nearly identical values for source_artists and " "source_titles; this likely reflects confusion between artist " "and title roles, so confidence has been downgraded." ) notes = f"{notes} {extra_note}".strip() if notes else extra_note row["parsing_confidence"] = conf_f row["parsing_notes"] = notes enriched_rows.append(row) return enriched_rows def process_file( input_path: str, output_path: str, model: str, batch_size: int, max_rows: Optional[int], rpm: Optional[float], max_retries: int, num_workers: int, ) -> None: api_key = get_openai_api_key() ensure_openai_initialized(api_key) client = OpenAI(api_key=api_key) # If only one worker is requested, use the existing sequential batching logic. if num_workers <= 1: last_request_ts: Optional[float] = None batch_rows: List[Tuple[int, Dict[str, Any]]] = [] enriched_rows: List[Dict[str, Any]] = [] total_rows = 0 openai_rows = 0 num_batches = 0 for global_idx, row in tqdm( iter_jsonl(input_path, max_rows=max_rows), desc="Processing rows", unit="row", ): total_rows += 1 batch_rows.append((global_idx, row)) openai_rows += 1 if len(batch_rows) >= batch_size: num_batches += 1 batch_items = build_batch_items(batch_rows) results, last_request_ts = call_openai_for_batch( batch_items=batch_items, model=model, client=client, max_retries=max_retries, rpm=rpm, last_request_ts=last_request_ts, ) enriched_rows.extend(merge_results_into_rows(batch_rows, results)) batch_rows = [] # Process any remaining rows. if batch_rows: num_batches += 1 batch_items = build_batch_items(batch_rows) results, last_request_ts = call_openai_for_batch( batch_items=batch_items, model=model, client=client, max_retries=max_retries, rpm=rpm, last_request_ts=last_request_ts, ) enriched_rows.extend(merge_results_into_rows(batch_rows, results)) write_jsonl(output_path, enriched_rows) print( "Finished processing.\n" f" Total rows read: {total_rows}\n" f" Rows sent to OpenAI: {openai_rows}\n" f" OpenAI batches: {num_batches}\n" f" Output written to: {output_path}", file=sys.stderr, ) return # Parallel mode: use a thread pool to run multiple batch requests concurrently. last_request_ts = None # rpm is not used in parallel mode batch_rows = [] enriched_by_idx: Dict[int, Dict[str, Any]] = {} total_rows = 0 openai_rows = 0 num_batches = 0 futures: List[Any] = [] with ThreadPoolExecutor(max_workers=num_workers) as executor: # First pass: read rows and submit OpenAI batch requests. for global_idx, row in iter_jsonl(input_path, max_rows=max_rows): total_rows += 1 batch_rows.append((global_idx, row)) openai_rows += 1 if len(batch_rows) >= batch_size: num_batches += 1 local_batch = batch_rows batch_rows = [] batch_items = build_batch_items(local_batch) future = executor.submit( call_openai_for_batch, batch_items=batch_items, model=model, client=client, max_retries=max_retries, rpm=None, # rpm limiting disabled in parallel mode last_request_ts=None, ) futures.append((future, local_batch)) # Process any remaining rows. if batch_rows: num_batches += 1 local_batch = batch_rows batch_rows = [] batch_items = build_batch_items(local_batch) future = executor.submit( call_openai_for_batch, batch_items=batch_items, model=model, client=client, max_retries=max_retries, rpm=None, last_request_ts=None, ) futures.append((future, local_batch)) # Collect results from all futures, tracking progress as batches complete. with tqdm( total=total_rows, desc=f"Processing OpenAI batches (workers={num_workers})", unit="row", ) as pbar: for future, local_batch in futures: results, _ = future.result() merged_rows = merge_results_into_rows(local_batch, results) for (idx, _), enriched in zip(local_batch, merged_rows): enriched_by_idx[idx] = enriched pbar.update(len(local_batch)) # Write rows ordered by original index. ordered_indices = sorted(enriched_by_idx.keys()) enriched_rows_out = [enriched_by_idx[i] for i in ordered_indices] write_jsonl(output_path, enriched_rows_out) print( "Finished processing.\n" f" Total rows read: {total_rows}\n" f" Rows sent to OpenAI: {openai_rows}\n" f" OpenAI batches: {num_batches}\n" f" OpenAI workers: {num_workers}\n" f" Output written to: {output_path}", file=sys.stderr, ) def run_dry_run(model: str, batch_size: int, rpm: Optional[float], max_retries: int) -> None: """ Run a small synthetic example batch including the Ariana Grande x Arca mashup. """ api_key = get_openai_api_key() ensure_openai_initialized(api_key) client = OpenAI(api_key=api_key) example_rows: List[Tuple[int, Dict[str, Any]]] = [] # Example based on the user's description. example_rows.append( ( 0, { "song_name": "Ariana Grande x Arca - One Last Time / Urchin (Mashup)", "artists": ["Jane Remover"], "album_name": None, "genre": ( "deconstructed club, repetitive, pop rap, anthemic, playful, " "medley, boastful, lo-fi, mashup, dariacore, slacker rock, male " "vocalist, quirky, sampling, rhythmic, female vocalist, party, " "electro house, energetic, lgbt, mashup, androgynous vocals, " "eclectic, electronic dance music, jersey club, dance-popbubblegum " "bass, slowcore, complex, futuristic, happy, summer" ), "key": None, "bpm": None, "label": None, "release_date": None, "delimiter_found": None, "mashup_keyword": "mashup", "data_source": "dry_run_example", "split_parts": None, "metadata": None, "mashup_strength": None, "yt_video_title": ( "jane remover - ariana grande x arca - one last time / urchin (mashup)" ), "yt_channel_title": "peri music channel :0", "yt_match_score": 100.0, "yt_duration": None, "yt_url": "https://www.youtube.com/watch?v=wlTUDoNQtN4", "yt_views": None, }, ) ) batch_items = build_batch_items(example_rows) results, _ = call_openai_for_batch( batch_items=batch_items, model=model, client=client, max_retries=max_retries, rpm=rpm, last_request_ts=None, ) enriched = merge_results_into_rows(example_rows, results) print(json.dumps(enriched, ensure_ascii=False, indent=2)) def main() -> None: args = parse_args() if args.dry_run: if args.input or args.output: print( "Warning: --dry-run ignores --input/--output and uses in-memory examples only.", file=sys.stderr, ) run_dry_run( model=args.model, batch_size=args.batch_size, rpm=args.rpm, max_retries=args.max_retries, ) return if not args.input or not args.output: print( "Error: --input and --output are required unless --dry-run is specified.", file=sys.stderr, ) sys.exit(1) process_file( input_path=args.input, output_path=args.output, model=args.model, batch_size=args.batch_size, max_rows=args.max_rows, rpm=args.rpm, max_retries=args.max_retries, num_workers=args.num_workers, ) if __name__ == "__main__": main()