Replace the per-decode "launch browser + login + open catalog" model with a
persistent warm Vinpin seat that eliminates cold-start, brand-switch cost and the
seat livelock. Additive + fail-safe: every warm operation degrades to the proven
cold per-decode path, so behaviour never regresses.
Warm daemon (VinpinDaemonService, worker-process singleton):
- Scheduler warms the seat at 08:00 and tears it down at 21:00 Europe/Istanbul
(proper TZ via Intl, no hardcoded offset); warms on worker start if inside hours;
reconcile() every 60s with a reentrancy guard.
- Keepalive nudges the RDS session (mouse.move) every ~75s while warm+idle; it
SKIPS during any active seat op (busy flag) and never takes a lock that blocks a
decode.
- decode(vin): inside hours ensure warm (warm-on-demand once) then delegate to the
driver; off-hours delegate straight to the cold path. Never throws.
Driver warm path (VinpinDriverService):
- warmUp() launches+logs in ONCE and opens Fiat ePER + Renault Rpartstore + Renault
Dialogys windows without closing each other, then OCR-binds each taskbar button
(order read via OCR, not hardcoded; raise self-heals by probing slots + OCR
verify). isWarm()/teardownWarm()/keepalivePing() added.
- warmDecode(): taskbar-raise the brand window (Fiat→ePER, Renault→Rpartstore w/
Dialogys fallback), run the EXISTING in-catalog decode on the warm window, OCR
the modal, parse, then Escape to ready the field for the next VIN. Runs under the
wall-clock budget; a hang still aborts.
- Health-recovery: a dropped seat (Disconnected/no-free-sessions OCR marker) →
teardown + re-warm ONCE, then retry the decode once.
- Refactored runDialogys into openDialogysSubmenu + runDialogysSearch so the warm
path searches without a window-closing reopen; cold Dialogys flow unchanged.
Safety nets retained: VINPIN_DECODE_BUDGET_MS + sessionPoisoned breaker (warm
budget abort → teardown+poison+null), single-seat serialization (runExclusive),
decode() never throws. Gated by VINPIN_ENABLED; VINPIN_WARM_DAEMON=false forces the
legacy cold path (kill-switch). Widened VINPIN_MODAL_REGION to ~900px.
Wiring: processor calls getVinpinDaemon().decode(); worker starts the daemon on
boot and stops it (releasing the seat) on shutdown. BullMQ concurrency 1 +
attempts:1 unchanged.
Tests: business-hours warm/teardown scheduling (injected clock/TZ), brand→taskbar
routing, taskbar OCR-order binding, keepalive-skips-during-decode, and
session-drop→re-warm→retry recovery. All existing vinpin/queue tests stay green.
NOTE: un-dev-testable (prod holds the single seat) — pixel/taskbar coords are
OCR-verified + marked TUNE and need live prod validation; warm falls back to cold
until confirmed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A bad/unresolvable VIN could leave a catalog window open on the shared Vinpin
seat; the next decode's ensureBrandGrid found it "resumed open" and looped
(close→brand-grid-not-confirmed→ePER-open→browser-disconnect→relaunch) forever.
BullMQ attempts:2 + 30s backoff auto-re-fed every failure straight back into the
stuck seat, starving all real decodes for minutes.
- queue: attempts:1, drop the 30s exponential backoff (extract VINPIN_DECODE_JOB_
OPTIONS). The driver already runs its own bounded internal retries; a BullMQ
retry on top is what compounded the livelock. Null decode still persists as
not_found; a hard infra throw stays user-retriable (failed).
- driver: hard per-decode wall-clock budget (VINPIN_DECODE_BUDGET_MS, 150s) via
withDeadline() racing each attempt; on abort → close() + poison seat + return
null (no retry into the stuck state).
- driver: sessionPoisoned flag — set at nav-loop exhaustion (ensureBrandGrid /
establishSession), budget abort, and failed post-decode cleanup; the NEXT
decode forces a full cold re-establish instead of reconnecting to the resumed
desktop. Cleared on any confirmed-clean grid/catalogue.
- driver: finally-cleanup after every decode — on failure/not-found return the
seat to a clean brand grid; if that can't reach the grid, poison + tear down.
- driver: basic VIN sanity (17 alphanumerics) before touching the seat.
Healthy Fiat/Renault happy paths are byte-identical when nothing is stuck.
Cannot be live-tested (seat is on prod) — needs prod validation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Vinpin catalog matcher could route a European Renault VIN
(VF1RFE00653633190, decoded "KADJAR") onto the China-market catalog
"KADJAR ÇİN" (source XZH, 22 categories) instead of the correct European
"KADJAR" (XFE, 44 categories), serving wrong parts. Root cause: the
Turkish "ÇİN" lost its Ç/İ to the ASCII token strip and collapsed to a
dropped 1-char "N", so "KADJAR ÇİN" tokenized identically to "KADJAR" —
the market qualifier was invisible to the scorer.
Fixes:
- modelTokens now folds diacritics (NFD + combining-mark strip) so
"ÇİN" survives as the ASCII token "CIN".
- Scorer prefers an EXACT normalized model match (no extra tokens) over
a superset, via a bonus kept smaller than the year-range swing so
multi-generation routing (2022 TIPO-EGEA → MCA) is unaffected.
- New MARKET_QUALIFIERS set (ÇİN/CIN, CHINA, CHINE, RUSYA, ... grounded
in the real Renault/Dacia catalog rows) heavily penalizes candidates
whose EXTRA tokens are region qualifiers; generation tokens are not
penalized, so generations stay matchable.
- Richer-catalog (categoryCount) tiebreak among equal-score candidates;
match() query selects the category count via a correlated subquery.
Adds unit tests incl. the KADJAR case, CLIO/DUSTER generation cases, and
diacritic-folding. Fiat behavior unchanged; all 39 vinpin tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two Vinpin decode-flow UX bugs in the search page:
1. Poll window (VINPIN_MAX_POLLS 6→30, still 3s interval) now covers a
cold Vinpin VDI decode (~60-100s) instead of bailing at 18s. On
poll-exhaust we no longer fall through to the no-catalog dead-end;
instead a reassuring brand-neutral "still working, ready shortly"
card (decodePendingHint) — the decode caches server-side so a later
re-submit returns instantly.
2. The poll-exhaust fallback previously forced brandName:"Fiat" onto the
no-catalog prompt, so a Renault VIN read "Fiat". Removed. The
no-catalog copy is brand-aware via the API's noCatalog.brandName, with
brand-neutral fallback keys (noCatalogHintNeutral / browseCatalogCtaNeutral)
when no brand is available. A Renault VIN never says "Fiat".
Web-only, no flag. New i18n keys added to tr.json + en.json. No API change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Latency optimizations in the flag-gated (VINPIN_ENABLED) Vinpin decode driver.
Every change keeps the existing fixed-wait value as a fallback cap, so
worst-case behaviour and robustness are unchanged — only the common case is
faster. No live seat session was run (prod holds the single Horizon seat).
#2 Renault→Fiat symmetric swap: ensureReady no longer tears the whole browser
down on a Renault→Fiat flow switch (was a ~60-80s cold restart). It now keeps
the authenticated Horizon/VinPower session, returns to the brand grid via the
existing ensureBrandGrid (which closes a resumed foreign catalog window) and
reopens Fiat via the normal tile flow — symmetric with the Fiat→Renault
direction. Guarded fallback: if the in-session swap can't reach the grid /
throws, it falls back to close()+cold re-establish.
#3 OCR-poll-until-ready: replaced big fixed post-action sleeps whose completion
is OCR-detectable with a capped poll (screenshot→ocrRegion→regex every ~700ms,
return on match, cap == old fixed wait). Converted: afterFiatOpen(12s),
afterVinSubmit(7s, Fiat modal), afterRpartstoreSubmit(8s),
afterRenaultCatalogOpen(12s, ×2), plus cold-path afterLogin(20s) and
afterVinPowerLogin(18s). Left afterVinpinLaunch(14s) fixed — the VinPower login
dialog has no reliable OCR marker (detected via DOM), and it doubles as a
generic connecting-screen settle. Left afterDialogysSubmit fixed (out of scope).
#4 Micro-trims: field-clear Backspace burst 40→5 (VINPIN_FIELD_CLEAR_BACKSPACES),
key-type delay 45-50ms→20ms (VINPIN_TYPE_DELAY_MS, 17-char VIN ~850→~340ms),
afterAlertDismiss 700→250ms.
New pure/injectable pollForText helper in vinpin.ocr.ts (unit-tested:
early-return, cap-never-exceeded, first-read match). typecheck + biome clean;
31 vinpin unit tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
VINPIN köprüsüne Fiat'ın yanına Renault/Dacia desteği eklendi.
- Marka yönlendirme: selectVinpinBrandFlow(vin) WMI'den akışı seçer
(Renault/Dacia → Rpartstore/Dialogys, geri kalan → mevcut Fiat ePER,
Fiat davranışı byte-identical). isVinpinBrandAllowed allowlist'i
fiat + renault + dacia'ya genişletildi.
- Renault akışı durum-toleranslı: koltuk son katalogu (Rpartstore)
sunucu-taraflı hatırlayıp açık resume ediyor; ensureRpartstore grid /
submenu / açık-pencere / yükleniyor durumlarını tanıyıp kendini
toparlıyor. Rpartstore ana akış, Dialogys best-effort fallback.
- Ortak ensureBrandGrid artık resume olmuş yabancı katalog penceresini
(Rpartstore/Dialogys/ePER) kapatıp grid'e dönüyor → Renault↔Fiat
ardışık decode'ları (tek koltuk) artık kırılmıyor.
- parseRenaultHeader/isUsableRenaultParse: OCR başlığından model+marka
(RENAULT/DACIA) + yıl; bilinen-token allowlist ile contiguous model
koşusu. Matcher + processor kararlaştırılan markaya göre PL24
catalog_vehicle eşliyor (Fiat varsayılan korunur).
- Testler: Renault parser + marka-yönlendirme/allowlist birim testleri.
Flag-gated (VINPIN_ENABLED). Canlı doğrulama: 5/5 Renault/Dacia VIN
(Kadjar, Clio II, Clio IV, Dacia Duster, Latitude) doğru decode; Fiat
Egea spot-check korunuyor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SOURCE_COMMIT isn't substituted into docker-compose build args by Coolify
(resolved to a constant "unknown"), so the cache-bust never fired. Removing the
dead ARG/RUN + compose arg. Web-only deploys use a Coolify force-rebuild instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Auto-deploys (docker build with cache) were reusing a stale build layer, so
web-only commits shipped an old bundle — the switcher removal (#216) and the
canonical fix (#217) only appeared after a manual --no-cache/force rebuild.
Reference SOURCE_COMMIT (substituted by Coolify) right before `COPY . .`, so the
build layer's cache key changes every commit and COPY + `pnpm build` re-run. The
deps stage stays cached on the lockfile, so installs aren't repeated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Correction to #216: "Birleşik" must fold the catalog into OUR canonical 20-bucket
structure (pcat + eper), not show parts-catalogs' raw tree. Only "Orijinal
Katalog" swaps to the native ePER tree for Fiat.
- treeSource → eper-pekidi for Fiat (Orijinal); canonical tab renders CanonicalView
- reverts #216's "canonical tab → pcat source tree"
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Simplify the previous change: instead of a third source-switcher pill, reuse the
two tabs that are already there. For an eper (Fiat) vehicle:
"Orijinal Katalog" → the native ePER tree (default)
"Birleşik" → parts-catalogs
Non-eper vehicles keep the original source/canonical tabs untouched.
- web: derive treeSource from browseMode when the vehicle has eper; canonical
tab renders the pcat source tree (not CanonicalView) for eper vehicles
- removes the ePER/Diğer switcher, SOURCE_LABELS, and the activeSource state
Net −27 lines.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fiat dealers know the native ePER tree; parts-catalogs' broader-but-lazy folders
are where they hit gaps (a motor OEM missing under "Motor" — the reason we
crawled pekidi ePER). So for a multi-source decoded vehicle, default the
"Orijinal Katalog" view to eper-pekidi — fully populated with parts + diagrams —
instead of showing every source's roots mixed together.
- categories.service.getCategoryTree(vehicleId, source?): prefer eper-pekidi when
present; ?source= overrides; cache keyed per source
- categories: getCatalogSources(vehicleId) → sources present, default (eper) first
- controller: tree/:id/sources endpoint + ?source query
- web: source switcher pill (ePER ⇄ Diğer) in source mode when >1 source;
defaults to the first (eper). pcat never hidden, just one tap away.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A failed ePER VIN lookup ("vehicle not found") whose Cyrillic "не найден" alert
OCRs to garbage ("He HaaeHs") slipped the not-found check; the parser then
grabbed the operator's last-browsed catalog from the breadcrumb
("FIAT » TIPO-EGEA") and false-mapped it to a catalog — NM4131 landed on the
ancient "Tipo 1100-1370-1580 (1987-1993)", plus 2× PANDA. Every genuine decode
carries a SINCOM; the breadcrumb false positives never do.
- parser: isUsableParse now requires a SINCOM (a model alone is not a decode)
- matcher: modelTokens keeps 4-digit engine displacements (1100/1370/1580) —
only 1950-2039 count as years — so ancient catalogs no longer collapse to
["TIPO"] and win the fewest-extra-tokens tiebreak
- matcher: recency tiebreak on equal score — never fall back to an ancient
generation for a bare / year-less model
15 unit tests (parser + matcher) pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Panelin 'Bonus süre ekle (goodwill)' aksiyonu 360 gün gibi süreler
gerektiriyor; 90 gün sınırı iş ihtiyacını karşılamıyordu. Üst sınır
3650 (10 yıl) parmak-kayması koruması olarak kalıyor. Panel tarafı
(sp) aynı limite güncellendi.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fast worker canlıydı ama pcat'in 90/dk penceresini ana worker yiyordu → fast
işler sürekli 18s source-rate defer (BMW init'i slot bulamadı). checkSourceRate
artık şerit-bazlı: fast = total/3 (pcat 30), main = kalan (60); toplam
değişmedi → upstream yükü aynı. Cooldown pause'u da doğru worker'ı duraklatıyor.
Spec fast-queue davranışına güncellendi (17/17).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Kök neden: ana kuyruk saatlerce üretim>tüketim rejiminde (children patlaması);
lifo'da sonradan eklenen HER iş bir öncekini gömer → taze araç init'leri bile
açlığa düşer (BMW 320i 58dk 0 parça). Fix: catalog-prefetch-fast kuyruğu +
ikinci worker (conc=4, 120/dk) — decode-tetiği ve fast:true işler (children
zinciri dahil) buraya akar; per-source redis rate gate iki worker'ı da ortak
sınırlar (upstream korunur). vehicles.service fast kuyruğa bağlandı.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/dashboard/catalog/:brand/:modelId sayfasında birleşik görünüm yoktu — backend
endpoint'i (/catalog/vehicles/:id/canonical) hazırdı ama web'e hiç bağlanmamıştı.
CanonicalView'a endpoint/catalogMode/brandName/variantSearch prop'ları eklendi
(varyant qs dahil); sayfaya vehicles/$id ile aynı toggle. Varsayılan Orijinal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(1) Placeholder'lar subgroup içinde olduğundan başlık "0 kategori" diyordu —
sayaç artık flat+subgroup birleşik unique. (2) Kalabalık markalar (pl24 BMW
elektrik 537 entry) görünümü boğuyordu — kova başına en-paylaşılan 40 entry
(vehicle_count desc).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Kullanıcının orijinal tasarımı: aynı (kaynak, marka)da kategori yapısı birebir
aynı — değişen yalnız parça kodları/şemalar. Önceki şablon sadece kova/alt-yaprak
BAŞLIKLARINI overlay ediyordu → taze araçta "Aydınlatma — 0 kategori". Yeni:
canonical_template_entries (migration 0031) — markanın paylaşılan kaynak-kategori
ADLARI (≥2 araç); backfill'de rebuild. overlayTemplate eksik adları disabled
placeholder olarak basar (unavailable=true, CategoryGrid zaten soluk+tıklanamaz
render ediyor) → decode anında her kova tam adlarıyla dolu; drill tamamlanınca
placeholder'lar gerçek satıra dönüşür.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"Fotoğraf okunamadı" genel metni gerçek nedeni (örn. 503 "görselden şase
okuma şu anda kullanılamıyor", 400 "desteklenmeyen tür") yutuyordu — dev'de
OPENROUTER_API_KEY boş kaldığında teşhisi zorlaştırdı. ApiError mesajı artık
toast açıklamasında; 429 için Türkçe "çok fazla deneme" metni; PostHog
reason artık http_<status> taşıyor.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>