fix: duplicate detection for 'Usted ya tiene cita previa' + timing bug fixes

Bugs fixed:
- _track_rtt(ms) on success log returned None → switched to _current_load()
- _track_rtt(round(ms,1)) on 'Hora no permitida' path: returned None,
  double-tracked RTT (already tracked in make_request), AND called round()
  in the path (violates zero-overhead rule) → switched to _current_load()
- cur_load = _current_load() in skew retry loop: computed but never used
  → removed (dead code, adds overhead per retry iteration)

Duplicate detection:
- Added DUPLICATE_PATTERNS tuple centralized duplicate string matching
- Added 'Uciente' discovered: server returns 'Usted ya tiene cita previa en
  la gestion especificada' when NIE already has a cita for this servicio/cola
- Added _is_duplicate() helper, used in all 3 detection sites (initial, retry,
  reconnect)
- Updated 'else' catch-all with clearer guidance message

Tests:
- Updated test_loop_breaks_on_duplicate to work with _is_duplicate() refactor
- Added test_duplicate_detection_includes_new_string
- Updated E2E getHorasCitas test: Oct 20 fully booked → Oct 21 (9 slots)
This commit is contained in:
cc-ultrav-1
2026-10-05 00:14:47 +02:00
parent 3c960c1ce5
commit b4b26938c4
2 changed files with 41 additions and 13 deletions

View File

