#!/usr/bin/env python3 """ read_kobo.py — parse a Kobo/ODK XLSForm and write a normalised JSON cache. Sheet names and formats vary across deployments. The script therefore works in two phases: Phase 1 (--list-sheets): Read and print the workbook's sheet names as JSON. Pass the output to the LLM so it can identify which sheet is the survey, which is choices, and (optionally) which is a data/response sheet. Phase 2 (default): Read the sheets the LLM identified and write the cache. Pass --survey-sheet and --choices-sheet to override the defaults ('survey' / 'choices'). Phase 3 (query modes): Query an existing kobo_.json cache without re-parsing the xlsx. Use these in Step 3 to pull targeted slices instead of reading the whole cache into context. Usage: # Phase 1 — inspect sheets python3 scripts/read_kobo.py .xlsx --list-sheets # Phase 2 — parse and write cache (structural rows excluded by default) python3 scripts/read_kobo.py .xlsx --slug \\ [--survey-sheet "Survey"] [--choices-sheet "Choices"] \\ [--include-structural] # include begin_group, note, calculate, etc. # Phase 3 query modes (read from cache, not xlsx): python3 scripts/read_kobo.py --cache kobo_.json --summary python3 scripts/read_kobo.py --cache kobo_.json --names q85,q87,q88 python3 scripts/read_kobo.py --cache kobo_.json --group wash python3 scripts/read_kobo.py --cache kobo_.json --names q85 --with-choices CONTEXT DISCIPLINE (Step 3): The agent MUST use --summary first (cheap orientation), then --names or --group for the specific variables each indicator needs. It MUST NOT read the whole kobo_.json into context. The --summary output is ~200 bytes; --names for 2-3 variables is ~1-2 KB. Reading the whole file is ~100 KB — 50× too large. Phase 2 output: kobo_.json beside the xlsx (or -o path). JSON shape (Phase 2): { "source_file": "", "sheet_names": [...], "survey_sheet": "Survey", "choices_sheet": "Choices", "n_total": 133, # null if no data sheet "skipped_rows": 0, "survey": [ # substantive rows only (structural excluded by default) {"type": "select_one foo", "name": "var_name", "label": "Question text", "relevant": "...", "list_name": "foo"} # empty fields omitted ], "choices": { "list_name": [{"name": "opt", "label": "Option label"}, ...] } } """ import argparse import json import os import re import sys def _require_openpyxl(): """Import openpyxl lazily — only needed for Phase 1/2 (xlsx parsing).""" try: import openpyxl return openpyxl except ImportError: sys.exit("read_kobo.py requires openpyxl: pip3 install openpyxl --break-system-packages") # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _get_sheet(wb, name): """Return sheet by exact name (case-insensitive). Raises KeyError if absent.""" lower_map = {s.lower(): s for s in wb.sheetnames} key = name.lower() if key not in lower_map: available = ", ".join(f"'{s}'" for s in wb.sheetnames) sys.exit(f"read_kobo.py: sheet '{name}' not found. Available: {available}") return wb[lower_map[key]] def _header_map(ws): """Return {col_index: header_text} from the first non-empty row.""" for row in ws.iter_rows(min_row=1, max_row=5, values_only=True): if any(c is not None for c in row): return {i: (str(c).strip() if c is not None else "") for i, c in enumerate(row)} return {} def _find_col(headers, *candidates): """First header index matching any candidate (case-insensitive).""" lower = {v.lower(): k for k, v in headers.items()} for c in candidates: if c.lower() in lower: return lower[c.lower()] return None def _label_col(headers): """Detect the English label column: 'label', 'label::English*', or first 'label*'.""" for idx, h in headers.items(): if h.lower() == "label": return idx for idx, h in headers.items(): if re.match(r"label::english", h.lower()): return idx for idx, h in headers.items(): if h.lower().startswith("label"): return idx return None # --------------------------------------------------------------------------- # Phase 1 — list sheets # --------------------------------------------------------------------------- def list_sheets(xlsx_path): openpyxl = _require_openpyxl() wb = openpyxl.load_workbook(xlsx_path, read_only=True, data_only=True) result = { "source_file": os.path.basename(xlsx_path), "sheet_names": wb.sheetnames, "instruction": ( "Pass --survey-sheet and --choices-sheet to read_kobo.py, " "optionally --data-sheet for n_total. " "Omit --data-sheet if no response dataset is present." ) } print(json.dumps(result, indent=2)) # --------------------------------------------------------------------------- # Phase 2 — parse # --------------------------------------------------------------------------- def parse_kobo(xlsx_path, slug, survey_sheet_name, choices_sheet_name, data_sheet_name, include_structural=False): openpyxl = _require_openpyxl() wb = openpyxl.load_workbook(xlsx_path, read_only=True, data_only=True) sheet_names = wb.sheetnames # ---- survey sheet ---- survey_ws = _get_sheet(wb, survey_sheet_name) rows = list(survey_ws.iter_rows(values_only=True)) if not rows: sys.exit("read_kobo.py: survey sheet is empty") headers = _header_map(survey_ws) type_col = _find_col(headers, "type") name_col = _find_col(headers, "name") label_col = _label_col(headers) rel_col = _find_col(headers, "relevant") if type_col is None or name_col is None: sys.exit(f"read_kobo.py: survey sheet '{survey_sheet_name}' missing 'type' or 'name' column. " f"Columns found: {list(headers.values())}") STRUCTURAL = {"begin_group", "end_group", "begin_repeat", "end_repeat", "note", "calculate", "hidden", "start", "end", "deviceid", "simserial", "phonenumber", "username", "audit"} survey_rows = [] skipped = 0 for row in rows[1:]: try: type_val = str(row[type_col]).strip() if row[type_col] is not None else "" name_val = str(row[name_col]).strip() if row[name_col] is not None else "" if not type_val and not name_val: continue # blank row label_val = "" if label_col is not None and label_col < len(row) and row[label_col] is not None: label_val = str(row[label_col]).strip() relevant_val = "" if rel_col is not None and rel_col < len(row) and row[rel_col] is not None: relevant_val = str(row[rel_col]).strip() list_name = None m = re.match(r"select_(one|multiple)\s+(\S+)", type_val, re.IGNORECASE) if m: list_name = m.group(2) base_type = type_val.split()[0].lower() if type_val else "" is_structural = base_type in STRUCTURAL # Skip structural rows unless --include-structural requested (D12 slim) if is_structural and not include_structural: continue # Build a slim entry — omit empty fields (D12 context fix) entry: dict = {"type": type_val, "name": name_val} if label_val: entry["label"] = label_val if relevant_val: entry["relevant"] = relevant_val if list_name: entry["list_name"] = list_name # Keep is_structural only when including structural rows (for caller awareness) if include_structural: entry["is_structural"] = is_structural survey_rows.append(entry) except Exception: skipped += 1 # ---- choices sheet ---- choices = {} if choices_sheet_name: choices_ws = _get_sheet(wb, choices_sheet_name) ch_rows = list(choices_ws.iter_rows(values_only=True)) if ch_rows: ch_headers = _header_map(choices_ws) ln_col = _find_col(ch_headers, "list_name", "list name") cn_col = _find_col(ch_headers, "name") cl_col = _label_col(ch_headers) if ln_col is not None and cn_col is not None: for row in ch_rows[1:]: try: ln = str(row[ln_col]).strip() if row[ln_col] is not None else "" cn = str(row[cn_col]).strip() if row[cn_col] is not None else "" clv = "" if cl_col is not None and cl_col < len(row) and row[cl_col] is not None: clv = str(row[cl_col]).strip() if ln and cn: choices.setdefault(ln, []).append({"name": cn, "label": clv}) except Exception: pass # ---- data/response sheet — count rows (optional) ---- n_total = None if data_sheet_name: data_ws = _get_sheet(wb, data_sheet_name) data_rows = list(data_ws.iter_rows(values_only=True)) if len(data_rows) > 1: n_total = sum(1 for r in data_rows[1:] if any(c is not None for c in r)) else: n_total = 0 result = { "source_file": os.path.basename(xlsx_path), "sheet_names": sheet_names, "survey_sheet": survey_sheet_name, "choices_sheet": choices_sheet_name, "n_total": n_total, "skipped_rows": skipped, "survey": survey_rows, "choices": choices, } if slug: result["slug"] = slug return result # --------------------------------------------------------------------------- # Phase 3 — query modes (read from existing cache JSON) # --------------------------------------------------------------------------- def load_cache(cache_path: str) -> dict: with open(cache_path, encoding="utf-8") as f: return json.load(f) def query_summary(cache: dict) -> dict: """Return a cheap orientation summary: module groups + question counts. This is the ONLY query that should run before knowing which variables to look up. """ survey = cache.get("survey", []) # Attempt to infer groups from name prefixes (e.g. q85 → group by leading alpha) # Use a simple heuristic: questions with same first word in name or label prefix groups: dict[str, list[str]] = {} current_group = "_root" for row in survey: name = row.get("name", "") # Detect group boundaries via original structural info if available if row.get("_group"): current_group = row["_group"] groups.setdefault(current_group, []).append(name) substantive = [r for r in survey if not r.get("is_structural", False)] return { "source_file": cache.get("source_file"), "n_total": cache.get("n_total"), "survey_question_count": len(substantive), "choice_list_count": len(cache.get("choices", {})), "all_question_names": [r["name"] for r in substantive], "question_labels": {r["name"]: (r.get("label") or r["name"]) for r in substantive}, "note": "Use --names q1,q2 or --group to retrieve specific questions with their choice lists.", } def query_names(cache: dict, names: list[str], with_choices: bool = True) -> dict: """Return specific survey entries (+ their choice lists if with_choices).""" name_set = set(names) survey = cache.get("survey", []) matched = [r for r in survey if r.get("name") in name_set] result: dict = {"questions": matched} if with_choices: choices_needed = set() for r in matched: if r.get("list_name"): choices_needed.add(r["list_name"]) choices = cache.get("choices", {}) result["choices"] = {k: choices[k] for k in choices_needed if k in choices} return result def query_group(cache: dict, group_name: str, with_choices: bool = True) -> dict: """Return all survey entries whose _group field matches group_name (case-insensitive).""" survey = cache.get("survey", []) g = group_name.lower() matched = [r for r in survey if r.get("_group", "").lower() == g or r.get("name", "").lower().startswith(g)] result: dict = {"group": group_name, "questions": matched} if with_choices: choices_needed = {r["list_name"] for r in matched if r.get("list_name")} choices = cache.get("choices", {}) result["choices"] = {k: choices[k] for k in choices_needed if k in choices} return result # --------------------------------------------------------------------------- # main # --------------------------------------------------------------------------- def main(): ap = argparse.ArgumentParser( description=( "Phase 1: --list-sheets. " "Phase 2: parse xlsx to cache. " "Phase 3: query cache with --cache + (--summary | --names | --group)." ) ) # Phase 2 positional (optional so Phase 3 works without it) ap.add_argument("xlsx", nargs="?", default=None, help="Path to the Kobo/ODK .xlsx file (Phase 1 & 2 only)") ap.add_argument("--list-sheets", action="store_true", help="Phase 1: print sheet names as JSON and exit") ap.add_argument("--slug", default=None, help="Short identifier (used in output filename)") ap.add_argument("--survey-sheet", default="survey", help="Name of the survey sheet (default: 'survey')") ap.add_argument("--choices-sheet", default="choices", help="Name of the choices sheet (default: 'choices')") ap.add_argument("--data-sheet", default=None, help="Name of the response/data sheet for n_total (omit if absent)") ap.add_argument("--include-structural", action="store_true", help="Include structural rows (begin_group, note, calculate, …) in cache") ap.add_argument("-o", "--out", default=None, help="Output JSON path (default: kobo_.json beside the xlsx)") # Phase 3 query flags ap.add_argument("--cache", default=None, help="Phase 3: path to an existing kobo_.json cache to query") ap.add_argument("--summary", action="store_true", help="Phase 3: return cheap orientation summary (question names + counts)") ap.add_argument("--names", default=None, help="Phase 3: comma-separated variable names to retrieve, e.g. q85,q87") ap.add_argument("--group", default=None, help="Phase 3: return all questions in this survey group/module") ap.add_argument("--no-choices", action="store_true", help="Phase 3: omit choice lists from --names / --group output") a = ap.parse_args() # ---- Phase 3: query mode ---- if a.cache: cache = load_cache(a.cache) with_choices = not a.no_choices if a.summary: result = query_summary(cache) elif a.names: names = [n.strip() for n in a.names.split(",") if n.strip()] result = query_names(cache, names, with_choices=with_choices) elif a.group: result = query_group(cache, a.group, with_choices=with_choices) else: ap.error("--cache requires one of --summary, --names, or --group") print(json.dumps(result, ensure_ascii=False, indent=2)) return # ---- Phase 1 ---- if a.xlsx is None: ap.error("xlsx path is required unless using --cache") if a.list_sheets: list_sheets(a.xlsx) return # ---- Phase 2 ---- slug = a.slug or os.path.splitext(os.path.basename(a.xlsx))[0] out_path = a.out or os.path.join( os.path.dirname(os.path.abspath(a.xlsx)), f"kobo_{slug}.json" ) data = parse_kobo( a.xlsx, slug, survey_sheet_name=a.survey_sheet, choices_sheet_name=a.choices_sheet, data_sheet_name=a.data_sheet, include_structural=a.include_structural, ) with open(out_path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) n_survey = len(data["survey"]) print(f"wrote {out_path}") print(f" n_total={data['n_total']} survey_questions={n_survey} " f"choice_lists={len(data['choices'])} skipped_rows={data['skipped_rows']}") if __name__ == "__main__": main()