fix: harden dynamic rectification boundary

This commit is contained in:
Jesse_Chen
2026-07-19 01:06:23 +08:00
parent df0adcdfc8
commit 49ab717752
7 changed files with 691 additions and 458 deletions
+87 -252
View File
@@ -3,26 +3,35 @@
# dependencies = []
# ///
# ─── How to run ───
# .venv/bin/python -m pytest -q tests/test_dynamic_rectification.py
"""Candidate-backed opportunities and deterministic dynamic-choice scoring."""
# .venv/bin/python -m pytest -q tests/test_dynamic_rectification_scoring.py
"""Public dynamic-rectification packet and deterministic scoring entrypoints."""
from __future__ import annotations
import hashlib
import json
import math
from collections import defaultdict
from collections.abc import Mapping, Sequence
from datetime import date, datetime, time, timedelta
from typing import Final, Literal, TypedDict
from uuid import NAMESPACE_URL, UUID, uuid5
from collections.abc import Sequence
from typing import Literal, TypedDict
from uuid import NAMESPACE_URL, uuid5
ALGORITHM_VERSION: Final = "birth-time-choice-scoring-v2"
MIN_INFORMATION_GAIN: Final = 0.15
SUPPORTED_DIMENSIONS: Final = frozenset(
{"education", "relocation", "relationship", "career", "health_pressure"}
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
@@ -36,228 +45,40 @@ class WinningSegment(TypedDict):
width_minutes: int
Confidence = Literal["low", "medium", "high"]
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_windows(birth_date: str, as_of_date: 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
window_count = min(4, day_count, max(2, math.ceil(day_count / (6 * 365))))
boundaries = [first + timedelta(days=day_count * index // window_count) for index in range(window_count)]
return [
(start, as_of if index == window_count - 1 else boundaries[index + 1] - timedelta(days=1))
for index, start in enumerate(boundaries)
]
def _candidate_window_rows(request: dict) -> list[dict]:
"""Compute each candidate chart once and reuse it for every dimension/window."""
from scripts.active_rectification_event_engine import (
DOMAIN_CONFIG,
_candidate_datetimes,
_candidate_row,
)
windows = _experience_windows(request["birth_date"], request["as_of_date"])
if not windows:
return []
synthetic_events = []
event_windows: dict[str, tuple[str, date, date]] = {}
for dimension in sorted(SUPPORTED_DIMENSIONS):
for window_start, window_end in windows:
event_id = str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{dimension}:{window_start}:{window_end}"))
midpoint = window_start + (window_end - window_start) / 2
synthetic_events.append(
{"id": event_id, "domain": dimension, "date": midpoint.isoformat(), "precision": "day"}
)
event_windows[event_id] = (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": synthetic_events,
}
rows = [_candidate_row(calculation_request, candidate) for candidate in _candidate_datetimes(calculation_request)]
activations = {
event_id: {row["time"]: 0.0 for row in rows}
for event_id in event_windows
}
missing_layers = sorted({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 [
{
"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_layers else [],
}
for event_id, (dimension, window_start, window_end) in event_windows.items()
]
def _compute_candidate_model(request: dict) -> dict:
return {
"version": ALGORITHM_VERSION,
"birth_date": request["birth_date"],
"as_of_date": request["as_of_date"],
"range": {"start_time": request["start_time"], "end_time": request["end_time"]},
"candidate_times": _candidate_times(request["birth_date"], request["start_time"], request["end_time"]),
"windows": _candidate_window_rows(request),
}
def _validate_candidate_model(model: dict, request: dict) -> dict:
expected_keys = {"version", "birth_date", "as_of_date", "range", "candidate_times", "windows"}
candidates = _candidate_times(request["birth_date"], request["start_time"], request["end_time"])
try:
valid_header = (
set(model) == expected_keys
and model["version"] == ALGORITHM_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["candidate_times"] == candidates
and isinstance(model["windows"], list)
)
first_window = _experience_windows(request["birth_date"], request["as_of_date"])
minimum_date = first_window[0][0] if first_window else date.max
maximum_date = date.fromisoformat(request["as_of_date"])
window_keys = [
(row.get("dimension_code"), row.get("window_start"), row.get("window_end"))
for row in model["windows"] if isinstance(row, dict)
]
valid_windows = len(window_keys) == len(set(window_keys)) and all(
isinstance(row, dict)
and set(row) == {"dimension_code", "window_start", "window_end", "activations", "missing_layers"}
and row["dimension_code"] in SUPPORTED_DIMENSIONS
and minimum_date <= date.fromisoformat(row["window_start"])
<= date.fromisoformat(row["window_end"]) <= maximum_date
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 model["windows"]
)
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 _opportunities(model: dict) -> list[dict]:
grouped: dict[str, list[dict]] = defaultdict(list)
for row in model["windows"]:
if not row["missing_layers"]:
grouped[row["dimension_code"]].append(row)
opportunities = []
candidates = model["candidate_times"]
for dimension, windows in sorted(grouped.items()):
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 members]
if not 2 <= len(populated) <= 4:
continue
probabilities = [len(members) / len(candidates) for _, members in populated]
information_gain = -sum(value * math.log(value) for value in probabilities) / math.log(len(populated))
if information_gain < MIN_INFORMATION_GAIN:
continue
partition_basis = []
partitions = []
for window, members in populated:
basis = {
"version": ALGORITHM_VERSION,
"dimension": dimension,
"window_start": window["window_start"],
"window_end": window["window_end"],
"members": sorted(members),
}
partition_basis.append(basis)
partitions.append(
{
"partition_id": _canonical_hash(basis),
"descriptor": f"{window['window_start']}--{window['window_end']}",
"fallback_label": f"{window['window_start'][:4]}{window['window_end'][:4]}",
"candidate_scores": {candidate: 1.0 if candidate in members else 0.0 for candidate in candidates},
}
)
fingerprint = _canonical_hash({"version": ALGORITHM_VERSION, "partitions": partition_basis})
opportunities.append(
{
"opportunity_id": _canonical_hash({"version": ALGORITHM_VERSION, "dimension": dimension, "partitions": partition_basis}),
"dimension_code": dimension,
"neutral_context": dimension,
"estimated_information_gain": round(information_gain, 6),
"candidate_partition_fingerprint": fingerprint,
"fallback_prompt": f"下面哪个时间段更接近你在 {dimension} 方面的明显变化?",
"partitions": partitions,
}
)
return sorted(opportunities, key=lambda item: (-item["estimated_information_gain"], item["opportunity_id"]))
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"])
candidates = _candidate_times(
request["birth_date"], request["start_time"], request["end_time"]
)
_validated_choice_evidence(request.get("evidence"), candidates)
model = request.get("candidate_model")
candidate_model = _compute_candidate_model(request) if model is None else _validate_candidate_model(model, request)
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", []))
opportunities = [
item for item in _opportunities(candidate_model)
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": opportunities,
"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": candidate_model,
"candidate_model": model,
}
@@ -271,7 +92,7 @@ def _winning_segments(rows: Sequence[ChoiceRow], top_score: float) -> list[list[
for row in rows:
if row["score"] != top_score:
continue
follows = segments and (
follows = bool(segments) and (
_minute_value(row["time"]) - _minute_value(segments[-1][-1]["time"])
) % 1_440 == 1
if follows:
@@ -285,8 +106,8 @@ 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 to precomputed effective choice evidence."""
ranked = sorted(rows, key=lambda row: _minute_value(row["time"]))
"""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
@@ -318,7 +139,7 @@ def adjudicate_choice_rows(
reasons.append("missing_mandatory_layers")
if confidence == "low" and effective_answer_count < 3:
reasons.append("insufficient_effective_evidence")
fingerprint = request_fingerprint or _canonical_hash(list(ranked))
fingerprint = request_fingerprint or _canonical_hash(ranked)
return {
"result_id": str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}")),
"confidence": confidence,
@@ -338,7 +159,9 @@ def adjudicate_choice_rows(
}
def _validated_choice_evidence(evidence_rows: list | None, candidates: Sequence[str]) -> tuple[list[dict], set[str]]:
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:
@@ -351,54 +174,66 @@ def _validated_choice_evidence(evidence_rows: list | None, candidates: Sequence[
}
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"
field = (
"option_id"
if isinstance(evidence, dict) and "option_id" in evidence
else "partition evidence"
)
raise ValueError(f"choice evidence contains invalid {field}")
try:
UUID(evidence["question_id"])
except (ValueError, TypeError, AttributeError) as exc:
raise ValueError("partition evidence question_id must be a UUID") from exc
if evidence["question_id"] in question_ids:
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]
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")
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")
_validate_scores(evidence, candidates)
if evidence["dimension_code"] not in SUPPORTED_DIMENSIONS:
raise ValueError("choice evidence dimension is unsupported")
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")
question_ids.add(evidence["question_id"])
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"])
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:
scores = evidence["candidate_scores"]
gain = evidence["information_gain"]
for candidate in candidates:
totals[candidate] += float(scores[candidate]) * float(gain)
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()
]
@@ -0,0 +1,259 @@
# /// 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
ALGORITHM_VERSION: Final = "birth-time-choice-scoring-v2"
MIN_INFORMATION_GAIN: Final = 0.15
SUPPORTED_DIMENSIONS: Final = frozenset(
{"education", "relocation", "relationship", "career", "health_pressure"}
)
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_windows(birth_date: str, as_of_date: 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
count = min(4, day_count, max(2, math.ceil(day_count / (6 * 365))))
boundaries = [first + timedelta(days=day_count * index // count) for index in range(count)]
return [
(start, as_of if index == count - 1 else boundaries[index + 1] - timedelta(days=1))
for index, start in enumerate(boundaries)
]
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,
)
windows = experience_windows(request["birth_date"], request["as_of_date"])
if not windows:
return []
events = []
event_windows: dict[str, tuple[str, date, date]] = {}
for dimension in sorted(SUPPORTED_DIMENSIONS):
for window_start, window_end in windows:
event_id = str(uuid5(
NAMESPACE_URL,
f"{ALGORITHM_VERSION}:{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] = (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 [
{
"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, (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,
"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", "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["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_windows(request["birth_date"], request["as_of_date"])
minimum = generated[0][0] if generated else date.max
maximum = date.fromisoformat(request["as_of_date"])
keys = [
(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) == {
"dimension_code", "window_start", "window_end", "activations", "missing_layers"
}
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[str, list[dict]] = defaultdict(list)
for row in model["windows"]:
if not row["missing_layers"]:
grouped[row["dimension_code"]].append(row)
result = []
for dimension, windows in sorted(grouped.items()):
opportunity = _dimension_opportunity(dimension, windows, model["candidate_times"])
if opportunity is not None:
result.append(opportunity)
return sorted(result, key=lambda item: (-item["estimated_information_gain"], item["opportunity_id"]))
def _dimension_opportunity(dimension: str, windows: list[dict], candidates: list[str]) -> dict | None:
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,
"dimension": dimension,
"window_start": window["window_start"],
"window_end": window["window_end"],
"members": sorted(members),
}
for window, members in populated
]
partitions = [
{
"partition_id": canonical_hash(item),
"descriptor": f"{item['window_start']}--{item['window_end']}",
"fallback_label": f"{item['window_start'][:4]}{item['window_end'][:4]}",
"candidate_scores": {
candidate: 1.0 if candidate in item["members"] else 0.0
for candidate in candidates
},
}
for item in basis
]
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": dimension,
"estimated_information_gain": round(gain, 6),
"candidate_partition_fingerprint": fingerprint,
"fallback_prompt": f"下面哪个时间段更接近你在 {dimension} 方面的明显变化?",
"partitions": partitions,
}
+12 -8
View File
@@ -1286,8 +1286,6 @@ API_COMMAND_MAP = {
'active-rectification-questions': '/api/active_rectification_questions',
'active-rectification-score': '/api/active_rectification_score',
'active-rectification-events': '/api/active_rectification_events',
'dynamic-rectification-opportunities': '/api/dynamic_rectification_opportunities',
'dynamic-rectification-score': '/api/dynamic_rectification_score',
'case-validation': '/api/case_validation',
'divisional-yoga': '/api/divisional_yoga',
'deep-varga-avastha': '/api/deep_varga_avastha',
@@ -1322,8 +1320,6 @@ TECHNIQUE_EXAMPLE_ENDPOINTS = {
'/api/active_rectification_questions',
'/api/active_rectification_score',
'/api/active_rectification_events',
'/api/dynamic_rectification_opportunities',
'/api/dynamic_rectification_score',
'/api/relationship',
'/api/remedies',
'/api/sade_sati',
@@ -1449,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()
@@ -6909,6 +6912,9 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
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')
@@ -6926,6 +6932,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
}
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',
@@ -6966,6 +6973,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
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',
}
@@ -7745,8 +7753,6 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'/api/active_rectification_questions': self._compute_active_rectification_questions,
'/api/active_rectification_score': self._compute_active_rectification_score,
'/api/active_rectification_events': self._compute_active_rectification_events,
'/api/dynamic_rectification_opportunities': self._compute_dynamic_rectification_opportunities,
'/api/dynamic_rectification_score': self._compute_dynamic_rectification_score,
'/api/relationship': self._compute_relationship,
'/api/remedies': self._compute_remedies,
'/api/sade_sati': self._compute_sade_sati,
@@ -7872,8 +7878,6 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'/api/prashna': 'Compute Prashna chart and answer evidence',
'/api/rectification_gate': 'Evaluate birth-time precision gate',
'/api/active_rectification_events': 'Score dated life events against actual birth-time candidates',
'/api/dynamic_rectification_opportunities': 'Build candidate-backed dynamic rectification opportunities',
'/api/dynamic_rectification_score': 'Score server-resolved dynamic rectification choices',
'/api/relationship': 'Compute relationship and spouse-status evidence',
'/api/remedies': 'Generate low-risk remedies from doshas/strength/dasha',
'/api/sade_sati': 'Compute Sade Sati status and phase',