from __future__ import annotations import hashlib import json from scripts.active_rectification_questions import build_questionnaire, score_answers from scripts.active_rectification_selector import select_next_questions from scripts.rectification.scoring_service import score_from_matrix def test_selector_asks_one_question_and_ranks_by_separation() -> None: questionnaire = build_questionnaire("2001-02-03 10:20", uncertainty_minutes=30) selection = questionnaire["selection"] assert len(selection["selected_questions"]) == 1 assert selection["selected_questions"][0]["id"] == questionnaire["questions"][0]["id"] assert selection["selected_questions"][0]["why_asked"] assert selection["selected_questions"][0]["candidate_ids_distinguished"] assert selection["selected_questions"][0]["technique_routes"] assert selection["ranking"] def test_selector_skips_non_discriminating_questions() -> None: questionnaire = { "question_bank": [ { "id": "neutral_only", "domain": "fine_timing", "prompt": "几乎同时吗?", "sensitivity": ["KP_cusp"], "scoring_map": { "A": {"cluster": "neutral", "points": 0}, "B": {"cluster": "neutral", "points": 0}, "C": {"cluster": "neutral", "points": 0}, "D": {"cluster": "neutral", "points": 0}, }, }, { "id": "usable_question", "domain": "career", "prompt": "工作是否变动?", "sensitivity": ["D10"], "scoring_map": { "A": {"cluster": "career_up", "points": 2}, "B": {"cluster": "career_up", "points": 1}, "C": {"cluster": "career_down", "points": -2}, "D": {"cluster": "neutral", "points": 0}, }, }, ], "candidate_scan": {"candidate_count": 61}, } selection = select_next_questions(questionnaire, {}, limit=1) assert selection["selected_questions"][0]["id"] == "usable_question" assert any(item["question_id"] == "neutral_only" and item["skipped"] for item in selection["ranking"]) def test_selector_changes_domain_after_uncertainty() -> None: questionnaire = build_questionnaire("2001-02-03 10:20", uncertainty_minutes=30) first = questionnaire["questions"][0]["id"] selection = select_next_questions( { "question_bank": questionnaire["questions"], "candidate_scan": questionnaire["candidate_scan"], }, {first: "D"}, limit=1, ) assert selection["selected_questions"] assert selection["selected_questions"][0]["id"] != first assert selection["selected_questions"][0]["domain"] != questionnaire["questions"][0]["domain"] def test_selector_stops_when_no_answer_can_improve_separation() -> None: questionnaire = { "question_bank": [ { "id": "fine_only", "domain": "fine_timing", "prompt": "先内后外吗?", "sensitivity": ["KP_cusp"], "scoring_map": { "A": {"cluster": "neutral", "points": 0}, "B": {"cluster": "neutral", "points": 0}, "C": {"cluster": "neutral", "points": 0}, "D": {"cluster": "neutral", "points": 0}, }, } ], "candidate_scan": {"candidate_count": 61}, } selection = select_next_questions(questionnaire, {}, limit=1) assert selection["selected_questions"] == [] assert selection["stop"] is True assert selection["stop_reason"] == "no_answer_can_improve_separation" def _minute_question(question_id: str, domain: str, layer: str) -> dict[str, object]: return { "id": question_id, "domain": domain, "prompt": "这个虚构事件是否发生?", "sensitivity": [layer], "positive_cluster": f"{question_id}_yes", "negative_cluster": f"{question_id}_no", "factual_reliability": 0.9, "domain_priority": 1, "scoring_map": { "A": {"cluster": f"{question_id}_yes", "points": 2}, "B": {"cluster": f"{question_id}_yes", "points": 1}, "C": {"cluster": f"{question_id}_no", "points": -2}, "D": {"cluster": "neutral", "points": 0}, }, } def _minute_window(*changing_layers: str) -> dict[str, object]: return { "candidate_count": 3, "transitions": [{"between": ["10:19", "10:20"]}], "rows": [ { "divisional_ascendants": { layer: {"sign": sign if layer in changing_layers else "Leo"} for layer in ("D9", "D10", "D24") } } for sign in ("Aries", "Taurus", "Gemini") ], } def test_selector_changes_with_remaining_candidate_window() -> None: questions = [ _minute_question("relationship_split", "relationship", "D9"), _minute_question("career_split", "career", "D10"), ] relationship_window = { "candidate_count": 3, "transitions": [{"between": ["10:19", "10:20"]}], "rows": [ {"divisional_ascendants": {"D9": {"sign": "Aries", "degree": 1}, "D10": {"sign": "Leo"}}}, {"divisional_ascendants": {"D9": {"sign": "Taurus", "degree": 2}, "D10": {"sign": "Leo"}}}, {"divisional_ascendants": {"D9": {"sign": "Gemini", "degree": 3}, "D10": {"sign": "Leo"}}}, ], } career_window = { "candidate_count": 3, "transitions": [{"between": ["10:20", "10:21"]}], "rows": [ {"divisional_ascendants": {"D9": {"sign": "Aries"}, "D10": {"sign": "Leo", "degree": 1}}}, {"divisional_ascendants": {"D9": {"sign": "Aries"}, "D10": {"sign": "Virgo", "degree": 2}}}, {"divisional_ascendants": {"D9": {"sign": "Aries"}, "D10": {"sign": "Libra", "degree": 3}}}, ], } first = select_next_questions( {"question_bank": questions, "candidate_scan": {"candidate_count": 3, "minute_scan": relationship_window}}, {}, ) second = select_next_questions( {"question_bank": questions, "candidate_scan": {"candidate_count": 3, "minute_scan": career_window}}, {}, ) assert first["selected_questions"][0]["id"] == "relationship_split" assert second["selected_questions"][0]["id"] == "career_split" assert first["selected_questions"][0]["minute_relevance"] > 0 assert second["selected_questions"][0]["minute_relevance"] > 0 def test_fictional_adaptive_interview_records_each_selected_question_and_minute_relevance() -> None: questions = [ {**_minute_question("career_split", "career", "D10"), "round": 1}, {**_minute_question("education_split", "education", "D24"), "round": 2}, {**_minute_question("relationship_split", "relationship", "D9"), "round": 3}, ] questionnaire = { "question_bank": questions, "candidate_scan": {"candidate_count": 3, "minute_scan": _minute_window("D9", "D10", "D24")}, } answers: dict[str, str] = {} transcript = [] for round_number, answer in enumerate(("A", "B", "C"), start=1): selection = select_next_questions(questionnaire, answers, limit=1) selected = selection["selected_questions"][0] transcript.append({ "round": round_number, "question": selected["id"], "minute_relevance": selected["minute_relevance"], }) answers[selected["id"]] = answer assert transcript == [ {"round": 1, "question": "career_split", "minute_relevance": 4.35}, {"round": 2, "question": "education_split", "minute_relevance": 4.35}, {"round": 3, "question": "relationship_split", "minute_relevance": 4.35}, ] assert select_next_questions(questionnaire, answers)["stop"] is True def test_selector_changes_do_not_change_legacy_or_v5_score_bytes() -> None: questions = [ {**_minute_question("relationship_split", "relationship", "D9"), "round": 1}, {**_minute_question("career_split", "career", "D10"), "round": 2}, ] answers = {"relationship_split": "A", "career_split": "C"} questionnaires = [ { "questions": questions, "candidate_scan": {"candidate_count": 3, "minute_scan": _minute_window("D9")}, }, { "questions": questions, "candidate_scan": {"candidate_count": 3, "minute_scan": _minute_window("D10")}, }, ] selections = [select_next_questions(questionnaire, {}) for questionnaire in questionnaires] assert [selection["selected_questions"][0]["id"] for selection in selections] == [ "relationship_split", "career_split", ] legacy_bytes = [] for questionnaire in questionnaires: scored = score_answers(questionnaire, answers) legacy_contract = { key: value for key, value in scored.items() if key not in {"calculation", "next_round_selection"} } legacy_bytes.append(json.dumps( legacy_contract, ensure_ascii=True, sort_keys=True, separators=(",", ":"), ).encode()) assert legacy_bytes[0] == legacy_bytes[1] assert hashlib.sha256(legacy_bytes[0]).hexdigest() == "bb1a300606458357b2b8d94a487272f15d82c6f0b51847c660ba20153773fb63" v5_request = { "birth_date": "1997-08-08", "start_time": "05:13", "end_time": "05:14", "lat": 36.419, "lon": 114.213, "tz": 8.0, "events": [ { "id": "00000000-0000-4000-8000-000000000001", "domain": "education", "event_kind": "education_start", "date_start": "2016-09-01", "date_end": "2016-09-30", "precision": "month", "summary": "fictional enrollment", "subject": "self", }, { "id": "00000000-0000-4000-8000-000000000002", "domain": "career", "event_kind": "career_entry", "date_start": "2020-07-01", "date_end": "2020-07-31", "precision": "month", "summary": "fictional first role", "subject": "self", }, ], } built = { "candidate_times": ["05:13", "05:14"], "matrix": { v5_request["events"][0]["id"]: { "05:13": {"points": 4.0, "rule_ids": ["D24:fixture"]}, "05:14": {"points": 1.0, "rule_ids": ["D24:fixture"]}, }, v5_request["events"][1]["id"]: { "05:13": {"points": -1.0, "rule_ids": ["D10:fixture"]}, "05:14": {"points": 3.0, "rule_ids": ["D10:fixture"]}, }, }, "missing_layers": [], } v5_bytes = json.dumps( score_from_matrix(v5_request, built), ensure_ascii=True, sort_keys=True, separators=(",", ":"), ).encode() assert hashlib.sha256(v5_bytes).hexdigest() == "23171c47b9746f4b0a440c9b9ac6d8401d622672cd0d6d780789334b732e9d8f"