# DocumentsReasoningDataset.py from __future__ import annotations import json import random import re from pathlib import Path from typing import Any, Optional from datasets import load_dataset try: import utils.configs_loader as ProjectConfigs except Exception: ProjectConfigs = None # ------------------------------------------------------------ # Split config # ------------------------------------------------------------ SAMPLE_SPLITS = { "easy": 0.25, # only supporting documents "normal": 0.25, # original MuSiQue context "messy": 0.20, # support + distractors, shuffled "messy_essay": 0.10, # uglier real-world-ish document format "missing_one": 0.15, # remove one required support document "missing_all": 0.05, # remove all support documents } DEFAULT_SYSTEM_PROMPT = ( "You are a concise document reasoning assistant. " "Use only the provided tags as evidence. " "Reason inside and . " "If required evidence is missing, answer exactly: Not enough information. " "Keep reasoning short, grounded, and cite document ids when useful." ) class DocumentsReasoningDataset: def __init__( self, path: str = "dataset/documents_reasoning", hf_dataset: str = "dgslibisey/MuSiQue", train_split: str = "train", validation_split: str = "validation", samples_split: dict[str, float] = SAMPLE_SPLITS, target_train_samples: int = 35_000, target_validation_samples: int = 1_000, seed: int = 42, max_distractors: int = 8, hide_title_prob: float = 0.20, ): self.path = Path(path) self.hf_dataset = hf_dataset self.train_split = train_split self.validation_split = validation_split self.samples_split = self._normalize_splits(samples_split) self.target_train_samples = target_train_samples self.target_validation_samples = target_validation_samples self.seed = seed self.rng = random.Random(seed) self.max_distractors = max_distractors self.hide_title_prob = hide_title_prob self.prompt = self._load_system_prompt() # ------------------------------------------------------------ # Public API # ------------------------------------------------------------ def build(self) -> dict[str, Any]: """ Builds: dataset/documents_reasoning/train.jsonl dataset/documents_reasoning/validation.jsonl dataset/documents_reasoning/validation_stress.jsonl Output format per line: { "messages": [ {"role": "system", "content": "..."}, {"role": "user", "content": "..."}, {"role": "assistant", "content": "..."} ], "meta": {...} } """ self.path.mkdir(parents=True, exist_ok=True) print(f"[Dataset] Loading {self.hf_dataset} ...") ds = load_dataset(self.hf_dataset) train_ds = ds[self.train_split] val_ds = ds[self.validation_split] print("[Dataset] Building train records...") train_records = self._build_records( dataset=train_ds, target_samples=self.target_train_samples, split_name="train", sample_splits=self.samples_split, ) print("[Dataset] Building validation records...") validation_records = self._build_records( dataset=val_ds, target_samples=self.target_validation_samples, split_name="validation", sample_splits={ "normal": 0.70, "easy": 0.20, "messy": 0.10, }, ) print("[Dataset] Building stress validation records...") validation_stress_records = self._build_records( dataset=val_ds, target_samples=min(500, self.target_validation_samples), split_name="validation_stress", sample_splits={ "missing_one": 0.60, "missing_all": 0.25, "messy_essay": 0.15, }, ) train_path = self.path / "train.jsonl" validation_path = self.path / "validation.jsonl" stress_path = self.path / "validation_stress.jsonl" self._write_jsonl(train_path, train_records) self._write_jsonl(validation_path, validation_records) self._write_jsonl(stress_path, validation_stress_records) summary = { "train_path": str(train_path), "validation_path": str(validation_path), "validation_stress_path": str(stress_path), "train_samples": len(train_records), "validation_samples": len(validation_records), "validation_stress_samples": len(validation_stress_records), } print("[Dataset] Done.") print(json.dumps(summary, indent=2, ensure_ascii=False)) return summary # ------------------------------------------------------------ # Record building # ------------------------------------------------------------ def _build_records( self, dataset, target_samples: int, split_name: str, sample_splits: dict[str, float], ) -> list[dict[str, Any]]: records: list[dict[str, Any]] = [] counts = self._target_counts(target_samples, sample_splits) all_indices = list(range(len(dataset))) for kind, target_count in counts.items(): made = 0 attempts = 0 indices = all_indices[:] self.rng.shuffle(indices) print(f"[Build:{split_name}] kind={kind}, target={target_count}") while made < target_count and attempts < target_count * 30: idx = indices[attempts % len(indices)] sample = dataset[idx] attempts += 1 record = self._make_variant( sample=sample, kind=kind, split_name=split_name, ) if record is None: continue records.append(record) made += 1 if made < target_count: print( f"[WARN] kind={kind}: requested {target_count}, made {made}. " f"This usually means many missing-info variants leaked answers." ) self.rng.shuffle(records) return records def _make_variant( self, sample: dict[str, Any], kind: str, split_name: str, ) -> Optional[dict[str, Any]]: sample_id = self._scalar(sample.get("id", "")) paragraphs = self._records_from_nested(sample.get("paragraphs", [])) subquestions = self._records_from_nested(sample.get("question_decomposition", [])) question = self._scalar(sample.get("question", "")) answer = self._scalar(sample.get("answer", "")) answer_aliases = [self._scalar(x) for x in self._as_list(sample.get("answer_aliases", []))] answerable = bool(sample.get("answerable", True)) if not paragraphs or not subquestions or not question: return None paragraph_by_idx = self._paragraph_by_idx(paragraphs) support_idxs = self._support_indices(subquestions) support_paragraphs = [ paragraph_by_idx[idx] for idx in support_idxs if idx in paragraph_by_idx ] distractor_paragraphs = [ p for p in paragraphs if int(p["idx"]) not in support_idxs ] if not support_paragraphs: return None hide_titles = self.rng.random() < self.hide_title_prob removed_support_idxs: set[int] = set() if kind == "easy": kept = support_paragraphs[:] self.rng.shuffle(kept) context_style = "clean" elif kind == "normal": kept = paragraphs[:] context_style = "clean" elif kind == "messy": kept = self._sample_support_plus_distractors( support=support_paragraphs, distractors=distractor_paragraphs, max_distractors=self.max_distractors, ) self.rng.shuffle(kept) context_style = "clean" elif kind == "messy_essay": kept = self._sample_support_plus_distractors( support=support_paragraphs, distractors=distractor_paragraphs, max_distractors=self.max_distractors, ) self.rng.shuffle(kept) context_style = "essay" hide_titles = self.rng.random() < 0.50 elif kind == "missing_one": if len(support_paragraphs) < 2: return None removed = self.rng.choice(support_paragraphs) removed_idx = int(removed["idx"]) removed_support_idxs.add(removed_idx) kept_support = [ p for p in support_paragraphs if int(p["idx"]) != removed_idx ] kept = self._sample_support_plus_distractors( support=kept_support, distractors=distractor_paragraphs, max_distractors=min(self.max_distractors, 6), ) self.rng.shuffle(kept) context_style = "clean" if self._missing_answer_leaks( subquestions=subquestions, kept_paragraphs=kept, kept_support_idxs={int(p["idx"]) for p in kept}, final_answer=answer, answer_aliases=answer_aliases, ): return None elif kind == "missing_all": if len(distractor_paragraphs) < 2: return None kept = self.rng.sample( distractor_paragraphs, k=min(len(distractor_paragraphs), 6), ) self.rng.shuffle(kept) removed_support_idxs = set(support_idxs) context_style = "clean" if self._missing_answer_leaks( subquestions=subquestions, kept_paragraphs=kept, kept_support_idxs={int(p["idx"]) for p in kept}, final_answer=answer, answer_aliases=answer_aliases, ): return None else: raise ValueError(f"Unknown sample kind: {kind}") kept_idxs = {int(p["idx"]) for p in kept} user_prompt = self._make_user_prompt( question=question, paragraphs=kept, context_style=context_style, hide_titles=hide_titles, ) assistant_content = self._make_assistant_response( answer=answer, answerable=answerable, subquestions=subquestions, paragraph_by_idx=paragraph_by_idx, kept_idxs=kept_idxs, ) return { "messages": [ { "role": "system", "content": self.prompt, }, { "role": "user", "content": user_prompt, }, { "role": "assistant", "content": assistant_content, }, ], "meta": { "source": self.hf_dataset, "split": split_name, "sample_id": sample_id, "kind": kind, "question": question, "answer": answer, "support_idxs": sorted(list(support_idxs)), "kept_idxs": sorted(list(kept_idxs)), "removed_support_idxs": sorted(list(removed_support_idxs)), }, } # ------------------------------------------------------------ # Prompt / response formatting # ------------------------------------------------------------ def _make_user_prompt( self, question: str, paragraphs: list[dict[str, Any]], context_style: str = "clean", hide_titles: bool = False, ) -> str: documents_text = self._format_documents( paragraphs=paragraphs, style=context_style, hide_titles=hide_titles, ) return self._clean_text(f""" Documents: {documents_text} Question: {question} Instructions: Use only the information inside the tags. If a required fact is missing from the documents, answer exactly: Not enough information. Keep the reasoning concise and grounded in the documents. """) def _make_assistant_response( self, answer: str, answerable: bool, subquestions: list[dict[str, Any]], paragraph_by_idx: dict[int, dict[str, Any]], kept_idxs: set[int], ) -> str: lines: list[str] = [] missing_any = not answerable lines.append("I will answer using only the provided documents.") lines.append("") lines.append("Decomposition:") for i, step in enumerate(subquestions, start=1): step_question = self._scalar(step.get("question", "")) step_answer = self._scalar(step.get("answer", "")) support_idx = self._safe_int(step.get("paragraph_support_idx", None)) lines.append(f"{i}. {step_question}") if support_idx is not None and support_idx in kept_idxs: paragraph = paragraph_by_idx.get(support_idx) title = ( paragraph.get("title", f"Document {support_idx}") if paragraph is not None else f"Document {support_idx}" ) lines.append( f' From document id="{support_idx}" titled "{title}", ' f"the answer is {step_answer}." ) else: missing_any = True lines.append( " The required evidence is not found in the provided documents." ) if missing_any: final = "Not enough information." else: lines.append("") lines.append( f"From these evidence steps, the final answer is {answer}." ) final = answer reasoning = "\n".join(lines) return self._clean_text(f""" {reasoning} Final Answer: {final} """) def _format_documents( self, paragraphs: list[dict[str, Any]], style: str = "clean", hide_titles: bool = False, ) -> str: parts: list[str] = [] for p in paragraphs: idx = int(p["idx"]) raw_title = self._scalar(p.get("title", f"Document {idx}")) title = f"Document {idx}" if hide_titles else raw_title text = self._scalar(p.get("paragraph_text", "")) if style == "clean": body = text elif style == "essay": body = f"Source excerpt: {text}" else: raise ValueError(f"Unknown context style: {style}") parts.append( f'\n' f'{body}\n' f'' ) return "\n\n".join(parts) # ------------------------------------------------------------ # MuSiQue helpers # ------------------------------------------------------------ def _support_indices(self, subquestions: list[dict[str, Any]]) -> set[int]: out: set[int] = set() for step in subquestions: idx = self._safe_int(step.get("paragraph_support_idx", None)) if idx is not None: out.add(idx) return out def _paragraph_by_idx( self, paragraphs: list[dict[str, Any]], ) -> dict[int, dict[str, Any]]: out: dict[int, dict[str, Any]] = {} for p in paragraphs: idx = self._safe_int(p.get("idx", None)) if idx is not None: out[idx] = p return out def _sample_support_plus_distractors( self, support: list[dict[str, Any]], distractors: list[dict[str, Any]], max_distractors: int, ) -> list[dict[str, Any]]: kept = support[:] if distractors: k = min(len(distractors), max_distractors) kept += self.rng.sample(distractors, k=k) return kept # ------------------------------------------------------------ # Leak checks for missing-information samples # ------------------------------------------------------------ def _missing_answer_leaks( self, subquestions: list[dict[str, Any]], kept_paragraphs: list[dict[str, Any]], kept_support_idxs: set[int], final_answer: str, answer_aliases: list[str], ) -> bool: """ Prevent poisoned missing-info samples. If we remove a support document but the final answer or missing intermediate answer still appears in the remaining context, then "Not enough information" may be wrong. """ context = self._context_for_leak_check(kept_paragraphs) final_candidates = [final_answer] + answer_aliases if self._any_candidate_leaks(final_candidates, context): return True for step in subquestions: support_idx = self._safe_int(step.get("paragraph_support_idx", None)) if support_idx is not None and support_idx not in kept_support_idxs: step_answer = self._scalar(step.get("answer", "")) if self._any_candidate_leaks([step_answer], context): return True return False def _context_for_leak_check(self, paragraphs: list[dict[str, Any]]) -> str: chunks: list[str] = [] for p in paragraphs: chunks.append(self._scalar(p.get("title", ""))) chunks.append(self._scalar(p.get("paragraph_text", ""))) return self._normalize_for_match(" ".join(chunks)) def _any_candidate_leaks( self, candidates: list[str], normalized_context: str, ) -> bool: for candidate in candidates: candidate = self._scalar(candidate) if not candidate: continue norm = self._normalize_for_match(candidate) # Skip tiny noisy strings. # Example: "a", "an", "UK", "3" # But keep 4-digit years like 1960. if len(norm) < 3: continue if norm.isdigit() and len(norm) < 4: continue if norm in normalized_context: return True return False def _normalize_for_match(self, text: str) -> str: text = str(text).lower() text = re.sub(r"\s+", " ", text) return text.strip() # ------------------------------------------------------------ # Generic normalization helpers # ------------------------------------------------------------ def _records_from_nested(self, value: Any) -> list[dict[str, Any]]: """ Handles both common HF nested formats: 1. list-of-dicts: [ {"idx": 0, "title": "..."}, {"idx": 1, "title": "..."} ] 2. dict-of-lists: { "idx": [0, 1], "title": ["...", "..."] } """ if value is None: return [] # Sometimes scalar nested value is wrapped once. if isinstance(value, list) and len(value) == 1 and isinstance(value[0], list): value = value[0] # list-of-dicts if isinstance(value, list): return [x for x in value if isinstance(x, dict)] # dict-of-lists if isinstance(value, dict): keys = list(value.keys()) if not keys: return [] max_len = 0 for k in keys: v = value[k] if isinstance(v, (list, tuple)): max_len = max(max_len, len(v)) else: max_len = max(max_len, 1) records: list[dict[str, Any]] = [] for i in range(max_len): rec = {} for k in keys: v = value[k] if isinstance(v, (list, tuple)): rec[k] = v[i] if i < len(v) else None else: rec[k] = v records.append(rec) return records return [] def _as_list(self, value: Any) -> list[Any]: if value is None: return [] if isinstance(value, list): return value if isinstance(value, tuple): return list(value) return [value] def _scalar(self, value: Any) -> str: if value is None: return "" if isinstance(value, list) and len(value) == 1: return self._scalar(value[0]) return str(value) def _safe_int(self, value: Any) -> Optional[int]: try: if value is None: return None if isinstance(value, list) and len(value) == 1: value = value[0] return int(value) except Exception: return None def _escape_attr(self, text: str) -> str: text = str(text) return ( text.replace("&", "&") .replace('"', """) .replace("<", "<") .replace(">", ">") ) def _clean_text(self, text: str) -> str: text = text.strip() text = re.sub(r"\n{3,}", "\n\n", text) return text # ------------------------------------------------------------ # Config / IO # ------------------------------------------------------------ def _load_system_prompt(self) -> str: if ProjectConfigs is not None: try: prompt = ProjectConfigs.get(("PROMPTS", "LLM")) if isinstance(prompt, str) and prompt.strip(): return prompt.strip() except Exception: pass return DEFAULT_SYSTEM_PROMPT def _normalize_splits(self, splits: dict[str, float]) -> dict[str, float]: total = sum(splits.values()) if total <= 0: raise ValueError("samples_split must sum to > 0") return {k: v / total for k, v in splits.items()} def _target_counts( self, target_samples: int, splits: dict[str, float], ) -> dict[str, int]: splits = self._normalize_splits(splits) counts = { kind: int(target_samples * ratio) for kind, ratio in splits.items() } diff = target_samples - sum(counts.values()) if diff != 0: largest_kind = max(splits, key=splits.get) counts[largest_kind] += diff return counts def _write_jsonl( self, path: Path, records: list[dict[str, Any]], ) -> None: with path.open("w", encoding="utf-8") as f: for record in records: f.write(json.dumps(record, ensure_ascii=False) + "\n")