102 Commits

Author SHA1 Message Date
Semih
5555a628b7 feat(billing-actions): bonus/trial uzatma limiti 90 → 3650 gün
Goodwill bonus'ta 360 gün gibi süreler gerekiyor; 90 gün sınırı iş
ihtiyacını karşılamıyordu. UI + route guard 3650'ye (10 yıl, parmak
kayması koruması) çekildi. Spoke tarafındaki eş limit sase.tr PR'ı ile
gevşiyor — o merge olana kadar spoke 90 üstünü 400 ile reddeder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 10:34:58 +03:00
Semih
c61df73e51 feat(category-mapping): kuyrukta orijinal (kaynak-dili) adı göster
Çeviri artefaktları gerçek parçayı gizliyor ("Charger Management"→"Şarjör
Yönetimi") — LLM v3 pasında orijinal ad 985 kova + 1.764 alt düzeltmesi
getirdi. Reviewer da aynı sinyali görsün: örnek adın altında amber renkte
"⟵ <orijinal>" satırı (name_original, çeviriden farklıysa).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:18:50 +03:00
Semih
a3564c66a5 fix(category-mapping): DB hatasını JSON olarak döndür (boş 500 yerine)
Kök neden: super_panel_reader read-only idi → saveMapping "permission denied
for table category_canonical_map" (42501) fırlatıyordu → unhandled 500, boş
gövde → client "Unexpected end of JSON input". Grant verildi (INSERT/UPDATE
category_canonical_map + UPDATE(canonical_category_id,canonical_subcategory_id)
categories). Route artık DB hatasını yakalayıp {ok:false,error} döner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 07:37:43 +03:00
Semih
06a1524ab3 feat(category-mapping): "Genel bırak" — karışık sayfaları kalıcı işaretle
"Debriyaj 1" gibi patlak-şema sayfaları (set+disk+bilya karışık) tek alt-
kategoriye atanamaz; reviewer atlayınca kuyrukta tekrar beliriyor. Yeni buton:
is_manual=true + sub=NULL kaydeder → kuyruk sorgusu bu adları kalıcı hariç
tutar; Birleşik'te kova → Genel altında kalır (doğru). Kaydet artık no-sub
modunda alt seçilmeden yanlışlıkla genel-kayıt yapmaz (koruma + yönlendirme).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 07:29:12 +03:00
Semih
663d9cb816 fix(daily-overview): 24h KPI ham success yerine decode health göstersin
'Sase 24h success 48.5% (-20.7pp)' alarmı yine coverage-gap artefaktıydı:
99 sorgunun 51'i katalog-boşluğu (41'i sadece 4 Tofaş-Fiat NM4* VIN'inin
5-17× retry'ı), gerçek infra hatası 0 — decode health %100.

- getSaseHealthSnapshot: getOperationalHealth ile aynı partition'la
  decodable/decodeHealth/coverageGap alanları eklendi (ham alanlar duruyor)
- Ana sayfa KPI: başlık metrik decode health; ham oran + katalog-boşluğu
  sayısı hint'te
- buildActionItems: düşüş uyarısı artık decodeHealthDeltaPp üzerinden

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 06:22:14 +03:00
Semih
0a4a78c943 fix(vin-anomaly): deploy-regression kontrolünde katalog-boşluğunu hariç tut
Deploy 006cf1fd (yalnızca widget assets) −25pp "regression" alarmı verdi:
8 satırlık post-window'da tek kullanıcının tanınmayan tek VIN'i (2 retry)
ham success-rate'i 75%'e düşürdü. windowSlice artık vin-anomaly.ts
windowStats ile aynı semantiği kullanıyor: coverage gap'ler (no-catalog /
unknown VIN, trafiğin ~%20-38'i) hariç, DISTINCT (user,vin) lookup bazlı.
regressed ayrıca ≥2 distinct real-failed lookup istiyor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 12:28:48 +03:00
Semih
c4027c303f fix(category-mapping): kod/tarih adlarını + subsuz kovaları kuyruktan çıkar
"(1901- )" gibi kod/tarih yaprakları (inherit ile kova almış, isimde sub sinyali
yok) ve Aksesuar gibi alt-yaprağı olmayan kovalar kuyruğa girmesin. REVIEWABLE'a
eklendi: NOT STRUCTURAL + isimde ≥3 harfli kelime var (NORM ~ '[a-z]{3,}').
no-sub'a: bucket'ın alt-yaprağı olmalı. Artık her satır gerçekten sub-atanabilir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 06:22:29 +03:00
Semih
970bf4af95 fix(category-mapping): kuyrukta sadece parça-tutan yaprakları göster
Şemsiye/dal kategoriler ("Fren Sistemi", "Frenler", "Yakıt Sistemi") kuyruğa
girmesin — bunların tek alt-yaprağı yok, üst-seviyede doğrular. REVIEWABLE
filtresi: çocuğu olmayan (leaf) VE parça tutan kategoriler. Şemsiyeler 0-1
yaprak-parçalı olduğu için HAVING count>1 ile düşer. Reviewer yalnızca
sub-classify edilebilir spesifik parça gruplarını görür. Fast index-probe
(parent_id/category_id), tüm-kovalar worst-case 0.75s.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 06:16:47 +03:00
Semih
a969061c15 feat(operations): kanonik kategori manuel eşleme modülü
Otomatik keyword pası sonrası kalan boşlukları SP'den kapatmak için. İki kuyruk:
"alt-kategori eksik" (mapped ama sub yok — asıl iş) + "kova eksik" (unmapped).
İsim-seviyesinde (source+normalized_name) review — karar o adı paylaşan tüm
araçlara yayılır. Kaydet → category_canonical_map is_manual=true (backfill
korumalı) + anında categories'e materialize. Kaynak/kova filtresi, etki-sıralı
kuyruk, kova-başına ilerleme. saseDb raw query (canonical tabloları prisma
şemasında yok). /operations/category-mapping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:38:28 +03:00
Semih
826be6716e fix(sase): api-key SDK cevaplarında spoke TransformInterceptor zarfını aç
api-keys internal uçları success alanı döndürmediği için spoke'un global
interceptor'ı {success,data} zarfı ekliyor (lifecycle/billing uçları kendi
success'ini döndürdüğünden zarflanmıyor). unwrapEnvelope iki şekli de kabul
eder; AdminApiError artık {error:{message}} zarfındaki mesajı da okur.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 06:33:18 +03:00
Semih
eee3675589 feat(sase): Public API anahtar yönetimi — /projects/sase/api-keys
Sase.tr'nin yeni Public API'sinin (Faz 1+2) anahtar yaşam döngüsü artık
panelden yönetiliyor:
- Liste: katman-1 RO DB (apikeys modeli pinlendi, key hash kolonu bilinçli
  dışarıda) + bugünkü decode-kota kullanımı spoke /usage ucundan (Redis)
- Üretim: tip seçimi (live/test/widget-pk), domain kilidi, günlük decode
  limiti, geçerlilik, istek/dk — düz metin anahtar tek seferlik modal
- Düzenleme: limit/domains/rate/expiry PATCH; kapat/aç
- apikeys tablosu prod'da yokken sayfa kırılmaz (promote-öncesi uyarı)
- admin-sdk sase: 6 yeni endpoint (auto-audit)

Spoke karşılığı: sase.tr feature/api-key-admin (dev)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 06:26:43 +03:00
Semih
ca551b74f1 fix(catalog-gap): distinct-VIN sayımı — negatif-cache şişmesini gider
catalog-coverage detektörü "No catalog" gap'lerini ham log satırıyla
sayıyordu; aynı katalogsuz VIN redis negatif-cache'ten defalarca servis
edilince (+ lock_wait) gap olduğundan büyük görünüyordu (Renault 38–45
"failure" = gerçekte ~12 araç, 8'i tek tekrarlı VIN).

- failures = count(DISTINCT vin); ham hacim `events` olarak korunur
- negatif-cache/lock_wait satırları tutulur ama VIN/kullanıcı başına 1 kez
- severity breadth eşiği 15 → 10 (yeni distinct-VIN ölçeği)
- worker hypothesis metni doğrulanan kök nedene güncellendi: VIN→araç
  resolution gap (mapping/veri eksikliği DEĞİL); fix yönü doResolveVin
  marka-katalog fallback + categories.service loadError
- telegram/özet metni "başarısız sorgu" → "farklı araç (VIN)"

Etki (7g): 12 marka-gap → 5 gerçek (Renault/Fiat/Opel/Dacia/Nissan),
tek/çift-VIN gürültüsü elendi.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 08:44:24 +03:00
Semih
e915dfe31c fix(vin-anomaly): volume_drop'u sürdürülebilir + haftanın-günü-duyarlı yap
volume_drop iki nedenle false-positive üretiyordu (2026-06-14 Pazar, decode
tamamen çalışırken saatte bir −%100 "kesinti" bastı):
1. Tek 15dk pencere — düşük B2B decode trafiği patlamalı (bir kullanıcı birkaç
   sorgu yapar, sonra bir saat sessizlik), tek bir 0 penceresi sağlıklıyken bile
   normal.
2. Ardışık-7-gün baseline — decode talebinde güçlü haftalık mevsimsellik var
   (hafta sonu sakin); 7 gün baseline yoğun hafta içini hafta sonu beklentisine
   katlıyor → normal bir Pazar (~1-2/saat) hafta-içi-şişmiş ~18/saat baseline'a
   karşı −%100 okunuyor.

Düzeltme: volume_drop artık SON 1 SAATİ, AYNI GÜN/AYNI SAAT son 4 haftanın
ortalamasına karşı değerlendiriyor; yalnız o gün/saat tarihsel olarak gerçek
hacim görüyorsa (≥12/saat) ve son saat bunun ≥%80 altındaysa tetikleniyor.
Hafta sonu saatleri aynı-gün baseline'ı eşiğin altında kaldığı için kendiliğinden
susuyor; gerçek bir hafta-içi-yoğun-saat çöküşü hâlâ tetikliyor (prod query_logs
ile doğrulandı: bugünün Pazar saatleri SUSUYOR, Çarşamba 13:00 ~13/saat hâlâ izleniyor).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:29:03 +03:00
Semih
5dde24855c fix(sase): p95_latency_spike — fresh-only p95 + min fresh volume + 8s floor
p95 was computed over ALL successful rows: cache hits (~10-30ms) set the
baseline, a single lock_wait row (duplicate click waiting on the in-flight
decode of the same VIN) set the spike. On a healthy 3-query window this read
as 'P95 15ms → 2058ms (137× baseline)' (2026-06-11, 4th such false alert).

- p95 now over FRESH decodes only (source='cache', db_hit/redis_positive and
  lock_wait rows excluded; null-safe via coalesce)
- both windows must have ≥4 fresh decodes — a 'p95' of fewer is one slow
  request, not a tail
- absolute floor raised 1s → 8s (fresh decodes legitimately take seconds;
  matches the dashboard's fresh-P95 ok≤8s tone)

Simulated on today's prod data: alert window had 0 fresh decodes → silent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:21:20 +03:00
Semih
b9ea801d0f feat(analytics): proxy analizi — provider/servis karnesi + banlı IP takibi
/analytics/proxy: Sase proxy_logs telemetrisinden (sase.tr dev 45f7e3f)
provider × servis karnesi (A-F: başarı/ban/transport/latency ağırlıklı),
banlı exit IP / sticky session tablosu, ≥400 status dağılımı (402 = proxy
bakiye), günlük seyir. Tablo prod'a promote edilene dek bilgilendirici boş
durum gösterir.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:30:07 +03:00
Semih
4454547c59 feat(analytics): reported empty categories page
Lists catalog_issue_reported PostHog events (one-click 'Bildir' on
sase.tr empty/error catalog states, sase.tr dev c972a17): time, reporter
email (distinct_id → Sase users), VIN, vehicle, category, source, and
the page it happened on. Category distribution + unique-reporter KPIs,
7/30/90d ranges, linked from /analytics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:00:36 +03:00
Semih
e9275e6f44 fix(analytics): catalog-gap — heuristic skip-filter for pre-flag prod rows
Prod doesn't write pcat_skipped/pcat_transient yet (sase fd78957 is on
dev), so the 2026-06-11 15:31 run still raised false gaps for Audi
(10/10 rows cold-skip), Hyundai (2/3) and VW (1/3 + 2 untraceable cache
echoes). A real pcat answer needs a network round-trip, so pcat <= 10ms
means skipped — exclude those rows. Verified on prod data: the three
false brands drop below threshold, Renault/Fiat/Honda etc. unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:35:29 +03:00
Semih
76ef20ec4c fix(analytics): catalog-gap — ignore rows where pcat never evaluated
'No catalog — identified as X' rows produced during a pcat cold-pool
skip, transport blip, or open circuit breaker are not evidence of a
coverage gap; counting them raises false brand-gap insights (Audi,
2026-06-11). Excludes rows carrying the new pcat_skipped /
pcat_transient markers (sase.tr dev fd78957) or pcat_circuit_open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:50:45 +03:00
Semih
9c10513fde fix(analytics): VIN decode dashboard — honest KPI definitions
Raw succeeded/total read 61% on a healthy pipeline because coverage
gaps (no-catalog, unknown VIN) counted as failures, and latency
percentiles mixed ~10ms cache hits with ~5-40s provider chains
(P50 28ms next to P95 25.8s meant nothing).

- partition failures exactly: no_catalog / unknown / real (timeout,
  provider) via one CASE bucket
- Başarı = decode health (coverage gaps excluded), Gerçek hata real
  failures only, new Kapsama dışı card (katalog yok + unknown VIN)
- P50/P95/P99 now fresh decodes only, cache hits excluded
- evening brief gains realErrorRatePct/decodeHealthPct/noCatalog24h

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:12:33 +03:00
Semih
8e6c0d0131 fix(analytics): unknown_vin_spike — exclude identified-but-no-catalog, dedupe
After the success_rate_drop fix, the SAME benign data (one Fiat VIN, "No catalog —
identified as Fiat", retried across a couple users) just moved the false positive to
unknown_vin_spike (live: "Tanınamadı 11.1%→62.5%, 5/8, critical"). A car we DID
identify but lack a catalog for is a coverage gap, not an unrecognized VIN.

- unknown_vins / unknown_vin_users now count only TRULY unrecognized VINs:
  exclude identified_no_catalog=1 (the "No catalog — identified as X" case).
- Add distinct_unknown_lookups (deduped) + gate the check on ≥4 distinct
  unrecognized lookups so a couple users' uncovered cars can't trip it.

Validated vs prod query_logs: all recent windows truly_unknown=0 → GATED; a real
"tanınamadı" surge (≥2 users, ≥4 distinct unrecognized VINs, rate ≥2× baseline)
still fires. typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:46:21 +03:00
Semih
1d9de6d8e6 fix(analytics): VIN success_rate_drop — exclude coverage gaps, dedupe retries
Completes acce1fc (which gated volume_spike/unknown_vin_spike for retry noise but
left success_rate_drop on a raw row-rate). Today two 🔴 success_rate_drop alerts
fired (88.9%→60%, 61.9%→33.3%) on a single user retrying one catalogless Fiat VIN
6× in a low-volume window — 0 real decode failures.

Root cause: success_rate_drop used succeeded/total over raw rows with only a
MIN_CURRENT_VOLUME=5 gate — no distinct-user gate and no coverage-gap exclusion,
unlike the sibling checks. At ~2.6 decodes/15min, one user's retries collapse the
"rate".

Fix — bring it to the same standard:
- Measure REAL (infra) failures only: exclude coverage gaps (no-catalog / unknown
  / unsupported VIN; ~20-35% of traffic, driven by which cars users query, not
  decode health — already tracked by unknown_vin_spike).
- Dedupe by DISTINCT (user,vin) lookup so retries count once.
- Gate on ≥2 distinct users + ≥6 decodable lookups; fire on ≥10pp rise in real
  failure rate AND ≥20% absolute floor.
- Also added an absolute-volume floor (MIN_VOLUME_SPIKE_ABS=25) to volume_spike,
  which fired at now=8 yesterday (below baseline).

Validated against prod query_logs: both alert windows now GATED (real_failed=0);
the 06-10 09:30 real-incident window (7 users, 5/8 real failures, DataImpulse
degradation) still FIRES. typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:39:38 +03:00
Semih
acce1fcc8e fix(analytics): VIN anomaly detector — suppress single-user retry noise
Night of 2026-06-11 one user retrying a catalogless Toyota VIN tripped
volume_spike + unknown_vin_spike, and ~2.5/window night baselines made
quiet windows fire volume_drop at -100%.

- volume_spike: require >=2 distinct users and >=5 distinct (user,vin)
  lookups so same-VIN retries don't count as a spike
- unknown_vin_spike: require unknown failures from >=2 distinct users
- volume_drop: raise expected-per-window floor from 1 to 4

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:45:36 +03:00
Semih
79b8b6b356 feat(analytics): recordings intelligence — signal filters + replay links
Recordings tab filters by signal (errors / long >=5m / active >=30 clicks)
with live counts, highlights error sessions, and adds a one-click PostHog
replay player link per recording. Turns the 5k+ archived recordings into a
triage surface for watching problem sessions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 23:25:45 +03:00
Semih
fffc81f132 fix(sase): catalog-coverage Prisma make_interval(bigint) error
Prisma serialized windowDays as bigint -> make_interval(days => bigint) does
not exist (42883), so detectCatalogCoverageGaps threw on every real call: the
evening brief silently dropped its Sase-DB half and catalog-gap-detect never
created insights. Use a JS Date param (matches the rest of the codebase).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 22:37:12 +03:00
Semih
ea63f4d167 feat(analytics): experiment significance + experiments in the evening brief
- experiment results compute a two-proportion z-test p-value + 95% CI per
  variant vs control (significant = p<0.05); shown in the archive detail view
  and used to label the leader honestly.
- evening brief: getExperimentBriefSummary feeds active-experiment results
  (leader, lift, p-value, significance) into the brief metrics; prompt tells
  the LLM to surface only statistically significant winners, not noisy lifts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 22:33:05 +03:00
Semih
99965f4088 feat(analytics): survey + experiment results in the archive detail view
Survey detail now shows the response funnel (shown -> sent -> dismissed) +
per-question distributions (rating/choice bars, open-text list), keyed by
$survey_id -> $survey_response_<questionId> from the archived definition.
Feature-flag/experiment detail shows per-variant exposure -> conversion with
lift-vs-control + a goal-event selector (signup/decode/trial/checkout/payment).
All from posthog_events; no schema change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:59:13 +03:00
Semih
400ed224c5 feat(archive): add groups + recordings to the PostHog archive
- groups: posthog-resource-archive now also snapshots group types + group
  instances (groups_types/ + groups/?group_type_index) into
  posthog_resource_snapshots (change-detected).
- recordings: new posthog-recording-archive job (@*/6h) captures complete
  metadata for EVERY recording into posthog_recordings (skipDuplicates over a
  rolling 90d window) + MinIO dump — a superset of the insight pipeline's
  promoted SessionMeta subset. rrweb blobs still archived by archive-recordings.
- /posthog-archive browser: Groups / Group Types tabs + a Recordings tab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:19:54 +03:00
Semih
e953f5fc34 feat(analytics): browsable PostHog Archive (/posthog-archive)
Read view over posthog_resource_snapshots: type tabs (feature flags,
experiments, surveys, insights, dashboards, annotations, actions) with
resource counts, latest-per-resource table, and a detail page showing the
full lossless payload + change history (version timeline). Nav entry added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:05:41 +03:00
Semih
dd54f4983b feat(archive): capture all PostHog resource modules to DB + MinIO
Extends archival beyond events/persons/cohorts. New generic resource
archiver (posthog-resource-archive@*/6h) snapshots surveys, feature flags,
experiments, dashboards, insights, annotations, and actions into
posthog_resource_snapshots — change-detected (hash over non-volatile config),
lossless `data` payload — and dumps a full per-type daily copy to MinIO
(resources/<type>/<date>.json.gz). listResource() pages any PostHog REST
resource. Survey responses + flag calls are already captured as events.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:31:19 +03:00
Semih
a9e0d93aa1 feat(analytics): PostHog product-analytics dashboard (/analytics, sase default)
Reusable AnalyticsDashboard over the locally-archived posthog_events stream
(~15min fresh, validated against live PostHog within 1%):
- KPIs with period-over-period deltas: visitors, signups, signup conversion,
  trials, VIN decodes, parts views, OEM copies, WAU
- Activation funnel (signup cohort -> decode -> parts -> OEM)
- Conversion funnel (trial -> checkout -> payment)
- Acquisition by first-touch source (utm_source -> referrer -> direct)
- Daily trend + product quality (decode success %, empty-parts %, empty catalog)

Parameterized by projectKey (reusable across spokes); sase is the default.
Nav entry added. Reads posthog_events locally — no live PostHog API at render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:21:29 +03:00
Semih
01c93daf6a feat(brief): Sase 360° evening brief — page + worker job + Telegram@20:00
Daily Sase brief aggregating VIN decode health, catalog coverage gaps,
revenue (Stripe/iyzico TRY), users/subscriptions, PostHog behavior + UTM
source, Meta ads (env-gated), and the insight pipeline into one
critical-points-first report.

- Brief model (panel DB): one row/project/day (date, headline, summary,
  health score, structured body, raw 360 metrics).
- Panel: getSaseBriefMetrics() (saseDb half) + /api/internal/sase-brief-data.
- Worker: sase-evening-brief job collects panel metrics + PostHog
  (hogqlQuery) + Meta (lib/meta, env-gated) + pipeline stats -> DeepSeek
  structured brief -> upsert Brief -> Telegram. Scheduled 17:00 UTC
  (20:00 Europe/Istanbul).
- UI: /projects/sase/briefs (table of days) + /briefs/[id] (detail:
  critical-first, sections by status, recommendations, raw metrics).

Meta dormant until META_ACCESS_TOKEN/META_AD_ACCOUNT_ID set on the worker.
Needs a `briefs` table migration on the panel DB before first run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 19:17:01 +03:00
Semih
1d5daf2e5c feat(insights): detect parts/catalog coverage failures (client + server)
Surfaces "vehicle resolved but no parts/categories" — Sase's #1 churn
signal, previously invisible to insights (a prospect emailed support
instead of any insight firing).

Client (PostHog session tagger):
- fetch the catalog_*/category_view_changed/empty_catalog_cta_clicked
  event family (was never pulled) and tag parts_render_blocked (P1) /
  catalog_empty_result (P2), scoped to the real /dashboard product (demo
  excluded). Harden the search-affordance-misuse guard so a real parts
  failure is never suppressed. Route both tags to bug_triage.

Server (decode-log catalog gaps):
- new catalog-gap-detect worker job (every 6h) -> panel internal API
  /api/internal/catalog-gap-check aggregates "No catalog - identified as
  X" query_logs failures by brand -> upserts catalog_coverage_gap
  insights (Telegram on P1), respecting founder triage. Add NO_CATALOG
  error bucket.

Validated: both apps typecheck clean; tagger smoke 21/21; aggregation
over 90d real data yields 13 brand gaps (Renault/Fiat/Honda P1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:22:09 +03:00
Semih
b61d955256 feat(insights): semantic dedup gate + dismissed-theme suppression in analyze
The fingerprint hash (even after a7fe80f) still over-splits: the same root
problem produces different tag-sets / first-error / first-failed-endpoint
across sessions, so it hashes to a new fingerprint and creates a duplicate
insight. And generic ux_friction/frustrated_session sessions (category-tree
vs parts-panel vs schema-image) carry no structured signal to separate
sub-problems — only the LLM's reading of the timeline does. Dismissed insights
were only suppressed on an EXACT fingerprint repeat; a near-variant got a fresh
fingerprint and re-opened the theme. Dismissed rows were also excluded from the
fast-path cache, so every recurring session re-ran the LLM for no reason.

Two-layer dedup so (1) duplicates can't be created and (2) nothing similar to a
dismissed/duplicate theme is re-opened:

Layer 1 — fingerprint (or aliased fingerprint) fast-path, no LLM:
  - dismissed/duplicate  → SUPPRESS (bump occ/lastSeen, keep status)
  - validated/shipped    → flag REGRESSION (cheap, no re-analysis)
  - active & fresh        → attach occurrence
  - active & stale        → fall through to refresh re-analysis (unchanged)

Layer 2 — semantic dedup gate before creating a NEW insight (flash, no new infra):
  - build a bounded catalog: all active insights + recently-touched
    dismissed/duplicate/validated/shipped (lookback-windowed, capped)
  - ask the model whether the session is the SAME underlying problem as a
    catalog entry (anti-hallucination: only accept catalog ids; conf >= 0.72)
  - on match: alias this fingerprint onto that insight + attach/suppress, so the
    next identical session fast-paths in Layer 1 (no LLM, no duplicate row)
  - no match: create a new insight as before

Schema: Insight.aliasFingerprints String[] (additive; applied via prisma db push
on deploy). Dedup-gate cost is logged to costLedger as promptTag=dedup_gate.

Tunables: INSIGHT_DEDUP_GATE (default on), INSIGHT_DEDUP_THRESHOLD (0.72),
INSIGHT_DEDUP_SUPPRESS_DAYS (90), INSIGHT_DEDUP_CATALOG_MAX (120).

Pure decision helpers (classifyExistingAction, shouldAcceptMatch) extracted and
covered by a smoke test (apps/worker `pnpm test`, 18/18). tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 20:41:18 +03:00
Semih
8a81cb77bf feat(insights): multi-project Sentry archive
The sentry-archive job was hardcoded to a single SENTRY_PROJECT slug.
Now that sase has a second Sentry project (sase-web, browser SDK), the
worker has to pull both — otherwise frontend events live only in
Sentry's UI and never reach the panel's sentry_events table for the
behavioral-insight pipeline to pick up.

Changes:

- SENTRY_PROJECT becomes a CSV (e.g. "python,sase-web") parsed once at
  module load into PROJECTS[]. Backward-compatible: a single value
  keeps working exactly as before.
- Issues are fetched org-wide in a single loop (unchanged endpoint)
  but each issue's Sentry project slug is now persisted via the new
  `sentrySourceProject` column on sentry_issues.
- Events are project-scoped on Sentry's side, so the job iterates
  projects and calls listEventsPage(slug, cursor) for each. Per-project
  pagination + dedup is preserved. A project-level failure is recorded
  in `error` but doesn't abort the other projects.
- New result shape: `perProject: { [slug]: { fetched, inserted, duplicate } }`
  Pipeline log gains a `[python=N/M sase-web=N/M]` breakdown so it's
  obvious at a glance which project produced what.
- Schema: nullable `sentrySourceProject` on both SentryIssue and
  SentryEvent (+ composite index per projectKey for queries that want
  to filter "panel project = sase AND sentry project = sase-web").
  Migration is a pure nullable add — `prisma db push` on container
  start is safe. Existing rows stay null until next sync touches them.

After merge, set `SENTRY_PROJECT=python,sase-web` on the panel-worker
Coolify app and redeploy.
2026-06-01 18:37:10 +03:00
Semih
928728dc66 feat(insights): Phase C — Sentry issues + events archive
Mirrors Sentry into our DB before the free tier prunes events (~30d).

- `SentryIssue` (aggregate state, upserted to latest) + `SentryEvent`
  (raw occurrences, lossless full payload JSONB, dedup by eventId, cold
  dump to MinIO `sentry-archive/{project}/YYYY/MM/DD.jsonl.gz`).
- `lib/sentry.ts`: read-only client, Link-header cursor pagination,
  listIssuesPage / listEventsPage. EU-region aware (SENTRY_API_BASE).
- `sentry-archive` job @hourly: issues upsert + events newest-first with
  skipDuplicates, stops once a page is all-duplicates (caught up).
- Config via env: SENTRY_AUTH_TOKEN / SENTRY_ORG / SENTRY_PROJECT /
  SENTRY_API_BASE.

Verified live against otolog/python (EU): a test event archived on run 1,
0 inserts / 1 duplicate on run 2 (dedup), issue upserted idempotently.

Note: Sase API currently sends to an inaccessible org's DSN (hardcoded
fallback); repointing SENTRY_DSN to otolog/python is a separate deploy step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:32:50 +03:00
Semih
ab86dbdf59 feat(insights): Phase B — PostHog person + cohort snapshots
Append-only history of person properties and cohort definitions, since
the free tier overwrites the live person / prunes data. A new row is
written only when the payload changes (hash compare), so the tables stay
a compact change-timeline rather than a daily full copy.

- `PosthogPersonSnapshot` (personId join key to PosthogEvent.personId,
  all distinct_ids, properties JSONB, propertiesHash).
- `PosthogCohortSnapshot` (cohortId, name, count, filters, stateHash).
- `listPersons` / `listCohorts` REST helpers (next-pagination) in posthog.ts.
- `stableHash` (sorted-key JSON hash) in hash.ts for change detection.
- `posthog-identity-archive` job wired @03:00 daily. Latest-hash lookup
  via one DISTINCT ON query, inserts only changed rows via createMany.

Verified against prod: 242 persons → 242 snapshots on first run, 0 on
immediate re-run (change detection). 0 cohorts currently → graceful no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:47:20 +03:00
Semih
f663c0aa09 feat(insights): permanent archive of PostHog events + raw recordings
Phase A of the external observability archive. PostHog Cloud free tier
deletes data on a rolling window (events ~1yr, session recordings ~30d);
this mirrors both into our own infra permanently (hot Postgres + cold
MinIO gzip JSONL).

PostHog raw event archive:
- New `PosthogEvent` model (uuid PK → dedup, full properties JSONB,
  hot columns promoted + indexed).
- `posthog-event-archive` job: watermark-paged HogQL pull (cursor in the
  existing IngestionWatermark via a "sase:events" stream key), createMany
  + skipDuplicates for idempotency, 365d backfill. Daily closed-day cold
  dump to MinIO `posthog-archive/{project}/YYYY/MM/DD.jsonl.gz`.
- `hogqlQuery` helper added to posthog.ts. Timestamp cursor uses
  parseDateTimeBestEffort() — ClickHouse 500s on a raw ISO8601 literal
  (verified live against the project).

Raw rrweb recording preservation (separate from compress, which only
covers scored sessions and caps blobs):
- `SessionMeta.rrwebArchivedAt` / `rrwebArchiveKey`.
- `archive-recordings` job: every recording within 25d (margin before
  30d deletion), full blob range (no 12-cap), gzip → MinIO
  `rrweb-archive/{project}/YYYY/MM/DD/{sessionId}.jsonl.gz`.

- `putBuffer` gzip helper in minio.ts.
- Both jobs wired into pipeline.ts (event-archive@*/15min, recordings@*/6h).
- retention.ts untouched → new table + buckets persist forever (the goal).

Schema applies via the existing `prisma db push` on web start (additive:
new table + nullable columns).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:15:29 +03:00
Semih
fb33692452 feat(vin-decode): VIN row detail modal with timings + behaviour
Click a VIN in the list to open a modal with the full decode internals:
- Proxy times per provider (pcat/emex/pl24/vin-api) from query_logs.timings
- Race/lock status (lock_wait ms + cache_source='lock_wait' → "aynı VIN
  paralelde çözülüyordu"), candidate_pick, pl24 circuit, aborted
- Candidate counts (pcat_car_count, emex_candidate_count), result_kind, wmi
- User behaviour for that vehicle, correlated from PostHog custom events in
  the panel DB by user_id + vehicle_id: categories opened (parts_panel_viewed)
  and OEM copied (oem_code_copied + distinct codes)

New: getVinDecodeDetail/getVinUserBehavior (cross-DB: sase RO + panel),
GET /api/sase/query-log/[id] (auth-guarded), client VinDetailButton modal.
Both queries verified against prod data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:31:28 +03:00
Semih
09438cf513 fix(vin-decode): repair Business page 500 + drop redundant VIN buttons
- Business page returned 500: getTrialFunnel's `ORDER BY CASE bucket ...`
  referenced the SELECT-list alias inside an expression, which Postgres
  can't resolve (error 42703 "column bucket does not exist"). Wrap the
  GROUP BY in a subquery so `bucket` is a real column the ORDER BY can use.
  Verified against prod: original errors, fixed query returns ordered buckets.
- Remove the "Çözülen / Çözülemeyen VIN'ler" dashboard buttons — the VIN
  list already has a success/error status filter, so they were redundant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:22:21 +03:00
Semih
985592aa73 feat(vin-decode): WMI opportunity radar, per-source p50, unknown-VIN spike alarm
Systematize Faz 4 + close three observability gaps:

- WMI opportunity radar (getWmiOpportunities): group query_logs by
  substring(vin,1,3) = WMI, rank low-success WMIs by distinct-user demand.
  brandMatched=0 flags fully-uncovered manufacturer codes. Surfaced on the
  Trends page (#wmi) + a dashboard header shortcut. Replaces the manual Faz 4.
- Per-source p50: add percentile_cont(0.50) to getProviderAttempts and
  getProviderDeepStats; show P50·P95 on the dashboard and provider drill-down.
- unknown_vin_spike anomaly: track "tanınamadı" rate in the 15min/baseline
  windows, fire when it jumps >=2x baseline (or surges from ~0), and attribute
  the dominant failing source (+aborted count) in the Telegram message. Runs
  in the existing 5min anomaly cron; worker/telegram unchanged (generic type).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 01:58:26 +03:00
Semih
f05e0bc808 feat(vin-decode): add solved/unsolved VIN list shortcut buttons
Add two header buttons on the VIN decode dashboard linking to the
existing /vins list pre-filtered by success=true / success=false,
carrying the current time-range window via the `from` param so the
list matches the dashboard view. Shows actual VIN codes (not counts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:07:55 +03:00
Semih
0419cc271c feat(content): Phase 8c publish layer — panel→n8n webhook (sync) + LinkedIn/blog
Approved drafts publish via a single n8n `publish-content` webhook; the panel
POSTs the effective content (founder edits merged over generated body) and
awaits a synchronous Respond-to-Webhook result (sp.semih.ai is Tailscale-only,
so we avoid an n8n→panel callback). n8n routes by channel.

- lib/n8n.ts: publishToN8n client (X-Content-Secret header, timeout, tolerant
  result parsing: ok|success + publishedUrl|url|postUrl|permalink)
- publishDraft server action: approved|failed → publishing → published(+url) /
  failed(+error), audit-logged; effective content = bodyJson + founderEdits
- DraftCard: "Yayınla" / "Yeniden yayınla" button + publishing state
- docs/n8n: importable publish-content workflow (Webhook → Switch → LinkedIn /
  HTTP-blog → Respond) + runbook (contract, panel envs, LinkedIn OAuth setup,
  blog endpoint = sase.tr POST /blog/posts/internal Bearer)

Needs panel-web envs N8N_PUBLISH_WEBHOOK_URL + N8N_WEBHOOK_SECRET. Publish is a
graceful no-op (clear error) until those are set and the n8n workflow exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:42:11 +03:00
Semih
fdce3f6bd0 feat(content): Phase 8 content generation (Faz A+B) for Sase.tr
Hybrid content automation pilot: generation + review + drafts live in the
panel (reusing the insight pipeline's DeepSeek client, prompt_templates
versioning, cost_ledger and budget_settings); publishing/distribution will
go through n8n (Faz C, not built). Channels: blog, LinkedIn, X, Instagram.
Topic sourcing is automatic (LLM-generated ideas). Approval model: drafts
sit in the panel for manual review/edit/publish.

Faz A (worker):
- ContentTopic / ContentDraft Prisma models (content_topics, content_drafts)
- content-prompts.ts: 5 seed prompts (topic ideas[pro] + blog[pro] +
  linkedin/x/instagram[flash]), Turkish B2B automotive tone, per-channel
  JSON schemas
- content-budget.ts: separate budget envelope (sums only content_* spend)
- content-topics job (auto idea gen, backlog-capped, title dedupe) +
  content-generate job (queued topic -> one draft per channel)
- content-pipeline scheduler (separate BullMQ queue, topics@*/8h,
  generate@*/10min), wired into index.ts; seeded via seed-runtime
- content budget settings (caps + content_paused kill switch); seed default
  content_paused=true for a safe first deploy

Faz B (web):
- /content (queue + auto/manual triggers + manual topic form),
  /content/t/[id] (per-channel draft cards: preview, JSON edits,
  approve/reject), /content/costs (content-only spend)
- server actions (audit-logged), manual trigger API routes, contentQueue(),
  nav + Cmd+K entries
- content caps surfaced on /insights/settings/budgets + whitelisted

Both packages typecheck. Schema applies on deploy (web start runs
prisma db push).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:11:05 +03:00
Semih
4984190273 fix(insights): sanitizer warning — raise threshold + add real leak signal
The previous 500-token threshold fired on virtually every authenticated
session because rrweb masks inputs at source and stripQuery() removes
URL params before sanitize() runs — so 0 matches is the normal case,
not an anomaly. Confirmed on session 019e455e: 9m32s authed session
with ~1093 tokens, 0 matches, no actual PII leak.

- Raise broad threshold 500→2000 tokens
- Add precise leak detector: any unmasked \`input [tag] "text"\` line
  in the compressed timeline AND authed AND 0 matches → alert. This
  catches the actual failure mode (PostHog source-side masking broken)
  instead of guessing from total token volume.
- Sync UI warning threshold to match (500→2000).
2026-05-20 17:07:57 +03:00
Semih
16695de6d2 feat(insights): compress.ts noise filter + per-session reprocess
Drop successful (2xx/3xx) asset and telemetry GETs from the semantic
timeline — these were eating MAX_LINES (80) before any user-behavior
signal could surface. In a 9m32s session we observed only the first
1:18 reached the LLM input because PostHog /collect/* pings filled
the budget. Failures (4xx/5xx) are still emitted as signal.

Filtered prefixes: /collect/, /flags, /array/, /static/, /assets/,
/api/surveys, /css2. Also raise MAX_LINES 80→120.

Add optional { sessionId } to POST /api/insights/reprocess so a single
session can be re-run end-to-end without resetting a whole batch.
2026-05-20 17:00:31 +03:00
Semih
091e9daa2f fix(sase): VIN filter buildHref — dangling-else bug
For string filter values (q, success, sort, etc.), the URL query string
came out empty — array values (provider, status with multi-select)
worked. Root cause was a brace-less if/for/if chain:

  if (Array.isArray(v)) for (const item of v) if (item) usp.append(k, item);
  else usp.set(k, String(v));

JS associates the `else` with the inner `if (item)`, not the outer
`if (Array.isArray(v))`. So when v is a string, the outer if is false,
nothing runs, usp stays empty, and the URL becomes just "?". The status
pills and the "Ara" search button both looked broken because their
patches were string-typed.

Same buildHref in users/_query.ts already had braces, which is why
the user list search was unaffected.

Add the missing braces. Also drops the temporary console.log
diagnostics from the VIN filter bar.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 08:58:54 +03:00
Semih
dbdc0d476b debug: trace inside buildHref 2026-05-19 08:54:36 +03:00
Semih
4defb0bfea debug: expand JSON in nav log 2026-05-19 08:51:26 +03:00
Semih
1b198620ad debug: console.log inside VinsFilterBar.nav + submit (temporary) 2026-05-19 08:48:51 +03:00
Semih
38429a98cd fix(sase): Ara search button — Base UI Button forces type='button'
Root cause: @base-ui/react's Button hard-overrides the type prop to
'button' via getButtonProps in useButton(). Even when we write
<Button type='submit'>, mergeProps replaces it with 'button' before
DOM, so the button does nothing inside a form. Visually it looks
disabled because clicking it doesn't trigger any state change.

Fix: for form submit buttons, use a native <button type='submit'>
styled with buttonVariants() to match. Onsubmit was already in place
from the previous commit — this is the missing half.

Applied to both VIN list and user list filter bars.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 08:25:49 +03:00