| from dotenv import load_dotenv |
| load_dotenv() |
|
|
| from pathlib import Path |
|
|
| import gradio as gr |
|
|
| from adapters.modal_chat_model import ModalChatModel |
| from core.bind import ( |
| list_kobo_sheets, |
| parse_kobo, |
| kobo_summary, |
| run_bind_step, |
| suggest_sheets, |
| ) |
| from core.route import resolve_route, step1_trace_line |
| from core.schemas import BindResponse |
| from core.select import select_step |
| from core.session import load_state, new_session, write_state |
| from core.spec import assemble_spec_yaml, render_spec_md |
| from ui.components import ( |
| PATH_MESSAGE, |
| make_lens_group, |
| make_path_radio, |
| make_sector_group, |
| make_sector_group_no_catalog, |
| make_type_group, |
| ) |
|
|
| _DEMO_XLSX = Path(__file__).parent / "demo_data" / "vasyr_2023.xlsx" |
|
|
|
|
| def _trace_md(trace: list[str]) -> str: |
| """Join trace beats with Markdown hard breaks so each renders on its own line. |
| |
| Gradio's Markdown collapses a single '\\n' into a space; two trailing spaces |
| before the newline force a hard break. |
| """ |
| return " \n".join(trace) |
|
|
|
|
| def run_pipeline( |
| path_value, question_v, sectors_v, sectors_no_cat_v, lens_v, type_v, session_id, |
| kobo_file_path, demo_mode, survey_sheet, choices_sheet, |
| ): |
| """Page-2 generator: stream route -> select -> mapping, then advance to page 3. |
| |
| Yields a 7-tuple: (page1_update, page2_update, page3_update, trace_md, spec_md, session_id, spec_download) |
| """ |
| sectors_v = (sectors_v or []) + (sectors_no_cat_v or []) |
| if not sectors_v and path_value != PATH_MESSAGE: |
| yield (gr.update(), gr.update(), gr.update(), "β Select at least one sector.", gr.update(), session_id, gr.update()) |
| return |
|
|
| trace: list[str] = [] |
|
|
| |
| trace.append("β Routingβ¦") |
| yield ( |
| gr.update(visible=False), gr.update(visible=True), gr.update(visible=False), |
| _trace_md(trace), gr.update(), session_id, gr.update(), |
| ) |
|
|
| |
| input_mode = "message" if path_value == PATH_MESSAGE else "chips" |
| model = ModalChatModel() if input_mode == "message" else None |
| try: |
| res = resolve_route( |
| input_mode, question_v or "", |
| sectors_v or [], lens_v or [], type_v or [], model, |
| ) |
| except (ValueError, Exception) as exc: |
| trace[-1] = f"β Routingβ¦ β {exc}" |
| yield (gr.update(), gr.update(), gr.update(), _trace_md(trace), gr.update(), session_id, gr.update()) |
| return |
|
|
| in_scope = res.in_scope |
| session_id = new_session(input_mode, question_v or "") |
| write_state(session_id, { |
| "current_step": 1, |
| "route": res.route.model_dump(), |
| "route_scope": {"in_scope": in_scope, "out_of_scope": res.out_of_scope}, |
| "trace": [step1_trace_line(res)], |
| }) |
| trace[-1] = f"β Routingβ¦ β {', '.join(in_scope) or '(none in scope)'}" |
| yield (gr.update(), gr.update(), gr.update(), _trace_md(trace), gr.update(), session_id, gr.update()) |
|
|
| if not in_scope: |
| trace.append("β No in-scope sectors β nothing to select.") |
| yield (gr.update(), gr.update(), gr.update(), _trace_md(trace), gr.update(), session_id, gr.update()) |
| return |
|
|
| |
| trace.append("β Selecting indicatorsβ¦") |
| yield (gr.update(), gr.update(), gr.update(), _trace_md(trace), gr.update(), session_id, gr.update()) |
| try: |
| result = select_step(in_scope) |
| except Exception as exc: |
| trace[-1] = f"β Selecting indicatorsβ¦ β {exc}" |
| yield (gr.update(), gr.update(), gr.update(), _trace_md(trace), gr.update(), session_id, gr.update()) |
| return |
|
|
| trace[-1] = f"β Selecting indicatorsβ¦ β {len(result.selected_ids)} found" |
| write_state(session_id, { |
| "current_step": 2, |
| "selected_ids": result.selected_ids, |
| "indicator_defs": result.indicator_defs, |
| }) |
| yield (gr.update(), gr.update(), gr.update(), _trace_md(trace), gr.update(), session_id, gr.update()) |
|
|
| |
| if demo_mode: |
| xlsx_path = _DEMO_XLSX |
| elif kobo_file_path is None: |
| xlsx_path = None |
| elif hasattr(kobo_file_path, "path"): |
| xlsx_path = Path(kobo_file_path.path) |
| elif isinstance(kobo_file_path, dict): |
| xlsx_path = Path(kobo_file_path.get("path") or kobo_file_path.get("name", "")) |
| else: |
| xlsx_path = Path(str(kobo_file_path)) |
| if xlsx_path is None: |
| trace.append("β No XLSForm uploaded β bind loop skipped.") |
| stub_md = "## Analysis spec\n\n*Upload a Kobo XLSForm and re-run to generate the spec.*" |
| yield ( |
| gr.update(visible=False), gr.update(visible=False), gr.update(visible=True), |
| _trace_md(trace), stub_md, session_id, gr.update(), |
| ) |
| return |
|
|
| slug = xlsx_path.stem.lower().replace(" ", "_") |
| session_dir = Path("sessions") / session_id |
| trace.append("β Parsing instrumentβ¦") |
| yield (gr.update(), gr.update(), gr.update(), _trace_md(trace), gr.update(), session_id, gr.update()) |
|
|
| try: |
| cache_path = parse_kobo( |
| xlsx_path, slug, session_dir / f"kobo_{slug}.json", |
| survey_sheet=survey_sheet, choices_sheet=choices_sheet, |
| ) |
| except Exception as exc: |
| trace[-1] = f"β Parsing instrumentβ¦ β {exc}" |
| yield (gr.update(), gr.update(), gr.update(), _trace_md(trace), gr.update(), session_id, gr.update()) |
| return |
|
|
| trace[-1] = f"β Parsing instrumentβ¦ β {slug}" |
| summary_map = kobo_summary(cache_path) |
|
|
| |
| trace.append("Step 3 β Mapping indicators to the dataset:") |
| yield (gr.update(), gr.update(), gr.update(), _trace_md(trace), gr.update(), session_id, gr.update()) |
|
|
| bind_results: dict = {} |
| model = ModalChatModel() |
| verdict_labels = { |
| "MEASURABLE": "Measurable", |
| "PROXY": "Proxy", |
| "NOT_MEASURABLE": "Not measurable", |
| } |
| for ind_id in result.selected_ids: |
| ind_def = result.indicator_defs.get(ind_id, {}) |
| try: |
| bind_res = run_bind_step(ind_id, ind_def, cache_path, summary_map, model) |
| except Exception as exc: |
| bind_res = BindResponse( |
| indicator_id=ind_id, variables=[], measurable="NOT_MEASURABLE", |
| reasons=f"Bind error: {exc}", result_ids=[], |
| ) |
| bind_results[ind_id] = bind_res.model_dump() |
| write_state(session_id, {"bind_results": {ind_id: bind_res.model_dump()}}) |
|
|
| vars_str = ", ".join(bind_res.variables) or "(no matching variable)" |
| verdict = verdict_labels[bind_res.measurable] |
| trace.append(f" {ind_def.get('label', ind_id)} β {vars_str} β¦ {verdict}") |
| yield (gr.update(), gr.update(), gr.update(), _trace_md(trace), gr.update(), session_id, gr.update()) |
|
|
| |
| trace.append("β Assembling analysis specβ¦") |
| yield (gr.update(), gr.update(), gr.update(), _trace_md(trace), gr.update(), session_id, gr.update()) |
|
|
| try: |
| state = load_state(session_id) |
| spec_yaml = assemble_spec_yaml( |
| session_dir, slug, state["route"], bind_results, result.indicator_defs, |
| ) |
| spec_md_path = render_spec_md(spec_yaml, session_dir) |
| write_state(session_id, { |
| "current_step": 3, |
| "artifacts": {"spec_yaml": str(spec_yaml), "spec_md": str(spec_md_path)}, |
| }) |
| trace[-1] = "β Assembling analysis specβ¦ β" |
| spec_md_content = spec_md_path.read_text() |
| except Exception as exc: |
| trace[-1] = f"β Assembling analysis specβ¦ β {exc}" |
| yield (gr.update(), gr.update(), gr.update(), _trace_md(trace), gr.update(), session_id, gr.update()) |
| return |
|
|
| yield ( |
| gr.update(visible=False), gr.update(visible=False), gr.update(visible=True), |
| _trace_md(trace), spec_md_content, session_id, |
| gr.update(value=str(spec_md_path), visible=True), |
| ) |
|
|
|
|
| _SPEC_TABLE_CSS = """ |
| <style> |
| #spec-viewer table { |
| table-layout: fixed; |
| width: 100%; |
| font-size: 0.85em; |
| line-height: 1.4; |
| } |
| /* Col 1 Indicator: 9%, Col 2 Definition: 14%, Col 3 Measurable: 13%, |
| Col 4 Reasons: 40%, Col 5 Variables: 12%, Col 6 Result ID: 12% */ |
| #spec-viewer table th:nth-child(1), |
| #spec-viewer table td:nth-child(1) { width: 9%; word-break: break-word; } |
| #spec-viewer table th:nth-child(2), |
| #spec-viewer table td:nth-child(2) { width: 14%; word-break: break-word; } |
| #spec-viewer table th:nth-child(3), |
| #spec-viewer table td:nth-child(3) { width: 13%; } |
| #spec-viewer table th:nth-child(4), |
| #spec-viewer table td:nth-child(4) { width: 40%; word-break: break-word; } |
| #spec-viewer table th:nth-child(5), |
| #spec-viewer table td:nth-child(5) { width: 12%; word-break: break-word; } |
| #spec-viewer table th:nth-child(6), |
| #spec-viewer table td:nth-child(6) { width: 12%; word-break: break-word; } |
| </style> |
| """ |
|
|
|
|
| def build_demo() -> gr.Blocks: |
| with gr.Blocks(title="Analyste") as demo: |
| gr.HTML(_SPEC_TABLE_CSS) |
| session_id = gr.State("") |
|
|
| |
| with gr.Group(visible=True) as page1: |
| gr.Markdown( |
| "## Analyste\n" |
| "*It reads a Kobo survey and writes the spec for its own analysis β " |
| "telling you what it can and can't claim.*" |
| ) |
| path = make_path_radio() |
| with gr.Group(visible=True) as message_group: |
| question = gr.Textbox( |
| label="Your question", |
| placeholder="e.g. What does the food security data tell us about Aleppo?", |
| lines=2, |
| ) |
| with gr.Group(visible=False) as chips_group: |
| sectors = make_sector_group() |
| sectors_no_cat = make_sector_group_no_catalog() |
| lens = make_lens_group() |
| type_ = make_type_group() |
|
|
| gr.Markdown("---") |
| gr.Markdown("**Choose a dataset**") |
| with gr.Row(equal_height=True): |
| with gr.Column(): |
| kobo_file = gr.File( |
| label="Upload Kobo XLSForm (.xlsx)", |
| file_types=[".xlsx"], |
| height=140, |
| ) |
| with gr.Column(): |
| demo_btn = gr.Button( |
| "π Use demo dataset (VASyR 2023)", |
| size="lg", |
| variant="secondary", |
| ) |
| demo_note = gr.Markdown(visible=False) |
| with gr.Group(visible=False) as sheet_group: |
| with gr.Row(): |
| survey_sheet_dd = gr.Dropdown(label="Survey sheet", interactive=True) |
| choices_sheet_dd = gr.Dropdown(label="Choices sheet", interactive=True) |
|
|
| run_btn = gr.Button("Run", variant="primary") |
|
|
| demo_mode = gr.State(False) |
|
|
| |
| with gr.Group(visible=False) as page2: |
| gr.Markdown("## 2. Work in progress") |
| trace_md = gr.Markdown("") |
|
|
| |
| with gr.Group(visible=False) as page3: |
| gr.Markdown("## 3. Analysis spec") |
| spec_md = gr.Markdown("", elem_id="spec-viewer") |
| spec_download = gr.File(label="Download spec (.md)", visible=False) |
| gr.Markdown("---") |
| gr.Button( |
| "Validate β start analysis", |
| interactive=False, |
| variant="secondary", |
| ) |
| gr.Markdown( |
| "### 4. Analysis β *not built in this demo*\n" |
| "*This demo stops at the certified analysis spec. Running the numbers " |
| "is the next stage (out of scope here).*", |
| ) |
|
|
| def _toggle(path_value): |
| is_msg = path_value == PATH_MESSAGE |
| return gr.update(visible=is_msg), gr.update(visible=not is_msg) |
|
|
| path.change(_toggle, inputs=path, outputs=[message_group, chips_group]) |
|
|
| def _detect_sheets(xlsx_path_val, is_demo: bool = False): |
| try: |
| if is_demo: |
| path_to_check = _DEMO_XLSX |
| elif hasattr(xlsx_path_val, "path"): |
| path_to_check = Path(xlsx_path_val.path) |
| elif isinstance(xlsx_path_val, dict): |
| path_to_check = Path(xlsx_path_val.get("path") or xlsx_path_val.get("name", "")) |
| else: |
| path_to_check = Path(str(xlsx_path_val)) |
| sheets = list_kobo_sheets(path_to_check) |
| survey, choices = suggest_sheets(sheets) |
| return ( |
| is_demo, |
| gr.update(value="β VASyR 2023 loaded" if is_demo else "", visible=is_demo), |
| gr.update(visible=True), |
| gr.update(choices=sheets, value=survey), |
| gr.update(choices=sheets, value=choices), |
| ) |
| except Exception: |
| return ( |
| is_demo, |
| gr.update(visible=False), |
| gr.update(visible=False), |
| gr.update(choices=[], value=None), |
| gr.update(choices=[], value=None), |
| ) |
|
|
| _sheet_outputs = [demo_mode, demo_note, sheet_group, survey_sheet_dd, choices_sheet_dd] |
|
|
| kobo_file.upload( |
| lambda f: _detect_sheets(f, is_demo=False), |
| inputs=[kobo_file], |
| outputs=_sheet_outputs, |
| ) |
| demo_btn.click( |
| lambda: _detect_sheets(None, is_demo=True), |
| outputs=_sheet_outputs, |
| ) |
|
|
| run_btn.click( |
| run_pipeline, |
| inputs=[path, question, sectors, sectors_no_cat, lens, type_, session_id, |
| kobo_file, demo_mode, survey_sheet_dd, choices_sheet_dd], |
| outputs=[page1, page2, page3, trace_md, spec_md, session_id, spec_download], |
| ) |
| return demo |
|
|
|
|
| demo = build_demo() |
|
|