yannsay's picture
Deploy clean to build-small-hackathon org (32B Qwen, step-3 table)
aa1d0aa verified
Raw
History Blame Contribute Delete
17.2 kB
#!/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_<slug>.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 <dataset>.xlsx --list-sheets
# Phase 2 — parse and write cache (structural rows excluded by default)
python3 scripts/read_kobo.py <dataset>.xlsx --slug <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_<slug>.json --summary
python3 scripts/read_kobo.py --cache kobo_<slug>.json --names q85,q87,q88
python3 scripts/read_kobo.py --cache kobo_<slug>.json --group wash
python3 scripts/read_kobo.py --cache kobo_<slug>.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_<slug>.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_<slug>.json beside the xlsx (or -o path).
JSON shape (Phase 2):
{
"source_file": "<filename>",
"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 <name> 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_<slug>.json beside the xlsx)")
# Phase 3 query flags
ap.add_argument("--cache", default=None,
help="Phase 3: path to an existing kobo_<slug>.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()