diff --git a/frontend/src/lib/birth-time-journey-adapters.ts b/frontend/src/lib/birth-time-journey-adapters.ts index b7ae47fb..5e6872ad 100644 --- a/frontend/src/lib/birth-time-journey-adapters.ts +++ b/frontend/src/lib/birth-time-journey-adapters.ts @@ -45,6 +45,7 @@ const optionSchema = z.object({ const questionSchema = z.object({ id: z.string().trim().min(1), prompt: z.string().trim().min(1), + domain: z.string().trim().min(1).optional(), round: z.number().int().min(1).optional(), options: z.array(optionSchema).optional(), }).passthrough(); @@ -90,6 +91,9 @@ const scoringSchema = z.object({ })), next_round: z.number().int().min(1).nullable().default(null), next_round_questions: z.array(questionSchema).default([]), + next_round_selection: z.object({ + selected_questions: z.array(questionSchema).default([]), + }).nullable().optional(), }).passthrough(); const eventDomainSchema = z.enum([ @@ -232,6 +236,7 @@ function normalizeQuestion(question: z.infer): Rectificat return { id: question.id, prompt: question.prompt, + ...(question.domain ? { domain: question.domain } : {}), ...(question.round ? { round: question.round } : {}), ...(question.options ? { options: question.options } : {}), }; @@ -239,11 +244,14 @@ function normalizeQuestion(question: z.infer): Rectificat export function parseRectificationScoring(value: unknown): RectificationScoringResult { const parsed = scoringSchema.parse(value); + const selectedQuestions = parsed.next_round_selection?.selected_questions ?? []; return { answeredCount: parsed.answered_count, candidateClusterRankings: parsed.candidate_cluster_rankings, nextRound: parsed.next_round, - nextRoundQuestions: parsed.next_round_questions.map(normalizeQuestion), + nextRoundQuestions: (selectedQuestions.length > 0 + ? selectedQuestions + : parsed.next_round_questions).map(normalizeQuestion), raw: parsed, }; } diff --git a/frontend/src/lib/birth-time-journey-response.ts b/frontend/src/lib/birth-time-journey-response.ts index 953c0cee..0619eb7e 100644 --- a/frontend/src/lib/birth-time-journey-response.ts +++ b/frontend/src/lib/birth-time-journey-response.ts @@ -51,6 +51,7 @@ export function projectJourneyResponse( questionnaire: response.questionnaire, persistedProgress, candidateResult: response.candidateResult, + serverSelectedQuestion: response.scoring?.nextRoundQuestions[0] ?? null, lifeEvents: response.lifeEvents, }), }; @@ -93,6 +94,7 @@ function projectStoredTurn( questionnaire: stored.questionnaire, persistedProgress: stored.persistedProgress, candidateResult: stored.candidateResult ?? null, + serverSelectedQuestion: stored.scoring?.nextRoundQuestions[0] ?? null, lifeEvents: stored.lifeEvents ?? [], }); } diff --git a/frontend/src/lib/birth-time-journey-score-transition.ts b/frontend/src/lib/birth-time-journey-score-transition.ts index b28c6153..ee04af1c 100644 --- a/frontend/src/lib/birth-time-journey-score-transition.ts +++ b/frontend/src/lib/birth-time-journey-score-transition.ts @@ -26,6 +26,7 @@ function completedTurn( questionnaire: stored.questionnaire, persistedProgress: stored.persistedProgress, candidateResult, + serverSelectedQuestion: stored.scoring?.nextRoundQuestions[0] ?? null, lifeEvents: stored.lifeEvents ?? [], }); } diff --git a/frontend/src/lib/birth-time-journey-service.ts b/frontend/src/lib/birth-time-journey-service.ts index 9cc1d90c..3d257031 100644 --- a/frontend/src/lib/birth-time-journey-service.ts +++ b/frontend/src/lib/birth-time-journey-service.ts @@ -27,6 +27,7 @@ export type RectificationAnswer = "A" | "B" | "C" | "D"; export type RectificationQuestion = { readonly id: string; readonly prompt: string; + readonly domain?: string; readonly round?: number; readonly options?: readonly { readonly key: RectificationAnswer; diff --git a/frontend/src/lib/birth-time-journey-turn.ts b/frontend/src/lib/birth-time-journey-turn.ts index 025ce2e2..df2430a1 100644 --- a/frontend/src/lib/birth-time-journey-turn.ts +++ b/frontend/src/lib/birth-time-journey-turn.ts @@ -174,9 +174,40 @@ export type JourneyTurnProjectionInput = { readonly askedDomains: readonly EvidenceDomain[]; } | null; readonly candidateResult: CandidateResult | null; + readonly serverSelectedQuestion?: { readonly id: string; readonly domain?: string } | null; readonly lifeEvents: readonly LifeEvent[]; }; +const serverDomainMap: Readonly> = { + education: "education", + relocation: "relocation", + residence: "relocation", + relationship: "relationship", + career: "career", + career_learning: "career", + public_work: "career", + finance: "finance", + health_pressure: "health_pressure", +}; + +function serverAdaptiveQuestion( + question: JourneyTurnProjectionInput["serverSelectedQuestion"], + adaptiveRound: number, +): QuestionSpec | null { + if (!question?.domain) return null; + const domain = serverDomainMap[question.domain]; + if (!domain) return null; + return { + questionId: question.id, + phase: "adaptive", + domain, + requestedPrecision: ["year", "month"], + allowUnknown: true, + purposeCode: `candidate_difference_${domain}`, + plannerVersion: "server-next-round-selection-v1", + }; +} + function projectionPhase(input: JourneyTurnProjectionInput, progress: JourneyProgress): JourneyProgress["phase"] { if (input.snapshot.state === "ready") return "ready"; if (input.candidateResult) { @@ -219,7 +250,7 @@ export function projectJourneyTurn(input: JourneyTurnProjectionInput): JourneyTu const decisionProgress = input.candidateResult?.confidence === "low" ? { ...projectedProgress, adaptiveRound } : projectedProgress; - const nextQuestion = phase === "baseline" || phase === "adaptive" + const plannedQuestion = phase === "baseline" || phase === "adaptive" ? planEvidenceQuestion({ phase, samples: input.questionnaire?.samples ?? [], @@ -228,6 +259,9 @@ export function projectJourneyTurn(input: JourneyTurnProjectionInput): JourneyTu adaptiveRound, }) : null; + const nextQuestion = phase === "adaptive" + ? serverAdaptiveQuestion(input.serverSelectedQuestion, adaptiveRound) ?? plannedQuestion + : plannedQuestion; return { turnVersion: input.turnVersion, nextAction: deriveNextAction({ diff --git a/frontend/tests/rectification-adaptive-selector.test.ts b/frontend/tests/rectification-adaptive-selector.test.ts new file mode 100644 index 00000000..f2215257 --- /dev/null +++ b/frontend/tests/rectification-adaptive-selector.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseRectificationScoring } from "../src/lib/birth-time-journey-adapters.ts"; +import { projectJourneyTurn } from "../src/lib/birth-time-journey-turn.ts"; +import type { CandidateResult } from "../src/lib/birth-time-evidence.ts"; + +const snapshot = { + state: "rectifying", + assistantIntent: "continue_rectification_questions", + input: "rectification_questions", + route: "rectification", + confidence: "low", + canApply: false, + activeTime: null, + reportedRange: { label: "10:00—11:00", startTime: "10:00", endTime: "11:00" }, +} as const; + +const lowResult: CandidateResult = { + resultId: "1d8ee348-61a3-433d-8907-ff6d281b9992", + confidence: "low", + canApply: false, + winningSegment: null, + eventCount: 3, + domainCount: 3, + topScore: 8, + secondScore: 7, + marginPercent: 12.5, + reasons: [], + evidence: [], + algorithmVersion: "birth-time-event-scoring-v1", +}; + +const questionnaire = { samples: [ + { d4Sign: "Aries", d9Sign: "Leo", d10Sign: "Virgo", d24Sign: "Gemini", d30Sign: "Pisces" }, + { d4Sign: "Aries", d9Sign: "Leo", d10Sign: "Libra", d24Sign: "Gemini", d30Sign: "Pisces" }, +] } as const; + +const lifeEvents = [ + { id: "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5", domain: "education", date: "2011", precision: "year" }, + { id: "0790866c-ad5e-4a45-b2b4-a5c73f6be6ea", domain: "relocation", date: "2019", precision: "year" }, + { id: "0ef52e51-ab5f-453b-81e5-adb44a929224", domain: "health_pressure", date: "2021", precision: "year" }, +] as const; + +test("scoring adapter prefers the server-selected next question", () => { + const scoring = parseRectificationScoring({ + answered_count: 1, + candidate_cluster_rankings: [], + next_round: 2, + next_round_questions: [{ id: "fallback", domain: "career", prompt: "fallback" }], + next_round_selection: { + selected_questions: [{ id: "selected", domain: "relationship", prompt: "selected" }], + }, + }); + + assert.equal(scoring.nextRoundQuestions[0]?.id, "selected"); + assert.equal(scoring.nextRoundQuestions[0]?.domain, "relationship"); +}); + +test("adaptive projection prefers a valid server-selected question", () => { + const turn = projectJourneyTurn({ + turnVersion: 1, + snapshot, + questionnaire, + persistedProgress: { adaptiveRound: 0, askedDomains: [] }, + candidateResult: lowResult, + serverSelectedQuestion: { id: "relationship_followup", domain: "relationship" }, + lifeEvents, + }); + + assert.equal(turn.nextAction.kind, "ask_adaptive_evidence"); + if (turn.nextAction.kind === "ask_adaptive_evidence") { + assert.equal(turn.nextAction.question.questionId, "relationship_followup"); + assert.equal(turn.nextAction.question.domain, "relationship"); + } +}); + +test("invalid server domains fall back locally and baseline stays local", () => { + const adaptive = projectJourneyTurn({ + turnVersion: 1, + snapshot, + questionnaire, + persistedProgress: { adaptiveRound: 0, askedDomains: [] }, + candidateResult: lowResult, + serverSelectedQuestion: { id: "invalid", domain: "unsupported" }, + lifeEvents, + }); + assert.equal(adaptive.nextAction.kind, "ask_adaptive_evidence"); + if (adaptive.nextAction.kind === "ask_adaptive_evidence") { + assert.equal(adaptive.nextAction.question.domain, "career"); + } + + const baseline = projectJourneyTurn({ + turnVersion: 0, + snapshot: { ...snapshot, confidence: null }, + questionnaire, + persistedProgress: { adaptiveRound: 0, askedDomains: [] }, + candidateResult: null, + serverSelectedQuestion: { id: "relationship_followup", domain: "relationship" }, + lifeEvents: lifeEvents.slice(0, 1), + }); + assert.equal(baseline.nextAction.kind, "ask_baseline_evidence"); + if (baseline.nextAction.kind === "ask_baseline_evidence") { + assert.equal(baseline.nextAction.question.domain, "career"); + } +}); diff --git a/scripts/active_rectification_questions.py b/scripts/active_rectification_questions.py index cb0b978b..502cc3cd 100644 --- a/scripts/active_rectification_questions.py +++ b/scripts/active_rectification_questions.py @@ -5,9 +5,18 @@ from __future__ import annotations import argparse import json +import sys from datetime import datetime, timedelta +from pathlib import Path from typing import Any + +SCRIPTS_DIR = Path(__file__).resolve().parent +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +from active_rectification_selector import select_next_questions + OPTIONS = [ {"key": "A", "label": "明确有,且时间大致吻合", "score": 2}, {"key": "B", "label": "有类似,但时间略偏或不够重大", "score": 1}, @@ -15,6 +24,18 @@ OPTIONS = [ {"key": "D", "label": "不确定 / 不记得", "score": 0}, ] +QUESTION_RELIABILITY = { + "education": 1.0, + "residence": 0.95, + "relationship": 0.95, + "career": 0.9, + "career_learning": 0.88, + "health_pressure": 0.72, + "public_work": 0.82, + "fine_timing": 0.45, +} + + QUESTION_TEMPLATES = [ ("education_environment_shift", 1, "education", ["D24", "D4", "Dasha"], "age_16_to_18", "16-18岁附近,是否有明显学业、学校、专业方向或学习环境变化?", "middle_candidate_cluster", "against_D24_sensitive_cluster"), ("residence_relocation_shift", 1, "residence", ["D4", "12H", "Rahu/Ketu", "Transit"], "age_20_to_24", "20-24岁附近,是否有搬家、离乡、长期异地、住宿或居住结构变化?", "D4_relocation_cluster", "against_D4_relocation_cluster"), @@ -69,6 +90,26 @@ def _candidate_scan( samples.append(sample) has_true_recast = all("varga_lagna" in sample for sample in samples) has_kp_recast = all("kp_cusps" in sample for sample in samples) + minute_scan = None + if lat is not None and lon is not None and tz is not None: + try: + from candidate_time_sensitivity_scan import scan_candidate_times + except ModuleNotFoundError: # pragma: no cover - package import + from scripts.candidate_time_sensitivity_scan import scan_candidate_times + minute_scan = scan_candidate_times( + { + "year": center.year, + "month": center.month, + "day": center.day, + "hour": center.hour, + "minute": center.minute, + "lat": lat, + "lon": lon, + "tz": tz, + }, + uncertainty_minutes=uncertainty_minutes, + step_minutes=step_minutes, + ) computed_layers = ["time_range", "candidate_cluster", "question_sensitivity_map"] blocked_layers = ["true_varga_recast", "true_kp_cusp_recast", "true_arudha_recast"] if has_true_recast: @@ -84,6 +125,7 @@ def _candidate_scan( "candidate_count": candidate_count, "cluster_labels": ["early_candidate_cluster", "middle_candidate_cluster", "late_candidate_cluster"], "samples": samples, + "minute_scan": minute_scan, "sensitivity_summary": { "method": "minute_feature_scan_v2", "high_value_layers": ["D2", "D4", "D9", "D10", "D24", "D30", "D60", "UL", "A7", "A10", "KP_cusp"], @@ -201,6 +243,18 @@ def build_questionnaire( "window": window, "prompt": prompt, "options": OPTIONS, + "positive_cluster": yes_bias, + "negative_cluster": no_bias, + "factual_reliability": QUESTION_RELIABILITY.get(domain, 0.75), + "why_this_question": [ + f"用于区分 {yes_bias} 与 {no_bias},并核对 {', '.join(sensitivity)} 在候选窗口内的差异。" + ], + "answer_impact": { + "A": f"提高 {yes_bias} 的优先级。", + "B": f"弱支持 {yes_bias}。", + "C": f"提高 {no_bias} 的相对权重。", + "D": "保持中性并转问其他可核验主题。", + }, "scoring_map": { "A": {"effect": "support", "cluster": yes_bias, "points": 2}, "B": {"effect": "weak_support", "cluster": yes_bias, "points": 1}, @@ -208,18 +262,23 @@ def build_questionnaire( "D": {"effect": "neutral", "cluster": "neutral", "points": 0}, }, }) + candidate_scan = _candidate_scan( + _parse_time(birth_time), + uncertainty_minutes, + step_minutes, + lat=lat, + lon=lon, + tz=tz, + ayanamsa=ayanamsa, + ) + selection = select_next_questions( + {"questions": questions, "candidate_scan": candidate_scan}, {}, limit=1 + ) return { "scope": "active_birth_time_rectification_questionnaire", "schema_version": 1, - "candidate_scan": _candidate_scan( - _parse_time(birth_time), - uncertainty_minutes, - step_minutes, - lat=lat, - lon=lon, - tz=tz, - ayanamsa=ayanamsa, - ), + "candidate_scan": candidate_scan, + "selection": selection, "workflow": [ "candidate_time_scan", "varga_arudha_kp_sensitivity_diff", @@ -357,6 +416,16 @@ def score_answers( "downgrade_reasons": downgrade_reasons, } ) + next_round_selection = select_next_questions( + { + "questions": questions, + "candidate_scan": questionnaire.get("candidate_scan") + if isinstance(questionnaire.get("candidate_scan"), dict) + else None, + }, + {item["id"]: item["answer"] for item in applied}, + limit=1, + ) return { "scope": "active_birth_time_rectification_scoring", "schema_version": 1, @@ -412,6 +481,7 @@ def score_answers( ], "next_round": next_round, "next_round_questions": [question for question in unanswered if question.get("round") == next_round], + "next_round_selection": next_round_selection, "applied_scoring": applied, "unknown_question_ids": unknown_ids, "invalid_answers": invalid_answers, diff --git a/scripts/active_rectification_selector.py b/scripts/active_rectification_selector.py new file mode 100644 index 00000000..17f35823 --- /dev/null +++ b/scripts/active_rectification_selector.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +"""Adaptive selector for active birth-time rectification questions.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, Iterable + + +DEFAULT_DOMAIN_ORDER = [ + "education", + "residence", + "relationship", + "career", + "career_learning", + "health_pressure", + "public_work", + "fine_timing", +] + +DOMAIN_RELIABILITY = { + "education": 1.0, + "residence": 0.95, + "relationship": 0.95, + "career": 0.9, + "career_learning": 0.88, + "health_pressure": 0.72, + "public_work": 0.82, + "fine_timing": 0.45, +} + +DOMAIN_EFFORT = { + "education": 0.88, + "residence": 0.9, + "relationship": 0.85, + "career": 0.82, + "career_learning": 0.8, + "health_pressure": 0.7, + "public_work": 0.75, + "fine_timing": 0.5, +} + +QUESTION_LAYER_MAP = { + "D1": "d1_ascendant", + "D4": "divisional_ascendants", + "D9": "divisional_ascendants", + "D10": "divisional_ascendants", + "D24": "divisional_ascendants", + "D30": "divisional_ascendants", + "D60": "divisional_ascendants", + "UL": "arudha", + "A7": "arudha", + "A10": "arudha", + "KP_cusp": "kp_cusps", +} + + +@dataclass(frozen=True) +class SelectorInput: + questions: list[dict[str, Any]] + answers: dict[str, str] + candidate_count: int | None = None + prior_question_ids: list[str] | None = None + + +def _is_question(value: Any) -> bool: + return isinstance(value, dict) and bool(value.get("id")) + + +def _normalize_questions(questions: Iterable[dict[str, Any]]) -> list[dict[str, Any]]: + normalized = [] + for question in questions: + if not _is_question(question): + continue + normalized.append(question) + return normalized + + +def _question_answer_span(question: dict[str, Any]) -> int: + scoring_map = question.get("scoring_map") + if not isinstance(scoring_map, dict): + return 0 + clusters = [] + for option in ("A", "B", "C", "D"): + item = scoring_map.get(option) + if isinstance(item, dict): + clusters.append(str(item.get("cluster") or "neutral")) + return len({cluster for cluster in clusters if cluster != "neutral"}) + + +def _minute_feature_value(row: dict[str, Any], route: str, layer: str) -> Any: + if route == "d1_ascendant": + return { + "sign": row.get("d1_ascendant"), + "degree_in_sign": row.get("d1_degree_in_sign"), + "longitude": row.get("d1_longitude"), + } + if route == "divisional_ascendants": + return (row.get("divisional_ascendants") or {}).get(layer) + if route == "arudha": + return (row.get("arudha") or {}).get(layer) + if route == "kp_cusps": + return row.get("kp_cusps") + return None + + +def _minute_relevance(question: dict[str, Any], minute_scan: dict[str, Any] | None) -> float: + if not isinstance(minute_scan, dict): + return 0.0 + rows = minute_scan.get("rows") + if not isinstance(rows, list) or not rows: + return 0.0 + layers = [str(layer) for layer in (question.get("sensitivity") or []) if str(layer)] + if not any(QUESTION_LAYER_MAP.get(layer) for layer in layers): + return 0.0 + signatures = [] + for row in rows: + if not isinstance(row, dict): + continue + signature = tuple( + json.dumps(_minute_feature_value(row, route, layer), sort_keys=True, ensure_ascii=False, default=str) + for layer in sorted(layers) + for route in [QUESTION_LAYER_MAP.get(layer)] + if route + ) + signatures.append(signature) + if not signatures: + return 0.0 + distinct = len(set(signatures)) + transition_count = len(minute_scan.get("transitions") or []) + spread = distinct / max(len(signatures), 1) + return round(spread * 4.0 + min(transition_count, 6) * 0.35, 3) + + +def _question_effort(question: dict[str, Any]) -> float: + prompt = str(question.get("prompt") or "") + return max(0.35, min(1.0, 1.0 - (len(prompt) / 120.0))) + + +def _question_reliability(question: dict[str, Any]) -> float: + domain = str(question.get("domain") or "") + return float(question.get("factual_reliability") or DOMAIN_RELIABILITY.get(domain, 0.75)) + + +def _question_domain_priority(question: dict[str, Any], prior_domain_counts: dict[str, int]) -> float: + domain = str(question.get("domain") or "") + base = float(question.get("domain_priority") or 0.0) + if not base: + try: + base = float(len(DEFAULT_DOMAIN_ORDER) - DEFAULT_DOMAIN_ORDER.index(domain)) + except ValueError: + base = 1.0 + penalty = prior_domain_counts.get(domain, 0) * 1.5 + return base - penalty + + +def _question_gating(question: dict[str, Any], candidate_count: int | None) -> tuple[bool, str | None]: + if _question_answer_span(question) <= 0: + return False, "no_discriminating_answer_clusters" + if str(question.get("domain") or "") == "fine_timing" and (candidate_count is None or candidate_count > 12): + return False, "fine_timing_requires_narrower_candidate_window" + if str(question.get("domain") or "") == "health_pressure" and (candidate_count is None or candidate_count > 24): + return False, "health_pressure_deferred_until_broader_context_is_resolved" + return True, None + + +def _score_question( + question: dict[str, Any], + *, + candidate_count: int | None, + prior_domain_counts: dict[str, int], + minute_scan: dict[str, Any] | None, +) -> dict[str, Any]: + domain = str(question.get("domain") or "") + reliability = _question_reliability(question) + effort = _question_effort(question) + discriminative_span = _question_answer_span(question) + domain_priority = _question_domain_priority(question, prior_domain_counts) + cluster_bias = float(question.get("question_gain_bias") or 0.0) + minute_relevance = _minute_relevance(question, minute_scan) + candidate_bonus = 0.0 + if candidate_count is not None: + if candidate_count <= 9: + candidate_bonus = 2.0 + elif candidate_count <= 21: + candidate_bonus = 1.0 + else: + candidate_bonus = 0.35 + score = ( + discriminative_span * 3.0 + + reliability * 2.5 + + effort * 1.5 + + domain_priority * 0.2 + + candidate_bonus + + minute_relevance + + cluster_bias + ) + if str(question.get("domain") or "") == "fine_timing": + score -= 2.5 + if prior_domain_counts.get(domain, 0): + score -= prior_domain_counts[domain] * 1.0 + return { + "question_id": question.get("id"), + "domain": domain, + "score": round(score, 3), + "factual_reliability": round(reliability, 3), + "user_effort": round(1.0 - effort, 3), + "domain_diversity_penalty": prior_domain_counts.get(domain, 0), + "candidate_separation": discriminative_span, + "minute_relevance": round(minute_relevance, 3), + } + + +def select_next_questions(questionnaire: dict[str, Any], answers: dict[str, str] | None = None, *, limit: int = 1) -> dict[str, Any]: + questions = _normalize_questions( + questionnaire.get("question_bank") if isinstance(questionnaire.get("question_bank"), list) else questionnaire.get("questions") or [] + ) + answered = {} + prior_domain_counts: dict[str, int] = {} + answers = answers or {} + question_by_id = {str(question["id"]): question for question in questions} + + for question_id, choice in answers.items(): + question = question_by_id.get(str(question_id)) + if not question: + continue + answered[str(question_id)] = str(choice or "").strip().upper() + domain = str(question.get("domain") or "") + prior_domain_counts[domain] = prior_domain_counts.get(domain, 0) + 1 + + remaining = [question for question in questions if str(question.get("id") or "") not in answered] + candidate_count = None + candidate_scan = questionnaire.get("candidate_scan") + if isinstance(candidate_scan, dict): + raw_count = candidate_scan.get("candidate_count") + if isinstance(raw_count, int): + candidate_count = raw_count + minute_scan = candidate_scan.get("minute_scan") if isinstance(candidate_scan, dict) else None + + ranked = [] + for question in remaining: + allowed, reason = _question_gating(question, candidate_count) + if not allowed: + ranked.append( + { + "question_id": question.get("id"), + "domain": question.get("domain"), + "score": None, + "skipped": True, + "skip_reason": reason, + "factual_reliability": _question_reliability(question), + "candidate_separation": _question_answer_span(question), + } + ) + continue + ranked.append(_score_question(question, candidate_count=candidate_count, prior_domain_counts=prior_domain_counts, minute_scan=minute_scan)) + + usable = [item for item in ranked if not item.get("skipped")] + usable.sort(key=lambda item: (-float(item["score"]), str(item["domain"]), str(item["question_id"]))) + + if not usable: + return { + "selected_questions": [], + "ranking": ranked, + "stop": True, + "stop_reason": "no_answer_can_improve_separation", + } + + best = usable[0] + if float(best["score"]) < 4.0: + return { + "selected_questions": [], + "ranking": ranked, + "stop": True, + "stop_reason": "no_answer_can_improve_separation", + } + + selected = [] + seen_domains: set[str] = set() + for item in usable: + question = question_by_id[str(item["question_id"])] + domain = str(question.get("domain") or "") + if domain in seen_domains: + continue + allowed, _ = _question_gating(question, candidate_count) + if not allowed: + continue + selected.append({ + "id": question["id"], + "domain": domain, + "prompt": question.get("prompt"), + "why_asked": list(question.get("why_this_question") or []), + "candidate_ids_distinguished": [str(question.get("positive_cluster") or ""), str(question.get("negative_cluster") or "")], + "technique_routes": list(question.get("sensitivity") or []), + "answer_impact": dict(question.get("answer_impact") or {}), + "selection_score": item["score"], + "factual_reliability": item["factual_reliability"], + "user_effort": item["user_effort"], + "candidate_separation": item["candidate_separation"], + "minute_relevance": item.get("minute_relevance", 0.0), + }) + seen_domains.add(domain) + if len(selected) >= max(1, limit): + break + + return { + "selected_questions": selected, + "ranking": ranked, + "stop": not bool(selected), + "stop_reason": None if selected else "no_answer_can_improve_separation", + } diff --git a/scripts/candidate_time_sensitivity_scan.py b/scripts/candidate_time_sensitivity_scan.py index 351798f4..099600e0 100644 --- a/scripts/candidate_time_sensitivity_scan.py +++ b/scripts/candidate_time_sensitivity_scan.py @@ -6,6 +6,7 @@ from __future__ import annotations import argparse import json import subprocess +import sys from collections import Counter from datetime import datetime, timedelta from pathlib import Path @@ -30,7 +31,7 @@ _VARGAS = ("D4", "D9", "D10", "D24", "D30") def _engine_json(command: str, payload: dict[str, Any], *, timeout: int = 20) -> dict[str, Any]: - args = ["python3", str(ENGINE), command] + args = [sys.executable, str(ENGINE), command] for key in ("year", "month", "day", "hour", "minute", "lat", "lon", "tz"): args.extend([f"--{key}", str(payload[key])]) if command == "varga-full": @@ -39,8 +40,8 @@ def _engine_json(command: str, payload: dict[str, Any], *, timeout: int = 20) -> return json.loads(completed.stdout) -def _all_varga_ascendants(payload: dict[str, Any]) -> dict[str, str | None]: - values = {varga: None for varga in _VARGAS} +def _all_varga_ascendants(payload: dict[str, Any]) -> dict[str, dict[str, Any] | None]: + values: dict[str, dict[str, Any] | None] = {varga: None for varga in _VARGAS} try: raw = _engine_json("varga-full", payload) except subprocess.CalledProcessError: @@ -50,7 +51,8 @@ def _all_varga_ascendants(payload: dict[str, Any]) -> dict[str, str | None]: continue for varga in _VARGAS: if name.startswith(varga + "_"): - values[varga] = (chart.get("Ascendant") or {}).get("sign") + ascendant = chart.get("Ascendant") or {} + values[varga] = ascendant if isinstance(ascendant, dict) else None return values @@ -75,9 +77,21 @@ def scan_candidate_times(payload: dict[str, Any], *, uncertainty_minutes: int = "input_fingerprint": candidate_input_fingerprint(point), "d1_ascendant": asc.get("sign"), "d1_degree_in_sign": asc.get("degree_in_sign"), + "d1_longitude": asc.get("lon"), "divisional_ascendants": divisional, }) - signatures = [tuple([row["d1_ascendant"], *row["divisional_ascendants"].values()]) for row in rows] + signatures = [ + tuple([ + row["d1_ascendant"], + row["d1_degree_in_sign"], + row["d1_longitude"], + *[ + json.dumps(value, sort_keys=True, ensure_ascii=False, default=str) + for value in row["divisional_ascendants"].values() + ], + ]) + for row in rows + ] unavailable_vargas = [varga.upper() for varga in _VARGAS if all(row["divisional_ascendants"][varga.upper()] is None for row in rows)] supported_vargas = [varga.lower() for varga in _VARGAS if varga.upper() not in unavailable_vargas] modal = Counter(signatures).most_common(1)[0][0] @@ -88,7 +102,7 @@ def scan_candidate_times(payload: dict[str, Any], *, uncertainty_minutes: int = row["sensitive_layers"] = [ name for name, current, typical in zip( - ("D1", "D4", "D9", "D10", "D24", "D30"), + ("D1", "D1_degree", "D1_longitude", "D4", "D9", "D10", "D24", "D30"), signature, modal, strict=True, diff --git a/tests/test_active_rectification_questions.py b/tests/test_active_rectification_questions.py index 73e79458..7072d635 100644 --- a/tests/test_active_rectification_questions.py +++ b/tests/test_active_rectification_questions.py @@ -4,11 +4,11 @@ from scripts.active_rectification_questions import build_questionnaire, score_an def test_active_rectification_questions_generate_choice_based_workflow() -> None: - report = build_questionnaire("1955-02-24 19:15", uncertainty_minutes=30) + report = build_questionnaire("2001-02-03 10:20", uncertainty_minutes=30) assert report["scope"] == "active_birth_time_rectification_questionnaire" - assert report["candidate_scan"]["start"] == "1955-02-24 18:45" - assert report["candidate_scan"]["end"] == "1955-02-24 19:45" + assert report["candidate_scan"]["start"] == "2001-02-03 09:50" + assert report["candidate_scan"]["end"] == "2001-02-03 10:50" assert report["candidate_scan"]["candidate_count"] == 61 assert len(report["candidate_scan"]["samples"]) == 61 assert report["candidate_scan"]["sensitivity_summary"]["method"] == "minute_feature_scan_v2" @@ -25,10 +25,13 @@ def test_active_rectification_questions_generate_choice_based_workflow() -> None ) assert report["candidate_scan"]["samples"][0]["cluster"] == "early_candidate_cluster" assert report["candidate_scan"]["samples"][-1]["cluster"] == "late_candidate_cluster" + assert report["selection"]["selected_questions"] + assert all("factual_reliability" in question for question in report["questions"]) + assert all("positive_cluster" in question and "negative_cluster" in question for question in report["questions"]) def test_active_rectification_scores_answers_and_selects_next_round() -> None: - report = build_questionnaire("1955-02-24 19:15", uncertainty_minutes=30) + report = build_questionnaire("2001-02-03 10:20", uncertainty_minutes=30) scored = score_answers( report, { @@ -44,16 +47,17 @@ def test_active_rectification_scores_answers_and_selects_next_round() -> None: assert scored["answered_count"] == 5 assert scored["next_round"] == 2 assert scored["next_round_questions"] + assert scored["next_round_selection"]["selected_questions"] assert scored["candidate_cluster_rankings"][0]["score"] > scored["candidate_cluster_rankings"][-1]["score"] assert "does not convert candidates into birth-time truth" in scored["boundary"] def test_active_rectification_recasts_candidate_vargas_when_location_is_available() -> None: report = build_questionnaire( - "1993-04-17 14:49", - uncertainty_minutes=30, - lat=36.683333, - lon=114.35, + "2001-02-03 10:20", + uncertainty_minutes=1, + lat=25.04, + lon=121.56, tz=8, ) summary = report["candidate_scan"]["sensitivity_summary"] diff --git a/tests/test_active_rectification_selector.py b/tests/test_active_rectification_selector.py new file mode 100644 index 00000000..f20db8da --- /dev/null +++ b/tests/test_active_rectification_selector.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from scripts.active_rectification_questions import build_questionnaire +from scripts.active_rectification_selector import select_next_questions + + +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 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 diff --git a/tests/test_candidate_time_sensitivity_scan.py b/tests/test_candidate_time_sensitivity_scan.py index 40ee522c..3cea8414 100644 --- a/tests/test_candidate_time_sensitivity_scan.py +++ b/tests/test_candidate_time_sensitivity_scan.py @@ -5,14 +5,14 @@ def test_scanner_reports_real_divisional_transitions(monkeypatch): def fake_engine(command, payload, timeout=20): minute = payload["minute"] if command == "chart": - return {"ascendant": {"sign": "Leo", "degree_in_sign": 10 + minute / 100}} + return {"ascendant": {"sign": "Leo", "degree_in_sign": 10 + minute / 100, "lon": 130 + minute / 100}} ascendant = "Aries" if minute % 2 else "Taurus" return { - "D4_Turyamsa": {"Ascendant": {"sign": ascendant}}, - "D9_Navamsa": {"Ascendant": {"sign": ascendant}}, - "D10_Dasamsa": {"Ascendant": {"sign": ascendant}}, - "D24_Siddhamsa": {"Ascendant": {"sign": ascendant}}, - "D30_Trimsamsa": {"Ascendant": {"sign": ascendant}}, + "D4_Turyamsa": {"Ascendant": {"sign": ascendant, "degree_in_sign": minute / 10, "lon": minute}}, + "D9_Navamsa": {"Ascendant": {"sign": ascendant, "degree_in_sign": minute / 10, "lon": minute}}, + "D10_Dasamsa": {"Ascendant": {"sign": ascendant, "degree_in_sign": minute / 10, "lon": minute}}, + "D24_Siddhamsa": {"Ascendant": {"sign": ascendant, "degree_in_sign": minute / 10, "lon": minute}}, + "D30_Trimsamsa": {"Ascendant": {"sign": ascendant, "degree_in_sign": minute / 10, "lon": minute}}, } monkeypatch.setattr(scanner, "_engine_json", fake_engine) @@ -24,7 +24,9 @@ def test_scanner_reports_real_divisional_transitions(monkeypatch): assert report["candidate_count"] == 3 assert report["transitions"] assert report["pending_layers"] == ["UL", "A7", "A10", "KP_cusp"] - assert report["rows"][0]["divisional_ascendants"]["D9"] in {"Aries", "Taurus"} + assert report["rows"][0]["d1_longitude"] is not None + assert report["rows"][0]["divisional_ascendants"]["D9"]["sign"] in {"Aries", "Taurus"} + assert "D1" not in scanner._VARGAS assert report["input_contract"]["settings"]["node_mode"] == "mean" assert report["rows"][0]["input_fingerprint"] != report["rows"][1]["input_fingerprint"] assert report["stability_contract"]["minute_confirmation_allowed"] is False