from __future__ import annotations def test_suggest_sheets_exact_match(): from core.bind import suggest_sheets sheets = ["survey", "choices", "external_choices"] s, c = suggest_sheets(sheets) assert s == "survey" assert c == "choices" def test_suggest_sheets_case_insensitive_substring(): from core.bind import suggest_sheets sheets = ["Survey Sheet", "Choices List", "Settings"] s, c = suggest_sheets(sheets) assert s == "Survey Sheet" assert c == "Choices List" def test_suggest_sheets_partial_prefix(): from core.bind import suggest_sheets sheets = ["S_main", "C_main", "Other"] s, c = suggest_sheets(sheets) assert s == "S_main" assert c == "C_main" def test_list_kobo_sheets_calls_script(monkeypatch, tmp_path): from core import bind as bind_mod import core.runner as runner_mod calls = [] def fake_run(name, args): calls.append((name, args)) return ["survey", "choices"] monkeypatch.setattr(runner_mod, "run_skill_script", fake_run) xlsx = tmp_path / "test.xlsx" xlsx.touch() result = bind_mod.list_kobo_sheets(xlsx) assert result == ["survey", "choices"] assert calls[0][0] == "read_kobo.py" assert "--list-sheets" in calls[0][1] def test_list_kobo_sheets_handles_actual_script_format(monkeypatch, tmp_path): """read_kobo.py --list-sheets returns a dict with 'sheet_names' key, not 'sheets'.""" from core import bind as bind_mod import core.runner as runner_mod actual_response = { "source_file": "vasyr_2023.xlsx", "sheet_names": ["survey", "choices", "settings"], "instruction": "Pass --survey-sheet and --choices-sheet to read_kobo.py", } monkeypatch.setattr(runner_mod, "run_skill_script", lambda n, a: actual_response) xlsx = tmp_path / "vasyr.xlsx" xlsx.touch() result = bind_mod.list_kobo_sheets(xlsx) assert result == ["survey", "choices", "settings"] def test_parse_kobo_passes_sheet_args(monkeypatch, tmp_path): from core import bind as bind_mod import core.runner as runner_mod calls = [] monkeypatch.setattr(runner_mod, "run_skill_script_raw", lambda name, args: (calls.append((name, args)) or "wrote ok")) xlsx = tmp_path / "vasyr.xlsx" xlsx.touch() out = tmp_path / "kobo_vasyr.json" result = bind_mod.parse_kobo(xlsx, "vasyr", out, "Survey", "Choices") assert result == out assert calls[0][0] == "read_kobo.py" joined = " ".join(calls[0][1]) assert "--slug" in joined and "vasyr" in joined assert "--survey-sheet" in joined and "Survey" in joined assert "--choices-sheet" in joined and "Choices" in joined def test_kobo_summary_calls_script(monkeypatch, tmp_path): from core import bind as bind_mod import core.runner as runner_mod fake_summary = {"q1": "main_water_source", "q2": "food_cope_1"} calls = [] monkeypatch.setattr(runner_mod, "run_skill_script", lambda n, a: (calls.append((n, a)) or fake_summary)) cache = tmp_path / "kobo_test.json" result = bind_mod.kobo_summary(cache) assert result == fake_summary assert "--summary" in calls[0][1] assert str(cache) in calls[0][1] def test_kobo_names_passes_comma_joined(monkeypatch, tmp_path): from core import bind as bind_mod import core.runner as runner_mod fake_details = {"q1": {"label": "Water source", "type": "select_one"}} calls = [] monkeypatch.setattr(runner_mod, "run_skill_script", lambda n, a: (calls.append((n, a)) or fake_details)) cache = tmp_path / "kobo_test.json" result = bind_mod.kobo_names(cache, ["q1", "q2"]) assert result == fake_details joined = " ".join(calls[0][1]) assert "q1,q2" in joined assert "--with-choices" not in joined # choices are on by default; flag does not exist assert "--no-choices" not in joined # we want choices, so we must NOT pass --no-choices def test_run_bind_step_makes_two_llm_calls(monkeypatch, tmp_path): from core import bind as bind_mod from core.schemas import BindResponse, PickVarsResponse from core.ports import ChatModel pick_response = PickVarsResponse(candidate_variables=["cope_q1"]) bind_response = BindResponse( indicator_id="rcsi", variables=["cope_q1"], measurable="PROXY", reasons="Proves: ordinal coping frequency. Cannot prove: full rCSI score without recall period.", result_ids=["rcsi_proxy"], ) call_log = [] class FakeModel(ChatModel): def structured(self, messages, schema): call_log.append(schema.__name__) return pick_response if schema.__name__ == "PickVarsResponse" else bind_response def complete(self, messages): return "" fake_details = {"cope_q1": {"label": "How often coping?", "type": "integer"}} monkeypatch.setattr(bind_mod, "kobo_names", lambda cache, names: fake_details) indicator_def = { "label": "Reduced Coping Strategies Index", "definition": "Measures food insecurity via coping behaviour frequency.", "common_implementation_errors": "Must use 7-day recall.", "ki_assessment_note": "KI cannot self-report household behaviour.", } result = bind_mod.run_bind_step( "rcsi", indicator_def, tmp_path / "kobo.json", {"cope_q1": "coping strategy frequency"}, FakeModel(), ) assert call_log == ["PickVarsResponse", "BindResponse"] assert result.indicator_id == "rcsi" assert result.measurable == "PROXY" assert result.variables == ["cope_q1"] def test_run_bind_step_force_none_skips_verdict_when_no_candidates(monkeypatch, tmp_path): """force-NONE guard: when PICK yields no valid candidate variable, the verdict model is NOT called and the binding is deterministically NOT_MEASURABLE (code disposes, principle 2).""" from core import bind as bind_mod from core.schemas import PickVarsResponse from core.ports import ChatModel call_log = [] class FakeModel(ChatModel): def structured(self, messages, schema): call_log.append(schema.__name__) if schema.__name__ == "PickVarsResponse": return PickVarsResponse(candidate_variables=[]) raise AssertionError("verdict model must not be called when no candidates") def complete(self, messages): return "" monkeypatch.setattr(bind_mod, "kobo_names", lambda cache, names: {}) result = bind_mod.run_bind_step( "fcs", {"label": "FCS", "definition": "Food Consumption Score."}, tmp_path / "kobo.json", {}, FakeModel(), ) assert call_log == ["PickVarsResponse"] # verdict call skipped assert result.indicator_id == "fcs" assert result.measurable == "NOT_MEASURABLE" assert result.variables == [] def test_run_bind_step_hallucinated_candidates_force_none(monkeypatch, tmp_path): """PICK proposes only names that aren't in the survey → all dropped by the allowlist → force-NONE. No false-positive MEASURABLE on fabricated variables.""" from core import bind as bind_mod from core.schemas import PickVarsResponse from core.ports import ChatModel call_log = [] class FakeModel(ChatModel): def structured(self, messages, schema): call_log.append(schema.__name__) if schema.__name__ == "PickVarsResponse": return PickVarsResponse(candidate_variables=["Q1: a fabricated question"]) raise AssertionError("verdict model must not be called when no valid candidates") def complete(self, messages): return "" monkeypatch.setattr(bind_mod, "kobo_names", lambda cache, names: {}) summary_map = {"all_question_names": ["real_var"], "question_labels": {"real_var": "Real label"}} result = bind_mod.run_bind_step( "gov", {"label": "Governance", "definition": "d"}, tmp_path / "kobo.json", summary_map, FakeModel(), ) assert call_log == ["PickVarsResponse"] assert result.measurable == "NOT_MEASURABLE" assert result.variables == [] def test_run_bind_step_pick_error_falls_back_cleanly(monkeypatch, tmp_path): """No raw-error leak (PICK call): the PICK prompt feeds every survey label (~20k tokens) and is the call that truncated in the field (prompt_tokens=19817). If it raises, run_bind_step must NOT propagate — it returns a deterministic NOT_MEASURABLE and the verdict is never called, so the app never writes 'Bind error: …' into the spec.""" from core import bind as bind_mod from core.ports import ChatModel call_log = [] class FakeModel(ChatModel): def structured(self, messages, schema): call_log.append(schema.__name__) if schema.__name__ == "PickVarsResponse": raise ValueError( "Could not parse response content as the length limit was reached - " "CompletionUsage(completion_tokens=2048, prompt_tokens=19817)" ) raise AssertionError("verdict must not be called when PICK failed") def complete(self, messages): return "" summary_map = {"all_question_names": ["v"], "question_labels": {"v": "label"}} result = bind_mod.run_bind_step( "heating", {"label": "Heating", "definition": "d"}, tmp_path / "kobo.json", summary_map, FakeModel(), ) assert call_log == ["PickVarsResponse"] assert result.indicator_id == "heating" assert result.measurable == "NOT_MEASURABLE" assert "CompletionUsage" not in result.reasons assert "Could not parse" not in result.reasons def test_run_bind_step_verdict_error_falls_back_cleanly(monkeypatch, tmp_path): """No raw-error leak: if the verdict call raises (e.g. truncated/un-parseable output), the binding falls back to a deterministic NOT_MEASURABLE — the exception text must never reach the spec's reasons field.""" from core import bind as bind_mod from core.schemas import PickVarsResponse from core.ports import ChatModel class FakeModel(ChatModel): def structured(self, messages, schema): if schema.__name__ == "PickVarsResponse": return PickVarsResponse(candidate_variables=["cope_q1"]) raise ValueError( "Could not parse response content as the length limit was reached - " "CompletionUsage(completion_tokens=2048, prompt_tokens=301)" ) def complete(self, messages): return "" monkeypatch.setattr(bind_mod, "kobo_names", lambda cache, names: {"cope_q1": {"label": "Coping", "type": "integer"}}) summary_map = {"all_question_names": ["cope_q1"], "question_labels": {"cope_q1": "Coping"}} result = bind_mod.run_bind_step( "rcsi", {"label": "rCSI", "definition": "d"}, tmp_path / "kobo.json", summary_map, FakeModel(), ) assert result.indicator_id == "rcsi" assert result.measurable == "NOT_MEASURABLE" assert result.variables == ["cope_q1"] # keep the resolved vars; verdict was the failure assert "CompletionUsage" not in result.reasons # raw error must not leak assert "Could not parse" not in result.reasons def test_run_bind_step_uses_labels_in_prompt(monkeypatch, tmp_path): """The pick prompt must render question labels, not just raw variable ids.""" from core import bind as bind_mod from core.schemas import BindResponse, PickVarsResponse from core.ports import ChatModel captured = {} class FakeModel(ChatModel): def structured(self, messages, schema): if schema.__name__ == "PickVarsResponse": captured["pick_prompt"] = messages[0]["content"] return PickVarsResponse(candidate_variables=[]) return BindResponse( indicator_id="x", variables=[], measurable="NOT_MEASURABLE", reasons="none", result_ids=[], ) def complete(self, messages): return "" monkeypatch.setattr(bind_mod, "kobo_names", lambda cache, names: {}) summary_map = { "all_question_names": ["food_exp_share"], "question_labels": {"food_exp_share": "Share of cash spent on food"}, } bind_mod.run_bind_step( "x", {"label": "X", "definition": "d"}, tmp_path / "kobo.json", summary_map, FakeModel(), ) assert "Share of cash spent on food" in captured["pick_prompt"] assert "food_exp_share" in captured["pick_prompt"] # --------------------------------------------------------------------------- # normalise_candidate_vars # --------------------------------------------------------------------------- def test_normalise_keeps_valid_names(): from core.bind import normalise_candidate_vars labels = {"main_water_source_now": "Main water source", "means_access": "Means of access"} assert normalise_candidate_vars(["main_water_source_now"], labels) == ["main_water_source_now"] def test_normalise_strips_name_colon_label(): from core.bind import normalise_candidate_vars labels = {"food_exp_share": "Share of cash spent on food"} out = normalise_candidate_vars(["food_exp_share: Share of cash spent on food"], labels) assert out == ["food_exp_share"] def test_normalise_reverse_maps_bare_label_to_name(): """The exact Phase-E bug: a question label leaks in where a name belongs.""" from core.bind import normalise_candidate_vars labels = { "site_population_satisfaction_with_governance_representation": "Q1: How satisfied are you with the representation in the site governance", } out = normalise_candidate_vars( ["Q1: How satisfied are you with the representation in the site governance"], labels ) assert out == ["site_population_satisfaction_with_governance_representation"] def test_normalise_drops_unresolvable(): from core.bind import normalise_candidate_vars labels = {"a_var": "A label"} assert normalise_candidate_vars(["totally_unknown"], labels) == [] def test_normalise_dedupes_preserving_order(): from core.bind import normalise_candidate_vars labels = {"v1": "L1", "v2": "L2"} assert normalise_candidate_vars(["v2", "v1", "v2"], labels) == ["v2", "v1"] def test_run_bind_step_variables_come_from_pick_not_verdict(monkeypatch, tmp_path): """Regression: the verdict call leaks a label into variables; the final binding must use the validated PICK names instead.""" from core import bind as bind_mod from core.schemas import BindResponse, PickVarsResponse from core.ports import ChatModel class FakeModel(ChatModel): def structured(self, messages, schema): if schema.__name__ == "PickVarsResponse": return PickVarsResponse( candidate_variables=["site_population_satisfaction_with_governance_representation"] ) # Verdict call leaks a question label into variables — must be ignored. return BindResponse( indicator_id="governance_representation", variables=["Q1: How satisfied are you with the representation in the site governance"], measurable="PROXY", reasons="ordinal satisfaction proxy", result_ids=["whatever"], ) def complete(self, messages): return "" monkeypatch.setattr(bind_mod, "kobo_names", lambda cache, names: {}) summary_map = { "all_question_names": ["site_population_satisfaction_with_governance_representation"], "question_labels": { "site_population_satisfaction_with_governance_representation": "Q1: How satisfied are you with the representation in the site governance", }, } result = bind_mod.run_bind_step( "governance_representation", {"label": "Governance representation", "definition": "d"}, tmp_path / "kobo.json", summary_map, FakeModel(), ) assert result.variables == ["site_population_satisfaction_with_governance_representation"] assert result.measurable == "PROXY"