import fs from "node:fs"; import path from "node:path"; import { clamp01, createLogger, nowIso, type EntityType, type NetworkFingerprint, type PageType, type Platform } from "@src/shared"; import type { EventBus, SocialEvent } from "@src/events"; const log = createLogger("platform-model"); /** * Connector Learning Engine (§29–§31, §61, §71): persists what the crawler learns about a platform * into data/platform_model//*.json and turns repeated observations into confidence. */ export interface ResponsePattern { shape_hash: string; hostname: string; path_pattern: string; method: string; graphql_operation?: string; likely_entity_types: Partial>; // type → observed count observed_count: number; entity_yield_total: number; first_seen: string; last_seen: string; confidence: number; triggered_by: Record; // action type → count (runtime API discovery, §71) sample_url?: string; fields?: { path: string; semantic?: { kind: string; confidence: number }; example?: string }[]; } export interface ActionPattern { action_type: string; from_page_type: PageType | "ANY"; observed_count: number; avg_new_entities: number; avg_network_responses: number; to_page_types: Record; usual_shape_hashes: Record; failures: number; } export interface PageTypeKnowledge { page_type: PageType; observed_count: number; url_patterns: Record; avg_entities: number; entity_types: Record; avg_confidence: number; } export interface PlatformModelFile { platform: Platform; version: number; updated_at: string; response_patterns: Record; action_patterns: Record; // key: `${action_type}@${page_type}` page_types: Record; media_patterns: Record; sessions_learned: string[]; } export interface StepOutcome { step: number; action_type: string; action_id: string; from_page_type: PageType; to_page_type: PageType; to_url: string; page_confidence: number; entities_total: number; new_entities: number; entity_types: Record; responses: { fingerprint: NetworkFingerprint; entity_count: number; kind: string; url: string; fields?: ResponsePattern["fields"] }[]; failed: boolean; } export class PlatformModel { readonly file: string; private model: PlatformModelFile; private dirty = false; constructor(readonly platform: Platform, modelDir: string) { const dir = path.join(modelDir, platform); fs.mkdirSync(dir, { recursive: true }); this.file = path.join(dir, "platform_model.json"); this.model = fs.existsSync(this.file) ? (JSON.parse(fs.readFileSync(this.file, "utf8")) as PlatformModelFile) : { platform, version: 1, updated_at: nowIso(), response_patterns: {}, action_patterns: {}, page_types: {}, media_patterns: {}, sessions_learned: [] }; } get data(): PlatformModelFile { return this.model; } /** Learn from one executed step (action → observation, §30). */ learnStep(o: StepOutcome, sessionId: string): { newPatterns: string[] } { const m = this.model; if (!m.sessions_learned.includes(sessionId)) m.sessions_learned.push(sessionId); const newPatterns: string[] = []; const now = nowIso(); // Action patterns for (const key of [`${o.action_type}@${o.from_page_type}`, `${o.action_type}@ANY`]) { const ap: ActionPattern = m.action_patterns[key] ?? { action_type: o.action_type, from_page_type: key.endsWith("@ANY") ? "ANY" : o.from_page_type, observed_count: 0, avg_new_entities: 0, avg_network_responses: 0, to_page_types: {}, usual_shape_hashes: {}, failures: 0 }; const n = ap.observed_count; ap.avg_new_entities = (ap.avg_new_entities * n + o.new_entities) / (n + 1); ap.avg_network_responses = (ap.avg_network_responses * n + o.responses.length) / (n + 1); ap.observed_count = n + 1; ap.to_page_types[o.to_page_type] = (ap.to_page_types[o.to_page_type] ?? 0) + 1; if (o.failed) ap.failures++; for (const r of o.responses) if (r.entity_count > 0) ap.usual_shape_hashes[r.fingerprint.response_shape_hash] = (ap.usual_shape_hashes[r.fingerprint.response_shape_hash] ?? 0) + 1; m.action_patterns[key] = ap; } // Response patterns (runtime API discovery) for (const r of o.responses) { const h = r.fingerprint.response_shape_hash; if (!h) continue; if (r.kind === "media_manifest" || r.kind === "media_segment" || r.kind === "image") { const mk = `${r.fingerprint.hostname}:${r.kind}`; const mp = m.media_patterns[mk] ?? { hostname: r.fingerprint.hostname, kind: r.kind, observed_count: 0 }; mp.observed_count++; m.media_patterns[mk] = mp; continue; } let rp = m.response_patterns[h]; if (!rp) { rp = { shape_hash: h, hostname: r.fingerprint.hostname, path_pattern: r.fingerprint.path_pattern, method: r.fingerprint.method, graphql_operation: r.fingerprint.graphql_operation, likely_entity_types: {}, observed_count: 0, entity_yield_total: 0, first_seen: now, last_seen: now, confidence: 0, triggered_by: {}, sample_url: r.url, fields: r.fields }; m.response_patterns[h] = rp; if (r.entity_count > 0 || r.fingerprint.observed_entity_types.length) newPatterns.push(h); } rp.observed_count++; rp.last_seen = now; rp.entity_yield_total += r.entity_count; rp.triggered_by[o.action_type] = (rp.triggered_by[o.action_type] ?? 0) + 1; for (const t of r.fingerprint.observed_entity_types) rp.likely_entity_types[t] = (rp.likely_entity_types[t] ?? 0) + 1; if (!rp.fields && r.fields) rp.fields = r.fields; // confidence grows with repeated observation and consistent entity yield const consistency = rp.entity_yield_total > 0 ? Math.min(1, rp.entity_yield_total / rp.observed_count / 5) : 0.1; rp.confidence = clamp01(1 - Math.exp(-rp.observed_count / 4)) * (0.5 + 0.5 * consistency); } // Page types const pk: PageTypeKnowledge = m.page_types[o.to_page_type] ?? { page_type: o.to_page_type, observed_count: 0, url_patterns: {}, avg_entities: 0, entity_types: {}, avg_confidence: 0 }; const n = pk.observed_count; pk.avg_entities = (pk.avg_entities * n + o.entities_total) / (n + 1); pk.avg_confidence = (pk.avg_confidence * n + o.page_confidence) / (n + 1); pk.observed_count = n + 1; try { const u = new URL(o.to_url); const pat = u.pathname.split("/").map((s) => (/^[A-Za-z0-9_-]{8,}$/.test(s) || /^\d+$/.test(s) ? "*" : s)).join("/") || "/"; pk.url_patterns[pat] = (pk.url_patterns[pat] ?? 0) + 1; } catch { /* ignore */ } for (const [t, c] of Object.entries(o.entity_types)) pk.entity_types[t] = (pk.entity_types[t] ?? 0) + c; m.page_types[o.to_page_type] = pk; m.updated_at = now; this.dirty = true; return { newPatterns }; } /** Learned average entity yield per action type — feeds the information-gain engine. */ learnedYield(): Record { const out: Record = {}; for (const ap of Object.values(this.model.action_patterns)) if (ap.from_page_type === "ANY" && ap.observed_count >= 2) out[ap.action_type] = ap.avg_new_entities; return out; } /** * Degradation check (§32): on a page type we know well, observing far fewer entities than usual * (and than the adapter expects) signals a broken connector. */ isDegraded(pageType: PageType, observed: number, adapterExpected: number): boolean { const pk = this.model.page_types[pageType]; const expected = Math.max(adapterExpected, pk && pk.observed_count >= 5 ? pk.avg_entities * 0.3 : 0); return expected >= 3 && observed === 0; } /** Overall connector confidence (§74 summary). */ summary() { const rps = Object.values(this.model.response_patterns).filter((r) => Object.keys(r.likely_entity_types).length); const entityTypes = new Set(); for (const r of rps) for (const t of Object.keys(r.likely_entity_types)) entityTypes.add(t); for (const p of Object.values(this.model.page_types)) for (const t of Object.keys(p.entity_types)) entityTypes.add(t); const conf = rps.length ? rps.reduce((s, r) => s + r.confidence, 0) / rps.length : 0; const pageConf = Object.values(this.model.page_types).reduce((s, p) => s + p.avg_confidence, 0) / Math.max(1, Object.keys(this.model.page_types).length); return { platform: this.platform, page_types: Object.keys(this.model.page_types).length, entity_types: entityTypes.size, navigation_actions: new Set(Object.values(this.model.action_patterns).map((a) => a.action_type)).size, network_schemas: rps.length, media_patterns: Object.keys(this.model.media_patterns).length, confidence: Math.round(100 * clamp01(0.6 * conf + 0.4 * pageConf)), sessions: this.model.sessions_learned.length, }; } save(): void { if (!this.dirty) return; fs.writeFileSync(this.file, JSON.stringify(this.model, null, 2)); // Also split into the documented files (§29) for humans / other workers. const dir = path.dirname(this.file); fs.writeFileSync(path.join(dir, "response_patterns.json"), JSON.stringify(Object.values(this.model.response_patterns).sort((a, b) => b.confidence - a.confidence), null, 2)); fs.writeFileSync(path.join(dir, "action_patterns.json"), JSON.stringify(Object.values(this.model.action_patterns), null, 2)); fs.writeFileSync(path.join(dir, "page_types.json"), JSON.stringify(Object.values(this.model.page_types), null, 2)); fs.writeFileSync(path.join(dir, "media_patterns.json"), JSON.stringify(Object.values(this.model.media_patterns), null, 2)); this.dirty = false; log.debug("platform model saved", { file: this.file }); } /** Emit CONNECTOR_PATTERN_LEARNED for freshly learned response shapes. */ announce(bus: EventBus, sessionId: string, step: number, hashes: string[]): void { for (const h of hashes) { const rp = this.model.response_patterns[h]; if (!rp) continue; bus.emit({ event_type: "CONNECTOR_PATTERN_LEARNED", platform: this.platform, session_id: sessionId, step, payload: { pattern: "network_response", shape_hash: h, hostname: rp.hostname, path_pattern: rp.path_pattern, graphql_operation: rp.graphql_operation, likely_entity_types: rp.likely_entity_types, observed_count: rp.observed_count, confidence: rp.confidence }, }); } } } /** Compile a learned model into a connector manifest (§31/§74) — first version: declarative YAML-ish JSON. */ export function compileConnector(model: PlatformModel, outDir: string): string { const s = model.summary(); const d = model.data; fs.mkdirSync(outDir, { recursive: true }); const manifest = { platform: model.platform, compiled_at: nowIso(), learned_from_sessions: d.sessions_learned.length, confidence: s.confidence, page_types: Object.values(d.page_types).map((p) => ({ page_type: p.page_type, url_patterns: Object.entries(p.url_patterns).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([k]) => k), avg_entities: Math.round(p.avg_entities * 10) / 10 })), network_patterns: Object.values(d.response_patterns) .filter((r) => r.confidence >= 0.2 && Object.keys(r.likely_entity_types).length) .sort((a, b) => b.confidence - a.confidence) .map((r) => ({ shape_hash: r.shape_hash, status: r.confidence >= 0.5 ? "stable" : "provisional", hostname: r.hostname, path_pattern: r.path_pattern, method: r.method, graphql_operation: r.graphql_operation, likely_entity_types: r.likely_entity_types, confidence: Math.round(r.confidence * 100) / 100, triggered_by: r.triggered_by, key_fields: (r.fields ?? []).filter((f) => f.semantic && f.semantic.confidence >= 0.7).slice(0, 25) })), navigation: Object.values(d.action_patterns).filter((a) => a.from_page_type !== "ANY").map((a) => ({ action: a.action_type, from: a.from_page_type, to: a.to_page_types, avg_new_entities: Math.round(a.avg_new_entities * 10) / 10, observed: a.observed_count })), media: Object.values(d.media_patterns), }; const file = path.join(outDir, "manifest.json"); fs.writeFileSync(file, JSON.stringify(manifest, null, 2)); return file; } /** Subscribe to the bus for lightweight bookkeeping (pattern announcements are produced by the engine). */ export function attachPlatformModel(model: PlatformModel, bus: EventBus): () => void { return bus.on("SESSION_ENDED", (_ev: SocialEvent) => model.save()); }