300 lines
11 KiB
Python
300 lines
11 KiB
Python
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"
|