From cfe2915b3dea70f8a9f9917f846a17cc1c951f85 Mon Sep 17 00:00:00 2001 From: Selim Date: Fri, 10 Jul 2026 13:49:09 +0300 Subject: [PATCH] feat(assistant): unified assistant context store (OfflineAssistantContext) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voice Assistant · Companion AI · Safety Kernel · Offline Conversation · Vehicle Learning · Diagnostic AI · Maintenance AI katmanlarının BUGÜN ayrı ayrı okuduğu kaynakları TEK merkezde toplayan, periyodik + dondurulmuş snapshot yayınlayan store. useAssistantContextStore (yeni, tek dosya): - buildSnapshot(): SAF kurucu — enjekte kaynaklar, throw ETMEZ, girdiyi mutate ETMEZ, çıktı Object.freeze'li (kök + 10 bölüm). Whitelist: girdiye fazladan alan eklense bile snapshot'a SIZAMAZ. - readLiveSources(): canlı adaptör — her kaynak İZOLE try/catch (fail-soft); biri düşse diğer bölümler üretilmeye devam eder. - Lifecycle: abonelikler start'ta BİR KEZ kurulur, dispose'ta BİR KEZ kapatılır → snapshot başına subscribe/unsubscribe döngüsü YOK. start/stop idempotent, dangling görev guard'lı (zero-leak). Performans bütçesi: periyot 5 sn · DeviceTier=low → 10 sn. Görev runtimeManager.scheduleTask üstünde criticality:'NORMAL' + deferIdle:true → hot-path'e (3Hz hız/RPM) GİRMEZ. Dirty-flag ile park hâlinde tik sıfır ayırma yapar (60 sn bayatlama tavanı). Ağır soğuk-yol bölümleri (identity/learning) 30 sn (low: 60 sn) aralıkla okunur, arada önbellek döner; learning okuması zaten memoized + bounded(512) + tier-farkında entegrasyon servisinden gelir. Gizlilik: VIN · MAC · GPS koordinatı · ham PID/DID/CAN snapshot'a ASLA girmez. Navigasyon hedefi yalnız görünen ad (koordinatsız, ≤64 karakter). Kaynağı OLMAYAN alanlar uydurulmadı, dürüstçe null: deepScan.* (projede genel bir Deep Scan servisi YOK — yalnız DTCPanel'de lokal isDeepScanning UI state'i), driver.fatigueScore, status.ignition, media.ducking, network.wifi/mobile (yalnız navigator.connection.type kesinse dolar). Additive: hiçbir servise YAZMAZ, severity/driveSafe/diagnostic kararını DEĞİŞTİRMEZ (yalnız okur), henüz hiçbir tüketiciye ve SystemBoot'a bağlı DEĞİL → bu PR'da çalışma zamanı davranışı değişmez. Test: 25 test (snapshot · tier periyodu · freeze/immutability · fail-soft · null servis · dispose · zero-leak · gizlilik · dirty flag · manuel refresh · bellek büyümesi · hot-path). tsc -b temiz, lint temiz, tam suite 2628 yeşil (regresyon yok). Ledger #28 (cihazda test bekliyor). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q74z6tXWP39iBSXZYnLQW1 --- docs/DEVICE_VALIDATION_LEDGER.md | 1 + src/__tests__/assistantContextStore.test.ts | 496 +++++++++++ src/store/useAssistantContextStore.ts | 867 ++++++++++++++++++++ 3 files changed, 1364 insertions(+) create mode 100644 src/__tests__/assistantContextStore.test.ts create mode 100644 src/store/useAssistantContextStore.ts diff --git a/docs/DEVICE_VALIDATION_LEDGER.md b/docs/DEVICE_VALIDATION_LEDGER.md index b0193f4d..7746bf58 100644 --- a/docs/DEVICE_VALIDATION_LEDGER.md +++ b/docs/DEVICE_VALIDATION_LEDGER.md @@ -17,6 +17,7 @@ | # | Özellik | Nerede | Kabul ölçütü (cihazda ne gözlemlenmeli) | Eklendi | |---|---------|--------|------------------------------------------|---------| +| 28 | **🔴 Device Validation Pending — Unified Assistant Context (OfflineAssistantContext)** — Voice Assistant · Companion AI · Safety Kernel · Offline Conversation · Vehicle Learning · Diagnostic AI · Maintenance AI katmanlarının ayrı ayrı okuduğu kaynakları TEK merkezde toplayan, periyodik + `Object.freeze`'li snapshot yayınlayan store. SAF kurucu (`buildSnapshot`, throw etmez, girdiyi mutate etmez) + fail-soft canlı adaptör (`readLiveSources`, her kaynak izole try/catch). Periyot 5 sn · DeviceTier=low → 10 sn; görev `runtimeManager.scheduleTask` üstünde `criticality:'NORMAL' + deferIdle:true` → hot-path'e (3Hz hız/RPM) GİRMEZ. Dirty-flag ile park hâlinde sıfır maliyet; ağır soğuk-yol bölümleri (identity/learning) 30 sn (low: 60 sn) aralıkla okunur. Gizlilik: VIN/MAC/GPS/ham PID-DID-CAN snapshot'a ASLA girmez (whitelist kurucu; navigasyon hedefi yalnız ad, ≤64 karakter). Hiçbir servise YAZMAZ; severity/driveSafe/diagnostic kararı DEĞİŞMEZ. **Additive: henüz hiçbir tüketiciye ve SystemBoot'a bağlı DEĞİL** → bu PR'da çalışma zamanı davranışı değişmez. Kaynağı olmayan alanlar dürüstçe `null`: `deepScan.*` (genel Deep Scan servisi YOK), `driver.fatigueScore`, `status.ignition`, `media.ducking`, `network.wifi/mobile` (yalnız `navigator.connection.type` kesinse dolar). | `src/store/useAssistantContextStore.ts` (yeni), `src/__tests__/assistantContextStore.test.ts` (yeni, 25 test) | Gerçek araçta (tüketiciye bağlandıktan SONRA): (1) snapshot mid/high tier'da ~5 sn, BASIC_JS/low'da ~10 sn (veya mod çarpanıyla daha seyrek) aralıkla yenilenir; (2) park hâlinde (OBD sessiz) tik sıfır ayırma yapar — CPU profilinde snapshot işi görünmez; (3) BASIC_JS tier'da FPS/CPU regresyonu YOK, hız/RPM göstergesi 3Hz akıcılığını korur; (4) snapshot içeriğinde VIN/MAC/koordinat/ham hex GÖRÜNMEZ (Expert Mode / log denetimi); (5) uzun sürüşte (>1 sa) bellek büyümesi gözlenmez (yalnız son snapshot tutulur); (6) OBD/DTC/tanı servislerinden biri düşse bile snapshot üretilmeye devam eder. ⚠️ Test edilmeden 🟢'a çekme. | 2026-07-10 | | 25 | **🔴 Device Validation Pending — Learning Integration Layer (P2-5)** — P2-1→P2-4 öğrenme katmanları (Evidence Store · Decay · Pattern Engine) uygulamanın mevcut ekran/tanı akışlarına SALT-OKUNUR bağlandı. `vehicleLearningIntegrationService` Evidence Store'dan okur → decay uygular → pattern üretir → (a) Discovery Dashboard'a WEAK/CANDIDATE/STRONG/MANUAL_REVIEW/STALE/CONFLICT rozetleri + confidence/decay/araç/gözlem/ECU alanları + Strong/Candidate/Manual/Conflict filtreleri ekler, (b) Expert Mode'a "Araç Öğrenme" özet bölümü (toplam/weak/candidate/strong/manual/stale/prune + güçlü PID/DID adayları + marka cluster + son öğrenme) ekler, (c) Diagnostic Insight için `enrichInsight` ile öğrenme bağlamı üretir. Hiçbir registry/profile/store'a OTOMATİK YAZMAZ; safety/severity/driveSafe DEĞİŞMEZ; memoized + bounded(512) + fail-soft + zero-leak. BASIC_JS(low) tier yalnız özet istatistik (ağır pattern/conflict detayı kapalı). | `vehicleLearningIntegrationService.ts` (yeni), `discoveryDashboardModel.ts` (öğrenme filtreleri + AnnotationResolver), `DiscoveryDashboard.tsx` (rozet/alan/filtre), `settings/expert/VehicleLearningInspector.tsx` (yeni), `ExpertModePanel.tsx` (bölüm wiring) | Gerçek araçta: (1) yeni PID/DID discovery oluşur → Evidence Store kaydı oluşur → Dashboard'da doğru WEAK/CANDIDATE/STRONG rozeti görünür; (2) duplicate gözlem vehicleCount'i şişirmez (distinct fingerprint); (3) Expert "Araç Öğrenme" özeti doğru sayıları gösterir; (4) Diagnostic Insight öğrenme bağlamını (learnedEvidenceCount/relatedStrongPids/requiresManualReview) gösterir; (5) düşük cihaz tier'ında (BASIC_JS) FPS/CPU regresyonu oluşmaz (ağır pattern detayı kapalı). ⚠️ Salt-okunur/additive → alt katman davranışı değişmez (tsc+test+build yeşil). | 2026-07-10 | | 24 | **🔴 Device Validation Pending — P0-Güvenlik #4: production log hijyeni** (commit 049e290). terser `drop_console:true` (error+warn dahil TÜM console'u siliyor → üretimde hata gözlemlenebilirliği yoktu + logGate ölü kod) yerine dev-aware kesim: release'te yalnız `log/info/debug/trace` çıkarılır, `error`+`warn` KORUNUR (logGate runtime'da adaptif susturur). `NODE_ENV=development` build hiçbir console'ı çıkarmaz. Build-analiz kanıtı: release bundle'da `console.error(`/`warn(` var, `console.log(`/`info(`/`debug(` **çağrı = 0**; dev bundle'da log/info çağrıları korunur. | `vite.config.ts` (`_isDevBuild` + `drop_console: _isDevBuild ? false : ['log','info','debug','trace']`) | **Release APK** cihaz-üstü (debug build ile Chrome inspector): bir hata durumunda `console.error` GÖRÜNÜR (üretim gözlemlenebilirliği geri geldi); debug seviye log GÖRÜNMEZ. Debug APK'da tüm console akar (on-device debug korundu). Hot-path'te debug log kaynaklı ek CPU/IO gözlenmez. | 2026-07-08 | | 23 | **🔴 Device Validation Pending — P0-Güvenlik #2: cleartext trafik kısıtlama** (commit 77e5292). Global `android:usesCleartextTraffic="true"` kaldırıldı; `network_security_config` base-config ile tüm cleartext reddedilir, yalnız loopback (`localhost`/`127.0.0.1`) whitelist'te. Ölü dev IP `192.168.1.12` çıkarıldı. Ham `ServerSocket`'ler (passenger 8765 / diag 8899) bu politikadan etkilenmez. | `AndroidManifest.xml`, `res/xml/network_security_config.xml` | **Release APK**'da dış **https** API'ler (hava/Nominatim/Supabase/AI) çalışmaya devam eder; kasıtlı bir `http://` dış çağrı NSC ile REDDEDİLİR; cihaz-içi OSRM (`http://localhost:5000`) offline routing + yolcu paneli (ayrı telefonun tarayıcısı) çalışır. | 2026-07-08 | diff --git a/src/__tests__/assistantContextStore.test.ts b/src/__tests__/assistantContextStore.test.ts new file mode 100644 index 00000000..eb227cea --- /dev/null +++ b/src/__tests__/assistantContextStore.test.ts @@ -0,0 +1,496 @@ +/** + * useAssistantContextStore — Birleşik Asistan Bağlamı testleri. + * + * SAF çekirdek (`buildSnapshot`) enjekte edilen kaynaklarla, lifecycle ise + * enjekte edilen bağımlılıklarla (scheduler/tier/now/readSources/subscribeSources) + * test edilir → canlı servis mock'u GEREKMEZ, deterministik. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +import { + buildSnapshot, + startAssistantContext, + stopAssistantContext, + refreshAssistantContext, + getAssistantContextSnapshot, + snapshotPeriodMs, + useAssistantContextStore, + _resetAssistantContextForTest, + SNAPSHOT_PERIOD_MS, + LOW_TIER_SNAPSHOT_PERIOD_MS, + SNAPSHOT_TASK_ID, + type AssistantContextDeps, + type AssistantContextSources, + type AssistantContextSnapshot, +} from '../store/useAssistantContextStore'; +import type { DeviceTier } from '../platform/deviceCapabilities'; +import type { ScheduledTask } from '../core/runtime/AdaptiveRuntimeManager'; + +/* ── Test koşum takımı: sahte scheduler + sahte abonelikler ─────────────── */ + +interface Harness { + deps: AssistantContextDeps; + /** Scheduler'a kaydedilen görev (tick'i elle tetiklemek için). */ + task: () => ScheduledTask; + tickCount: () => number; + /** Kaynak aboneliği kurulum/kapanış sayaçları (zero-leak + hot-path kanıtı). */ + subscribeCalls: () => number; + unsubscribeCalls: () => number; + unscheduleCalls: () => number; + /** Abone edilmiş `onChange` — kaynak değişimini simüle eder (dirty flag). */ + emitChange: () => void; + setNow: (ms: number) => void; + readCalls: () => number; +} + +function makeHarness(opts: { + tier?: DeviceTier; + sources?: AssistantContextSources | (() => AssistantContextSources); +} = {}): Harness { + let now = 1_000_000; + let task: ScheduledTask | null = null; + let onChange: (() => void) | null = null; + let subscribeCalls = 0, unsubscribeCalls = 0, unscheduleCalls = 0, readCalls = 0, tickCount = 0; + + const deps: AssistantContextDeps = { + now: () => now, + tier: () => opts.tier ?? 'mid', + schedule: (t) => { + // Görevi sar: tick sayısını ölçelim. + task = { ...t, fn: () => { tickCount++; t.fn(); } }; + return () => { unscheduleCalls++; task = null; }; + }, + readSources: () => { + readCalls++; + const s = opts.sources; + return (typeof s === 'function' ? s() : s) ?? {}; + }, + subscribeSources: (cb) => { + subscribeCalls++; + onChange = cb; + return [() => { unsubscribeCalls++; }]; + }, + }; + + return { + deps, + task: () => { + if (!task) throw new Error('scheduler görevi kaydedilmedi'); + return task; + }, + tickCount: () => tickCount, + subscribeCalls: () => subscribeCalls, + unsubscribeCalls: () => unsubscribeCalls, + unscheduleCalls: () => unscheduleCalls, + emitChange: () => onChange?.(), + setNow: (ms) => { now = ms; }, + readCalls: () => readCalls, + }; +} + +/** Tam dolu, geçerli örnek kaynak seti. */ +function fullSources(): AssistantContextSources { + return { + identity: { fingerprintHash: 'abc123', manufacturer: 'Renault', profileHint: 'Renault', protocol: 'ISO15765' }, + health: { healthScore: 82, severity: 'warning', driveSafe: true, oilLifePercent: 60, wearRate: 0.2 }, + learning: { evidenceCount: 12, patternCount: 3, strongestConfidence: 0.77, learnedVehicleCount: 2 }, + status: { speed: 54, rpm: 1800, coolantTemp: 90, fuelLevel: 43, batteryVoltage: 14.1 }, + driver: { tripDuration: 25, cognitiveMode: 'AWARE' }, + navigation: { isNavigating: true, destination: 'Kadıköy', remainingKm: 12.4, remainingTime: 900 }, + media: { playing: true, volume: 60 }, + network: { online: true, wifi: true, mobile: false }, + device: { tier: 'mid', thermalStatus: 'normal', powerSaver: false }, + }; +} + +beforeEach(() => { _resetAssistantContextForTest(); }); +afterEach(() => { _resetAssistantContextForTest(); vi.restoreAllMocks(); }); + +/* ══════════════════════════════════════════════════════════════════════════ + * 1. Snapshot oluşturuluyor + * ════════════════════════════════════════════════════════════════════════ */ + +describe('buildSnapshot — snapshot oluşturma', () => { + it('tüm bölümleri içeren snapshot üretir', () => { + const snap = buildSnapshot(fullSources(), 12_345, 1); + + expect(snap.builtAt).toBe(12_345); + expect(snap.revision).toBe(1); + expect(snap.identity.fingerprintHash).toBe('abc123'); + expect(snap.identity.manufacturer).toBe('Renault'); + expect(snap.identity.protocol).toBe('ISO15765'); + expect(snap.health.healthScore).toBe(82); + expect(snap.health.severity).toBe('warning'); + expect(snap.health.driveSafe).toBe(true); + expect(snap.health.maintenance.oilLifePercent).toBe(60); + expect(snap.learning.evidenceCount).toBe(12); + expect(snap.learning.strongestConfidence).toBeCloseTo(0.77); + expect(snap.status.speed).toBe(54); + expect(snap.status.coolantTemp).toBe(90); + expect(snap.driver.tripDuration).toBe(25); + expect(snap.driver.cognitiveMode).toBe('AWARE'); + expect(snap.navigation.destination).toBe('Kadıköy'); + expect(snap.media.playing).toBe(true); + expect(snap.network.online).toBe(true); + expect(snap.device.tier).toBe('mid'); + }); + + it('imkânsız sensör değerlerini (NaN / -1 sentinel / aralık dışı) null yapar', () => { + const snap = buildSnapshot({ + status: { speed: 999, rpm: NaN, coolantTemp: -300, fuelLevel: 150, batteryVoltage: -1 }, + learning: { strongestConfidence: 5 }, + }, 1, 1); + + expect(snap.status.speed).toBeNull(); + expect(snap.status.rpm).toBeNull(); + expect(snap.status.coolantTemp).toBeNull(); + expect(snap.status.fuelLevel).toBeNull(); + expect(snap.status.batteryVoltage).toBeNull(); + expect(snap.learning.strongestConfidence).toBeNull(); + }); + + it('kaynağı olmayan alanları (deepScan / ignition / fatigueScore / ducking) null bırakır', () => { + const snap = buildSnapshot(fullSources(), 1, 1); + + expect(snap.deepScan.completed).toBeNull(); + expect(snap.deepScan.progress).toBeNull(); + expect(snap.deepScan.newDiscoveriesCount).toBeNull(); + expect(snap.deepScan.changedFirmware).toBeNull(); + expect(snap.deepScan.changedECU).toBeNull(); + expect(snap.status.ignition).toBeNull(); + expect(snap.driver.fatigueScore).toBeNull(); + expect(snap.media.ducking).toBeNull(); + }); +}); + +/* ══════════════════════════════════════════════════════════════════════════ + * 2-3. Tier'a göre snapshot periyodu + * ════════════════════════════════════════════════════════════════════════ */ + +describe('snapshot periyodu (DeviceTier bütçesi)', () => { + it('low tier → 10 saniye', () => { + expect(snapshotPeriodMs('low')).toBe(10_000); + expect(LOW_TIER_SNAPSHOT_PERIOD_MS).toBe(10_000); + + const h = makeHarness({ tier: 'low', sources: fullSources() }); + startAssistantContext(h.deps); + expect(h.task().periodMs).toBe(10_000); + }); + + it('mid ve high tier → 5 saniye', () => { + expect(snapshotPeriodMs('mid')).toBe(5_000); + expect(snapshotPeriodMs('high')).toBe(5_000); + expect(SNAPSHOT_PERIOD_MS).toBe(5_000); + + const mid = makeHarness({ tier: 'mid', sources: fullSources() }); + startAssistantContext(mid.deps); + expect(mid.task().periodMs).toBe(5_000); + stopAssistantContext(); + + const high = makeHarness({ tier: 'high', sources: fullSources() }); + startAssistantContext(high.deps); + expect(high.task().periodMs).toBe(5_000); + }); +}); + +/* ══════════════════════════════════════════════════════════════════════════ + * 4 + 10. Object.freeze / immutability + * ════════════════════════════════════════════════════════════════════════ */ + +describe('immutability — Object.freeze', () => { + it('snapshot ve tüm bölümleri dondurulmuştur', () => { + const snap = buildSnapshot(fullSources(), 1, 1); + + expect(Object.isFrozen(snap)).toBe(true); + expect(Object.isFrozen(snap.identity)).toBe(true); + expect(Object.isFrozen(snap.health)).toBe(true); + expect(Object.isFrozen(snap.health.maintenance)).toBe(true); + expect(Object.isFrozen(snap.learning)).toBe(true); + expect(Object.isFrozen(snap.deepScan)).toBe(true); + expect(Object.isFrozen(snap.status)).toBe(true); + expect(Object.isFrozen(snap.driver)).toBe(true); + expect(Object.isFrozen(snap.navigation)).toBe(true); + expect(Object.isFrozen(snap.media)).toBe(true); + expect(Object.isFrozen(snap.network)).toBe(true); + expect(Object.isFrozen(snap.device)).toBe(true); + }); + + it('snapshot mutasyonu değeri değiştirmez', () => { + const snap = buildSnapshot(fullSources(), 1, 1); + const mutable = snap as unknown as { status: { speed: number } }; + + try { mutable.status.speed = 999; } catch { /* strict mode → TypeError; ikisi de kabul */ } + + expect(snap.status.speed).toBe(54); + }); + + it('girdi kaynaklarını MUTASYONA UĞRATMAZ', () => { + const sources = fullSources(); + const before = JSON.stringify(sources); + + buildSnapshot(sources, 1, 1); + + expect(JSON.stringify(sources)).toBe(before); + }); +}); + +/* ══════════════════════════════════════════════════════════════════════════ + * 5-6. Fail-soft / null servis + * ════════════════════════════════════════════════════════════════════════ */ + +describe('fail-soft', () => { + it('bir kaynak bozuksa diğer bölümler üretilmeye devam eder', () => { + // health tamamen bozuk (yanlış tipler), status sağlam. + const broken = { + health: { healthScore: 'çok iyi', severity: 'çok kötü', driveSafe: 'evet' }, + status: { speed: 60, rpm: 2000 }, + } as unknown as AssistantContextSources; + + const snap = buildSnapshot(broken, 1, 1); + + expect(snap.health.healthScore).toBeNull(); + expect(snap.health.severity).toBeNull(); + expect(snap.health.driveSafe).toBeNull(); + expect(snap.status.speed).toBe(60); // diğer bölüm ETKİLENMEDİ + expect(snap.status.rpm).toBe(2000); + }); + + it('null / undefined kaynakla throw etmez, tümü null snapshot üretir', () => { + expect(() => buildSnapshot(null, 1, 1)).not.toThrow(); + expect(() => buildSnapshot(undefined, 1, 1)).not.toThrow(); + + const snap = buildSnapshot(null, 1, 1); + expect(snap.identity.fingerprintHash).toBeNull(); + expect(snap.status.speed).toBeNull(); + expect(snap.device.tier).toBeNull(); + expect(Object.isFrozen(snap)).toBe(true); + }); + + it('readSources throw ederse tik/başlatma çökmez, boş snapshot yayınlanır', () => { + const h = makeHarness(); + const deps: AssistantContextDeps = { + ...h.deps, + readSources: () => { throw new Error('servis öldü'); }, + }; + + expect(() => startAssistantContext(deps)).not.toThrow(); + + const snap = getAssistantContextSnapshot(); + expect(snap).not.toBeNull(); + expect(snap!.status.speed).toBeNull(); + }); + + it('subscribeSources throw ederse başlatma yine de tamamlanır', () => { + const h = makeHarness({ sources: fullSources() }); + const deps: AssistantContextDeps = { + ...h.deps, + subscribeSources: () => { throw new Error('abonelik yok'); }, + }; + + expect(() => startAssistantContext(deps)).not.toThrow(); + expect(getAssistantContextSnapshot()).not.toBeNull(); + }); +}); + +/* ══════════════════════════════════════════════════════════════════════════ + * 7-8. Dispose / zero-leak + * ════════════════════════════════════════════════════════════════════════ */ + +describe('lifecycle — dispose ve zero-leak', () => { + it('dispose tüm abonelikleri ve scheduler görevini kapatır', () => { + const h = makeHarness({ sources: fullSources() }); + startAssistantContext(h.deps); + + expect(h.subscribeCalls()).toBe(1); + expect(h.unsubscribeCalls()).toBe(0); + expect(h.unscheduleCalls()).toBe(0); + + stopAssistantContext(); + + expect(h.unsubscribeCalls()).toBe(1); + expect(h.unscheduleCalls()).toBe(1); + expect(getAssistantContextSnapshot()).toBeNull(); // eski referans tutulmaz + }); + + it('start idempotenttir — ikinci çağrı yeni abonelik/timer kurmaz', () => { + const h = makeHarness({ sources: fullSources() }); + startAssistantContext(h.deps); + startAssistantContext(h.deps); + startAssistantContext(h.deps); + + expect(h.subscribeCalls()).toBe(1); + }); + + it('stop idempotenttir — ikinci çağrı ek unsubscribe üretmez', () => { + const h = makeHarness({ sources: fullSources() }); + startAssistantContext(h.deps); + stopAssistantContext(); + stopAssistantContext(); + + expect(h.unsubscribeCalls()).toBe(1); + expect(h.unscheduleCalls()).toBe(1); + }); + + it('dispose sonrası tik çalışsa bile snapshot yayınlanmaz (dangling görev yok)', () => { + const h = makeHarness({ sources: fullSources() }); + startAssistantContext(h.deps); + const task = h.task(); + stopAssistantContext(); + + // Scheduler görevi kaldırıldı; yine de elde tutulan referansı tetiklemek çökmemeli. + expect(() => task.fn()).not.toThrow(); + }); +}); + +/* ══════════════════════════════════════════════════════════════════════════ + * 9. Gizlilik + * ════════════════════════════════════════════════════════════════════════ */ + +describe('gizlilik — VIN / MAC / GPS / ham PID sızmaz', () => { + it('kaynakta gizli alanlar olsa bile snapshot bunları taşımaz (whitelist)', () => { + const leaky = { + identity: { + fingerprintHash: 'abc123', + protocol: 'ISO15765', + vin: 'WF0AXXTTRAJA12345', + adapterMac: 'AA:BB:CC:DD:EE:FF', + }, + navigation: { + isNavigating: true, + destination: 'Kadıköy', + latitude: 40.9901, + longitude: 29.0250, + }, + status: { speed: 50, rawPid: '410C1AF8', rawCan: '7E8 03 41 0C' }, + } as unknown as AssistantContextSources; + + const snap = buildSnapshot(leaky, 1, 1); + const json = JSON.stringify(snap); + + expect(json).not.toContain('WF0AXXTTRAJA12345'); + expect(json).not.toContain('AA:BB:CC:DD:EE:FF'); + expect(json).not.toContain('40.9901'); + expect(json).not.toContain('29.025'); + expect(json).not.toContain('410C1AF8'); + expect(json).not.toContain('7E8'); + expect(json).not.toMatch(/"vin"|"adapterMac"|"latitude"|"longitude"|"rawPid"|"rawCan"/); + + // Yorumlanmış alanlar korunur. + expect(snap.identity.fingerprintHash).toBe('abc123'); + expect(snap.navigation.destination).toBe('Kadıköy'); + expect(snap.status.speed).toBe(50); + }); + + it('navigasyon hedefi yalnız ad taşır ve kırpılır (bellek + gizlilik)', () => { + const long = 'A'.repeat(200); + const snap = buildSnapshot({ navigation: { destination: long } }, 1, 1); + + expect(snap.navigation.destination).toHaveLength(64); + }); +}); + +/* ══════════════════════════════════════════════════════════════════════════ + * 11-12. Dirty flag / manuel yenileme + * ════════════════════════════════════════════════════════════════════════ */ + +describe('dirty flag ve manuel yenileme', () => { + it('kaynak değişmediyse tik yeniden snapshot kurmaz (revision sabit)', () => { + const h = makeHarness({ sources: fullSources() }); + startAssistantContext(h.deps); + + const rev0 = getAssistantContextSnapshot()!.revision; + const reads0 = h.readCalls(); + + h.task().fn(); // dirty değil + bayat değil → hiçbir şey ayırma + h.task().fn(); + + expect(getAssistantContextSnapshot()!.revision).toBe(rev0); + expect(h.readCalls()).toBe(reads0); // kaynak okuması bile yapılmadı + }); + + it('kaynak değişimi dirty yapar → sonraki tik yeni snapshot üretir', () => { + const h = makeHarness({ sources: fullSources() }); + startAssistantContext(h.deps); + + const rev0 = getAssistantContextSnapshot()!.revision; + h.emitChange(); + h.task().fn(); + + expect(getAssistantContextSnapshot()!.revision).toBe(rev0 + 1); + }); + + it('dirty olmasa bile snapshot bayatlarsa (>60 sn) yeniden kurulur', () => { + const h = makeHarness({ sources: fullSources() }); + startAssistantContext(h.deps); + + const rev0 = getAssistantContextSnapshot()!.revision; + h.setNow(1_000_000 + 61_000); + h.task().fn(); + + expect(getAssistantContextSnapshot()!.revision).toBe(rev0 + 1); + }); + + it('refreshAssistantContext dirty flag\'i atlar ve anında yeni snapshot döner', () => { + const h = makeHarness({ sources: fullSources() }); + startAssistantContext(h.deps); + + const rev0 = getAssistantContextSnapshot()!.revision; + const fresh = refreshAssistantContext(); + + expect(fresh.revision).toBe(rev0 + 1); + expect(getAssistantContextSnapshot()).toBe(fresh); // store da güncellendi + expect(Object.isFrozen(fresh)).toBe(true); + }); +}); + +/* ══════════════════════════════════════════════════════════════════════════ + * 13. Bellek büyümesi yok + * ════════════════════════════════════════════════════════════════════════ */ + +describe('bellek — snapshot birikmez', () => { + it('200 yeniden kurulum sonrası store yalnız SON snapshot\'ı tutar', () => { + const h = makeHarness({ sources: fullSources() }); + startAssistantContext(h.deps); + + const seen = new Set(); + for (let i = 0; i < 200; i++) { + h.emitChange(); + h.task().fn(); + seen.add(getAssistantContextSnapshot()!); + } + + // Store tek alanlı: geçmiş tutulmuyor, yalnız son snapshot erişilebilir. + const state = useAssistantContextStore.getState(); + expect(Object.keys(state).filter((k) => !k.startsWith('_'))).toEqual(['snapshot']); + expect(state.snapshot).toBe([...seen][seen.size - 1]); + expect(state.snapshot!.revision).toBe(201); // 1 (ilk) + 200 + }); +}); + +/* ══════════════════════════════════════════════════════════════════════════ + * 14. Hot-path etkilenmiyor + * ════════════════════════════════════════════════════════════════════════ */ + +describe('hot-path koruması', () => { + it('görev NORMAL kritiklikte ve idle\'a ötelenir (3Hz hot-path\'e girmez)', () => { + const h = makeHarness({ sources: fullSources() }); + startAssistantContext(h.deps); + + const task = h.task(); + expect(task.id).toBe(SNAPSHOT_TASK_ID); + expect(task.criticality).toBe('NORMAL'); // SAFETY değil → düşük tier'da yavaşlar + expect(task.deferIdle).toBe(true); // requestIdleCallback'e ötelenir + }); + + it('snapshot başına subscribe/unsubscribe döngüsü YOKTUR', () => { + const h = makeHarness({ sources: fullSources() }); + startAssistantContext(h.deps); + + for (let i = 0; i < 50; i++) { h.emitChange(); h.task().fn(); } + + expect(h.subscribeCalls()).toBe(1); // yalnız start'ta + expect(h.unsubscribeCalls()).toBe(0); // dispose'a kadar hiç + expect(h.tickCount()).toBe(50); + }); +}); diff --git a/src/store/useAssistantContextStore.ts b/src/store/useAssistantContextStore.ts new file mode 100644 index 00000000..5e939912 --- /dev/null +++ b/src/store/useAssistantContextStore.ts @@ -0,0 +1,867 @@ +/** + * useAssistantContextStore — Birleşik Asistan Bağlamı (OfflineAssistantContext). + * + * AMAÇ: Voice Assistant · Companion AI · Safety Kernel · Offline Conversation · + * Deep Scan · Vehicle Learning · Diagnostic AI · Maintenance AI katmanlarının + * BUGÜN her biri ayrı ayrı okuduğu kaynakları TEK merkezde toplar ve periyodik, + * dondurulmuş (immutable) bir snapshot yayınlar. Tüketiciler bu snapshot'ı okur. + * + * TASARIM (assistantSafetyKernel deseni — saf çekirdek + fail-soft canlı adaptör): + * • `buildSnapshot(sources, now, revision)` = SAF fonksiyon. Enjekte edilen ham + * kaynaklar üstünde çalışır, canlı servis OKUMAZ, asla throw ETMEZ, çıktısı + * `Object.freeze`'li. → cihazsız, mock'suz, deterministik test. + * • `readLiveSources()` = canlı kaynakları okuyan TEK adaptör. Her kaynak KENDİ + * try/catch'i içinde okunur → bir servisin hatası diğer bölümleri ÜRETMEYİ + * engellemez (fail-soft). + * • Lifecycle (`startAssistantContext`) abonelikleri BİR KEZ kurar, dispose'ta + * BİR KEZ kapatır — snapshot başına subscribe/unsubscribe döngüsü YOKTUR. + * + * PERFORMANS BÜTÇESİ (CLAUDE.md "Performans-Uyarlanabilir Hibrit"): + * • Snapshot periyodu: 5 sn · DeviceTier='low' → 10 sn. + * • Görev `runtimeManager.scheduleTask` üstünde, criticality='NORMAL' (mod + * çarpanına tabi) + `deferIdle:true` (requestIdleCallback) → HOT-PATH'e (3Hz + * hız/RPM) ASLA girmez. Güvenlik kararı ÜRETMEZ; bu yüzden SAFETY değildir. + * • Dirty-flag: hiçbir kaynak değişmediyse ve snapshot bayat değilse tik + * hiçbir şey ayırmaz (park hâlinde sıfır maliyet). + * • Ağır soğuk-yol bölümleri (Vehicle Identity · Vehicle Learning) her tikte + * DEĞİL, kendi yenileme aralıklarında okunur; arada önbellek döner. Low + * tier'da bu aralık iki katına çıkar ve pattern detayı zaten kapalıdır + * (vehicleLearningIntegrationService tier-farkında). + * + * GİZLİLİK (kesin): snapshot'a VIN · MAC · GPS koordinatı · ham PID/DID/CAN + * verisi ASLA yazılmaz. Yalnız YORUMLANMIŞ değerler taşınır. Navigasyon hedefi + * yalnız GÖRÜNEN AD olarak (koordinatsız, kırpılmış) girer. + * + * KESİN SINIRLAR: bu modül hiçbir servise YAZMAZ; severity/driveSafe/diagnostic + * kararlarını DEĞİŞTİRMEZ (yalnız okur); OBD poll / Discovery / Learning / SQL / + * Native davranışı DEĞİŞMEZ. Additive — mevcut tüketiciler bu store'a bağlanana + * kadar hiçbir davranış değişmez. + * + * BUGÜN KAYNAĞI OLMAYAN ALANLAR (uydurulmaz — dürüstçe `null`): + * • `deepScan.*` — projede genel bir Deep Scan servisi YOK (yalnız + * DTCPanel içinde lokal `isDeepScanning` UI state'i var). + * • `driver.fatigueScore` — sürücü yorgunluk skoru üreten bir servis YOK. + * • `status.ignition` — kontak durumu yayan bir sinyal YOK. + * • `media.ducking` — audioService duck durumunu DIŞA AÇMIYOR. + * • `network.wifi/mobile` — yalnız `navigator.connection.type` kesin + * söylüyorsa doldurulur; aksi hâlde null. + * Bu alanlar şema içinde yer tutar; kaynakları eklendiğinde adaptör doldurur. + */ + +import { create } from 'zustand'; + +import { runtimeManager, type ScheduledTask } from '../core/runtime/AdaptiveRuntimeManager'; +import { RuntimeMode } from '../core/runtime/runtimeTypes'; +import { getDeviceTier, type DeviceTier } from '../platform/deviceCapabilities'; + +import { onOBDData } from '../platform/obdService'; +import { onDTCState } from '../platform/dtcService'; +import { onTripState } from '../platform/tripLogService'; +import { diagnoseDtc } from '../platform/diagnosticKnowledgeEngine'; +import { getBrainState } from '../platform/diagnostic/maintenanceBrain'; +import { getNavigationState } from '../platform/navigationService'; +import { getMediaState } from '../platform/mediaService'; +import { getThermalLevel } from '../platform/thermalWatchdog'; +import { vehicleKnowledgeBaseStore } from '../platform/vehicleKnowledgeBase'; +import { resolveManufacturer } from '../platform/manufacturerIntelligenceEngine'; +import { vehicleLearningEvidenceStore } from '../platform/vehicleLearningEvidenceStore'; +import { vehicleLearningIntegrationService } from '../platform/vehicleLearningIntegrationService'; + +import { useCognitiveStore, type CognitiveMode } from './useCognitiveStore'; +import { useStore } from './useStore'; + +/* ══════════════════════════════════════════════════════════════════════════ + * Sabitler (performans bütçesi) + * ════════════════════════════════════════════════════════════════════════ */ + +/** Snapshot periyodu (mid/high tier). */ +export const SNAPSHOT_PERIOD_MS = 5_000; +/** Snapshot periyodu (DeviceTier='low'). */ +export const LOW_TIER_SNAPSHOT_PERIOD_MS = 10_000; + +/** Soğuk-yol bölümleri (identity/learning) bu süreden eski ise yeniden okunur. */ +const COLD_REFRESH_MS = 30_000; +/** Low tier'da soğuk-yol yenileme iki kat seyrek. */ +const COLD_REFRESH_MS_LOW = 60_000; + +/** + * Hiçbir kaynak değişmese bile snapshot bu süreden eskiyse yeniden kurulur + * (soğuk-yol bölümlerinin bayatlamaması için üst sınır). + */ +const MAX_SNAPSHOT_AGE_MS = 60_000; + +/** Tanı için değerlendirilecek maksimum aktif DTC (bounded — CLAUDE.md). */ +const MAX_DTC_EVAL = 12; +/** Öğrenme kanıtı okuma tavanı (bounded). */ +const MAX_EVIDENCE_SCAN = 512; +/** Navigasyon hedef adı üst sınırı (gizlilik + bellek). */ +const MAX_DEST_NAME_CHARS = 64; + +/** Scheduler görev kimliği (idempotent kayıt). */ +export const SNAPSHOT_TASK_ID = 'assistant-context-snapshot'; + +/* ══════════════════════════════════════════════════════════════════════════ + * Snapshot şeması (yalnız YORUMLANMIŞ veri — ham/gizli alan YOK) + * ════════════════════════════════════════════════════════════════════════ */ + +/** Araç kimliği — VIN taşımaz (yalnız türetilmiş fingerprint hash'i). */ +export interface AssistantIdentitySection { + readonly fingerprintHash: string | null; + readonly manufacturer: string | null; + readonly profileHint: string | null; + readonly protocol: string | null; +} + +/** Bakım özeti — maintenanceBrain'in senkron okunabilen türev değerleri. */ +export interface AssistantMaintenanceSummary { + readonly oilLifePercent: number | null; + readonly wearRate: number | null; +} + +/** Tanı severity — dtcDataSource.DTCSeverity ile hizalı (yalnız okunur). */ +export type AssistantSeverity = 'critical' | 'warning' | 'info'; + +/** Araç sağlığı — severity/driveSafe DEĞİŞTİRİLMEZ, aktif tanı motorundan OKUNUR. */ +export interface AssistantHealthSection { + readonly healthScore: number | null; + readonly severity: AssistantSeverity | null; + readonly driveSafe: boolean | null; + readonly maintenance: AssistantMaintenanceSummary; +} + +/** Araç öğrenme özeti (P2-1→P2-5 salt-okunur türevleri). */ +export interface AssistantLearningSection { + readonly evidenceCount: number | null; + readonly patternCount: number | null; + /** En yüksek decay uygulanmış güven (0–1). */ + readonly strongestConfidence: number | null; + /** Kanıtlara katkı veren tekil araç (fingerprint) sayısı. */ + readonly learnedVehicleCount: number | null; +} + +/** Deep Scan — bugün besleyen servis YOK; şema yer tutar (tümü null). */ +export interface AssistantDeepScanSection { + readonly completed: boolean | null; + readonly progress: number | null; + readonly newDiscoveriesCount: number | null; + readonly changedFirmware: boolean | null; + readonly changedECU: boolean | null; +} + +/** Araç durumu — imkânsız/desteklenmeyen değerler (-1 sentinel dahil) null'a düşer. */ +export interface AssistantStatusSection { + readonly speed: number | null; + readonly rpm: number | null; + readonly coolantTemp: number | null; + readonly fuelLevel: number | null; + readonly batteryVoltage: number | null; + /** Kontak sinyali yayan kaynak yok → daima null. */ + readonly ignition: boolean | null; +} + +/** Sürücü durumu. */ +export interface AssistantDriverSection { + /** Yorgunluk skoru üreten servis yok → daima null. */ + readonly fatigueScore: number | null; + /** Aktif yolculuk süresi (dakika); yolculuk yoksa null. */ + readonly tripDuration: number | null; + readonly cognitiveMode: CognitiveMode | null; +} + +/** Navigasyon — hedef yalnız GÖRÜNEN AD (koordinat YOK). */ +export interface AssistantNavigationSection { + readonly isNavigating: boolean | null; + readonly destination: string | null; + readonly remainingKm: number | null; + /** Kalan süre (saniye). */ + readonly remainingTime: number | null; +} + +/** Medya. */ +export interface AssistantMediaSection { + readonly playing: boolean | null; + readonly volume: number | null; + /** audioService duck durumunu dışa açmıyor → daima null. */ + readonly ducking: boolean | null; +} + +/** Ağ. */ +export interface AssistantNetworkSection { + readonly online: boolean | null; + readonly wifi: boolean | null; + readonly mobile: boolean | null; +} + +/** Cihaz. */ +export type AssistantThermalStatus = 'normal' | 'warm' | 'hot' | 'critical'; + +export interface AssistantDeviceSection { + readonly tier: DeviceTier | null; + readonly thermalStatus: AssistantThermalStatus | null; + readonly powerSaver: boolean | null; +} + +/** Tüm asistan katmanlarının ortak, dondurulmuş bağlamı. */ +export interface AssistantContextSnapshot { + /** Üretim zamanı (ms). */ + readonly builtAt: number; + /** Monoton artan sürüm — değişiklik tespiti için (referans eşitliği yerine). */ + readonly revision: number; + readonly identity: AssistantIdentitySection; + readonly health: AssistantHealthSection; + readonly learning: AssistantLearningSection; + readonly deepScan: AssistantDeepScanSection; + readonly status: AssistantStatusSection; + readonly driver: AssistantDriverSection; + readonly navigation: AssistantNavigationSection; + readonly media: AssistantMediaSection; + readonly network: AssistantNetworkSection; + readonly device: AssistantDeviceSection; +} + +/* ══════════════════════════════════════════════════════════════════════════ + * Ham kaynak girdisi (SAF kurucunun tek girdisi — tüm alanlar opsiyonel) + * ════════════════════════════════════════════════════════════════════════ */ + +/** + * Adaptörün topladığı ham okumalar. Her alan opsiyonel: okunamayan kaynak + * `undefined` bırakılır → ilgili snapshot alanı `null` olur (uydurma YOK). + * Ham hot-path verisi TAŞIMAZ; yalnız kurucunun sanitize edeceği skaler değerler. + */ +export interface AssistantContextSources { + identity?: { + fingerprintHash?: string; + manufacturer?: string; + profileHint?: string; + protocol?: string; + }; + health?: { + healthScore?: number; + severity?: AssistantSeverity; + driveSafe?: boolean; + oilLifePercent?: number; + wearRate?: number; + }; + learning?: { + evidenceCount?: number; + patternCount?: number; + strongestConfidence?: number; + learnedVehicleCount?: number; + }; + status?: { + speed?: number; + rpm?: number; + coolantTemp?: number; + fuelLevel?: number; + batteryVoltage?: number; + }; + driver?: { + tripDuration?: number; + cognitiveMode?: CognitiveMode; + }; + navigation?: { + isNavigating?: boolean; + destination?: string; + remainingKm?: number; + remainingTime?: number; + }; + media?: { + playing?: boolean; + volume?: number; + }; + network?: { + online?: boolean; + wifi?: boolean; + mobile?: boolean; + }; + device?: { + tier?: DeviceTier; + thermalStatus?: AssistantThermalStatus; + powerSaver?: boolean; + }; +} + +/* ══════════════════════════════════════════════════════════════════════════ + * Sanitizasyon (Sensor Resiliency — CLAUDE.md §2: imkânsız değer reddedilir) + * ════════════════════════════════════════════════════════════════════════ */ + +/** Sayı [min,max] aralığındaysa döner; değilse null (NaN/Infinity/-1 sentinel dahil). */ +function num(v: unknown, min: number, max: number): number | null { + return typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max ? v : null; +} + +function bool(v: unknown): boolean | null { + return typeof v === 'boolean' ? v : null; +} + +/** Boş olmayan, kırpılmış metin; aksi hâlde null. `max` karakterde kesilir. */ +function text(v: unknown, max: number): string | null { + if (typeof v !== 'string') return null; + const t = v.trim(); + if (!t) return null; + return t.length > max ? t.slice(0, max) : t; +} + +function severity(v: unknown): AssistantSeverity | null { + return v === 'critical' || v === 'warning' || v === 'info' ? v : null; +} + +function thermal(v: unknown): AssistantThermalStatus | null { + return v === 'normal' || v === 'warm' || v === 'hot' || v === 'critical' ? v : null; +} + +function tierOf(v: unknown): DeviceTier | null { + return v === 'low' || v === 'mid' || v === 'high' ? v : null; +} + +function cognitive(v: unknown): CognitiveMode | null { + return v === 'IMMERSIVE' || v === 'AWARE' || v === 'FOCUSED' || + v === 'PROTECTION' || v === 'CRITICAL' || v === 'LIMP_HOME' ? v : null; +} + +/* ══════════════════════════════════════════════════════════════════════════ + * SAF kurucu — asla throw etmez, çıktısı derin dondurulmuş + * ════════════════════════════════════════════════════════════════════════ */ + +/** + * Ham kaynaklardan dondurulmuş snapshot üretir. SAF: canlı servis okumaz, girdiyi + * MUTASYONA UĞRATMAZ, hata fırlatmaz. Eksik/bozuk her alan `null`'a düşer. + * + * Gizlilik: girdide VIN/MAC/koordinat/ham PID beklenmez; kurucu yalnız bilinen + * skaler alanları ALIR (whitelist) — girdiye fazladan alan eklense bile snapshot'a + * SIZAMAZ (yapısal garanti). + */ +export function buildSnapshot( + sources: AssistantContextSources | null | undefined, + now: number, + revision: number, +): AssistantContextSnapshot { + const s = sources ?? {}; + const builtAt = Number.isFinite(now) ? now : 0; + + const identity: AssistantIdentitySection = Object.freeze({ + fingerprintHash: text(s.identity?.fingerprintHash, 64), + manufacturer: text(s.identity?.manufacturer, 64), + profileHint: text(s.identity?.profileHint, 64), + protocol: text(s.identity?.protocol, 32), + }); + + const maintenance: AssistantMaintenanceSummary = Object.freeze({ + oilLifePercent: num(s.health?.oilLifePercent, 0, 100), + wearRate: num(s.health?.wearRate, 0, 1), + }); + + const health: AssistantHealthSection = Object.freeze({ + healthScore: num(s.health?.healthScore, 0, 100), + severity: severity(s.health?.severity), + driveSafe: bool(s.health?.driveSafe), + maintenance, + }); + + const learning: AssistantLearningSection = Object.freeze({ + evidenceCount: num(s.learning?.evidenceCount, 0, MAX_EVIDENCE_SCAN), + patternCount: num(s.learning?.patternCount, 0, MAX_EVIDENCE_SCAN), + strongestConfidence: num(s.learning?.strongestConfidence, 0, 1), + learnedVehicleCount: num(s.learning?.learnedVehicleCount, 0, MAX_EVIDENCE_SCAN), + }); + + // Deep Scan: besleyen servis yok → şema yer tutar, tüm alanlar null (uydurma YOK). + const deepScan: AssistantDeepScanSection = Object.freeze({ + completed: null, progress: null, newDiscoveriesCount: null, + changedFirmware: null, changedECU: null, + }); + + const status: AssistantStatusSection = Object.freeze({ + speed: num(s.status?.speed, 0, 300), + rpm: num(s.status?.rpm, 0, 10_000), + coolantTemp: num(s.status?.coolantTemp, -40, 200), + fuelLevel: num(s.status?.fuelLevel, 0, 100), + batteryVoltage: num(s.status?.batteryVoltage, 0, 20), + ignition: null, // kaynak yok + }); + + const driver: AssistantDriverSection = Object.freeze({ + fatigueScore: null, // kaynak yok + tripDuration: num(s.driver?.tripDuration, 0, 48 * 60), + cognitiveMode: cognitive(s.driver?.cognitiveMode), + }); + + const navigation: AssistantNavigationSection = Object.freeze({ + isNavigating: bool(s.navigation?.isNavigating), + destination: text(s.navigation?.destination, MAX_DEST_NAME_CHARS), + remainingKm: num(s.navigation?.remainingKm, 0, 5_000), + remainingTime: num(s.navigation?.remainingTime, 0, 24 * 3600), + }); + + const media: AssistantMediaSection = Object.freeze({ + playing: bool(s.media?.playing), + volume: num(s.media?.volume, 0, 100), + ducking: null, // kaynak yok + }); + + const network: AssistantNetworkSection = Object.freeze({ + online: bool(s.network?.online), + wifi: bool(s.network?.wifi), + mobile: bool(s.network?.mobile), + }); + + const device: AssistantDeviceSection = Object.freeze({ + tier: tierOf(s.device?.tier), + thermalStatus: thermal(s.device?.thermalStatus), + powerSaver: bool(s.device?.powerSaver), + }); + + return Object.freeze({ + builtAt, + revision: Number.isFinite(revision) ? revision : 0, + identity, health, learning, deepScan, status, driver, navigation, media, network, device, + }); +} + +/* ══════════════════════════════════════════════════════════════════════════ + * Canlı kaynak adaptörü — her kaynak İZOLE try/catch (fail-soft) + * ════════════════════════════════════════════════════════════════════════ */ + +/** Abonelik önbelleği: tik içinde SENKRON okunur (snapshot başına abone olunmaz). */ +interface SourceCache { + speed?: number; rpm?: number; coolantTemp?: number; fuelLevel?: number; batteryVoltage?: number; + dtcCodes: string[]; + tripActive: boolean; + tripDurationMin: number; +} + +function emptyCache(): SourceCache { + return { dtcCodes: [], tripActive: false, tripDurationMin: 0 }; +} + +let _cache: SourceCache = emptyCache(); + +/** Soğuk-yol önbellekleri (identity/learning) — kendi aralıklarında yenilenir. */ +let _identityCache: AssistantContextSources['identity']; +let _identityAt = 0; +let _learningCache: AssistantContextSources['learning']; +let _learningAt = 0; + +/** DTC → severity/driveSafe türevinin önbelleği (kod listesi değişmedikçe yeniden hesaplanmaz). */ +let _dtcKey = ''; +let _dtcDerived: { severity?: AssistantSeverity; driveSafe?: boolean } = {}; + +/** OBD -1 sentinel'i "desteklenmiyor" demektir → sanitizasyona `undefined` gider. */ +function fromSentinel(v: number | undefined): number | undefined { + return typeof v === 'number' && v >= 0 ? v : undefined; +} + +/** Aktif DTC listesinden en kötü severity + driveSafe türetir (tanı motorunu OKUR, değiştirmez). */ +function deriveDtcHealth(codes: readonly string[]): { severity?: AssistantSeverity; driveSafe?: boolean } { + if (codes.length === 0) return {}; + let worst: AssistantSeverity | undefined; + let driveSafe = true; + for (const code of codes.slice(0, MAX_DTC_EVAL)) { + try { + const insight = diagnoseDtc(code); + if (insight.driveSafe === false) driveSafe = false; + if (insight.severity === 'critical') worst = 'critical'; + else if (insight.severity === 'warning' && worst !== 'critical') worst = 'warning'; + else if (insight.severity === 'info' && worst === undefined) worst = 'info'; + } catch { /* tek kod hatası diğerlerini etkilemez */ } + } + return { severity: worst, driveSafe }; +} + +/** `navigator.connection.type` KESİN söylüyorsa wifi/mobile; aksi hâlde undefined. */ +function readConnectionType(): { wifi?: boolean; mobile?: boolean } { + try { + const conn = (navigator as unknown as { connection?: { type?: string } }).connection; + const t = conn?.type; + if (t === 'wifi') return { wifi: true, mobile: false }; + if (t === 'cellular') return { wifi: false, mobile: true }; + } catch { /* API yok */ } + return {}; +} + +function thermalStatusFromLevel(level: number): AssistantThermalStatus | undefined { + if (level === 0) return 'normal'; + if (level === 1) return 'warm'; + if (level === 2) return 'hot'; + if (level === 3) return 'critical'; + return undefined; +} + +/** Soğuk-yol: araç kimliği (en son görülen bilgi tabanı kaydı). VIN TAŞIMAZ. */ +function readIdentity(): AssistantContextSources['identity'] { + try { + const record = vehicleKnowledgeBaseStore.list()[0]; + if (!record) return undefined; + let manufacturer: string | undefined; + let profileHint: string | undefined; + try { + const resolved = resolveManufacturer(record); + manufacturer = resolved.manufacturer; + profileHint = resolved.profileHint; + } catch { /* çözümleyici hatası kimliği tamamen düşürmesin */ } + return { + fingerprintHash: record.fingerprintHash, + manufacturer, + profileHint: profileHint || record.profileHint, + protocol: record.protocol, + }; + } catch { return undefined; } +} + +/** Soğuk-yol: öğrenme özeti (salt-okunur, memoized entegrasyon servisi + kanıt deposu). */ +function readLearning(): AssistantContextSources['learning'] { + try { + const summary = vehicleLearningIntegrationService.getExpertSummary(); + + // En güçlü decay'li güven — entegrasyon servisinin (memoized) anotasyonlarından. + let strongest = 0; + try { + for (const ann of vehicleLearningIntegrationService.getAnnotationMap().values()) { + if (ann.decayedConfidence > strongest) strongest = ann.decayedConfidence; + } + } catch { /* anotasyon hatası özeti düşürmesin */ } + + // Kanıtlara katkı veren TEKİL araç sayısı (bounded tarama). + let learnedVehicleCount: number | undefined; + try { + const hashes = new Set(); + const list = vehicleLearningEvidenceStore.list(); + const bounded = list.length > MAX_EVIDENCE_SCAN ? list.slice(0, MAX_EVIDENCE_SCAN) : list; + for (const e of bounded) for (const h of e.supportingVehicleHashes ?? []) if (h) hashes.add(h); + learnedVehicleCount = hashes.size; + } catch { /* depo hatası → sayı bilinmiyor */ } + + return { + evidenceCount: summary.totalEvidence, + patternCount: summary.patternCount, + strongestConfidence: strongest, + learnedVehicleCount, + }; + } catch { return undefined; } +} + +/** + * Canlı kaynakları okur. Her bölüm KENDİ try/catch'i içinde — bir servisin hatası + * diğer bölümlerin üretilmesini ENGELLEMEZ. Asla throw etmez. + * + * `coldRefreshMs`: identity/learning bölümleri bu süreden eski değilse önbellekten + * döner (soğuk-yol bütçesi). + */ +export function readLiveSources(now: number, coldRefreshMs: number): AssistantContextSources { + const out: AssistantContextSources = {}; + + // ── Araç durumu (abonelik önbelleğinden — senkron, ek okuma yok) ── + try { + out.status = { + speed: fromSentinel(_cache.speed), + rpm: fromSentinel(_cache.rpm), + coolantTemp: fromSentinel(_cache.coolantTemp), + fuelLevel: fromSentinel(_cache.fuelLevel), + batteryVoltage: fromSentinel(_cache.batteryVoltage), + }; + } catch { /* fail-soft */ } + + // ── Sağlık: healthScore/bakım (maintenanceBrain) + severity/driveSafe (tanı motoru) ── + try { + const brain = getBrainState(); + const key = _cache.dtcCodes.join(','); + if (key !== _dtcKey) { + _dtcKey = key; + _dtcDerived = deriveDtcHealth(_cache.dtcCodes); + } + out.health = { + healthScore: brain.healthScore, + severity: _dtcDerived.severity, + driveSafe: _dtcDerived.driveSafe, + oilLifePercent: brain.oilLife, + wearRate: brain.wearRate, + }; + } catch { /* bakım beyni yok → sağlık bölümü boş */ } + + // ── Sürücü ── + try { + out.driver = { + tripDuration: _cache.tripActive ? _cache.tripDurationMin : undefined, + cognitiveMode: useCognitiveStore.getState().currentMode, + }; + } catch { /* store yok */ } + + // ── Navigasyon (hedef yalnız AD — koordinat ASLA) ── + try { + const nav = getNavigationState(); + out.navigation = { + isNavigating: nav.isNavigating, + destination: nav.destination?.name, + remainingKm: typeof nav.distanceMeters === 'number' ? nav.distanceMeters / 1000 : undefined, + remainingTime: nav.etaSeconds, + }; + } catch { /* navigasyon yok */ } + + // ── Medya ── + try { + out.media = { playing: getMediaState().playing }; + } catch { /* medya servisi yok */ } + try { + const v = useStore.getState().settings.volume; + out.media = { ...(out.media ?? {}), volume: v }; + } catch { /* ayar store'u yok */ } + + // ── Ağ ── + try { + out.network = { + online: typeof navigator !== 'undefined' ? navigator.onLine !== false : undefined, + ...readConnectionType(), + }; + } catch { /* navigator yok (SSR/test) */ } + + // ── Cihaz ── + try { + const device: NonNullable = {}; + try { device.tier = getDeviceTier(); } catch { /* tespit yok */ } + try { device.thermalStatus = thermalStatusFromLevel(getThermalLevel()); } catch { /* watchdog yok */ } + try { device.powerSaver = runtimeManager.getMode() === RuntimeMode.POWER_SAVE; } catch { /* runtime yok */ } + out.device = device; + } catch { /* fail-soft */ } + + // ── Soğuk-yol bölümleri (identity/learning) — kendi aralıklarında ── + if (_identityAt === 0 || now - _identityAt >= coldRefreshMs) { + _identityCache = readIdentity(); + _identityAt = now; + } + if (_identityCache) out.identity = _identityCache; + + if (_learningAt === 0 || now - _learningAt >= coldRefreshMs) { + _learningCache = readLearning(); + _learningAt = now; + } + if (_learningCache) out.learning = _learningCache; + + return out; +} + +/* ══════════════════════════════════════════════════════════════════════════ + * Zustand store — yalnız dondurulmuş snapshot yayınlar + * ════════════════════════════════════════════════════════════════════════ */ + +interface AssistantContextState { + /** Son dondurulmuş snapshot; henüz üretilmediyse null. */ + snapshot: AssistantContextSnapshot | null; + /** Snapshot yayınla (yalnız lifecycle çağırır). */ + _publish: (snap: AssistantContextSnapshot | null) => void; +} + +export const useAssistantContextStore = create()((set) => ({ + snapshot: null, + _publish: (snapshot) => set({ snapshot }), +})); + +/** Anlık snapshot (React dışı tüketiciler için). Henüz üretilmediyse null. */ +export function getAssistantContextSnapshot(): AssistantContextSnapshot | null { + return useAssistantContextStore.getState().snapshot; +} + +/* ══════════════════════════════════════════════════════════════════════════ + * Lifecycle — abonelikler BİR KEZ kurulur, dispose'ta BİR KEZ kapatılır + * ════════════════════════════════════════════════════════════════════════ */ + +/** Enjekte edilebilir bağımlılıklar (test için; prod varsayılanları canlı servisler). */ +export interface AssistantContextDeps { + now: () => number; + tier: () => DeviceTier; + schedule: (task: ScheduledTask) => () => void; + /** Ham kaynakları okur (soğuk-yol aralığı parametreyle gelir). */ + readSources: (now: number, coldRefreshMs: number) => AssistantContextSources; + /** Kaynak aboneliklerini kurar; her değişimde `onChange` çağrılır. Cleanup listesi döner. */ + subscribeSources: (onChange: () => void) => Array<() => void>; +} + +/** Canlı abonelikler — her biri izole; biri kurulamazsa diğerleri çalışmaya devam eder. */ +function liveSubscriptions(onChange: () => void): Array<() => void> { + const unsubs: Array<() => void> = []; + + try { + unsubs.push(onOBDData((d) => { + _cache.speed = d.speed; + _cache.rpm = d.rpm; + _cache.coolantTemp = d.engineTemp; + _cache.fuelLevel = d.fuelLevel; + _cache.batteryVoltage = d.batteryVoltage; + onChange(); + })); + } catch { /* OBD servisi yok */ } + + try { + unsubs.push(onDTCState((s) => { + const codes: string[] = []; + for (const c of s.codes ?? []) { + if (typeof c?.code === 'string' && c.code) codes.push(c.code); + if (codes.length >= MAX_DTC_EVAL) break; + } + _cache.dtcCodes = codes; + onChange(); + })); + } catch { /* DTC servisi yok */ } + + try { + unsubs.push(onTripState((s) => { + _cache.tripActive = s.active === true; + _cache.tripDurationMin = s.current?.liveDurationMin ?? 0; + onChange(); + })); + } catch { /* yolculuk servisi yok */ } + + try { unsubs.push(useCognitiveStore.subscribe(onChange)); } catch { /* store yok */ } + try { unsubs.push(runtimeManager.subscribe(onChange)); } catch { /* runtime yok */ } + + try { + if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { + window.addEventListener('online', onChange); + window.addEventListener('offline', onChange); + unsubs.push(() => { + window.removeEventListener('online', onChange); + window.removeEventListener('offline', onChange); + }); + } + } catch { /* window yok */ } + + return unsubs; +} + +const LIVE_DEPS: AssistantContextDeps = { + now: () => Date.now(), + tier: () => getDeviceTier(), + schedule: (task) => runtimeManager.scheduleTask(task), + readSources: readLiveSources, + subscribeSources: liveSubscriptions, +}; + +let _started = false; +let _deps: AssistantContextDeps = LIVE_DEPS; +let _unsubs: Array<() => void> = []; +let _unschedule: (() => void) | null = null; +let _dirty = true; +let _revision = 0; +let _lastBuiltAt = 0; + +/** DeviceTier'a göre snapshot periyodu (low → 10 sn, aksi → 5 sn). */ +export function snapshotPeriodMs(tier: DeviceTier): number { + return tier === 'low' ? LOW_TIER_SNAPSHOT_PERIOD_MS : SNAPSHOT_PERIOD_MS; +} + +/** DeviceTier'a göre soğuk-yol yenileme aralığı (low → 60 sn, aksi → 30 sn). */ +function coldRefreshMs(tier: DeviceTier): number { + return tier === 'low' ? COLD_REFRESH_MS_LOW : COLD_REFRESH_MS; +} + +function markDirty(): void { _dirty = true; } + +/** Snapshot'ı yeniden kurar ve yayınlar. Asla throw etmez. */ +function rebuild(): AssistantContextSnapshot { + let tier: DeviceTier = 'mid'; + try { tier = _deps.tier(); } catch { /* tespit yok → mid varsayımı */ } + + let now = 0; + try { now = _deps.now(); } catch { /* saat yok */ } + + let sources: AssistantContextSources = {}; + try { sources = _deps.readSources(now, coldRefreshMs(tier)) ?? {}; } catch { sources = {}; } + + const snap = buildSnapshot(sources, now, ++_revision); + _lastBuiltAt = now; + _dirty = false; + try { useAssistantContextStore.getState()._publish(snap); } catch { /* store yayını hatası akışı kırmasın */ } + return snap; +} + +/** + * Periyodik tik gövdesi. Hiçbir kaynak değişmediyse VE snapshot bayat değilse + * hiçbir şey ayırmaz (park hâlinde sıfır maliyet). + */ +function tick(): void { + try { + // Dispose sonrası elde kalmış bir görev referansı tetiklenirse: sessizce çık + // (dangling görev snapshot yayınlayamaz). + if (!_started) return; + if (!_dirty) { + let now = 0; + try { now = _deps.now(); } catch { /* saat yok */ } + if (now - _lastBuiltAt < MAX_SNAPSHOT_AGE_MS) return; + } + rebuild(); + } catch { /* tik hiçbir koşulda uygulamayı çökertmez */ } +} + +/** + * Store'u devreye alır: abonelikleri kurar, snapshot görevini scheduler'a kaydeder + * ve ilk snapshot'ı üretir. İDEMPOTENT — ikinci çağrı yeni abonelik/timer kurmaz. + * + * @returns cleanup thunk (= `stopAssistantContext`) + */ +export function startAssistantContext(deps?: Partial): () => void { + if (_started) return stopAssistantContext; + _started = true; + _deps = { ...LIVE_DEPS, ...(deps ?? {}) }; + + _cache = emptyCache(); + _identityCache = undefined; _identityAt = 0; + _learningCache = undefined; _learningAt = 0; + _dtcKey = ''; _dtcDerived = {}; + _dirty = true; + + try { _unsubs = _deps.subscribeSources(markDirty) ?? []; } catch { _unsubs = []; } + + let tier: DeviceTier = 'mid'; + try { tier = _deps.tier(); } catch { /* tespit yok */ } + + try { + _unschedule = _deps.schedule({ + id: SNAPSHOT_TASK_ID, + periodMs: snapshotPeriodMs(tier), + // NORMAL: güvenlik kararı üretmez → düşük tier'da yavaşlaması İSTENİR. + criticality: 'NORMAL', + // Hot-path'e girmez: tetiklenince requestIdleCallback'e ötelenir. + deferIdle: true, + fn: tick, + }); + } catch { _unschedule = null; } + + rebuild(); // ilk snapshot — tüketiciler boot'ta null görmesin + return stopAssistantContext; +} + +/** Tam temizlik (zero-leak): abonelikler, scheduler görevi, önbellekler, yayınlanan snapshot. */ +export function stopAssistantContext(): void { + if (!_started) return; + _started = false; + + if (_unschedule) { try { _unschedule(); } catch { /* yoksay */ } _unschedule = null; } + for (const u of _unsubs) { try { u(); } catch { /* abone kapanışı temizliği kırmasın */ } } + _unsubs = []; + + // Eski referanslar tutulmaz. + _cache = emptyCache(); + _identityCache = undefined; _identityAt = 0; + _learningCache = undefined; _learningAt = 0; + _dtcKey = ''; _dtcDerived = {}; + _dirty = true; + _lastBuiltAt = 0; + _deps = LIVE_DEPS; + + try { useAssistantContextStore.getState()._publish(null); } catch { /* yoksay */ } +} + +/** Manuel yenileme — dirty flag'i atlayıp anında yeni snapshot üretir ve döner. */ +export function refreshAssistantContext(): AssistantContextSnapshot { + return rebuild(); +} + +/** Bir sonraki tikte yeniden kurulmayı zorlar (kaynak değişimi bildirimi). */ +export function invalidateAssistantContext(): void { + markDirty(); +} + +/** @internal — testler arası tam izolasyon. */ +export function _resetAssistantContextForTest(): void { + stopAssistantContext(); + _revision = 0; +}