Merge local main changes into origin/main

This commit is contained in:
Jesse_Chen
2026-07-19 15:43:25 +08:00
172 changed files with 15274 additions and 1277 deletions
+246
View File
@@ -0,0 +1,246 @@
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
# ─── How to run ───
# .venv/bin/python -m pytest -q tests/test_dynamic_rectification_scoring.py
"""Public dynamic-rectification packet and deterministic scoring entrypoints."""
from __future__ import annotations
from collections.abc import Sequence
from typing import Literal, TypedDict
from uuid import NAMESPACE_URL, uuid5
from scripts.dynamic_rectification_opportunities import (
ALGORITHM_VERSION,
SUPPORTED_DIMENSIONS,
candidate_times,
candidate_window_rows,
canonical_hash,
compute_candidate_model,
experience_windows,
opportunities,
validate_candidate_model,
)
Confidence = Literal["low", "medium", "high"]
_candidate_times = candidate_times
_candidate_window_rows = candidate_window_rows
_canonical_hash = canonical_hash
_experience_windows = experience_windows
_opportunities = opportunities
_validate_candidate_model = validate_candidate_model
class ChoiceRow(TypedDict):
time: str
score: float
class WinningSegment(TypedDict):
start_time: str
end_time: str
representative_time: str
width_minutes: int
def _compute_candidate_model(request: dict) -> dict:
return compute_candidate_model(request, _candidate_window_rows)
def build_difference_packet(request: dict) -> dict:
"""Build reusable candidate activations and unused high-gain opportunities."""
candidates = _candidate_times(
request["birth_date"], request["start_time"], request["end_time"]
)
_validated_choice_evidence(request.get("evidence"), candidates)
persisted = request.get("candidate_model")
model = (
_compute_candidate_model(request)
if persisted is None
else _validate_candidate_model(persisted, request)
)
dismissed = set(request.get("dismissed_opportunity_ids", []))
fingerprints = set(request.get("partition_fingerprints", []))
unused = [
item for item in _opportunities(model)
if item["opportunity_id"] not in dismissed
and item["candidate_partition_fingerprint"] not in fingerprints
]
return {
"case_id": request["case_id"],
"scoring_version": ALGORITHM_VERSION,
"current_range": {
"start_time": request["start_time"], "end_time": request["end_time"]
},
"opportunities": unused,
"asked_question_fingerprints": list(request.get("question_fingerprints", [])),
"candidate_partition_fingerprints": list(request.get("partition_fingerprints", [])),
"recent_range_history": list(request.get("recent_ranges", [])),
"candidate_model": model,
}
def _minute_value(value: str) -> int:
hour, minute = value.split(":", maxsplit=1)
return int(hour) * 60 + int(minute)
def _winning_segments(rows: Sequence[ChoiceRow], top_score: float) -> list[list[ChoiceRow]]:
segments: list[list[ChoiceRow]] = []
for row in rows:
if row["score"] != top_score:
continue
follows = bool(segments) and (
_minute_value(row["time"]) - _minute_value(segments[-1][-1]["time"])
) % 1_440 == 1
if follows:
segments[-1].append(row)
else:
segments.append([row])
return segments
def adjudicate_choice_rows(
rows: Sequence[ChoiceRow], *, effective_answer_count: int, dimension_count: int,
missing_layers: Sequence[str], request_fingerprint: str = "",
) -> dict:
"""Apply v2 confidence gates while preserving submitted candidate chronology."""
ranked = list(rows)
scores = sorted({row["score"] for row in ranked}, reverse=True)
top_score = scores[0] if scores else 0.0
second_score = scores[1] if len(scores) > 1 else top_score
segments = _winning_segments(ranked, top_score) if ranked else []
winning_rows = segments[0] if len(segments) == 1 else []
segment: WinningSegment | None = None
if winning_rows:
segment = {
"start_time": winning_rows[0]["time"],
"end_time": winning_rows[-1]["time"],
"representative_time": winning_rows[(len(winning_rows) - 1) // 2]["time"],
"width_minutes": len(winning_rows),
}
margin = round((top_score - second_score) / max(abs(top_score), 1.0) * 100, 2)
blocked = len(segments) != 1 or bool(missing_layers)
high = (
not blocked and effective_answer_count >= 4 and dimension_count >= 3
and segment is not None and segment["width_minutes"] <= 5 and margin >= 20
)
medium = (
not blocked and effective_answer_count >= 3 and dimension_count >= 2
and segment is not None and segment["width_minutes"] <= 15 and margin >= 10
)
confidence: Confidence = "high" if high else "medium" if medium else "low"
reasons = []
if len(segments) != 1:
reasons.append("tied_leader" if segments else "no_candidate_rows")
if missing_layers:
reasons.append("missing_mandatory_layers")
if confidence == "low" and effective_answer_count < 3:
reasons.append("insufficient_effective_evidence")
fingerprint = request_fingerprint or _canonical_hash(ranked)
return {
"result_id": str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}")),
"confidence": confidence,
"can_apply": confidence == "high",
"winning_segment": segment,
"event_count": effective_answer_count,
"domain_count": dimension_count,
"top_score": top_score,
"second_score": second_score,
"margin_percent": margin,
"reasons": reasons,
"evidence": [],
"algorithm_version": ALGORITHM_VERSION,
"evidence_mode": "dynamic_choice",
"effective_answer_count": effective_answer_count,
"dimension_count": dimension_count,
}
def _validated_choice_evidence(
evidence_rows: list | None, candidates: Sequence[str],
) -> tuple[list[dict], set[str]]:
if not isinstance(evidence_rows, list):
raise ValueError("choice evidence must contain partition evidence")
if len(evidence_rows) > 10:
raise ValueError("choice evidence may contain at most 10 rows")
question_ids: set[str] = set()
dimensions: set[str] = set()
required = {
"question_id", "opportunity_id", "partition_id", "dimension_code",
"candidate_scores", "information_gain",
}
for evidence in evidence_rows:
if not isinstance(evidence, dict) or set(evidence) != required:
field = (
"option_id"
if isinstance(evidence, dict) and "option_id" in evidence
else "partition evidence"
)
raise ValueError(f"choice evidence contains invalid {field}")
question_id = evidence["question_id"].strip() if isinstance(evidence["question_id"], str) else ""
if not question_id:
raise ValueError("partition evidence question identifier must be non-empty")
if question_id in question_ids:
raise ValueError("duplicate question evidence is not allowed")
if any(
not isinstance(evidence[key], str) or not evidence[key].strip()
for key in ("opportunity_id", "partition_id")
):
raise ValueError("partition evidence identifier must be a non-empty string")
_validate_scores(evidence, candidates)
if evidence["dimension_code"] not in SUPPORTED_DIMENSIONS:
raise ValueError("choice evidence dimension is unsupported")
question_ids.add(question_id)
dimensions.add(evidence["dimension_code"])
return evidence_rows, dimensions
def _validate_scores(evidence: dict, candidates: Sequence[str]) -> None:
import math
scores = evidence["candidate_scores"]
gain = evidence["information_gain"]
valid_scores = isinstance(scores, dict) and set(scores) == set(candidates) and all(
not isinstance(score, bool)
and isinstance(score, int | float)
and math.isfinite(score)
and score >= 0
for score in scores.values()
)
if not valid_scores:
raise ValueError("candidate scores must exactly match the submitted range")
if (
isinstance(gain, bool) or not isinstance(gain, int | float)
or not math.isfinite(gain) or gain < 0
):
raise ValueError("choice evidence information gain must be finite")
def score_choice_evidence(request: dict) -> dict:
"""Sum only strict server-resolved primary evidence, then adjudicate it."""
candidates = _candidate_times(
request["birth_date"], request["start_time"], request["end_time"]
)
evidence_rows, dimensions = _validated_choice_evidence(
request.get("choice_evidence"), candidates
)
totals = {candidate: 0.0 for candidate in candidates}
for evidence in evidence_rows:
for candidate in candidates:
totals[candidate] += (
float(evidence["candidate_scores"][candidate])
* float(evidence["information_gain"])
)
rows: list[ChoiceRow] = [
{"time": candidate, "score": round(score, 6)} for candidate, score in totals.items()
]
return adjudicate_choice_rows(
rows,
effective_answer_count=len(evidence_rows),
dimension_count=len(dimensions),
missing_layers=[],
request_fingerprint=_canonical_hash(request),
)
+59
View File
@@ -0,0 +1,59 @@
"""Localized public copy for dynamic birth-time rectification choices."""
from __future__ import annotations
from collections.abc import Sequence
from datetime import date
from typing import Final, Literal, TypedDict, assert_never
DIMENSION_CONTEXT: Final = {
"education": "一次明显的升学、转学或学习方向变化",
"relocation": "一次明显的搬家、离乡或长期居住地变化",
"relationship": "一次明显的关系进入、结束或重要转变",
"career": "一次明显的工作、职业方向或身份变化",
"health_pressure": "一次持续的健康压力或生活压力变化",
}
SUPPORTED_DIMENSIONS: Final = frozenset(DIMENSION_CONTEXT)
class DateRange(TypedDict):
window_start: str
window_end: str
RangePrecision = Literal["year", "month", "day"]
def _range_label(item: DateRange, precision: RangePrecision) -> str:
start = date.fromisoformat(item["window_start"])
end = date.fromisoformat(item["window_end"])
match precision:
case "year":
return f"{start.year} 年" if start.year == end.year else f"{start.year}—{end.year} 年"
case "month":
if start.year == end.year:
return f"{start.year} 年 {start.month} 月—{end.month} 月"
return f"{start.year} 年 {start.month} 月—{end.year} 年 {end.month} 月"
case "day":
if start.year == end.year and start.month == end.month:
return f"{start.year} 年 {start.month} 月 {start.day} 日—{end.day} 日"
return (
f"{start.year} 年 {start.month} 月 {start.day} 日—"
f"{end.year} 年 {end.month} 月 {end.day} 日"
)
case unreachable:
assert_never(unreachable)
def visible_range_labels(items: Sequence[DateRange]) -> list[str]:
"""Use the least date precision that keeps every visible range distinct."""
labels = [_range_label(item, "year") for item in items]
for precision in ("month", "day"):
duplicates = {label for label in labels if labels.count(label) > 1}
if not duplicates:
break
labels = [
_range_label(item, precision) if label in duplicates else label
for item, label in zip(items, labels, strict=True)
]
return labels
@@ -0,0 +1,302 @@
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
# ─── How to run ───
# .venv/bin/python -m pytest -q tests/test_dynamic_rectification.py
"""Candidate-model construction and opportunity partitioning for rectification."""
from __future__ import annotations
import hashlib
import json
import math
from collections import defaultdict
from collections.abc import Callable, Mapping, Sequence
from datetime import date, datetime, time, timedelta
from typing import Final
from uuid import NAMESPACE_URL, uuid5
from scripts.dynamic_rectification_copy import (
DIMENSION_CONTEXT,
SUPPORTED_DIMENSIONS,
visible_range_labels,
)
ALGORITHM_VERSION: Final = "birth-time-choice-scoring-v2"
OPPORTUNITY_MODEL_VERSION: Final = "birth-time-opportunity-model-v2"
MIN_INFORMATION_GAIN: Final = 0.15
def canonical_hash(value: Mapping | Sequence) -> str:
encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def candidate_times(birth_date: str, start_time: str, end_time: str) -> list[str]:
day = date.fromisoformat(birth_date)
start = datetime.combine(day, time.fromisoformat(start_time))
end = datetime.combine(day, time.fromisoformat(end_time))
if end < start:
end += timedelta(days=1)
count = int((end - start).total_seconds() // 60) + 1
if not 1 <= count <= 1_440:
raise ValueError("candidate range must contain between 1 and 1440 minutes")
return [(start + timedelta(minutes=offset)).strftime("%H:%M") for offset in range(count)]
def experience_window_sets(
birth_date: str, as_of_date: str,
) -> list[tuple[str, list[tuple[date, date]]]]:
born = date.fromisoformat(birth_date)
as_of = date.fromisoformat(as_of_date)
try:
first = born.replace(year=born.year + 12)
except ValueError:
first = born.replace(year=born.year + 12, day=28)
if as_of < first:
return []
day_count = (as_of - first).days + 1
counts = [1] if day_count == 1 else list(range(2, min(4, day_count) + 1))
return [
(
f"periods-{count}",
[
(
first + timedelta(days=day_count * index // count),
as_of if index == count - 1 else (
first + timedelta(days=day_count * (index + 1) // count - 1)
),
)
for index in range(count)
],
)
for count in counts
]
def experience_windows(birth_date: str, as_of_date: str) -> list[tuple[date, date]]:
sets = experience_window_sets(birth_date, as_of_date)
return sets[-1][1] if sets else []
def candidate_window_rows(request: dict) -> list[dict]:
"""Compute each candidate chart once and reuse it across every window."""
from scripts.active_rectification_event_engine import (
DOMAIN_CONFIG,
_candidate_datetimes,
_candidate_row,
)
window_sets = experience_window_sets(request["birth_date"], request["as_of_date"])
if not window_sets:
return []
events = []
event_windows: dict[str, tuple[str, str, date, date]] = {}
for dimension in sorted(SUPPORTED_DIMENSIONS):
for window_group, windows in window_sets:
for window_start, window_end in windows:
event_id = str(uuid5(
NAMESPACE_URL,
f"{ALGORITHM_VERSION}:{window_group}:{dimension}:{window_start}:{window_end}",
))
midpoint = window_start + (window_end - window_start) / 2
events.append({
"id": event_id,
"domain": dimension,
"date": midpoint.isoformat(),
"precision": "day",
})
event_windows[event_id] = (
window_group, dimension, window_start, window_end,
)
calculation_request = {
"birth_date": request["birth_date"],
"start_time": request["start_time"],
"end_time": request["end_time"],
"lat": request["lat"],
"lon": request["lon"],
"tz": request["tz"],
"events": events,
}
candidates = _candidate_datetimes(calculation_request)
rows = [_candidate_row(calculation_request, candidate) for candidate in candidates]
activations = {
event_id: {row["time"]: 0.0 for row in rows} for event_id in event_windows
}
missing = {layer for row in rows for layer in row["missing_layers"]}
for row in rows:
for evidence in row["evidence"]:
activations[evidence["event_id"]][row["time"]] = float(evidence["points"])
return [
{
"window_group": window_group,
"dimension_code": dimension,
"window_start": window_start.isoformat(),
"window_end": window_end.isoformat(),
"activations": activations[event_id],
"missing_layers": [DOMAIN_CONFIG[dimension][0]]
if DOMAIN_CONFIG[dimension][0] in missing else [],
}
for event_id, (window_group, dimension, window_start, window_end) in event_windows.items()
]
def compute_candidate_model(request: dict, row_builder: Callable[[dict], list[dict]]) -> dict:
return {
"version": ALGORITHM_VERSION,
"opportunity_model_version": OPPORTUNITY_MODEL_VERSION,
"birth_date": request["birth_date"],
"as_of_date": request["as_of_date"],
"range": {"start_time": request["start_time"], "end_time": request["end_time"]},
"location": {
"lat": request["lat"],
"lon": request["lon"],
"tz": request["tz"],
},
"candidate_times": candidate_times(
request["birth_date"], request["start_time"], request["end_time"]
),
"windows": row_builder(request),
}
def validate_candidate_model(model: dict, request: dict) -> dict:
expected = {
"version", "opportunity_model_version", "birth_date", "as_of_date", "range", "location",
"candidate_times", "windows",
}
candidates = candidate_times(request["birth_date"], request["start_time"], request["end_time"])
try:
valid_header = (
set(model) == expected
and model["version"] == ALGORITHM_VERSION
and model["opportunity_model_version"] == OPPORTUNITY_MODEL_VERSION
and model["birth_date"] == request["birth_date"]
and model["as_of_date"] == request["as_of_date"]
and model["range"] == {
"start_time": request["start_time"], "end_time": request["end_time"]
}
and model["location"] == {
"lat": request["lat"], "lon": request["lon"], "tz": request["tz"]
}
and model["candidate_times"] == candidates
and isinstance(model["windows"], list)
)
valid_windows = _validate_windows(model["windows"], request, candidates)
except (KeyError, TypeError, ValueError):
valid_header = valid_windows = False
if not valid_header or not valid_windows:
raise ValueError("candidate model does not match the submitted request")
return model
def _validate_windows(windows: list, request: dict, candidates: list[str]) -> bool:
generated = experience_window_sets(request["birth_date"], request["as_of_date"])
minimum = generated[0][1][0][0] if generated else date.max
maximum = date.fromisoformat(request["as_of_date"])
groups = {name for name, _windows in generated}
keys = [
(row.get("window_group"), row.get("dimension_code"), row.get("window_start"), row.get("window_end"))
for row in windows if isinstance(row, dict)
]
return len(keys) == len(set(keys)) and all(
isinstance(row, dict)
and set(row) == {
"window_group", "dimension_code", "window_start", "window_end", "activations", "missing_layers"
}
and row["window_group"] in groups
and row["dimension_code"] in SUPPORTED_DIMENSIONS
and minimum <= date.fromisoformat(row["window_start"])
<= date.fromisoformat(row["window_end"]) <= maximum
and isinstance(row["activations"], dict)
and set(row["activations"]) == set(candidates)
and all(
not isinstance(score, bool)
and isinstance(score, int | float)
and math.isfinite(score)
and score >= 0
for score in row["activations"].values()
)
and isinstance(row["missing_layers"], list)
and all(isinstance(layer, str) and layer for layer in row["missing_layers"])
for row in windows
)
def opportunities(model: dict) -> list[dict]:
grouped: dict[tuple[str, str], list[dict]] = defaultdict(list)
for row in model["windows"]:
if not row["missing_layers"]:
grouped[(row["dimension_code"], row["window_group"])].append(row)
variants: dict[str, list[dict]] = defaultdict(list)
for (dimension, window_group), windows in sorted(grouped.items()):
opportunity = _dimension_opportunity(
dimension, window_group, windows, model["candidate_times"],
)
if opportunity is not None:
variants[dimension].append(opportunity)
result = [
sorted(items, key=lambda item: (-item["estimated_information_gain"], item["opportunity_id"]))[0]
for items in variants.values()
]
return sorted(result, key=lambda item: (-item["estimated_information_gain"], item["opportunity_id"]))
def _dimension_opportunity(
dimension: str, window_group: str, windows: list[dict], candidates: list[str],
) -> dict | None:
neutral_context = DIMENSION_CONTEXT[dimension]
memberships: dict[int, list[str]] = defaultdict(list)
for candidate in candidates:
winner = max(
range(len(windows)),
key=lambda index: (windows[index]["activations"][candidate], -index),
)
memberships[winner].append(candidate)
populated = [(windows[index], members) for index, members in sorted(memberships.items())]
if not 2 <= len(populated) <= 4:
return None
probabilities = [len(members) / len(candidates) for _, members in populated]
gain = -sum(value * math.log(value) for value in probabilities) / math.log(len(populated))
if gain < MIN_INFORMATION_GAIN:
return None
basis = [
{
"version": ALGORITHM_VERSION,
"window_group": window_group,
"dimension": dimension,
"window_start": window["window_start"],
"window_end": window["window_end"],
"members": sorted(members),
}
for window, members in populated
]
labels = visible_range_labels([
{"window_start": item["window_start"], "window_end": item["window_end"]}
for item in basis
])
partitions = [
{
"partition_id": canonical_hash(item),
"descriptor": f"{item['window_start']}--{item['window_end']}",
"fallback_label": label,
"candidate_scores": {
candidate: 1.0 if candidate in item["members"] else 0.0
for candidate in candidates
},
}
for item, label in zip(basis, labels, strict=True)
]
fingerprint = canonical_hash({"version": ALGORITHM_VERSION, "partitions": basis})
return {
"opportunity_id": canonical_hash({
"version": ALGORITHM_VERSION, "dimension": dimension, "partitions": basis
}),
"dimension_code": dimension,
"neutral_context": neutral_context,
"estimated_information_gain": round(gain, 6),
"candidate_partition_fingerprint": fingerprint,
"fallback_prompt": f"哪一个时间段更接近{neutral_context}?",
"partitions": partitions,
}
+102
View File
@@ -1445,6 +1445,13 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
scheme, _, token = authorization.partition(' ')
return token.strip() if scheme.lower() == 'bearer' else ''
def _require_dynamic_rectification_token(self):
configured = os.environ.get('JYOTISH_DYNAMIC_RECTIFICATION_TOKEN', '').strip()
supplied = self._job_access_token()
matches = secrets.compare_digest(supplied, configured)
if not configured or not matches:
raise Forbidden('Dynamic rectification server token is missing or invalid')
def _vedastro_status(self):
adapter = _load_local_module('vedastro_service_adapter')
endpoint = os.environ.get('VEDASTRO_API_ENDPOINT', '').strip()
@@ -1748,6 +1755,12 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
elif path == '/api/active_rectification_events':
result = self._compute_active_rectification_events(body)
self._json(result)
elif path == '/api/dynamic_rectification_opportunities':
result = self._compute_dynamic_rectification_opportunities(body)
self._json(result)
elif path == '/api/dynamic_rectification_score':
result = self._compute_dynamic_rectification_score(body)
self._json(result)
elif path == '/api/case_validation':
result = self._compute_case_validation(body)
self._json(result)
@@ -6891,6 +6904,95 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
**result,
}
def _dynamic_rectification_base(self, body, allowed_fields, field_label):
unsupported_fields = sorted(set(body) - allowed_fields)
if unsupported_fields:
raise BadRequest(f'unsupported dynamic rectification {field_label} field: {unsupported_fields[0]}')
birth_date = body.get('birth_date')
start_time = body.get('start_time')
end_time = body.get('end_time')
if not isinstance(birth_date, str):
raise BadRequest('birth_date must be YYYY-MM-DD')
if not isinstance(start_time, str) or not isinstance(end_time, str):
raise BadRequest('candidate times must be HH:MM')
for key in ('lat', 'lon', 'tz'):
if key not in body or body[key] in (None, ''):
raise BadRequest(f'{key} is required')
try:
datetime.strptime(birth_date, '%Y-%m-%d')
datetime.strptime(start_time, '%H:%M')
datetime.strptime(end_time, '%H:%M')
except ValueError as exc:
raise BadRequest('birth date or candidate time has invalid format') from exc
return {
**body,
'birth_date': birth_date,
'start_time': start_time,
'end_time': end_time,
'lat': self._get_float(body, 'lat', 0, -90, 90),
'lon': self._get_float(body, 'lon', 0, -180, 180),
'tz': self._get_float(body, 'tz', 0, -14, 14),
}
def _compute_dynamic_rectification_opportunities(self, body):
self._require_dynamic_rectification_token()
allowed_fields = {
'case_id', 'birth_date', 'as_of_date', 'start_time', 'end_time',
'lat', 'lon', 'tz', 'candidate_model', 'evidence',
'dismissed_opportunity_ids', 'question_fingerprints',
'partition_fingerprints', 'recent_ranges',
}
normalized = self._dynamic_rectification_base(body, allowed_fields, 'opportunity')
case_id = normalized.get('case_id')
as_of_date = normalized.get('as_of_date')
if not isinstance(case_id, str) or not case_id.strip():
raise BadRequest('case_id must be a non-empty string')
if not isinstance(as_of_date, str):
raise BadRequest('as_of_date must be YYYY-MM-DD')
try:
datetime.strptime(as_of_date, '%Y-%m-%d')
except ValueError as exc:
raise BadRequest('as_of_date must be YYYY-MM-DD') from exc
for key in ('evidence', 'dismissed_opportunity_ids', 'question_fingerprints', 'partition_fingerprints', 'recent_ranges'):
if not isinstance(normalized.get(key), list):
raise BadRequest(f'{key} must be an array')
if any(not isinstance(item, str) or not item for key in ('dismissed_opportunity_ids', 'question_fingerprints', 'partition_fingerprints') for item in normalized[key]):
raise BadRequest('dynamic rectification fingerprints and IDs must be non-empty strings')
for item in normalized['recent_ranges']:
if not isinstance(item, dict) or set(item) != {'start_time', 'end_time'}:
raise BadRequest('recent_ranges must contain only start_time and end_time')
try:
datetime.strptime(item['start_time'], '%H:%M')
datetime.strptime(item['end_time'], '%H:%M')
except (KeyError, TypeError, ValueError) as exc:
raise BadRequest('recent_ranges must contain valid HH:MM times') from exc
if normalized.get('candidate_model') is not None and not isinstance(normalized['candidate_model'], dict):
raise BadRequest('candidate_model must be an object')
module = _load_local_module('dynamic_rectification')
try:
result = module.build_difference_packet(normalized)
except (KeyError, TypeError, ValueError) as exc:
raise BadRequest(str(exc)) from exc
return {'success': True, 'endpoint': 'dynamic_rectification_opportunities', **result}
def _compute_dynamic_rectification_score(self, body):
self._require_dynamic_rectification_token()
allowed_fields = {
'birth_date', 'start_time', 'end_time', 'lat', 'lon', 'tz', 'choice_evidence',
}
normalized = self._dynamic_rectification_base(body, allowed_fields, 'score')
evidence = normalized.get('choice_evidence')
if not isinstance(evidence, list):
raise BadRequest('choice_evidence must be an array')
if any(isinstance(item, dict) and 'option_id' in item for item in evidence):
raise BadRequest('option_id is client-owned and cannot be scored')
module = _load_local_module('dynamic_rectification')
try:
result = module.score_choice_evidence(normalized)
except (KeyError, TypeError, ValueError) as exc:
raise BadRequest(str(exc)) from exc
return {'success': True, 'endpoint': 'dynamic_rectification_score', **result}
def _compute_case_validation(self, body):
planets, _, _ = self._normalized_planets_from_body(body)
current_md = body.get('current_md', body.get('dasha_lord', ''))