feat: add agent guided birth time rectification
This commit is contained in:
@@ -102,3 +102,38 @@ def test_active_rectification_score_api_validates_payload() -> None:
|
||||
|
||||
with pytest.raises(BadRequest, match="answers must be an object"):
|
||||
_handler()._compute_active_rectification_score({"questionnaire": {}})
|
||||
|
||||
|
||||
def test_active_rectification_events_api_scores_structured_events() -> None:
|
||||
result = _handler()._compute_active_rectification_events({
|
||||
"birth_date": "1993-04-17",
|
||||
"start_time": "14:29",
|
||||
"end_time": "14:31",
|
||||
"lat": 36.683333,
|
||||
"lon": 114.35,
|
||||
"tz": 8,
|
||||
"events": [
|
||||
{"id": "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5", "domain": "education", "date": "2011-09", "precision": "month"},
|
||||
{"id": "0790866c-ad5e-4a45-b2b4-a5c73f6be6ea", "domain": "career", "date": "2019-07-01", "precision": "day"},
|
||||
{"id": "0ef52e51-ab5f-453b-81e5-adb44a929224", "domain": "relationship", "date": "2021", "precision": "year"},
|
||||
],
|
||||
})
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["endpoint"] == "active_rectification_events"
|
||||
assert result["result_id"]
|
||||
assert result["event_count"] == 3
|
||||
|
||||
|
||||
def test_active_rectification_events_api_rejects_client_scores() -> None:
|
||||
with pytest.raises(BadRequest, match="unsupported active rectification event field"):
|
||||
_handler()._compute_active_rectification_events({
|
||||
"birth_date": "1993-04-17",
|
||||
"start_time": "14:29",
|
||||
"end_time": "14:31",
|
||||
"lat": 36.683333,
|
||||
"lon": 114.35,
|
||||
"tz": 8,
|
||||
"events": [],
|
||||
"confidence": "high",
|
||||
})
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from scripts.active_rectification_events import (
|
||||
CandidateScoreRow,
|
||||
adjudicate_candidate_rows,
|
||||
precision_weight,
|
||||
score_life_events,
|
||||
)
|
||||
|
||||
|
||||
def _row(time: str, score: float) -> CandidateScoreRow:
|
||||
return {
|
||||
"time": time,
|
||||
"score": score,
|
||||
"evidence": [],
|
||||
"missing_layers": [],
|
||||
}
|
||||
|
||||
|
||||
def test_high_confidence_requires_four_events_three_domains_and_narrow_leader() -> None:
|
||||
result = adjudicate_candidate_rows(
|
||||
[
|
||||
_row("14:22", 16),
|
||||
_row("14:23", 16),
|
||||
_row("14:24", 16),
|
||||
_row("14:25", 16),
|
||||
_row("14:26", 16),
|
||||
_row("14:27", 10),
|
||||
],
|
||||
event_count=4,
|
||||
domain_count=3,
|
||||
request_fingerprint="high-fixture",
|
||||
)
|
||||
|
||||
assert result["confidence"] == "high"
|
||||
assert result["can_apply"] is True
|
||||
assert result["winning_segment"] == {
|
||||
"start_time": "14:22",
|
||||
"end_time": "14:26",
|
||||
"representative_time": "14:24",
|
||||
"width_minutes": 5,
|
||||
}
|
||||
assert result["margin_percent"] == 37.5
|
||||
|
||||
|
||||
def test_tied_disjoint_candidates_abstain() -> None:
|
||||
result = adjudicate_candidate_rows(
|
||||
[_row("14:20", 10), _row("14:21", 8), _row("14:22", 10)],
|
||||
event_count=4,
|
||||
domain_count=3,
|
||||
request_fingerprint="tie-fixture",
|
||||
)
|
||||
|
||||
assert result["confidence"] == "low"
|
||||
assert result["can_apply"] is False
|
||||
assert "tied_leader" in result["reasons"]
|
||||
assert result["winning_segment"] is None
|
||||
|
||||
|
||||
def test_medium_confidence_never_allows_application() -> None:
|
||||
result = adjudicate_candidate_rows(
|
||||
[_row("14:20", 10), _row("14:21", 8)],
|
||||
event_count=3,
|
||||
domain_count=2,
|
||||
request_fingerprint="medium-fixture",
|
||||
)
|
||||
|
||||
assert result["confidence"] == "medium"
|
||||
assert result["can_apply"] is False
|
||||
assert result["winning_segment"]["representative_time"] == "14:20"
|
||||
|
||||
|
||||
def test_result_keeps_only_representative_minute_evidence() -> None:
|
||||
rows = [_row("14:20", 10), _row("14:21", 10), _row("14:22", 10), _row("14:23", 5)]
|
||||
for row in rows:
|
||||
row["evidence"] = [{
|
||||
"event_id": "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5",
|
||||
"domain": "education",
|
||||
"candidate_time": row["time"],
|
||||
"rule_ids": ["fixture"],
|
||||
"points": row["score"],
|
||||
}]
|
||||
|
||||
result = adjudicate_candidate_rows(
|
||||
rows,
|
||||
event_count=3,
|
||||
domain_count=2,
|
||||
request_fingerprint="representative-evidence-fixture",
|
||||
)
|
||||
|
||||
assert [item["candidate_time"] for item in result["evidence"]] == ["14:21"]
|
||||
|
||||
|
||||
def test_missing_mandatory_layer_caps_confidence_at_low() -> None:
|
||||
row = _row("14:20", 10)
|
||||
row["missing_layers"] = ["D24"]
|
||||
|
||||
result = adjudicate_candidate_rows(
|
||||
[row, _row("14:21", 5)],
|
||||
event_count=4,
|
||||
domain_count=3,
|
||||
request_fingerprint="missing-layer-fixture",
|
||||
)
|
||||
|
||||
assert result["confidence"] == "low"
|
||||
assert "missing_mandatory_layers" in result["reasons"]
|
||||
assert result["can_apply"] is False
|
||||
|
||||
|
||||
def test_date_precision_weights_are_fixed() -> None:
|
||||
assert precision_weight("day") == 1.0
|
||||
assert precision_weight("month") == 0.8
|
||||
assert precision_weight("year") == 0.5
|
||||
|
||||
|
||||
def test_real_local_scoring_uses_dated_events_and_actual_candidate_minutes() -> None:
|
||||
result = score_life_events({
|
||||
"birth_date": "1993-04-17",
|
||||
"start_time": "14:29",
|
||||
"end_time": "14:31",
|
||||
"lat": 36.683333,
|
||||
"lon": 114.35,
|
||||
"tz": 8.0,
|
||||
"events": [
|
||||
{
|
||||
"id": "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5",
|
||||
"domain": "education",
|
||||
"date": "2011-09",
|
||||
"precision": "month",
|
||||
},
|
||||
{
|
||||
"id": "0790866c-ad5e-4a45-b2b4-a5c73f6be6ea",
|
||||
"domain": "career",
|
||||
"date": "2019-07-01",
|
||||
"precision": "day",
|
||||
},
|
||||
{
|
||||
"id": "0ef52e51-ab5f-453b-81e5-adb44a929224",
|
||||
"domain": "relationship",
|
||||
"date": "2021",
|
||||
"precision": "year",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
assert result["result_id"]
|
||||
assert result["event_count"] == 3
|
||||
assert result["domain_count"] == 3
|
||||
assert result["algorithm_version"] == "birth-time-event-scoring-v1"
|
||||
assert result["confidence"] in {"low", "medium"}
|
||||
@@ -46,6 +46,33 @@ def test_active_rectification_scores_answers_and_selects_next_round() -> None:
|
||||
assert "final rectification requires scoring answers against actual candidate chart differences" in scored["boundary"]
|
||||
|
||||
|
||||
def test_active_rectification_scores_legacy_questions_missing_scoring_maps() -> None:
|
||||
report = build_questionnaire("1955-02-24 19:15", uncertainty_minutes=30)
|
||||
legacy_questionnaire = {
|
||||
"questions": [
|
||||
{
|
||||
"id": question["id"],
|
||||
"prompt": question["prompt"],
|
||||
"options": question["options"],
|
||||
}
|
||||
for question in report["questions"]
|
||||
]
|
||||
}
|
||||
|
||||
scored = score_answers(
|
||||
legacy_questionnaire,
|
||||
{
|
||||
"education_environment_shift": "A",
|
||||
"residence_relocation_shift": "A",
|
||||
"relationship_or_partner_entry": "B",
|
||||
},
|
||||
)
|
||||
|
||||
assert scored["answered_count"] == 3
|
||||
assert scored["invalid_answers"] == []
|
||||
assert scored["candidate_cluster_rankings"]
|
||||
|
||||
|
||||
def test_active_rectification_recasts_candidate_vargas_when_location_is_available() -> None:
|
||||
report = build_questionnaire(
|
||||
"1993-04-17 14:49",
|
||||
@@ -68,3 +95,23 @@ def test_active_rectification_recasts_candidate_vargas_when_location_is_availabl
|
||||
assert sample["arudha"]["UL"]["sign"]
|
||||
assert sample["kp_cusps"]["house_7"]["sub_lord"]
|
||||
assert sample["kp_cusps"]["house_10"]["sub_sub_lord"]
|
||||
|
||||
|
||||
def test_candidate_recast_contains_all_evidence_domain_vargas(monkeypatch) -> None:
|
||||
report = build_questionnaire(
|
||||
"1993-04-17 14:30", 30, 30,
|
||||
lat=31.2304, lon=121.4737, tz=8,
|
||||
)
|
||||
sample = report["candidate_scan"]["samples"][0]
|
||||
varga_lagna = sample["varga_lagna"]
|
||||
expected_legacy_keys = {
|
||||
"D4": "D4_Turyamsa",
|
||||
"D9": "D9_Navamsa",
|
||||
"D10": "D10_Dasamsa",
|
||||
"D24": "D24_Siddhamsa",
|
||||
"D30": "D30_Trimsamsa",
|
||||
}
|
||||
assert set(expected_legacy_keys).issubset(varga_lagna)
|
||||
for alias, legacy_key in expected_legacy_keys.items():
|
||||
assert varga_lagna[alias]["sign"]
|
||||
assert varga_lagna[alias] == varga_lagna[legacy_key]
|
||||
|
||||
@@ -10,12 +10,38 @@ MIGRATION = (
|
||||
/ "20260717020000_birth_time_journey.sql"
|
||||
)
|
||||
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
|
||||
EVIDENCE_MIGRATION = (
|
||||
MIGRATION.parent / "20260718010000_birth_time_evidence_rectification.sql"
|
||||
)
|
||||
AGENT_GUIDED_MIGRATION = (
|
||||
MIGRATION.parent / "20260718020000_agent_guided_birth_time_rectification.sql"
|
||||
)
|
||||
SCORING_JOB_MIGRATION = (
|
||||
MIGRATION.parent / "20260718030000_birth_time_scoring_job_lifecycle.sql"
|
||||
)
|
||||
|
||||
|
||||
def _sql() -> str:
|
||||
return re.sub(r"\s+", " ", MIGRATION.read_text(encoding="utf-8").lower()).strip()
|
||||
|
||||
|
||||
def test_birth_time_service_role_can_update_journey_profile_columns() -> None:
|
||||
migrations = sorted(MIGRATION.parent.glob("*.sql"))
|
||||
sql = re.sub(
|
||||
r"\s+",
|
||||
" ",
|
||||
"\n".join(path.read_text(encoding="utf-8") for path in migrations).lower(),
|
||||
).strip()
|
||||
|
||||
assert (
|
||||
"grant update ( reported_birth_time, active_birth_time, birth_time, "
|
||||
"birth_time_source, birth_time_period, birth_time_clue, "
|
||||
"uncertainty_before_minutes, uncertainty_after_minutes, "
|
||||
"birth_time_status, rectification_confidence, rectification_case_id "
|
||||
") on table public.profiles to service_role"
|
||||
) in sql
|
||||
|
||||
|
||||
def test_birth_time_profile_contract_separates_reported_and_active_times() -> None:
|
||||
sql = _sql()
|
||||
|
||||
@@ -117,3 +143,122 @@ def test_web_onboarding_uses_the_deterministic_free_journey() -> None:
|
||||
assert "consultation-billing" not in route
|
||||
assert 'entry_mode: entryMode' in mastra
|
||||
assert 'entry_mode: "direct_chart"' not in mastra
|
||||
|
||||
|
||||
def test_evidence_rectification_persists_server_owned_results() -> None:
|
||||
sql = re.sub(
|
||||
r"\s+",
|
||||
" ",
|
||||
EVIDENCE_MIGRATION.read_text(encoding="utf-8").lower(),
|
||||
).strip()
|
||||
|
||||
for definition in (
|
||||
"life_events jsonb not null default '[]'::jsonb",
|
||||
"candidate_result jsonb not null default '{}'::jsonb",
|
||||
"event_scoring_version text",
|
||||
"candidate_result_id uuid",
|
||||
"candidate_saved_at timestamptz",
|
||||
):
|
||||
assert f"add column if not exists {definition}" in sql
|
||||
|
||||
assert "'confirming'" in sql
|
||||
assert (
|
||||
"revoke update ( life_events, candidate_result, event_scoring_version, "
|
||||
"candidate_result_id, candidate_saved_at, confirmed_time, confirmed_at "
|
||||
") on table public.birth_time_rectification_cases from authenticated"
|
||||
) in sql
|
||||
assert "grant all on table public.birth_time_rectification_cases to service_role" in sql
|
||||
|
||||
|
||||
def test_agent_guided_rectification_migration_versions_turns_and_jobs() -> None:
|
||||
sql = re.sub(
|
||||
r"\s+", " ", AGENT_GUIDED_MIGRATION.read_text(encoding="utf-8").lower()
|
||||
).strip()
|
||||
|
||||
for definition in (
|
||||
"turn_version bigint not null default 0",
|
||||
"turn_state jsonb not null default '{}'::jsonb",
|
||||
"evidence_draft jsonb",
|
||||
"processed_action_ids uuid[] not null default '{}'::uuid[]",
|
||||
"adaptive_round integer not null default 0",
|
||||
"asked_domains text[] not null default '{}'::text[]",
|
||||
):
|
||||
assert f"add column if not exists {definition}" in sql
|
||||
|
||||
assert "jsonb_typeof(turn_state) = 'object'" in sql
|
||||
assert "evidence_draft is null or jsonb_typeof(evidence_draft) = 'object'" in sql
|
||||
assert "cardinality(processed_action_ids) <= 100" in sql
|
||||
assert "adaptive_round between 0 and 3" in sql
|
||||
assert "birth_time_rectification_scoring_jobs" in sql
|
||||
assert "id uuid primary key default gen_random_uuid()" in sql
|
||||
assert "case_id uuid not null references public.birth_time_rectification_cases(id) on delete cascade" in sql
|
||||
assert "status text not null default 'pending'" in sql
|
||||
assert "expires_at timestamptz not null" in sql
|
||||
assert "unique (case_id, evidence_fingerprint, algorithm_version)" in sql
|
||||
assert "revoke all on table public.birth_time_rectification_scoring_jobs from anon, authenticated" in sql
|
||||
assert "grant all on table public.birth_time_rectification_scoring_jobs to service_role" in sql
|
||||
|
||||
|
||||
def test_scoring_job_lifecycle_is_atomic_and_service_role_only() -> None:
|
||||
sql = re.sub(
|
||||
r"\s+", " ", SCORING_JOB_MIGRATION.read_text(encoding="utf-8").lower()
|
||||
).strip()
|
||||
|
||||
for function_name in (
|
||||
"create_birth_time_scoring_job",
|
||||
"claim_birth_time_scoring_job",
|
||||
"complete_birth_time_scoring_job",
|
||||
"fail_birth_time_scoring_job",
|
||||
):
|
||||
assert f"create or replace function public.{function_name}" in sql
|
||||
assert f"revoke all on function public.{function_name}" in sql
|
||||
assert f"grant execute on function public.{function_name}" in sql
|
||||
|
||||
create_body = sql.split("create or replace function public.create_birth_time_scoring_job", 1)[1]
|
||||
create_body = create_body.split("create or replace function public.claim_birth_time_scoring_job", 1)[0]
|
||||
assert "update public.birth_time_rectification_cases" in create_body
|
||||
assert "insert into public.birth_time_rectification_scoring_jobs" in create_body
|
||||
assert "and user_id = p_user_id" in create_body
|
||||
assert "and turn_version = p_expected_version" in create_body
|
||||
|
||||
claim_body = sql.split("create or replace function public.claim_birth_time_scoring_job", 1)[1]
|
||||
claim_body = claim_body.split("create or replace function public.complete_birth_time_scoring_job", 1)[0]
|
||||
assert "j.case_id = p_case_id" in claim_body
|
||||
assert "j.user_id = p_user_id" in claim_body
|
||||
assert "v_job.evidence_fingerprint is distinct from p_evidence_fingerprint" in claim_body
|
||||
assert "v_job.algorithm_version is distinct from p_algorithm_version" in claim_body
|
||||
assert claim_body.index(
|
||||
"v_job.algorithm_version is distinct from p_algorithm_version"
|
||||
) < claim_body.index("update public.birth_time_rectification_scoring_jobs")
|
||||
assert "#>> '{nextaction,jobid}' is distinct from p_job_id::text" in claim_body
|
||||
assert "#>> '{nextaction,kind}' is distinct from" in claim_body
|
||||
assert "status in ('pending', 'failed', 'processing')" in claim_body
|
||||
assert "status = 'processing'" in claim_body
|
||||
assert "v_job.updated_at <= p_now - interval '60 seconds'" in claim_body
|
||||
assert "expires_at = p_now + interval '15 minutes'" in claim_body
|
||||
assert "birth_time_scoring_job_expired" not in claim_body
|
||||
assert "v_candidate_result is distinct from v_job.result" in claim_body
|
||||
assert "birth_time_scoring_result_inconsistent" in claim_body
|
||||
|
||||
complete_body = sql.split("create or replace function public.complete_birth_time_scoring_job", 1)[1]
|
||||
complete_body = complete_body.split("create or replace function public.fail_birth_time_scoring_job", 1)[0]
|
||||
assert "update public.birth_time_rectification_scoring_jobs" in complete_body
|
||||
assert "update public.birth_time_rectification_cases" in complete_body
|
||||
|
||||
fail_body = sql.split("create or replace function public.fail_birth_time_scoring_job", 1)[1]
|
||||
assert "update public.birth_time_rectification_scoring_jobs" in fail_body
|
||||
assert "update public.birth_time_rectification_cases" in fail_body
|
||||
assert "security definer" in sql
|
||||
assert "to service_role" in sql
|
||||
|
||||
|
||||
def test_poll_scoring_route_authenticates_before_parsing() -> None:
|
||||
route = (
|
||||
FRONTEND / "src" / "app" / "api" / "birth-time-journey" / "route.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert route.index("supabase.auth.getUser()") < route.index(
|
||||
"birthTimeJourneyRequestSchema.safeParse"
|
||||
)
|
||||
assert 'case "poll_scoring"' in route
|
||||
assert "service.pollScoringJob(" in route
|
||||
|
||||
Reference in New Issue
Block a user