diff --git a/docs/DEVICE_VALIDATION_LEDGER.md b/docs/DEVICE_VALIDATION_LEDGER.md index 59b9121b..b0193f4d 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 | |---|---------|--------|------------------------------------------|---------| +| 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 | | 22 | **🔴 Device Validation Pending — P0-Güvenlik #1: teşhis HTTP sunucusu + ADB uçları release'te kapalı** (commit 8786764). `startDiagServer` release'te güvenli no-op; `/enable-adb` (root ADB) + `/root-check` uçları `BuildConfig.DEBUG` guard'ı altında → release'te `DEBUG=false` → R8 blokları **ve** yalnız onlarda kullanılan `runRootShell`'i APK'dan siler. DTCPanel çağrısı `import.meta.env.DEV` ile ikinci katman. BuildConfig üretimi build.gradle'de açıldı (debug=true / release=false doğrulandı). | `build.gradle` (buildConfig), `CarLauncherPlugin.java` (no-op + 2 endpoint guard), `DTCPanel.tsx` (DEV guard) | **Release APK**'da DTC paneli açıkken PC'den `http://:8899/` → **bağlantı reddi** (soket açılmaz); `strings`/apktool ile `enable-adb` / `service.adb.tcp.port` literal'i APK'da **bulunamaz**. **Debug APK**'da aynı uçlar ÇALIŞIR (saha teşhis workflow'u korundu). | 2026-07-08 | diff --git a/src/__tests__/vehicleLearningIntegrationService.test.ts b/src/__tests__/vehicleLearningIntegrationService.test.ts new file mode 100644 index 00000000..ce212be6 --- /dev/null +++ b/src/__tests__/vehicleLearningIntegrationService.test.ts @@ -0,0 +1,277 @@ +/** + * vehicleLearningIntegrationService.test.ts — Öğrenme Entegrasyon Katmanı (P2-5). + * + * Kilitler: diagnostic learning context ekleniyor · veri yoksa insight korunuyor · + * safety/severity/driveSafe DEĞİŞMİYOR · strong PID/DID doğru · manual-review/conflict doğru · + * dashboard rozet/filtre doğru · Expert summary doğru · stale decay sonrası doğru · BASIC_JS + * ağır detay üretmiyor · BALANCED/HIGH tam detay · memoization · girdi mutate edilmiyor · + * boş/bozuk fail-soft · dispose. + */ +import { describe, it, expect } from 'vitest'; +import { + VehicleLearningIntegrationService, + learningBadgesFor, + type LearningDiscoveryAnnotation, +} from '../platform/vehicleLearningIntegrationService'; +import { buildPatterns } from '../platform/vehicleLearningPatternEngine'; +import { type LearningEvidence } from '../platform/vehicleLearningEngine'; +import { type DiagnosticInsight } from '../platform/diagnosticKnowledgeEngine'; +import { + filterObservations, + selectVisible, + isLearningFilter, + type DiscoveryFilter, +} from '../components/discovery/discoveryDashboardModel'; + +const DAY = 86_400_000; +const NOW = 100 * DAY; + +/** Kanıt fabrikası. */ +function ev(over: Partial = {}): LearningEvidence { + const manufacturer = over.manufacturer ?? 'Renault'; + const protocol = over.protocol ?? '6'; + const source = over.discoverySource ?? 'PID'; + const pidOrDid = over.pidOrDid ?? 'A5'; + const mode = over.mode ?? (source === 'DID' ? '22' : '01'); + const hashes = over.supportingVehicleHashes ?? ['v1']; + return { + evidenceId: over.evidenceId ?? `${manufacturer}|${protocol}|${source}|${pidOrDid}|${mode}`, + manufacturer, profileHint: over.profileHint ?? manufacturer, protocol, + discoverySource: source, pidOrDid, mode, + ecuAddresses: over.ecuAddresses ?? ['7E8'], + supportingVehicleHashes: hashes, + vehicleCount: over.vehicleCount ?? hashes.length, + observationCount: over.observationCount ?? 6, + firstSeen: over.firstSeen ?? (NOW - 5 * DAY), + lastSeen: over.lastSeen ?? NOW, + confidence: over.confidence ?? 0.8, + status: over.status ?? 'candidate', + createdAt: over.createdAt ?? (NOW - 5 * DAY), + updatedAt: over.updatedAt ?? NOW, + }; +} + +function svc(list: LearningEvidence[], tier: 'low' | 'mid' | 'high' = 'high', now = NOW): VehicleLearningIntegrationService { + return new VehicleLearningIntegrationService(() => list, () => now, () => tier); +} + +/** Diagnostic Insight fixture (yalnız gerekli alanlar; gerçek şekle uygun). */ +function insight(over: Partial = {}): DiagnosticInsight { + return { + dtc: 'P0401', description: 'EGR', severity: 'warning', confidence: 0.6, + manufacturer: 'Renault', profileHint: 'Renault', + likelySystems: ['EGR'], relatedPids: over.relatedPids ?? ['A5'], relatedDids: over.relatedDids ?? [], + discoveredOnVehicle: over.discoveredOnVehicle ?? false, manufacturerSeenCount: 0, vehicleSeenCount: 0, + possibleCauses: [], recommendedChecks: [], driveSafe: over.driveSafe ?? true, + firstSeen: 0, lastSeen: 0, + ...over, + }; +} + +/* ── Diagnostic entegrasyonu ─────────────────────────────────────────────── */ +describe('diagnostic learning context', () => { + it('learning context insight’a EKLENİYOR (eşleşen evidence var)', () => { + const s = svc([ev({ pidOrDid: 'A5', supportingVehicleHashes: ['v1', 'v2'] })]); + const out = s.enrichInsight(insight({ relatedPids: ['A5'] })); + expect(out.learning).toBeTruthy(); + expect(out.learning!.learnedEvidenceCount).toBe(1); + expect(out.learning!.learnedOnManufacturer).toBe(true); + }); + + it('learning verisi YOKSA insight AYNEN korunuyor (learning undefined)', () => { + const s = svc([ev({ pidOrDid: 'ZZ' })]); // ilgisiz sinyal + const base = insight({ relatedPids: ['A5'] }); + const out = s.enrichInsight(base); + expect(out.learning).toBeUndefined(); + expect(out).toBe(base); // aynı referans → değişmedi + }); + + it('safety/severity/driveSafe/confidence learning nedeniyle DEĞİŞMİYOR', () => { + const s = svc([ev({ pidOrDid: 'A5', supportingVehicleHashes: ['v1', 'v2', 'v3'] })]); + const base = insight({ relatedPids: ['A5'], severity: 'critical', driveSafe: false, confidence: 0.42 }); + const out = s.enrichInsight(base); + expect(out.severity).toBe('critical'); + expect(out.driveSafe).toBe(false); + expect(out.confidence).toBe(0.42); + expect(out.learning).toBeTruthy(); // ek bağlam eklendi ama karar aynı + }); + + it('strong PID/DID alanları DOĞRU', () => { + const s = svc([ + ev({ evidenceId: 'p', discoverySource: 'PID', pidOrDid: 'A5', supportingVehicleHashes: ['v1', 'v2', 'v3'], firstSeen: NOW - 5 * DAY, lastSeen: NOW }), + ev({ evidenceId: 'd', discoverySource: 'DID', pidOrDid: 'F190', supportingVehicleHashes: ['v1', 'v2', 'v3'], firstSeen: NOW - 5 * DAY, lastSeen: NOW }), + ]); + const ctx = s.buildDiagnosticLearningContext(insight({ relatedPids: ['A5'], relatedDids: ['F190'] })); + expect(ctx!.relatedStrongPids).toContain('A5'); + expect(ctx!.relatedStrongDids).toContain('F190'); + }); + + it('manual-review / conflict uyarıları DOĞRU (aynı PID iki markada)', () => { + const s = svc([ + ev({ evidenceId: 'r', manufacturer: 'Renault', pidOrDid: 'A5', supportingVehicleHashes: ['v1', 'v2'] }), + ev({ evidenceId: 't', manufacturer: 'Toyota', pidOrDid: 'A5', supportingVehicleHashes: ['w1', 'w2'] }), + ]); + const ctx = s.buildDiagnosticLearningContext(insight({ relatedPids: ['A5'] })); + expect(ctx!.requiresManualReview).toBe(true); + expect(ctx!.learningWarnings.length).toBeGreaterThan(0); + }); +}); + +/* ── Dashboard rozet / filtre ────────────────────────────────────────────── */ +describe('dashboard annotation + badges + filters', () => { + it('annotateDiscovery doğru statü/decay döndürüyor', () => { + const s = svc([ev({ pidOrDid: 'A5', supportingVehicleHashes: ['v1', 'v2', 'v3'], status: 'strong' })]); + const ann = s.annotateDiscovery('PID', 'A5'); + expect(ann).toBeTruthy(); + expect(ann!.evidenceStatus).toBe('strong'); + expect(ann!.decayedConfidence).toBeGreaterThan(0); + }); + + it('learningBadgesFor doğru rozetleri üretiyor', () => { + const ann: LearningDiscoveryAnnotation = { + pidOrDid: 'A5', discoverySource: 'PID', evidenceStatus: 'strong', patternStatus: 'strong', + confidence: 0.8, decayedConfidence: 0.75, vehicleCount: 3, observationCount: 6, ecuCount: 1, + firstSeen: 0, lastSeen: NOW, stale: false, requiresManualReview: true, + conflictReasons: ['CROSS_MANUFACTURER_MEANING'], + }; + const badges = learningBadgesFor(ann); + expect(badges).toContain('STRONG'); + expect(badges).toContain('CONFLICT'); + expect(badges).toContain('MANUAL_REVIEW'); + expect(learningBadgesFor(null)).toEqual([]); + }); + + it('dashboard öğrenme filtreleri annotation ile DOĞRU süzüyor', () => { + // Sahte gözlemler (yalnız record.discoverySource + pidOrDid önemli) + const obs = [ + { record: { discoverySource: 'PID', pidOrDid: 'A5' }, status: 'new', seenCount: 1, firstAt: 0, lastAt: 0 }, + { record: { discoverySource: 'PID', pidOrDid: 'B2' }, status: 'new', seenCount: 1, firstAt: 0, lastAt: 0 }, + ] as unknown as Parameters[0]; + const annMap = new Map([ + ['PID:A5', { pidOrDid: 'A5', discoverySource: 'PID', evidenceStatus: 'strong', patternStatus: 'strong', confidence: 0.8, decayedConfidence: 0.7, vehicleCount: 3, observationCount: 6, ecuCount: 1, firstSeen: 0, lastSeen: 0, stale: false, requiresManualReview: false, conflictReasons: [] }], + ['PID:B2', { pidOrDid: 'B2', discoverySource: 'PID', evidenceStatus: 'weak', patternStatus: 'weak', confidence: 0.3, decayedConfidence: 0.3, vehicleCount: 1, observationCount: 1, ecuCount: 1, firstSeen: 0, lastSeen: 0, stale: false, requiresManualReview: true, conflictReasons: ['CROSS_MANUFACTURER_MEANING'] }], + ]); + const resolver = (o: { record: { discoverySource: string; pidOrDid: string } }) => annMap.get(`${o.record.discoverySource}:${o.record.pidOrDid}`) ?? null; + + expect(filterObservations(obs, 'strong', resolver)).toHaveLength(1); + expect(filterObservations(obs, 'manual', resolver)).toHaveLength(1); + expect(filterObservations(obs, 'conflict', resolver)).toHaveLength(1); + // resolver YOKSA fail-soft tümü (mevcut davranış korunur) + expect(filterObservations(obs, 'strong')).toHaveLength(2); + // mevcut filtreler bozulmadı + expect(filterObservations(obs, 'pid')).toHaveLength(2); + expect(selectVisible(obs, 'strong', '', resolver)).toHaveLength(1); + expect(isLearningFilter('strong')).toBe(true); + expect(isLearningFilter('pid' as DiscoveryFilter)).toBe(false); + }); +}); + +/* ── Expert summary ──────────────────────────────────────────────────────── */ +describe('expert summary', () => { + it('sayımlar DOĞRU (weak/candidate/strong + son öğrenme)', () => { + const s = svc([ + ev({ evidenceId: 'a', status: 'weak', pidOrDid: 'A1' }), + ev({ evidenceId: 'b', status: 'candidate', pidOrDid: 'A2' }), + ev({ evidenceId: 'c', status: 'strong', pidOrDid: 'A3', supportingVehicleHashes: ['v1', 'v2', 'v3'] }), + ]); + const sum = s.getExpertSummary(); + expect(sum.totalEvidence).toBe(3); + expect(sum.weakCount).toBe(1); + expect(sum.candidateCount).toBe(1); + expect(sum.strongCount).toBe(1); + expect(sum.lastLearnedAt).toBe(NOW); + expect(sum.manufacturerClusters.length).toBeGreaterThanOrEqual(1); + }); + + it('decay sonrası STALE durumu doğru (çok eski kanıt)', () => { + const s = svc([ev({ status: 'weak', confidence: 0.3, lastSeen: 0, firstSeen: 0 })], 'high', 3000 * DAY); + const sum = s.getExpertSummary(); + expect(sum.staleCount).toBe(1); + expect(sum.pruneCandidateCount).toBe(1); + }); +}); + +/* ── DeviceTier ──────────────────────────────────────────────────────────── */ +describe('DeviceTier davranışı', () => { + const conflicting = () => [ + ev({ evidenceId: 'r', manufacturer: 'Renault', pidOrDid: 'A5', supportingVehicleHashes: ['v1', 'v2', 'v3'] }), + ev({ evidenceId: 't', manufacturer: 'Toyota', pidOrDid: 'A5', supportingVehicleHashes: ['w1', 'w2', 'w3'] }), + ]; + + it('BASIC_JS (low) AĞIR detay ÜRETMİYOR (manual/conflict=0, patternDetail kapalı)', () => { + const sum = svc(conflicting(), 'low').getExpertSummary(); + expect(sum.patternDetailEnabled).toBe(false); + expect(sum.manualReviewCount).toBe(0); + expect(sum.conflictCount).toBe(0); + // annotation'da conflict/manual detayı da yok + const ann = svc(conflicting(), 'low').annotateDiscovery('PID', 'A5'); + expect(ann!.conflictReasons).toEqual([]); + expect(ann!.requiresManualReview).toBe(false); + }); + + it('BALANCED/HIGH TAM detay üretiyor (conflict işaretli)', () => { + const sum = svc(conflicting(), 'high').getExpertSummary(); + expect(sum.patternDetailEnabled).toBe(true); + expect(sum.conflictCount).toBeGreaterThan(0); + const ann = svc(conflicting(), 'high').annotateDiscovery('PID', 'A5'); + expect(ann!.conflictReasons.length).toBeGreaterThan(0); + expect(ann!.requiresManualReview).toBe(true); + }); +}); + +/* ── Memoization / immutability / fail-soft / dispose ────────────────────── */ +describe('memoization + immutability + fail-soft + dispose', () => { + it('memoization: aynı veri → ağır pattern hesabı TEK KEZ (buildPatterns memoize)', () => { + let builds = 0; + const list = [ev({ pidOrDid: 'A5', supportingVehicleHashes: ['v1', 'v2'] })]; + const s = new VehicleLearningIntegrationService( + () => list, () => NOW, () => 'high', + (evList, opts) => { builds++; return buildPatterns(evList, opts); }, + ); + s.getExpertSummary(); + s.getExpertSummary(); + s.annotateDiscovery('PID', 'A5'); + expect(builds).toBe(1); // memo → ağır adım tek kez + s.invalidate(); + s.getExpertSummary(); + expect(builds).toBe(2); // invalidate sonrası taze hesap + }); + + it('girdi evidence MUTATE EDİLMİYOR', () => { + const list = [ev({ pidOrDid: 'A5', supportingVehicleHashes: ['v1', 'v2'], ecuAddresses: ['7E8'] })]; + const snap = JSON.parse(JSON.stringify(list)); + const s = svc(list); + s.getExpertSummary(); + s.annotateDiscovery('PID', 'A5'); + s.buildDiagnosticLearningContext(insight({ relatedPids: ['A5'] })); + expect(list).toEqual(snap); + }); + + it('boş / bozuk veri FAIL-SOFT', () => { + const empty = svc([]); + expect(empty.getExpertSummary().totalEvidence).toBe(0); + expect(empty.annotateDiscovery('PID', 'A5')).toBeNull(); + expect(empty.buildDiagnosticLearningContext(insight())).toBeNull(); + + const broken = new VehicleLearningIntegrationService( + () => ([null, { evidenceId: '' }, ev({ pidOrDid: 'A5' })] as unknown as LearningEvidence[]), + () => NOW, () => 'high', + ); + expect(() => broken.getExpertSummary()).not.toThrow(); + expect(broken.getExpertSummary().totalEvidence).toBe(3); // bozuklar sayıda ama fail-soft + + // reader throw → fail-soft boş + const throwing = new VehicleLearningIntegrationService(() => { throw new Error('x'); }, () => NOW, () => 'high'); + expect(throwing.getExpertSummary().totalEvidence).toBe(0); + expect(throwing.annotateDiscovery('PID', 'A5')).toBeNull(); + }); + + it('enrichInsight null insight’ta çökmüyor + dispose temizliyor', () => { + const s = svc([ev({ pidOrDid: 'A5', supportingVehicleHashes: ['v1', 'v2'] })]); + expect(() => s.enrichInsight(null as unknown as DiagnosticInsight)).not.toThrow(); + s.getExpertSummary(); + expect(() => s.dispose()).not.toThrow(); + // dispose sonrası tekrar çalışır (fail-soft) + expect(s.getExpertSummary().totalEvidence).toBe(1); + }); +}); diff --git a/src/components/discovery/DiscoveryDashboard.tsx b/src/components/discovery/DiscoveryDashboard.tsx index 36184c75..12cc47cd 100644 --- a/src/components/discovery/DiscoveryDashboard.tsx +++ b/src/components/discovery/DiscoveryDashboard.tsx @@ -15,6 +15,12 @@ import { Radar, Search, Copy, Check, Cpu, Fingerprint } from 'lucide-react'; import { Clipboard } from '@capacitor/clipboard'; import { discoveryCaptureService } from '../../platform/obd/discovery'; import { useDiscoveryObservations } from './useDiscoveryObservations'; +import { + vehicleLearningIntegrationService, + learningBadgesFor, + type LearningBadge, + type LearningDiscoveryAnnotation, +} from '../../platform/vehicleLearningIntegrationService'; import { computeSummary, selectVisible, @@ -36,6 +42,11 @@ const FILTERS: readonly { id: DiscoveryFilter; label: string }[] = [ { id: 'new', label: 'Yeni' }, { id: 'duplicate', label: 'Duplicate' }, { id: 'known', label: 'Bilinen' }, + // P2-5 öğrenme filtreleri (salt-okunur) + { id: 'strong', label: 'Strong' }, + { id: 'candidate', label: 'Candidate' }, + { id: 'manual', label: 'Manuel İnceleme' }, + { id: 'conflict', label: 'Conflict' }, ]; const BADGE_CLASS: Record = { @@ -45,6 +56,22 @@ const BADGE_CLASS: Record = { UNSUPPORTED: 'bg-rose-500/15 text-rose-300 border-rose-500/30', }; +/** P2-5 öğrenme rozetleri renkleri (salt-okunur görünürlük). */ +const LEARNING_BADGE_CLASS: Record = { + WEAK: 'bg-white/10 text-white/60 border-white/20', + CANDIDATE: 'bg-indigo-500/15 text-indigo-300 border-indigo-500/30', + STRONG: 'bg-emerald-500/20 text-emerald-200 border-emerald-500/40', + MANUAL_REVIEW: 'bg-amber-500/20 text-amber-200 border-amber-500/40', + STALE: 'bg-zinc-500/15 text-zinc-300 border-zinc-500/30', + CONFLICT: 'bg-rose-500/20 text-rose-200 border-rose-500/40', +}; + +/** Discovery record'un öğrenme anahtarı (integration service ile aynı normalizasyon). */ +function learningKey(source: string, pidOrDid: string): string { + const id = (pidOrDid ?? '').toString().trim().toUpperCase().replace(/\s+/g, '').replace(/^0X/, ''); + return `${source === 'DID' ? 'DID' : 'PID'}:${id}`; +} + function formatTime(ms: number | null): string { if (!ms) return '—'; try { return new Date(ms).toLocaleTimeString('tr-TR'); } catch { return '—'; } @@ -69,8 +96,20 @@ export const DiscoveryDashboard = memo(function DiscoveryDashboard() { const [copied, setCopied] = useState(false); const scrollRef = useRef(null); + // P2-5: öğrenme anotasyon haritası (salt-okunur; service içi memoize). Evidence deposu bu + // katmanda yazılmadığından oturum içinde sabittir → mount'ta bir kez hesaplanır (zero-leak). + const annotationMap = useMemo>( + () => vehicleLearningIntegrationService.getAnnotationMap(), + [], + ); + const annotationOf = useCallback( + (o: { record: { discoverySource: string; pidOrDid: string } }): LearningDiscoveryAnnotation | null => + annotationMap.get(learningKey(o.record.discoverySource, o.record.pidOrDid)) ?? null, + [annotationMap], + ); + const summary = useMemo(() => computeSummary(observations), [observations]); - const visible = useMemo(() => selectVisible(observations, filter, query), [observations, filter, query]); + const visible = useMemo(() => selectVisible(observations, filter, query, annotationOf), [observations, filter, query, annotationOf]); const win = useMemo( () => computeVirtualWindow({ scrollTop, rowHeight: ROW_HEIGHT, viewportHeight: LIST_VIEWPORT, itemCount: visible.length }), [scrollTop, visible.length], @@ -173,6 +212,8 @@ export const DiscoveryDashboard = memo(function DiscoveryDashboard() { {rows.map((o) => { const r = o.record; const badges = observationBadges(o); + const ann = annotationOf(o); + const learningBadges = learningBadgesFor(ann); return (
( {b} ))} + {learningBadges.map((b) => ( + {b.replace('_', ' ')} + ))}
@@ -204,6 +248,19 @@ export const DiscoveryDashboard = memo(function DiscoveryDashboard() { Profil: {r.vehicleProfile || '—'} FW: {r.firmwareVersion || '—'}
+ {ann && ( +
+ Güven: {ann.confidence.toFixed(2)} + Decay: {ann.decayedConfidence.toFixed(2)} + Araç: {ann.vehicleCount} + Gözlem: {ann.observationCount} + ECU#: {ann.ecuCount} + Statü: {(ann.patternStatus ?? ann.evidenceStatus).toUpperCase()} + {ann.conflictReasons.length > 0 && ( + Çelişki: {ann.conflictReasons.join(', ')} + )} +
+ )} ); })} diff --git a/src/components/discovery/discoveryDashboardModel.ts b/src/components/discovery/discoveryDashboardModel.ts index b9490e6f..033a0e06 100644 --- a/src/components/discovery/discoveryDashboardModel.ts +++ b/src/components/discovery/discoveryDashboardModel.ts @@ -8,6 +8,14 @@ */ import type { DiscoveryObservation } from '../../platform/obd/discovery'; +import type { LearningDiscoveryAnnotation } from '../../platform/vehicleLearningIntegrationService'; + +/** + * Bir gözlemin öğrenme anotasyonunu çözen fonksiyon (salt-okunur). Dashboard, integration + * service'ten türetilmiş `${source}:${pidOrDid}` haritasını sağlar. Sağlanmazsa öğrenme + * filtreleri fail-soft tümünü döndürür (mevcut davranış bozulmaz). + */ +export type AnnotationResolver = (o: DiscoveryObservation) => LearningDiscoveryAnnotation | null; /* ── Rozetler ─────────────────────────────────────────────────────────────── */ @@ -48,11 +56,20 @@ export function computeSummary(observations: readonly DiscoveryObservation[]): D /* ── Filtre ───────────────────────────────────────────────────────────────── */ -export type DiscoveryFilter = 'all' | 'pid' | 'did' | 'new' | 'duplicate' | 'known'; +/** Mevcut keşif filtreleri + P2-5 öğrenme filtreleri (strong/candidate/manual/conflict). */ +export type DiscoveryFilter = + | 'all' | 'pid' | 'did' | 'new' | 'duplicate' | 'known' + | 'strong' | 'candidate' | 'manual' | 'conflict'; + +/** Bir öğrenme filtresi mi (annotation gerektirir). */ +export function isLearningFilter(filter: DiscoveryFilter): boolean { + return filter === 'strong' || filter === 'candidate' || filter === 'manual' || filter === 'conflict'; +} export function filterObservations( observations: readonly DiscoveryObservation[], filter: DiscoveryFilter, + annotationOf?: AnnotationResolver, ): DiscoveryObservation[] { switch (filter) { case 'pid': return observations.filter((o) => o.record.discoverySource === 'PID'); @@ -60,6 +77,11 @@ export function filterObservations( case 'new': return observations.filter((o) => o.status === 'new'); case 'known': return observations.filter((o) => o.status === 'known'); case 'duplicate': return observations.filter((o) => o.seenCount > 1); + // Öğrenme filtreleri — resolver yoksa fail-soft tümü (mevcut davranış korunur). + case 'strong': return annotationOf ? observations.filter((o) => (annotationOf(o)?.patternStatus ?? annotationOf(o)?.evidenceStatus) === 'strong') : observations.slice(); + case 'candidate': return annotationOf ? observations.filter((o) => { const a = annotationOf(o); return (a?.patternStatus ?? a?.evidenceStatus) === 'candidate'; }) : observations.slice(); + case 'manual': return annotationOf ? observations.filter((o) => annotationOf(o)?.requiresManualReview === true) : observations.slice(); + case 'conflict': return annotationOf ? observations.filter((o) => (annotationOf(o)?.conflictReasons.length ?? 0) > 0) : observations.slice(); case 'all': default: return observations.slice(); } @@ -87,13 +109,14 @@ export function searchObservations( return observations.filter((o) => searchHaystack(o).includes(q)); } -/** Filtre + arama zinciri (Dashboard'un görünen listesi). */ +/** Filtre + arama zinciri (Dashboard'un görünen listesi). Öğrenme filtreleri için resolver. */ export function selectVisible( observations: readonly DiscoveryObservation[], filter: DiscoveryFilter, query: string, + annotationOf?: AnnotationResolver, ): DiscoveryObservation[] { - return searchObservations(filterObservations(observations, filter), query); + return searchObservations(filterObservations(observations, filter, annotationOf), query); } /* ── Virtualization (sanallaştırma penceresi) ─────────────────────────────── */ diff --git a/src/components/settings/ExpertModePanel.tsx b/src/components/settings/ExpertModePanel.tsx index b16ec4eb..465f1098 100644 --- a/src/components/settings/ExpertModePanel.tsx +++ b/src/components/settings/ExpertModePanel.tsx @@ -33,6 +33,7 @@ import { ExpertRecoveryAction } from './expert/ExpertRecoveryAction'; import { CRMInspector } from './expert/CRMInspector'; import { CognitiveInspector } from './expert/CognitiveInspector'; import { ManufacturerDidInspector } from './expert/ManufacturerDidInspector'; +import { VehicleLearningInspector } from './expert/VehicleLearningInspector'; import { DiscoveryDashboard } from '../discovery/DiscoveryDashboard'; export type ExpertModeState = 'INIT' | 'SEARCHING' | 'UNVERIFIED' | 'SECURE'; @@ -352,6 +353,9 @@ export const ExpertModePanel = memo(function ExpertModePanel() { {/* Üretici DID Profili + Saha Keşif Aracı (Patch 12D) */} + {/* Araç Öğrenme özeti — salt-okunur evidence/pattern görünürlüğü (P2-5) */} + + {/* Keşif Panosu — katalog-dışı PID/DID canlı görünümü (PR-DISC-3) */} diff --git a/src/components/settings/expert/VehicleLearningInspector.tsx b/src/components/settings/expert/VehicleLearningInspector.tsx new file mode 100644 index 00000000..1b537751 --- /dev/null +++ b/src/components/settings/expert/VehicleLearningInspector.tsx @@ -0,0 +1,132 @@ +/** + * VehicleLearningInspector — Expert Mode "Araç Öğrenme" bölümü (P2-5, SALT-OKUNUR). + * + * Öğrenilmiş evidence/pattern özetini yalnız GÖRÜNÜRLÜK amacıyla gösterir: + * toplam/weak/candidate/strong, manual-review, stale/prune, en güçlü PID/DID adayları, + * marka cluster özeti, son öğrenme zamanı. + * + * KESİN: Onayla / registry'ye yaz / otomatik promote butonu YOK. Hiçbir güvenlik-kritik + * kararı değiştirmez. Yalnız vehicleLearningIntegrationService (salt-okunur) okur. Zero-leak: + * abonelik yok (on-demand); yalnız mount'ta bir kez hesaplar, panel açıkken (idle) çalışır. + */ + +import { memo, useMemo } from 'react'; +import { Brain, AlertTriangle, Layers, Clock } from 'lucide-react'; +import { + vehicleLearningIntegrationService, + type ExpertLearningSummary, +} from '../../../platform/vehicleLearningIntegrationService'; + +function formatTime(ms: number | null): string { + if (!ms) return '—'; + try { return new Date(ms).toLocaleString('tr-TR'); } catch { return '—'; } +} + +const StatCell = memo(function StatCell({ label, value, tone }: { label: string; value: string | number; tone?: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +}); + +export const VehicleLearningInspector = memo(function VehicleLearningInspector() { + // On-demand: panel render'ında bir kez hesaplanır (service içi memoize). + const s: ExpertLearningSummary = useMemo(() => vehicleLearningIntegrationService.getExpertSummary(), []); + + const hasData = s.totalEvidence > 0; + + return ( +
+
+
+ +
+
+
Araç Öğrenme
+
+ Evidence & Pattern özeti — salt-okunur +
+
+ {!s.patternDetailEnabled && ( + + Basit Mod + + )} +
+ + {!hasData ? ( +
+ +

Henüz öğrenilmiş kanıt yok.

+

Farklı araçlarda PID/DID keşfi biriktikçe burada görünecek.

+
+ ) : ( + <> + {/* Sayımlar */} +
+ + + + + + + + +
+ + {/* En güçlü adaylar */} +
+
+
+ Güçlü PID Adayları +
+
+ {s.strongPidCandidates.length ? s.strongPidCandidates.join(', ') : '—'} +
+
+
+
+ Güçlü DID Adayları +
+
+ {s.strongDidCandidates.length ? s.strongDidCandidates.join(', ') : '—'} +
+
+
+ + {/* Marka cluster özeti */} + {s.manufacturerClusters.length > 0 && ( +
+
Marka Kümeleri
+
+ {s.manufacturerClusters.map((c) => ( + + {c.manufacturer} · {c.count} + + ))} +
+
+ )} + + {/* Meta */} +
+ {s.patternCount} pattern + Son öğrenme: {formatTime(s.lastLearnedAt)} +
+ + {(s.manualReviewCount > 0 || s.conflictCount > 0) && ( +
+ + + {s.manualReviewCount} pattern manuel inceleme, {s.conflictCount} çelişki içeriyor. Bu adaylar + otomatik olarak profile/registry'ye yazılmaz — yalnız bilgi amaçlıdır. + +
+ )} + + )} +
+ ); +}); diff --git a/src/platform/vehicleLearningIntegrationService.ts b/src/platform/vehicleLearningIntegrationService.ts new file mode 100644 index 00000000..83f0e64b --- /dev/null +++ b/src/platform/vehicleLearningIntegrationService.ts @@ -0,0 +1,467 @@ +/** + * vehicleLearningIntegrationService — Öğrenme Entegrasyon Katmanı (P2-5). + * + * AMAÇ: P2-1→P2-4 arasında kurulan öğrenme katmanlarını (Evidence Store · Decay · Pattern + * Engine) uygulamanın kullanıcıya değer üreten mevcut ekran/tanı akışlarına SALT-OKUNUR + * bağlar. Bu servis: + * - Evidence Store'dan kanıtları okur (P2-2) + * - decay uygular (P2-3) + * - pattern engine ile pattern üretir (P2-4) + * - Diagnostic Knowledge için ek bağlam hazırlar (safety/severity/driveSafe DEĞİŞTİRMEZ) + * - Discovery Dashboard rozetleri + Expert Mode özeti için view-model üretir + * + * KESİN SINIRLAR (CLAUDE.md): hiçbir registry/profile/store'a OTOMATİK YAZMAZ · hiçbir + * güvenlik-kritik kararı tek başına değiştirmez · Cloud/SQL/LLM/Native/hot-path YOK · + * OBD 3Hz poll akışına dokunmaz · yeni bağımlılık YOK · bounded (≤512) · memoized · girdiyi + * MUTASYONA UĞRATMAZ · FAIL-SOFT (çökse bile alt katmanlar etkilenmez) · zero-leak dispose. + * + * Evidence Store merge · decay formülü · pattern promotion kuralları DEĞİŞMEZ — yalnız okunur. + */ + +import { vehicleLearningEvidenceStore } from './vehicleLearningEvidenceStore'; +import { type LearningEvidence } from './vehicleLearningEngine'; +import { + calculateDecayedConfidence, + isPruneCandidate, + PRUNE_CONFIDENCE_FLOOR, +} from './vehicleLearningDecay'; +import { + buildClusters, + buildPatterns, + type LearningPattern, + type PatternStatus, + type ConflictReason, +} from './vehicleLearningPatternEngine'; +import { getDeviceTier, type DeviceTier } from './deviceCapabilities'; +import type { DiagnosticInsight } from './diagnosticKnowledgeEngine'; + +/* ══════════════════════════════════════════════════════════════════════════ + * Sabitler / model + * ════════════════════════════════════════════════════════════════════════ */ + +const HOUR_MS = 3_600_000; +/** İşlenecek maksimum kanıt (bounded — CLAUDE.md). */ +export const MAX_EVIDENCE = 512; + +/** Discovery Dashboard / Expert rozetleri. */ +export type LearningBadge = 'WEAK' | 'CANDIDATE' | 'STRONG' | 'MANUAL_REVIEW' | 'STALE' | 'CONFLICT'; + +/** Bir keşif kaydının (source+pidOrDid) öğrenme anotasyonu — salt-okunur, dashboard için. */ +export interface LearningDiscoveryAnnotation { + pidOrDid: string; + discoverySource: 'PID' | 'DID'; + /** Kanıt (evidence) statüsü (P2-1). */ + evidenceStatus: LearningEvidence['status']; + /** Pattern statüsü (P2-4) — pattern yoksa null (ör. BASIC_JS'te detay kapalı). */ + patternStatus: PatternStatus | null; + confidence: number; // ham (P2-1) + decayedConfidence: number; // P2-3 + vehicleCount: number; + observationCount: number; + ecuCount: number; + firstSeen: number; + lastSeen: number; + stale: boolean; + requiresManualReview: boolean; + conflictReasons: ConflictReason[]; +} + +/** Diagnostic Insight'a EK bağlam (safety/severity/driveSafe'i DEĞİŞTİRMEZ). */ +export interface DiagnosticLearningContext { + learnedEvidenceCount: number; + learnedPatternCount: number; + strongestCandidateConfidence: number; + learnedOnThisVehicle: boolean; + learnedOnManufacturer: boolean; + relatedStrongPids: string[]; + relatedStrongDids: string[]; + learningWarnings: string[]; + requiresManualReview: boolean; +} + +/** Insight + opsiyonel öğrenme bağlamı (additive — mevcut alanlar korunur). */ +export type EnrichedDiagnosticInsight = DiagnosticInsight & { learning?: DiagnosticLearningContext }; + +/** Expert Mode "Vehicle Learning" bölümü view-model'i. */ +export interface ExpertLearningSummary { + totalEvidence: number; + weakCount: number; // evidence.status (P2-1) + candidateCount: number; + strongCount: number; + staleCount: number; // decayed; + lastLearnedAt: number | null; + /** BASIC_JS(low) → false: ağır pattern/conflict detayı kapalı. */ + patternDetailEnabled: boolean; +} + +export interface DiagnosticLearningOptions { + /** Şu anki aracın fingerprint hash'i (varsa) — learnedOnThisVehicle için. */ + currentVehicleHash?: string; +} + +/* ══════════════════════════════════════════════════════════════════════════ + * Saf yardımcılar + * ════════════════════════════════════════════════════════════════════════ */ + +function _norm(s: string | undefined | null): string { + return (s ?? '').toString().trim().toUpperCase().replace(/\s+/g, '').replace(/^0X/, ''); +} + +/** FNV-1a 32-bit (yerel, bağımlılıksız). */ +function _fnv1a(str: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +/** Kanıt listesinin ucuz imzası (memoization anahtarı) — bounded, O(n), fail-soft. */ +function _evidenceSignature(list: readonly LearningEvidence[]): string { + let acc = list.length >>> 0; + let maxUpd = 0; + for (const e of list) { + if (!e || typeof e !== 'object') { acc = (Math.imul(acc, 31) + 1) >>> 0; continue; } + acc = (Math.imul(acc, 31) + _fnv1a(e.evidenceId ?? '')) >>> 0; + acc = (Math.imul(acc, 31) + ((e.observationCount | 0))) >>> 0; + acc = (Math.imul(acc, 31) + ((e.lastSeen | 0))) >>> 0; + if ((e.updatedAt ?? 0) > maxUpd) maxUpd = e.updatedAt; + } + return `${list.length}:${maxUpd}:${acc}`; +} + +const STATUS_LABEL: Record = { + CROSS_MANUFACTURER_MEANING: 'Aynı sinyal birden fazla markada farklı anlam taşıyor', + CROSS_ECU_DID_INCONSISTENT: 'Aynı DID farklı ECU ailelerinde tutarsız', + CROSS_PROTOCOL_INCONSISTENT: 'Aynı sinyal farklı protokollerde uyumsuz', + CONFIDENCE_DIVERGENCE: 'Küme-arası güven ıraksaması', + STALE_VS_FRESH_CONFLICT: 'Eski kanıt yeni kanıtla çelişiyor', + MANUFACTURER_MISMATCH: 'Marka uyuşmazlığı', + DECODE_MEANING_CONFLICT: 'Aynı sinyal için çelişkili çözümleme', +}; + +/* ══════════════════════════════════════════════════════════════════════════ + * Servis (on-demand, memoized, fail-soft, zero-leak) + * ════════════════════════════════════════════════════════════════════════ */ + +interface _Computed { + evidence: LearningEvidence[]; + patterns: LearningPattern[]; + byKey: Map; // `${source}:${pidOrDid}` + tier: DeviceTier; + now: number; +} + +/** Enjekte edilebilir pattern kurucu (varsayılan P2-4 buildPatterns; test için sayılabilir). */ +export type PatternBuilder = (evidence: readonly LearningEvidence[], opts: { now: number; tier: DeviceTier }) => LearningPattern[]; + +export class VehicleLearningIntegrationService { + private readonly _readEvidence: () => LearningEvidence[]; + private readonly _now: () => number; + private readonly _tier: () => DeviceTier; + private readonly _buildPatterns: PatternBuilder; + private _memoKey: string | null = null; + private _memo: _Computed | null = null; + + constructor( + readEvidence: () => LearningEvidence[] = () => vehicleLearningEvidenceStore.list(), + now: () => number = () => Date.now(), + tier: () => DeviceTier = () => getDeviceTier(), + patternBuilder: PatternBuilder = buildPatterns, + ) { + this._readEvidence = readEvidence; + this._now = now; + this._tier = tier; + this._buildPatterns = patternBuilder; + } + + /* ── Çekirdek hesap (memoized) ─────────────────────────────────────────── */ + + private _compute(): _Computed { + const now = this._now(); + let tier: DeviceTier = 'high'; + let rawList: LearningEvidence[] = []; + try { tier = this._tier(); } catch { tier = 'high'; } + try { rawList = this._readEvidence() ?? []; } catch { rawList = []; } + if (!Array.isArray(rawList)) rawList = []; + const evidence = rawList.length > MAX_EVIDENCE ? rawList.slice(0, MAX_EVIDENCE) : rawList.slice(); + + // Memo anahtarı: imza + tier + saat-kovası (decay yavaş → saat granülaritesi yeterli). + const key = `${_evidenceSignature(evidence)}|${tier}|${Math.floor(now / HOUR_MS)}`; + if (this._memoKey === key && this._memo) return this._memo; + + let patterns: LearningPattern[] = []; + try { patterns = this._buildPatterns(evidence, { now, tier }); } catch { patterns = []; } + + // (source:pidOrDid) → anotasyon. Evidence baz alınır; pattern detayı (varsa) eklenir. + const byKey = new Map(); + const patternDetail = tier !== 'low'; + for (const e of evidence) { + try { + const pidOrDid = _norm(e.pidOrDid); + if (!pidOrDid) continue; + const source: 'PID' | 'DID' = e.discoverySource === 'DID' ? 'DID' : 'PID'; + const k = `${source}:${pidOrDid}`; + const decayed = calculateDecayedConfidence(e, now); + const stale = decayed < PRUNE_CONFIDENCE_FLOOR; + const ann: LearningDiscoveryAnnotation = { + pidOrDid, discoverySource: source, + evidenceStatus: e.status, patternStatus: null, + confidence: e.confidence, decayedConfidence: decayed, + vehicleCount: e.vehicleCount, observationCount: e.observationCount, + ecuCount: (e.ecuAddresses ?? []).length, + firstSeen: e.firstSeen, lastSeen: e.lastSeen, + stale, requiresManualReview: false, conflictReasons: [], + }; + const prev = byKey.get(k); + // Aynı sinyalin birden çok kanıtı: en yüksek decayed olanı temsilci al. + if (!prev || decayed > prev.decayedConfidence) byKey.set(k, ann); + } catch { /* fail-soft */ } + } + // Pattern detayını (statü/conflict/manual-review) yalnız mid/high'da anotasyona işle. + if (patternDetail) { + for (const p of patterns) { + try { + const rev = p.requiresManualReview || (p.conflictReasons?.length ?? 0) > 0; + for (const pid of p.observedPids ?? []) { + const ann = byKey.get(`PID:${_norm(pid)}`); + if (ann) { ann.patternStatus = p.status; ann.requiresManualReview ||= rev; _mergeReasons(ann, p.conflictReasons); } + } + for (const did of p.observedDids ?? []) { + const ann = byKey.get(`DID:${_norm(did)}`); + if (ann) { ann.patternStatus = p.status; ann.requiresManualReview ||= rev; _mergeReasons(ann, p.conflictReasons); } + } + } catch { /* fail-soft */ } + } + } + + const computed: _Computed = { evidence, patterns, byKey, tier, now }; + this._memoKey = key; + this._memo = computed; + return computed; + } + + /* ── Discovery Dashboard ───────────────────────────────────────────────── */ + + /** (source, pidOrDid) için öğrenme anotasyonu — yoksa null. Salt-okunur kopya. */ + annotateDiscovery(discoverySource: 'PID' | 'DID', pidOrDid: string): LearningDiscoveryAnnotation | null { + try { + const c = this._compute(); + const ann = c.byKey.get(`${discoverySource}:${_norm(pidOrDid)}`); + return ann ? { ...ann, conflictReasons: [...ann.conflictReasons] } : null; + } catch { return null; } + } + + /** Tüm anotasyonların `${source}:${pidOrDid}` → annotation haritası (dashboard toplu arama). */ + getAnnotationMap(): Map { + try { + const c = this._compute(); + const out = new Map(); + for (const [k, v] of c.byKey) out.set(k, { ...v, conflictReasons: [...v.conflictReasons] }); + return out; + } catch { return new Map(); } + } + + /* ── Expert Mode özeti ─────────────────────────────────────────────────── */ + + getExpertSummary(): ExpertLearningSummary { + const empty: ExpertLearningSummary = { + totalEvidence: 0, weakCount: 0, candidateCount: 0, strongCount: 0, + staleCount: 0, pruneCandidateCount: 0, manualReviewCount: 0, conflictCount: 0, + patternCount: 0, strongPidCandidates: [], strongDidCandidates: [], + manufacturerClusters: [], lastLearnedAt: null, patternDetailEnabled: true, + }; + try { + const c = this._compute(); + const patternDetail = c.tier !== 'low'; + let weak = 0, candidate = 0, strong = 0, stale = 0, prune = 0, lastLearnedAt = 0; + for (const e of c.evidence) { + try { + if (!e || typeof e !== 'object') continue; + if (e.status === 'weak') weak++; + else if (e.status === 'candidate') candidate++; + else if (e.status === 'strong') strong++; + if (calculateDecayedConfidence(e, c.now) < PRUNE_CONFIDENCE_FLOOR) stale++; + if (isPruneCandidate(e, c.now)) prune++; + const t = Math.max(e.lastSeen ?? 0, e.updatedAt ?? 0); + if (t > lastLearnedAt) lastLearnedAt = t; + } catch { /* tek kayıt hatası diğerlerini etkilemez */ } + } + + // Marka kümeleri (buildClusters — hafif grouping, tüm tier'larda). + let clusters: Array<{ manufacturer: string; count: number }> = []; + try { + const cl = buildClusters(c.evidence); + const byMfr = new Map(); + for (const g of cl) byMfr.set(g.manufacturer, (byMfr.get(g.manufacturer) ?? 0) + 1); + clusters = [...byMfr.entries()].map(([manufacturer, count]) => ({ manufacturer, count })) + .sort((a, b) => b.count - a.count || a.manufacturer.localeCompare(b.manufacturer)); + } catch { clusters = []; } + + // Pattern-türevi sayımlar + güçlü adaylar (yalnız mid/high — ağır detay). + let manualReview = 0, conflict = 0; + const strongPids = new Set(); + const strongDids = new Set(); + if (patternDetail) { + for (const p of c.patterns) { + if (p.requiresManualReview) manualReview++; + if ((p.conflictReasons?.length ?? 0) > 0) conflict++; + if (p.status === 'strong') { + for (const pid of p.observedPids ?? []) strongPids.add(_norm(pid)); + for (const did of p.observedDids ?? []) strongDids.add(_norm(did)); + } + } + } else { + // BASIC_JS: güçlü adayları evidence.status'tan türet (hafif). + for (const e of c.evidence) { + if (e.status !== 'strong') continue; + const id = _norm(e.pidOrDid); + if (!id) continue; + if (e.discoverySource === 'DID') strongDids.add(id); else strongPids.add(id); + } + } + + return { + totalEvidence: c.evidence.length, + weakCount: weak, candidateCount: candidate, strongCount: strong, + staleCount: stale, pruneCandidateCount: prune, + manualReviewCount: manualReview, conflictCount: conflict, + patternCount: c.patterns.length, + strongPidCandidates: [...strongPids].sort(), + strongDidCandidates: [...strongDids].sort(), + manufacturerClusters: clusters, + lastLearnedAt: lastLearnedAt || null, + patternDetailEnabled: patternDetail, + }; + } catch { return empty; } + } + + /* ── Diagnostic Knowledge bağlamı ──────────────────────────────────────── */ + + /** + * Bir Diagnostic Insight için öğrenme bağlamı üretir. Öğrenme verisi yoksa null döner + * (insight aynen korunur). Safety/severity/driveSafe'i ETKİLEMEZ — yalnız ek bilgi. + */ + buildDiagnosticLearningContext( + insight: Pick, + opts: DiagnosticLearningOptions = {}, + ): DiagnosticLearningContext | null { + try { + const c = this._compute(); + const relPids = new Set((insight?.relatedPids ?? []).map(_norm).filter(Boolean)); + const relDids = new Set((insight?.relatedDids ?? []).map(_norm).filter(Boolean)); + if (relPids.size === 0 && relDids.size === 0) return null; + const mfr = _norm(insight?.manufacturer); + const vhash = (opts.currentVehicleHash ?? '').trim(); + + let evidenceCount = 0; + let strongest = 0; + let onVehicle = false; + let onManufacturer = false; + for (const e of c.evidence) { + try { + if (!e || typeof e !== 'object') continue; + const id = _norm(e.pidOrDid); + const isPid = e.discoverySource !== 'DID'; + const match = isPid ? relPids.has(id) : relDids.has(id); + if (!match) continue; + evidenceCount++; + strongest = Math.max(strongest, calculateDecayedConfidence(e, c.now)); + if (vhash && (e.supportingVehicleHashes ?? []).includes(vhash)) onVehicle = true; + if (mfr && _norm(e.manufacturer) === mfr) onManufacturer = true; + } catch { /* fail-soft */ } + } + + let patternCount = 0; + let manualReview = false; + const strongPids = new Set(); + const strongDids = new Set(); + const warnings: string[] = []; + const seenReasons = new Set(); + for (const p of c.patterns) { + const pPids = (p.observedPids ?? []).map(_norm); + const pDids = (p.observedDids ?? []).map(_norm); + const hit = pPids.some((x) => relPids.has(x)) || pDids.some((x) => relDids.has(x)); + if (!hit) continue; + patternCount++; + strongest = Math.max(strongest, Number.isFinite(p.confidence) ? p.confidence : 0); + if (p.requiresManualReview) manualReview = true; + if (p.status === 'strong') { + for (const x of pPids) if (relPids.has(x)) strongPids.add(x); + for (const x of pDids) if (relDids.has(x)) strongDids.add(x); + } + for (const r of p.conflictReasons ?? []) { + if (!seenReasons.has(r)) { seenReasons.add(r); warnings.push(STATUS_LABEL[r] ?? String(r)); } + } + } + + // Öğrenme verisi hiç yoksa → null (insight aynen korunur). + if (evidenceCount === 0 && patternCount === 0) return null; + + return { + learnedEvidenceCount: evidenceCount, + learnedPatternCount: patternCount, + strongestCandidateConfidence: strongest, + learnedOnThisVehicle: onVehicle || (vhash === '' && !!insight?.discoveredOnVehicle), + learnedOnManufacturer: onManufacturer, + relatedStrongPids: [...strongPids].sort(), + relatedStrongDids: [...strongDids].sort(), + learningWarnings: warnings, + requiresManualReview: manualReview, + }; + } catch { return null; } + } + + /** + * Insight'ı öğrenme bağlamıyla zenginleştirir. Öğrenme yoksa insight AYNEN döner. Girdiyi + * MUTASYONA UĞRATMAZ; safety/severity/driveSafe/confidence DEĞİŞMEZ (yalnız `learning` eklenir). + */ + enrichInsight(insight: DiagnosticInsight, opts: DiagnosticLearningOptions = {}): EnrichedDiagnosticInsight { + if (!insight) return insight; + const ctx = this.buildDiagnosticLearningContext(insight, opts); + if (!ctx) return insight; // veri yok → aynen koru + return { ...insight, learning: ctx }; + } + + /* ── Lifecycle ─────────────────────────────────────────────────────────── */ + + /** Memo'yu geçersiz kılar (bir sonraki çağrı taze hesaplar). */ + invalidate(): void { this._memoKey = null; this._memo = null; } + + /** Zero-leak temizlik. */ + dispose(): void { this.invalidate(); } +} + +function _mergeReasons(ann: LearningDiscoveryAnnotation, reasons: readonly ConflictReason[] | undefined): void { + for (const r of reasons ?? []) if (!ann.conflictReasons.includes(r)) ann.conflictReasons.push(r); +} + +/* ══════════════════════════════════════════════════════════════════════════ + * Rozet türetimi (SAF — dashboard/expert) + * ════════════════════════════════════════════════════════════════════════ */ + +/** Bir anotasyondan öğrenme rozetleri (öncelik: CONFLICT/MANUAL_REVIEW > statü > STALE). */ +export function learningBadgesFor(ann: LearningDiscoveryAnnotation | null | undefined): LearningBadge[] { + if (!ann) return []; + const out: LearningBadge[] = []; + const status = ann.patternStatus ?? ann.evidenceStatus; + if (status === 'strong') out.push('STRONG'); + else if (status === 'candidate') out.push('CANDIDATE'); + else if (status === 'weak') out.push('WEAK'); + // 'rejected' → statü rozeti gösterme (aday değil). + if (ann.conflictReasons.length > 0) out.push('CONFLICT'); + if (ann.requiresManualReview) out.push('MANUAL_REVIEW'); + if (ann.stale) out.push('STALE'); + return out; +} + +/** Uygulama geneli tekil servis (on-demand — UI/mantık çağırınca üretir; wiring YOK). */ +export const vehicleLearningIntegrationService = new VehicleLearningIntegrationService();