"""Helper utility functions for text processing and data manipulation.""" from __future__ import annotations import json import re import textwrap from pathlib import Path from typing import Any, Dict, List, Tuple try: from PIL import Image as PILImage except Exception: # pragma: no cover PILImage = None from config import BASE_DIR from models import StateInfo def _slugify(text: str) -> str: """Convert text to a URL-friendly slug.""" return re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_") def _label_from_slug(slug: str) -> str: """Convert a slug back to a human-readable label.""" return " ".join(part.capitalize() for part in slug.replace("-", "_").split("_")) def _first_heading(markdown: str) -> str: """Extract the first heading from markdown text.""" for line in markdown.splitlines(): line = line.strip() if line.startswith("#"): return line.lstrip("#").strip() return "Untitled Report" def _extract_play_headings(markdown: str) -> List[str]: """Extract all play headings from markdown text.""" matches = re.findall(r"^###\s*(Play\s+\d+:[^\n]+)", markdown, flags=re.MULTILINE) return [m.strip() for m in matches] def load_state_catalog(catalog_path: Path) -> List[StateInfo]: """Load state information from catalog JSON file.""" if not catalog_path.exists(): return [] payload = json.loads(catalog_path.read_text(encoding="utf-8")) states = [] for item in payload.get("states", []): states.append( StateInfo( slug=item["slug"], label=item.get("label", _label_from_slug(item["slug"])), source=item.get("source", ""), title=item.get("title", "Untitled Report"), plays=item.get("plays", []), ) ) return states def _state_slug_from_img_url(img_url: str) -> str: """Extract state slug from image URL path.""" parts = Path(img_url).parts if len(parts) >= 2 and parts[0] == "charts": return _slugify(parts[1]) return "" def load_question_bank(path: Path) -> Tuple[List[Dict[str, Any]], str | None]: """Load question bank from JSON file.""" if not path.exists(): return [], f"Question bank not found: {path}" try: payload = json.loads(path.read_text(encoding="utf-8")) except Exception as exc: return [], f"Failed reading question bank: {exc}" if not isinstance(payload, list): return [], "Question bank must be a JSON list." entries: List[Dict[str, Any]] = [] for idx, item in enumerate(payload, start=1): if not isinstance(item, dict): continue question = str(item.get("question", "")).strip() img_url = str(item.get("img_url", "")).strip() if not question or not img_url: continue entries.append( { "qid": idx, "question": question, "img_url": img_url, "img_path": str(BASE_DIR / img_url), "state_slug": _state_slug_from_img_url(img_url), } ) if not entries: return [], "No valid question entries were found in question bank." return entries, None def _clip(text: str, max_chars: int = 900) -> str: """Clip text to maximum character length.""" text = " ".join(text.split()) if len(text) <= max_chars: return text return text[: max_chars - 3] + "..." def _chunk_section_or_preview(text: str) -> str: """Extract section title or preview text from a chunk.""" for line in text.splitlines(): stripped = line.strip() if not stripped: continue heading_match = re.match(r"^#{1,6}\s+(.+)$", stripped) if heading_match: return _clip(heading_match.group(1), max_chars=140) break collapsed = " ".join(text.split()) if not collapsed: return "No section title available" sentence_match = re.search(r"(.+?[.!?])(?:\s|$)", collapsed) if sentence_match: return _clip(sentence_match.group(1), max_chars=180) return _clip(collapsed, max_chars=180) def _normalize_answer_for_chart_matching(assistant_text: str) -> str: """Normalize assistant text for chart matching.""" lines = [] for line in assistant_text.splitlines(): if line.strip().lower().startswith("follow-up question:"): continue lines.append(line) cleaned = " ".join(lines) cleaned = re.sub(r"\[(?:S|WEB)\d+\]", " ", cleaned) return " ".join(cleaned.split()) def _wrap_caption(text: str, width: int = 64) -> str: """Wrap caption text to specified width.""" return textwrap.fill(" ".join(text.split()), width=width, break_long_words=False, break_on_hyphens=False) def _question_candidates_for_states( state_labels: List[str], question_bank: List[Dict[str, Any]], states_by_label: Dict[str, StateInfo], ) -> List[Dict[str, Any]]: """Filter question candidates based on selected states.""" if not question_bank: return [] state_slugs = {states_by_label[label].slug for label in state_labels if label in states_by_label} if not state_slugs: return question_bank filtered = [entry for entry in question_bank if entry.get("state_slug") in state_slugs] return filtered if filtered else question_bank def _parse_related_question_numbers(raw_text: str, candidate_count: int, max_items: int) -> List[int]: """Parse question numbers from LLM JSON response.""" parsed: Dict[str, Any] = {} try: parsed = json.loads(raw_text) except Exception: match = re.search(r"\{[\s\S]*\}", raw_text) if match: try: parsed = json.loads(match.group(0)) except Exception: parsed = {} numbers = parsed.get("question_numbers", []) if isinstance(parsed, dict) else [] if not isinstance(numbers, list): numbers = [] selected: List[int] = [] for value in numbers: if not isinstance(value, int): continue if value < 1 or value > candidate_count or value in selected: continue selected.append(value) if len(selected) >= max_items: break return selected def _keyword_fallback_questions( assistant_text: str, candidates: List[Dict[str, Any]], max_items: int, ) -> List[Dict[str, Any]]: """Find related questions using keyword matching as fallback.""" query_terms = set(re.findall(r"[a-z]{4,}", assistant_text.lower())) scored: List[Tuple[int, int]] = [] for idx, item in enumerate(candidates): question_terms = set(re.findall(r"[a-z]{4,}", str(item.get("question", "")).lower())) score = len(query_terms & question_terms) if score > 0: scored.append((score, idx)) scored.sort(reverse=True) return [candidates[idx] for _, idx in scored[:max_items]] def extract_play_section(state: StateInfo, play_title: str) -> str: """Extract a specific play section from a state's source document.""" if not state.source: return "" path = BASE_DIR / state.source if not path.exists(): return "" text = path.read_text(encoding="utf-8") pattern = rf"^###\s*{re.escape(play_title)}\s*$" start_match = re.search(pattern, text, flags=re.MULTILINE) if not start_match: play_num_match = re.search(r"Play\s+(\d+)", play_title) if play_num_match: play_num = play_num_match.group(1) fallback_pattern = rf"^###\s*Play\s+{play_num}:[^\n]+$" start_match = re.search(fallback_pattern, text, flags=re.MULTILINE) if not start_match: return "" remainder = text[start_match.end() :] next_play = re.search(r"^###\s*Play\s+\d+:[^\n]+$", remainder, flags=re.MULTILINE) end_idx = start_match.end() + next_play.start() if next_play else len(text) section = text[start_match.start() : end_idx].strip() return section def _state_scope_labels(state_slugs: List[str], states_by_slug: Dict[str, StateInfo]) -> str: """Convert state slugs to comma-separated labels.""" labels = [states_by_slug[slug].label for slug in state_slugs if slug in states_by_slug] return ", ".join(labels) def related_question_gallery_items( assistant_text: str, selected_questions: List[Dict[str, Any]], max_items: int = 10, ) -> Tuple[List[Tuple[Any, str]], str]: """Generate gallery items for related questions with charts.""" gallery: List[Tuple[Any, str]] = [] debug_qids: List[int] = [] debug_images: List[str] = [] seen_paths = set() for item in selected_questions: img_path = str(item.get("img_path", "")) if not img_path or img_path in seen_paths: continue if not Path(img_path).exists(): continue seen_paths.add(img_path) caption = _wrap_caption(_clip(str(item.get("question", "")), max_chars=240), width=64) if PILImage is not None: try: with PILImage.open(img_path) as img: gallery.append((img.copy(), caption)) except Exception: gallery.append((img_path, caption)) else: gallery.append((img_path, caption)) qid = item.get("qid") if isinstance(qid, int): debug_qids.append(qid) debug_images.append(Path(img_path).name) if debug_qids: debug_line = ( f"`Chart debug (latest response)` qids={debug_qids} " f"| images={debug_images} | shown={len(gallery)}" ) else: debug_line = "`Chart debug (latest response)` qids=[] | images=[] | shown=0" return gallery, debug_line