@@ -95,6 +95,23 @@ SKEW_EXTENDED_CAP = 15.0 # cap Phase 3 delay at 15s
SKEW_MAX_BUDGET = 120.0 # 120s per slot (covers cancellations up to 2min after midnight)
SKEW_MAX_RETRIES = 200 # hard safety cap (budget hits first at ~40 retries)
# ── DUPLICATE DETECTION ──────────────────────────────────────
# Strings that indicate the server already has a cita for this NIE + servicio/cola.
# When ANY of these substrings appear in the result, STOP immediately — do NOT
# retry, do NOT try other slots. This prevents accidental duplicate bookings.
DUPLICATE_PATTERNS = (
"ya existente",
"DNI",
"existe",
"Usted ya tiene cita previa", # ← discovered: server returns this when NIE already has a cita for the servicio/cola
"cita previa en la gestion", # sub-string match for the above
)
def _is_duplicate(result):
"""Check if the result string indicates an existing cita for this NIE."""
rl = result.lower()
return any(p.lower() in rl for p in DUPLICATE_PATTERNS)
# ── CITA TYPES CATALOG ─────────────────────────────────────────
# Static catalog from live API query (ScServicioFiltrado1 + ScColaVo).
# Maps (servicio, cola) → metadata dict.
@@ -1073,7 +1090,7 @@ for hora in PRIORITY:
log(f" {NOMBRE} {APE1} {APE2} — NIE: {DOC}", level="SUCCESS")
log(f" Cola: {COLA} (C/PORTUGAL 17 TARDE - PADRÓN)", level="SUCCESS")
log(f" Motivo: {COMENTARIO}", level="SUCCESS")
log(f" Server load: {_track_rtt(ms) if ms else 'N/A'}", level="SUCCESS")
log(f" Server load: {_current_load()}", level="SUCCESS")
log(f" Response time: {ms:.0f}ms", level="SUCCESS")
log(f" Slot total: {slot_total_ms:.0f}ms ({slot_attempt_count} attempt(s))", level="SUCCESS")
log(f" Time-to-success (pre-fire→cita): {t_prefire_to_success:.0f}ms", level="SUCCESS")
@@ -1089,7 +1106,7 @@ for hora in PRIORITY:
"attempts": slot_attempt_count, "avg_rtt_ms": round(sum(slot_rtts)/len(slot_rtts), 1),
"status": "taken_certified", "rtts": slot_rtts
})
load = _track_rtt(round(ms, 1))
load = _current_load() # _track_rtt already called in make_request — no double-track
log(f" → Slot {hora}: certified taken in {slot_total_ms:.0f}ms (load: {load}). Moving to next.", level="WARN")
elif result == "Fecha posterior al limite permitido":
@@ -1097,7 +1114,7 @@ for hora in PRIORITY:
# hasn't rolled yet. We must keep retrying until one of these:
# 1. Booking succeeds → "Cita insertada" (slot available, not taken)
# 2. Slot certified taken → "Hora no permitida" on retry
# 3. Duplicate detected → "already exists" / "DNI" / "existe"
# 3. Duplicate detected → "ya existente" / "DNI" / "existe" / "Usted ya tiene cita previa"
# 4. Other definitive error → non-Fecha-posterior response
# 5. Time budget exhausted (120s) → cannot certify, try next slot
log(f" → Date beyond server limit. Retrying {hora} with 3-phase backoff...", level="WARN")
@@ -1124,7 +1141,6 @@ for hora in PRIORITY:
# Phase 3 (attempt 13+): 2s × 1.2^n, capped 15s — extended, catches cancellations
delay = _skew_delay(skew_attempt)
delay = min(delay, SKEW_MAX_BUDGET - elapsed) # don't sleep past budget
cur_load = _current_load() # check load before each retry (RTT already tracked by make_request)
time.sleep(delay)
skew_attempt += 1
@@ -1152,7 +1168,7 @@ for hora in PRIORITY:
log(f" Attempts: {slot_attempt_count} | Slot total: {slot_total_ms:.0f}ms | Avg RTT: {avg_rtt:.0f}ms", level="SUCCESS")
log(f" Time-to-success (pre-fire→cita): {t_prefire_to_success:.0f}ms | (booking start→cita): {t_success_total:.0f}ms", level="SUCCESS")
break
elif "ya existente" in result2 or "DNI" in result2 or "existe" in result2:
elif _is_duplicate(result2): # covers "ya existente", "DNI", "existe", "Usted ya tiene cita previa"
log(f" → DUPLICATE on retry: {result2[:200]} — STOPPING", level="ERROR")
duplicate_detected = True # signal outer loop to STOP entirely
break
@@ -1184,7 +1200,7 @@ for hora in PRIORITY:
if booked_hora or duplicate_detected:
break # success OR duplicate certifies — do NOT try any more slots
elif "ya existente" in result or "DNI" in result or "existe" in result:
elif _is_duplicate(result): # covers "ya existente", "DNI", "existe", "Usted ya tiene cita previa"
t_slot_end = time.time()
slot_total_ms = (t_slot_end - t_slot_start) * 1000
slot_timings.append({
@@ -1227,7 +1243,7 @@ for hora in PRIORITY:
log(f" Attempts: {slot_attempt_count} | Slot total: {slot_total_ms:.0f}ms | Avg RTT: {avg_rtt:.0f}ms", level="SUCCESS")
log(f" Time-to-success (pre-fire→cita): {t_prefire_to_success:.0f}ms | (booking start→cita): {t_success_total:.0f}ms", level="SUCCESS")
break
elif "ya existente" in result_retry or "DNI" in result_retry or "existe" in result_retry:
elif _is_duplicate(result_retry): # covers "ya existente", "DNI", "existe", "Usted ya tiene cita previa"
log(f" → DUPLICATE after reconnect: {result_retry[:200]} — STOPPING", level="ERROR")
duplicate_detected = True
break
@@ -1243,8 +1259,9 @@ for hora in PRIORITY:
log(f" → Force-reconnect failed: {e}", level="ERROR")
else:
log(f" → Unexpected: {result[:150]}", level="WARN")
log(f" Stopping to avoid accidental duplication.", level="WARN")
# Catch-all for any other server response — stop to avoid accidental double-booking
log(f" → Unexpected server response: {result[:150]}", level="WARN")
log(f" Stopping to avoid accidental duplication. Add to DUPLICATE_PATTERNS if this is a known duplicate signal.", level="WARN")
break
# Small delay between attempts (avoid overwhelming server)

View File

@@ -336,9 +336,19 @@ class TestBookingLoopSafety(unittest.TestCase):
source = f.read()
loop_start = source.index("for hora in PRIORITY:")
loop_section = source[loop_start:loop_start+10000]
dup_section = loop_section[loop_section.index("ya existente"):loop_section.index("ya existente")+500]
# Must use _is_duplicate() (centralised duplicate detection) OR the old inline
# string check — either way, the loop must break on a duplicate
if "_is_duplicate" in loop_section:
dup_idx = loop_section.index("_is_duplicate")
else:
dup_idx = loop_section.index("ya existente")
dup_section = loop_section[dup_idx:dup_idx+500]
self.assertIn("break", dup_section)
def test_duplicate_detection_includes_new_string(self):
"""DUPLICATE_PATTERNS must include 'Usted ya tiene cita previa'."""
self.assertIn("Usted ya tiene cita previa", _ns.get("DUPLICATE_PATTERNS", ""))
def test_duplicate_detected_flag_stops_outer_loop(self):
"""A duplicate detected during ANY retry must stop the ENTIRE booking
process — not just the current slot — by setting duplicate_detected=True
@@ -708,14 +718,15 @@ class TestE2ELiveAPI(unittest.TestCase):
self.endpoint = self.api_base + "/ScCitaVo1"
def test_live_getHorasCitas_returns_9_slots(self):
"""getHorasCitas for cola I / fecha 20-10-2026 should return 9 slots.
(Oct 19 is fully booked from midnight run; Oct 20 is the next available.)"""
"""getHorasCitas for cola I / fecha 21-10-2026 should return 9 slots.
(Oct 19 fully booked from midnight run; Oct 20 fully booked from bot rush;
Oct 21 is the next available date with 9 slots.)"""
import http.client, ssl
ctx = ssl.create_default_context()
conn = http.client.HTTPSConnection("webservices.alicante.es", 443, context=ctx, timeout=15)
conn.connect()
body = json.dumps({"name": "getHorasCitas", "parameters": [
{"servicio": "S"}, {"cola": "I"}, {"fecha": "20-10-2026"}
{"servicio": "S"}, {"cola": "I"}, {"fecha": "21-10-2026"}
]}, separators=(",", ":"))
headers = {
"Content-Type": "application/vnd.oracle.adf.action+json",