// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // Services.swift — réseau : client JSON avec cache disque (mode hors ligne de // consultation), service d'univers (listes/recherche), stats live, statut des // 13 plateformes (HEAD + latence) et KA Agent (chat SSE vers api-ka). // Repris de l'app iOS KA (~/Desktop/KA/KA/Core/Services.swift) + StatusService. import Foundation // MARK: - Client HTTP + cache disque actor APIClient { static let shared = APIClient() private let session: URLSession private let cacheDir: URL init() { let cfg = URLSessionConfiguration.default cfg.timeoutIntervalForRequest = 15 cfg.waitsForConnectivity = false session = URLSession(configuration: cfg) cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] .appendingPathComponent("ka-json", isDirectory: true) try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) } private func cacheFile(for url: URL) -> URL { let name = url.absoluteString.data(using: .utf8)!.base64EncodedString() .replacingOccurrences(of: "/", with: "_") return cacheDir.appendingPathComponent(String(name.suffix(120)) + ".json") } /// JSON brut ; en cas d'échec réseau, sert la dernière copie disque (hors ligne). func json(_ url: URL, ttl: TimeInterval = 120) async throws -> JSONValue { let file = cacheFile(for: url) if let attrs = try? FileManager.default.attributesOfItem(atPath: file.path), let date = attrs[.modificationDate] as? Date, Date().timeIntervalSince(date) < ttl, let data = try? Data(contentsOf: file), let cached = try? JSONDecoder().decode(JSONValue.self, from: data) { return cached } do { var req = URLRequest(url: url) req.setValue("KA-macOS/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent") let (data, resp) = try await session.data(for: req) guard let http = resp as? HTTPURLResponse, http.statusCode == 200 else { throw URLError(.badServerResponse) } let value = try JSONDecoder().decode(JSONValue.self, from: data) try? data.write(to: file) return value } catch { if let data = try? Data(contentsOf: file), let cached = try? JSONDecoder().decode(JSONValue.self, from: data) { return cached // hors ligne : dernier contenu connu } throw error } } } // MARK: - Univers : listes, recherche, stats enum UniverseService { static func listURL(_ u: Universe, query: String?, city: String? = nil, limit: Int = 30, params: [String: String] = [:]) -> URL? { guard let path = u.listPath else { return nil } var comps = URLComponents(url: u.baseURL.appendingPathComponent(""), resolvingAgainstBaseURL: false)! // listPath peut contenir déjà une query (ex. events?upcoming=true) let split = path.split(separator: "?", maxSplits: 1) comps.path = String(split[0]) var items: [URLQueryItem] = split.count > 1 ? split[1].split(separator: "&").map { let kv = $0.split(separator: "=", maxSplits: 1) return URLQueryItem(name: String(kv[0]), value: kv.count > 1 ? String(kv[1]) : nil) } : [] if let q = query, !q.isEmpty { items.append(.init(name: u.searchParam, value: q)) } if let c = city, !c.isEmpty { items.append(.init(name: "city", value: c)) } for (k, v) in params.sorted(by: { $0.key < $1.key }) where !v.isEmpty { items.append(.init(name: k, value: v)) } items.append(.init(name: "limit", value: String(limit))) comps.queryItems = items return comps.url } static func fetch(_ u: Universe, query: String? = nil, city: String? = nil, limit: Int = 30, params: [String: String] = [:]) async throws -> [KAItem] { guard let url = listURL(u, query: query, city: city, limit: limit, params: params), let map = u.map else { return [] } let root = try await APIClient.shared.json(url) let obj = root.object ?? [:] let raw = obj[u.itemsKey]?.array ?? obj["items"]?.array ?? obj["results"]?.array ?? obj["hits"]?.array ?? root.array ?? [] return raw.compactMap { $0.object.flatMap(map) } } /// Total d'une requête (champ `total` de la réponse liste) — pour les alertes. static func total(_ u: Universe, query: String? = nil, params: [String: String] = [:]) async -> Int? { guard let url = listURL(u, query: query, limit: 1, params: params), let root = try? await APIClient.shared.json(url, ttl: 60) else { return nil } return root.object?.num("total", "count").map(Int.init) } /// Items GÉOLOCALISÉS pour une région de carte. /// lou-ka / immo-ka / job-ka : endpoint geojson avec bbox RÉEL (vérifié). /// Autres univers : liste standard filtrée client sur la région. static func mapItems(_ u: Universe, west: Double, south: Double, east: Double, north: Double, limit: Int = 150) async -> [KAItem] { let bboxCapable = ["lou-ka": "/api/listings.geojson", "immo-ka": "/api/listings.geojson", "job-ka": "/api/jobs.geojson"] if let path = bboxCapable[u.id], let map = u.map { var comps = URLComponents(string: "https://\(u.domain)\(path)")! comps.queryItems = [ .init(name: "bbox", value: "\(west),\(south),\(east),\(north)"), .init(name: "limit", value: String(limit)), ] guard let url = comps.url, let root = try? await APIClient.shared.json(url, ttl: 90), let features = root.object?["features"]?.array else { return [] } return features.compactMap { f -> KAItem? in guard let fo = f.object, var props = fo["properties"]?.object, let coords = fo["geometry"]?.object?["coordinates"]?.array, coords.count >= 2, let lon = coords[0].number, let lat = coords[1].number else { return nil } props["lat"] = .number(lat) props["lng"] = .number(lon) return map(props) } } // repli : liste + filtre client let items = (try? await fetch(u, limit: limit)) ?? [] return items.filter { it in guard let la = it.latitude, let lo = it.longitude else { return false } return la >= south && la <= north && lo >= west && lo <= east } } /// Total « en direct » depuis /api/stats (clé selon l'univers, chemins a.b acceptés) static func liveTotal(_ u: Universe) async -> Int? { let path = u.id == "trouve-ka" ? "/api/status" : "/api/stats" guard let url = URL(string: "https://\(u.domain)\(path)"), let root = try? await APIClient.shared.json(url, ttl: 300) else { return nil } for key in u.statTotalKeys { var cur: JSONValue? = root for part in key.split(separator: ".") { cur = cur?.object?[String(part)] } if let n = cur?.number { return Int(n) } } return nil } } // MARK: - Statut des 13 plateformes (HEAD sur https://www./) struct ServiceHealth: Identifiable, Equatable { let id: String // domaine let name: String // « Lou-KA », « Groupe-KA »… var up: Bool? // nil = vérification en cours / inconnue var latencyMs: Int? var checkedAt: Date? } enum StatusService { /// Les 13 services : le hub Groupe-KA + les 12 univers. static var allServices: [ServiceHealth] { [ServiceHealth(id: Ecosystem.hubDomain, name: "Groupe-KA", up: nil, latencyMs: nil, checkedAt: nil)] + Ecosystem.all.map { ServiceHealth(id: $0.domain, name: $0.name, up: nil, latencyMs: nil, checkedAt: nil) } } /// HEAD sur la racine ; vert si 200–399 (ou 405 : le serveur répond mais /// refuse HEAD — les apps Next de l'écosystème), latence mesurée. static func check(domain: String) async -> (up: Bool, latencyMs: Int) { guard let url = URL(string: "https://\(domain)/") else { return (false, 0) } var req = URLRequest(url: url) req.httpMethod = "HEAD" req.timeoutInterval = 10 req.setValue("KA-macOS/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent") let start = Date() do { let (_, resp) = try await URLSession.shared.data(for: req) let ms = Int(Date().timeIntervalSince(start) * 1000) guard let http = resp as? HTTPURLResponse else { return (false, ms) } return ((200..<400).contains(http.statusCode) || http.statusCode == 405, ms) } catch { return (false, Int(Date().timeIntervalSince(start) * 1000)) } } } // MARK: - Menu de resto (Resto·Ka, /api/restaurants/{uid}) struct RestoMenuSection: Identifiable { let id = UUID() let name: String let items: [(name: String, price: String?)] } enum RestoMenuLoader { static func load(uid: String) async -> [RestoMenuSection] { guard let url = URL(string: "https://www.resto-ka.com/api/restaurants/\(uid)"), let root = try? await APIClient.shared.json(url, ttl: 600), let menus = root.object?["menus"]?.array else { return [] } var sections: [RestoMenuSection] = [] for menu in menus.prefix(1) { for s in menu.object?["sections"]?.array ?? [] { guard let so = s.object, let name = so.str("name") else { continue } let items: [(String, String?)] = (so["items"]?.array ?? []).prefix(30).compactMap { i in guard let io = i.object, let n = io.str("name") else { return nil } return (n, io.num("price").flatMap { $0 > 0 ? $0.money2 : nil }) } if !items.isEmpty { sections.append(RestoMenuSection(name: name, items: items)) } } } return sections } } // MARK: - KA Agent (SSE) struct AgentEvent { let kind: Kind; enum Kind { case delta(String), tool(String), done, error(String) } } enum AgentService { static let endpoint = URL(string: "https://www.api-ka.com/api/agent/chat")! static func stream(site: String, messages: [[String: String]]) -> AsyncThrowingStream { AsyncThrowingStream { continuation in let task = Task { var req = URLRequest(url: endpoint) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.timeoutInterval = 90 req.httpBody = try JSONSerialization.data(withJSONObject: ["site": site, "messages": messages]) let (bytes, resp) = try await URLSession.shared.bytes(for: req) guard (resp as? HTTPURLResponse)?.statusCode == 200 else { throw URLError(.badServerResponse) } var event = "" for try await line in bytes.lines { if line.hasPrefix("event: ") { event = String(line.dropFirst(7)) } else if line.hasPrefix("data: ") { let data = Data(line.dropFirst(6).utf8) let obj = (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:] switch event { case "delta": if let t = obj["text"] as? String { continuation.yield(.init(kind: .delta(t))) } case "tool": continuation.yield(.init(kind: .tool(obj["name"] as? String ?? "recherche"))) case "done": continuation.yield(.init(kind: .done)); continuation.finish(); return case "error": continuation.yield(.init(kind: .error(obj["message"] as? String ?? "erreur"))) default: break } } } continuation.finish() } continuation.onTermination = { _ in task.cancel() } } } }