| 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 |
| assert "--no-choices" not in joined |
|
|
|
|
| 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"] |
| 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"] |
| assert "CompletionUsage" not in result.reasons |
| 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"] |
|
|
|
|
| |
| |
| |
|
|
| 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"] |
| ) |
| |
| 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" |
|
|