feat(rectification): 出卡加精度门槛,补经历改成系统点名
Independent Staging Quality Gate / validate (push) Failing after 6m28s
Independent Staging Quality Gate / publish (push) Skipped

宽度超过 10 分钟或头名并列时不再出交付卡,改为按大运边界逐条问、
用类型芯片和年/月选择器录入。跳过的线换问法再问一次;答「这类事
都没有过」的不再问。用户说「没有了」仍立刻给目前范围。Skill 10.0.27。

BUG-740~743
This commit is contained in:
jesse-ux
2026-09-16 18:35:27 +08:00
parent 317e9f1886
commit cfb41daf3d
75 changed files with 4763 additions and 383 deletions
+3 -1
View File
@@ -218,7 +218,7 @@ def score_candidates(request: RectificationRequest) -> dict[str, Any]:
fingerprint = sha256({
key: value
for key, value in request.items()
if key not in {"asked_probe_keys", "dropped_asked_probe_keys", "column_times", "refresh_probes"}
if key not in {"asked_probe_keys", "dropped_asked_probe_keys", "declined_domains", "column_times", "refresh_probes"}
})
result_id = str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}"))
candidate_decisions = build_candidate_decisions(
@@ -517,6 +517,7 @@ def block_scan(request: RectificationRequest) -> dict[str, Any]:
"precision_stage": {"current": "block_scan"},
"blocks": blocks,
"discriminating_event_probes": [],
"guided_collect_windows": [],
"acceptance_allowed": False,
"selection_allowed": False,
"display_allowed": False,
@@ -524,6 +525,7 @@ def block_scan(request: RectificationRequest) -> dict[str, Any]:
**receipt,
"precision_stage": {"current": "block_scan"},
"discriminating_event_probes": [],
"guided_collect_windows": [],
"acceptance_allowed": False,
"selection_allowed": False,
},
+23 -1
View File
@@ -52,7 +52,7 @@ _EVENT_PROVENANCE_FIELDS = frozenset({
})
_REQUEST_FIELDS = frozenset({
"birth_date", "start_time", "end_time", "lat", "lon", "tz", "events",
"ayanamsa", "node_mode", "asked_probe_keys", "column_times", "minute_step", "blocks",
"ayanamsa", "node_mode", "asked_probe_keys", "declined_domains", "column_times", "minute_step", "blocks",
"refresh_probes",
}) | _REQUEST_PROVENANCE_FIELDS
ASKED_PROBE_KEY_MAX_LENGTH = 200
@@ -169,6 +169,7 @@ class RectificationRequest(TypedDict):
timezone_source: NotRequired[str | None]
local_time_status: NotRequired[str | None]
asked_probe_keys: NotRequired[list[str]]
declined_domains: NotRequired[list[str]]
dropped_asked_probe_keys: NotRequired[int]
column_times: NotRequired[list[str]]
refresh_probes: NotRequired[bool]
@@ -357,6 +358,27 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) ->
cleaned_request["asked_probe_keys"] = cleaned_keys
if dropped:
cleaned_request["dropped_asked_probe_keys"] = dropped
if "declined_domains" in body:
raw_domains = body.get("declined_domains")
if not isinstance(raw_domains, list) or len(raw_domains) > 20:
raise ValueError("declined_domains must contain between 0 and 20 strings")
cleaned_domains: list[str] = []
seen_domains: set[str] = set()
allowed = {
"education", "career", "relocation", "relationship",
"family", "finance", "health_pressure",
}
for index, item in enumerate(raw_domains):
if not isinstance(item, str) or not item.strip():
raise ValueError(f"declined_domains[{index}] must be a non-empty domain name")
domain = item.strip()
if domain not in allowed:
raise ValueError(f"declined_domains[{index}] is not a collect domain")
if domain in seen_domains:
continue
seen_domains.add(domain)
cleaned_domains.append(domain)
cleaned_request["declined_domains"] = cleaned_domains
if "column_times" in body:
raw_times = body.get("column_times")
if not isinstance(raw_times, list) or not 1 <= len(raw_times) <= 64:
+1
View File
@@ -755,6 +755,7 @@ def build_decision_receipt(
"precision_stage": packet["precision_stage"],
"oos_blind_prompts": packet["oos_blind_prompts"],
"discriminating_event_probes": packet.get("discriminating_event_probes") or [],
"guided_collect_windows": packet.get("guided_collect_windows") or [],
"event_clarification_probes": packet.get("event_clarification_probes") or [],
"evidence_collection_probes": packet.get("evidence_collection_probes") or [],
"candidate_contrast_opportunities": packet.get("candidate_contrast_opportunities") or [],
+197
View File
@@ -1779,6 +1779,203 @@ def discriminating_event_probes(
return probes
GUIDED_COLLECT_LIMIT = 6
GUIDED_DOMAIN_ORDER = (
"education",
"career",
"relocation",
"relationship",
"family",
"finance",
"health_pressure",
)
_GUIDED_TRACK_DOMAIN = {
"nara:d9": "relationship",
"nara:d10": "career",
}
def _guided_track_starts(
context: dict[str, Any],
*,
birth_date: str,
lo: int,
hi: int,
include_pratyantar: bool,
varga_narayana: bool,
vim_cache: dict[tuple[Any, ...], list[date]],
narayana_cache: dict[tuple[Any, ...], list[date] | None],
) -> dict[str, list[date]]:
tracks: dict[str, list[date]] = {}
moon = float(context["planet_longitudes"]["Moon"])
vim_key = (*_vim_cache_key(birth_date, moon, lo, hi), include_pratyantar)
if vim_key not in vim_cache:
vim_cache[vim_key] = _vim_start_dates(
birth_date, moon, lo, hi, include_pratyantar=include_pratyantar,
)
tracks["vim"] = vim_cache[vim_key]
natal_key = (*_narayana_cache_key(
int(context["ascendant_index"]), context["planet_longitudes"], birth_date, lo, hi,
), None)
if natal_key not in narayana_cache:
narayana_cache[natal_key] = _narayana_start_dates(
int(context["ascendant_index"]), context["planet_longitudes"], birth_date, lo, hi,
)
natal = narayana_cache[natal_key]
if natal is not None:
tracks["nara"] = natal
if not varga_narayana:
return tracks
for layer in ("d9", "d10"):
raw = _layer_value(context, layer)
if not isinstance(raw, int):
continue
layer_key = (*_narayana_cache_key(
raw, context["planet_longitudes"], birth_date, lo, hi,
), layer)
if layer_key not in narayana_cache:
narayana_cache[layer_key] = _narayana_start_dates(
raw, context["planet_longitudes"], birth_date, lo, hi,
)
dates = narayana_cache[layer_key]
if dates is not None:
tracks[f"nara:{layer}"] = dates
return tracks
def _guided_year_windows(dates: Sequence[date]) -> list[tuple[int, int, int]]:
by_year: dict[int, list[date]] = {}
for item in dates:
by_year.setdefault(item.year, []).append(item)
windows: list[tuple[int, int, int]] = []
for year in sorted(by_year):
months = [item.month for item in by_year[year]]
windows.append((year, min(months), max(months)))
return windows
def guided_collect_windows(
request: dict[str, Any],
built: dict[str, Any],
*,
candidate_times: Sequence[str],
today: date | None = None,
) -> list[dict[str, Any]]:
"""Boundary windows for guided collect. Does not change auto probe generation."""
birth_date = str(request.get("birth_date") or "").strip()
birth_year = _birth_year(birth_date)
if birth_year is None:
return []
try:
datetime.strptime(birth_date, "%Y-%m-%d")
except ValueError:
return []
remaining = _remaining_contexts(built, candidate_times)
if len(remaining) < 2:
remaining = _static_contexts(built)
work = [item for item in remaining if _scoreable(item)]
if len(work) < 2:
return []
declined = {
str(item).strip()
for item in (request.get("declined_domains") or [])
if str(item).strip() in GUIDED_DOMAIN_ORDER
}
now = today or date.today()
lo, hi = birth_year + 5, min(now.year, birth_year + 80)
remaining_layers = _differing_layers(work)
fallback = [
domain
for domain in _probe_domains(
remaining_layers,
[item for item in (request.get("events") or []) if isinstance(item, dict)],
d1_differs="d1" in remaining_layers,
)
if domain not in declined
]
if not fallback:
fallback = [domain for domain in GUIDED_DOMAIN_ORDER if domain not in declined]
if not fallback:
return []
vim_cache: dict[tuple[Any, ...], list[date]] = {}
narayana_cache: dict[tuple[Any, ...], list[date] | None] = {}
starts_by_time: dict[str, dict[str, list[date]]] = {}
for context in work:
time = _context_time(context)
if not time:
continue
starts_by_time[time] = _guided_track_starts(
context,
birth_date=birth_date,
lo=lo,
hi=hi,
include_pratyantar=True,
varga_narayana=True,
vim_cache=vim_cache,
narayana_cache=narayana_cache,
)
rows: list[dict[str, Any]] = []
seen: set[tuple[int, int, int, str]] = set()
unlayered_index = 0
for left, right in _representative_pairs(work):
left_time = str(_context_time(left) or "")
right_time = str(_context_time(right) or "")
left_tracks = starts_by_time.get(left_time) or {}
right_tracks = starts_by_time.get(right_time) or {}
for track in ("vim", "nara", "nara:d9", "nara:d10"):
left_dates = left_tracks.get(track) or []
right_dates = right_tracks.get(track) or []
if not left_dates or not right_dates:
continue
for index, (one, two) in enumerate(zip(left_dates, right_dates)):
if one == two:
continue
gathered: list[date] = []
for context in work:
time = _context_time(context)
dates = (starts_by_time.get(time or "") or {}).get(track) or []
if index < len(dates):
gathered.append(dates[index])
if len(gathered) < 2 or min(gathered) == max(gathered):
continue
ordered = sorted(gathered)
cut = ordered[0] + (ordered[-1] - ordered[0]) / 2
left_n = sum(1 for item in gathered if item <= cut)
right_n = len(gathered) - left_n
if left_n < 1 or right_n < 1:
continue
track_domain = _GUIDED_TRACK_DOMAIN.get(track)
if track_domain and track_domain not in declined:
domain = track_domain
else:
domain = fallback[unlayered_index % len(fallback)]
unlayered_index += 1
if domain in declined:
continue
for year, month_lo, month_hi in _guided_year_windows(gathered):
if year < lo or year > hi:
continue
key = (year, month_lo, month_hi, domain)
if key in seen:
continue
seen.add(key)
rows.append({
"year": year,
"month_lo": month_lo,
"month_hi": month_hi,
"domain": domain,
"split": {"left": left_n, "right": right_n},
})
rows.sort(key=lambda row: (
abs(row["split"]["left"] - row["split"]["right"]),
abs(now.year - int(row["year"])),
-int(row["year"]),
int(row["month_lo"]),
str(row["domain"]),
))
return rows[:GUIDED_COLLECT_LIMIT]
def prospective_windows_for_time(
request: dict[str, Any],
built: dict[str, Any],
@@ -688,6 +688,7 @@ def build_refinement_packet(
"precision_stage": {"current": "block_scan"},
"oos_blind_prompts": [],
"discriminating_event_probes": [],
"guided_collect_windows": [],
"event_clarification_probes": [],
"evidence_collection_probes": [],
"candidate_contrast_opportunities": [],
@@ -708,6 +709,7 @@ def build_refinement_packet(
discriminating_event_probe_set,
event_clarification_probes,
evidence_collection_probes,
guided_collect_windows,
prospective_event_windows,
)
grid_times = list(built.get("candidate_times") or candidate_times)
@@ -731,6 +733,11 @@ def build_refinement_packet(
)
probes = bundle["probes"]
dropped = list(bundle["dropped"])
windows = guided_collect_windows(
request,
built,
candidate_times=probe_times,
)
clarification = event_clarification_probes(request)
collection = evidence_collection_probes(request)
if probe_times == grid_times:
@@ -783,6 +790,7 @@ def build_refinement_packet(
"precision_stage": stage,
"oos_blind_prompts": oos_blind_prompts(request),
"discriminating_event_probes": probes,
"guided_collect_windows": windows,
"event_clarification_probes": clarification,
"evidence_collection_probes": collection,
"candidate_contrast_opportunities": opportunities,