merge: reconcile staging deployment histories
Deploy staging to test server / deploy (push) Successful in 12m36s

This commit is contained in:
linmeng
2026-07-30 13:26:44 +08:00
73 changed files with 5578 additions and 330 deletions
+192 -1
View File
@@ -148,6 +148,8 @@ _VEDASTRO_RECTIFICATION_DOMAIN_MAP = {
def _rectification_event_date_range(event):
if event.get('date_start') and event.get('date_end'):
return str(event['date_start']), str(event['date_end'])
value = str(event.get('date') or '')
precision = str(event.get('precision') or '')
if precision == 'day':
@@ -173,7 +175,7 @@ def _rectification_event_representative_date(start_date, end_date):
def _select_vedastro_rectification_events(events):
precision_rank = {'year': 1, 'month': 2, 'day': 3}
precision_rank = {'range': 1, 'year': 2, 'quarter': 3, 'month': 4, 'day': 5}
selected_by_domain = {}
eligible_event_count = 0
unsupported_events = []
@@ -294,6 +296,23 @@ def _safe_vedastro_minute_snapshot_summary(candidate_time, report):
}
def _vedastro_minute_snapshot_is_complete(report, summary):
if (
not isinstance(report, dict)
or report.get('source') != 'vedastro_official'
or summary.get('status') != 'ok'
or not summary.get('available')
):
return False
layers = summary.get('layers') if isinstance(summary.get('layers'), dict) else {}
return all(
isinstance(layers.get(name), dict)
and layers[name].get('status') == 'ok'
and bool(layers[name].get('fingerprint'))
for name in _VEDASTRO_MINUTE_SENSITIVE_LAYERS
)
def _compare_vedastro_minute_snapshots(candidate_snapshots):
comparison_ready = len(candidate_snapshots) == 2 and all(
item.get('available') for item in candidate_snapshots
@@ -324,6 +343,18 @@ def _compare_vedastro_minute_snapshots(candidate_snapshots):
}
def _safe_vedastro_adapter_call(call, *args, **kwargs):
try:
result = call(*args, **kwargs)
except TimeoutError:
return {'available': False, 'status': 'timeout', '_failure_kind': 'timeout'}
except Exception:
return {'available': False, 'status': 'exception', '_failure_kind': 'exception'}
if not isinstance(result, dict):
return {'available': False, 'status': 'invalid_response', '_failure_kind': 'exception'}
return result
def _rectification_candidate_ready_for_external_validation(result):
"""Allow external validation once local scoring has a narrow, auditable lead."""
segment = result.get('winning_segment')
@@ -1633,6 +1664,7 @@ API_COMMAND_MAP = {
'rectification-v5-candidate-features': '/api/rectification/v5/candidate-features',
'rectification-v5-score': '/api/rectification/v5/score',
'rectification-v5-diagnostics': '/api/rectification/v5/diagnostics',
'rectification-v5-vedastro-validate': '/api/rectification/v5/vedastro-validate',
'case-validation': '/api/case_validation',
'divisional-yoga': '/api/divisional_yoga',
'deep-varga-avastha': '/api/deep_varga_avastha',
@@ -1671,6 +1703,7 @@ TECHNIQUE_EXAMPLE_ENDPOINTS = {
'/api/rectification/v5/candidate-features',
'/api/rectification/v5/score',
'/api/rectification/v5/diagnostics',
'/api/rectification/v5/vedastro-validate',
'/api/relationship',
'/api/remedies',
'/api/sade_sati',
@@ -2126,6 +2159,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
self._json(self._compute_rectification_v5_score(body))
elif path == '/api/rectification/v5/diagnostics':
self._json(self._compute_rectification_v5_diagnostics(body))
elif path == '/api/rectification/v5/vedastro-validate':
self._json(self._compute_rectification_v5_vedastro_validate(body))
elif path == '/api/dynamic_rectification_opportunities':
result = self._compute_dynamic_rectification_opportunities(body)
self._json(result)
@@ -7570,6 +7605,154 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
**diagnostics(self._rectification_v5_request(body)),
}
def _compute_rectification_v5_vedastro_validate(self, body):
if not isinstance(body, dict):
raise BadRequest('request body must be an object')
candidate_times = body.get('candidate_times')
if (
not isinstance(candidate_times, list)
or len(candidate_times) != 2
or any(not isinstance(value, str) or not re.fullmatch(r'(?:[01]\d|2[0-3]):[0-5]\d', value) for value in candidate_times)
or candidate_times[0] == candidate_times[1]
):
raise BadRequest('candidate_times must contain exactly two distinct HH:MM values: primary then runner-up')
request_body = dict(body)
request_body.pop('candidate_times', None)
request = self._rectification_v5_request(request_body)
from scripts.active_rectification_event_engine import _candidate_datetimes
allowed_candidate_times = {value.strftime('%H:%M') for value in _candidate_datetimes(request)}
if any(value not in allowed_candidate_times for value in candidate_times):
raise BadRequest('candidate_times must fall within the V5 candidate range')
birth_day = datetime.strptime(request['birth_date'], '%Y-%m-%d')
selected_events, eligible_event_count, unsupported_events = _select_vedastro_rectification_events(
request['events']
)
adapter = _load_local_module('vedastro_service_adapter')
minute_cache = {}
range_scan_cache = {}
minute_reports = []
minute_raw_reports = []
candidate_validations = []
raw_reports = []
for role, candidate_time in zip(('primary', 'runner_up'), candidate_times):
hour, minute = candidate_time.split(':', 1)
candidate_case = {
'year': birth_day.year,
'month': birth_day.month,
'day': birth_day.day,
'hour': int(hour),
'minute': int(minute),
'second': 0,
'lat': request['lat'],
'lon': request['lon'],
'tz': request['tz'],
}
if candidate_time not in minute_cache:
minute_cache[candidate_time] = _safe_vedastro_adapter_call(
adapter.run_rectification_minute_snapshot_for_case,
candidate_case,
case_id=f'rectification_v5_{role}_{candidate_time.replace(":", "")}',
)
minute_report = minute_cache[candidate_time]
minute_raw_reports.append(minute_report)
raw_reports.append(minute_report)
minute_reports.append(_safe_vedastro_minute_snapshot_summary(candidate_time, minute_report))
event_scans = []
for event, adapter_domain, mapping_mode, start_date, end_date in selected_events:
cache_key = (candidate_time, adapter_domain, start_date, end_date)
if cache_key not in range_scan_cache:
range_scan_cache[cache_key] = _safe_vedastro_adapter_call(
adapter.run_range_scan_for_case,
candidate_case,
adapter_domain,
start_date,
end_date,
case_id=f'rectification_v5_{role}_{candidate_time.replace(":", "")}',
)
report = range_scan_cache[cache_key]
raw_reports.append(report)
event_scans.append(_safe_vedastro_range_scan_summary(
event,
adapter_domain,
mapping_mode,
start_date,
end_date,
report,
))
candidate_validations.append({
'role': role,
'candidate_time': candidate_time,
'metric': _vedastro_candidate_metric(event_scans),
'events': event_scans,
})
minute_comparison = _compare_vedastro_minute_snapshots(minute_reports)
minute_snapshots_verified = bool(
minute_comparison['comparison_ready']
and all(
_vedastro_minute_snapshot_is_complete(report, summary)
for report, summary in zip(minute_raw_reports, minute_reports)
)
)
scans_succeeded = bool(selected_events) and all(
item['metric']['successful_event_count'] == item['metric']['requested_event_count']
for item in candidate_validations
)
search_events_primary_supports_local_winner = bool(
scans_succeeded
and _vedastro_metric_key(candidate_validations[0]['metric'])
> _vedastro_metric_key(candidate_validations[1]['metric'])
)
blockers = []
failure_kinds = {report.get('_failure_kind') for report in raw_reports}
if 'timeout' in failure_kinds or any(report.get('status') == 'timeout' for report in raw_reports):
blockers.append('vedastro_timeout')
if 'exception' in failure_kinds:
blockers.append('vedastro_exception')
if not minute_snapshots_verified or not scans_succeeded:
blockers.append('vedastro_official_response_missing')
if minute_snapshots_verified and not minute_comparison['discriminated']:
blockers.append('vedastro_minute_sensitive_layers_not_discriminated')
if not selected_events:
blockers.append('vedastro_supported_events_missing')
blockers = list(dict.fromkeys(blockers))
passed = not blockers
return {
'success': True,
'endpoint': 'rectification_v5_vedastro_validate',
'status': 'pass' if passed else 'fail',
'passed': passed,
'can_confirm_exact_minute': False,
'candidate_times': {
'primary': candidate_times[0],
'runner_up': candidate_times[1],
},
'blockers': blockers,
'minute_sensitive_validation': {
'status': 'pass' if minute_snapshots_verified and minute_comparison['discriminated'] else 'fail',
'candidates': minute_reports,
**minute_comparison,
},
'event_validation': {
'status': 'pass' if scans_succeeded else 'fail',
'eligible_event_count': eligible_event_count,
'supported_event_count': len(selected_events),
'selection_policy': (
'one_strongest_event_per_native_adapter_domain; '
'direct_mapping_before_proxy; higher_precision_before_lower_precision; newest_on_equal_precision'
),
'unsupported_events': unsupported_events,
'search_events_primary_supports_local_winner': search_events_primary_supports_local_winner,
'candidates': candidate_validations,
},
}
def _compute_active_rectification_events_v4(self, body):
"""Compatibility projection; validation and calculations are owned by V5 services."""
from scripts.rectification.api_service import score_candidates
@@ -8445,6 +8628,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'/api/rectification/v5/candidate-features': self._compute_rectification_v5_candidate_features,
'/api/rectification/v5/score': self._compute_rectification_v5_score,
'/api/rectification/v5/diagnostics': self._compute_rectification_v5_diagnostics,
'/api/rectification/v5/vedastro-validate': self._compute_rectification_v5_vedastro_validate,
'/api/relationship': self._compute_relationship,
'/api/remedies': self._compute_remedies,
'/api/sade_sati': self._compute_sade_sati,
@@ -8574,6 +8758,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'/api/rectification/v5/candidate-features': 'Scan immutable candidate static features once per calculation specification',
'/api/rectification/v5/score': 'Build the V5 event-by-candidate contribution matrix and score candidate ranges',
'/api/rectification/v5/diagnostics': 'Run V5 stability diagnostics over the server-owned contribution matrix',
'/api/rectification/v5/vedastro-validate': 'Validate one V5 primary/runner-up pair with safe official VedAstro summaries',
'/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',
@@ -8651,6 +8836,12 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'lat': 36.419, 'lon': 114.213, 'tz': 8,
'events': [{'id': '00000000-0000-4000-8000-000000000001', 'domain': 'education', 'event_kind': 'education_milestone', 'date_start': '2016-09-01', 'date_end': '2016-09-30', 'precision': 'month', 'summary': '大学入学'}],
},
'/api/rectification/v5/vedastro-validate': {
'birth_date': '1997-08-08', 'start_time': '05:00', 'end_time': '05:03',
'lat': 36.419, 'lon': 114.213, 'tz': 8,
'candidate_times': ['05:01', '05:02'],
'events': [{'id': '00000000-0000-4000-8000-000000000001', 'domain': 'education', 'event_kind': 'education_milestone', 'date_start': '2016-09-01', 'date_end': '2016-09-30', 'precision': 'month', 'summary': '大学入学'}],
},
'/api/relationship': {'planets': SAMPLE_PLANETS, 'asc_sign': 'Aries', 'dasha_info': {'maha_dasha': 'Venus', 'antar_dasha': 'Jupiter'}},
'/api/remedies': {'shadbala': {'Sun': {'rupas': 4.1}, 'Moon': {'rupas': 3.8}}, 'doshas': ['manglik'], 'dasha_lord': 'Venus'},
'/api/sade_sati': {'moon_degree': SAMPLE_PLANETS['Moon']['lon'], 'asc_degree': SAMPLE_ASCENDANT['lon'], 'saturn_degree': SAMPLE_PLANETS['Saturn']['lon']},
+45 -8
View File
@@ -17,8 +17,12 @@ SCOREABLE_EVENT_KINDS: dict[str, frozenset[str]] = {
"health_pressure": frozenset({"self_health_event"}),
}
DATE_PRECISIONS = frozenset({"day", "month", "quarter", "year", "range"})
_REQUEST_FIELDS = frozenset({"birth_date", "start_time", "end_time", "lat", "lon", "tz", "events"})
_EVENT_FIELDS = frozenset({"id", "domain", "event_kind", "date_start", "date_end", "precision", "summary"})
_BIRTH_TIME_SOURCES = frozenset({"hospital_record", "family_exact", "approximate", "period_only", "unknown", "legacy_import"})
_LOCAL_TIME_STATUSES = frozenset({"resolved", "not_provided", "ambiguous", "nonexistent"})
_REQUEST_PROVENANCE_FIELDS = frozenset({"birth_time_source", "timezone_id", "timezone_source", "local_time_status"})
_EVENT_PROVENANCE_FIELDS = frozenset({"date_source", "date_reliability", "date_corroboration", "date_conflict_status"})
_REQUEST_FIELDS = frozenset({"birth_date", "start_time", "end_time", "lat", "lon", "tz", "events"}) | _REQUEST_PROVENANCE_FIELDS
_EVENT_FIELDS = frozenset({"id", "domain", "event_kind", "date_start", "date_end", "precision", "summary"}) | _EVENT_PROVENANCE_FIELDS
_CLOCK = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d\Z")
@@ -30,6 +34,10 @@ class LifeEvent(TypedDict):
date_end: str
precision: DatePrecision
summary: NotRequired[str]
date_source: NotRequired[str | None]
date_reliability: NotRequired[str | None]
date_corroboration: NotRequired[str | None]
date_conflict_status: NotRequired[str | None]
class RectificationRequest(TypedDict):
@@ -40,6 +48,10 @@ class RectificationRequest(TypedDict):
lon: float
tz: float
events: list[LifeEvent]
birth_time_source: NotRequired[str | None]
timezone_id: NotRequired[str | None]
timezone_source: NotRequired[str | None]
local_time_status: NotRequired[str | None]
JsonObject = dict[str, Any]
@@ -64,6 +76,24 @@ def _calendar_date(value: Any, label: str) -> date:
raise ValueError(f"{label} must be a valid YYYY-MM-DD value") from exc
def _copy_nullable_text(
source: dict[str, Any], target: dict[str, Any], name: str, label: str, maximum: int,
allowed: frozenset[str] | None = None,
) -> None:
if name not in source:
return
value = source[name]
if value is None:
target[name] = None
return
if not isinstance(value, str) or not value.strip() or len(value.strip()) > maximum:
raise ValueError(f"{label} must be null or a non-empty string up to {maximum} characters")
cleaned = value.strip()
if allowed is not None and cleaned not in allowed:
raise ValueError(f"{label} is invalid")
target[name] = cleaned
def normalize_rectification_request(body: Any, *, today: date | None = None) -> RectificationRequest:
if not isinstance(body, dict):
raise ValueError("request body must be an object")
@@ -77,9 +107,6 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) ->
raise ValueError("start_time must be HH:MM")
if not isinstance(end_time, str) or not _CLOCK.fullmatch(end_time):
raise ValueError("end_time must be HH:MM")
if start_time > end_time:
raise ValueError("start_time must not exceed end_time")
events = body.get("events")
if not isinstance(events, list) or not 1 <= len(events) <= 100:
raise ValueError("events must contain between 1 and 100 items")
@@ -112,7 +139,7 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) ->
summary = raw_event.get("summary", "")
if not isinstance(summary, str) or len(summary) > 1_000:
raise ValueError(f"events[{index}].summary must be a string up to 1000 characters")
cleaned_events.append({
cleaned_event: dict[str, Any] = {
"id": event_id,
"domain": cast(str, domain),
"event_kind": cast(str, event_kind),
@@ -120,9 +147,14 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) ->
"date_end": end_day.isoformat(),
"precision": cast(DatePrecision, precision),
"summary": summary.strip(),
})
}
_copy_nullable_text(raw_event, cleaned_event, "date_source", f"events[{index}].date_source", 120)
_copy_nullable_text(raw_event, cleaned_event, "date_reliability", f"events[{index}].date_reliability", 120)
_copy_nullable_text(raw_event, cleaned_event, "date_corroboration", f"events[{index}].date_corroboration", 1_000)
_copy_nullable_text(raw_event, cleaned_event, "date_conflict_status", f"events[{index}].date_conflict_status", 120)
cleaned_events.append(cast(LifeEvent, cleaned_event))
return {
cleaned_request: dict[str, Any] = {
"birth_date": birth_day.isoformat(),
"start_time": start_time,
"end_time": end_time,
@@ -131,3 +163,8 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) ->
"tz": _bounded_number(body, "tz", -14, 14),
"events": cleaned_events,
}
_copy_nullable_text(body, cleaned_request, "birth_time_source", "birth_time_source", 120, _BIRTH_TIME_SOURCES)
_copy_nullable_text(body, cleaned_request, "timezone_id", "timezone_id", 120)
_copy_nullable_text(body, cleaned_request, "timezone_source", "timezone_source", 80)
_copy_nullable_text(body, cleaned_request, "local_time_status", "local_time_status", 120, _LOCAL_TIME_STATUSES)
return cast(RectificationRequest, cleaned_request)
+10 -4
View File
@@ -12,22 +12,28 @@ def _winner(rows: Sequence[CandidateScoreRow]) -> str | None:
return max(rows, key=lambda row: row["score"])["time"] if rows else None
def _minute_value(value: str) -> int:
return int(value[:2]) * 60 + int(value[3:])
def _primary_cluster(rows: Sequence[CandidateScoreRow], relative_floor: float = .97) -> list[str]:
if not rows:
return []
peak = max(row["score"] for row in rows)
floor = peak * relative_floor if peak >= 0 else peak / relative_floor
selected = [row["time"] for row in rows if row["score"] >= floor]
selected = sorted((row["time"] for row in rows if row["score"] >= floor), key=_minute_value)
if not selected:
return []
groups: list[list[str]] = []
for current in selected:
minute = lambda value: int(value[:2]) * 60 + int(value[3:])
if groups and minute(current) - minute(groups[-1][-1]) == 1:
if groups and (_minute_value(current) - _minute_value(groups[-1][-1])) % 1_440 == 1:
groups[-1].append(current)
else:
groups.append([current])
return max(groups, key=lambda group: (max(next(row["score"] for row in rows if row["time"] == time) for time in group), len(group)))
if len(groups) > 1 and (_minute_value(groups[0][0]) - _minute_value(groups[-1][-1])) % 1_440 == 1:
groups[0] = [*groups.pop(), *groups[0]]
scores = {row["time"]: row["score"] for row in rows}
return max(groups, key=lambda group: (max(scores[time] for time in group), sum(max(scores[time], 0) for time in group)))
def _subtract(rows: Sequence[CandidateScoreRow], removed_ids: set[str]) -> list[CandidateScoreRow]:
+16 -2
View File
@@ -150,13 +150,27 @@ def score_from_matrix(request: RectificationRequest, built: dict[str, Any]) -> l
def calculation_spec(request: RectificationRequest) -> dict[str, Any]:
return {
def json_number(value: float) -> int | float:
return int(value) if value.is_integer() else value
spec = {
"version": INPUT_CONTRACT_VERSION,
"birthDate": request["birth_date"],
"candidateRange": {"start": request["start_time"], "end": request["end_time"]},
"latitude": request["lat"], "longitude": request["lon"], "timezoneOffsetHours": request["tz"],
"latitude": json_number(request["lat"]),
"longitude": json_number(request["lon"]),
"timezoneOffsetHours": json_number(request["tz"]),
"ayanamsa": "lahiri", "nodeMode": "mean", "minuteStep": 1,
}
for source, target in (
("birth_time_source", "birthTimeSource"),
("timezone_id", "timezoneId"),
("timezone_source", "timezoneSource"),
("local_time_status", "localTimeStatus"),
):
if source in request:
spec[target] = request[source] # type: ignore[literal-required]
return spec
def sha256(value: Any) -> str: