yannsay's picture
Deploy clean to build-small-hackathon org (32B Qwen, step-3 table)
aa1d0aa verified
Raw
History Blame Contribute Delete
16.3 kB
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"