SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
28 days agolast push
Swift 100%
11.5 KB · 232 lines swift
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// Services.swift — réseau : client JSON avec cache disque (mode hors ligne de3// consultation), service d'univers (listes/recherche), stats live, et4// KA Agent (chat SSE vers l'API centrale api-ka).5import Foundation67// MARK: - Client HTTP + cache disque89actor APIClient {10    static let shared = APIClient()11    private let session: URLSession12    private let cacheDir: URL1314    init() {15        let cfg = URLSessionConfiguration.default16        cfg.timeoutIntervalForRequest = 1517        cfg.waitsForConnectivity = false18        session = URLSession(configuration: cfg)19        cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]20            .appendingPathComponent("ka-json", isDirectory: true)21        try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)22    }2324    private func cacheFile(for url: URL) -> URL {25        let name = url.absoluteString.data(using: .utf8)!.base64EncodedString()26            .replacingOccurrences(of: "/", with: "_")27        return cacheDir.appendingPathComponent(String(name.suffix(120)) + ".json")28    }2930    /// JSON brut ; en cas d'échec réseau, sert la dernière copie disque (hors ligne).31    func json(_ url: URL, ttl: TimeInterval = 120) async throws -> JSONValue {32        let file = cacheFile(for: url)33        if let attrs = try? FileManager.default.attributesOfItem(atPath: file.path),34           let date = attrs[.modificationDate] as? Date, Date().timeIntervalSince(date) < ttl,35           let data = try? Data(contentsOf: file),36           let cached = try? JSONDecoder().decode(JSONValue.self, from: data) {37            return cached38        }39        do {40            var req = URLRequest(url: url)41            req.setValue("KA-iOS/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent")42            let (data, resp) = try await session.data(for: req)43            guard let http = resp as? HTTPURLResponse, http.statusCode == 200 else {44                throw URLError(.badServerResponse)45            }46            let value = try JSONDecoder().decode(JSONValue.self, from: data)47            try? data.write(to: file)48            return value49        } catch {50            if let data = try? Data(contentsOf: file),51               let cached = try? JSONDecoder().decode(JSONValue.self, from: data) {52                return cached // hors ligne : dernier contenu connu53            }54            throw error55        }56    }57}5859// MARK: - Univers : listes, recherche, stats6061enum UniverseService {62    static func listURL(_ u: Universe, query: String?, city: String? = nil, limit: Int = 30, params: [String: String] = [:]) -> URL? {63        guard let path = u.listPath else { return nil }64        var comps = URLComponents(url: u.baseURL.appendingPathComponent(""), resolvingAgainstBaseURL: false)!65        // listPath peut contenir déjà une query (ex. events?upcoming=true)66        let split = path.split(separator: "?", maxSplits: 1)67        comps.path = String(split[0])68        var items: [URLQueryItem] = split.count > 169            ? split[1].split(separator: "&").map {70                let kv = $0.split(separator: "=", maxSplits: 1)71                return URLQueryItem(name: String(kv[0]), value: kv.count > 1 ? String(kv[1]) : nil)72            } : []73        if let q = query, !q.isEmpty { items.append(.init(name: u.searchParam, value: q)) }74        if let c = city, !c.isEmpty { items.append(.init(name: "city", value: c)) }75        for (k, v) in params.sorted(by: { $0.key < $1.key }) where !v.isEmpty {76            items.append(.init(name: k, value: v))77        }78        items.append(.init(name: "limit", value: String(limit)))79        comps.queryItems = items80        return comps.url81    }8283    static func fetch(_ u: Universe, query: String? = nil, city: String? = nil, limit: Int = 30, params: [String: String] = [:]) async throws -> [KAItem] {84        // Trouve·Ka : /api/search EXIGE q (422 sinon) — jamais d'appel à vide85        if u.id == "trouve-ka", (query ?? "").isEmpty { return [] }86        guard let url = listURL(u, query: query, city: city, limit: limit, params: params), let map = u.map else { return [] }87        let root = try await APIClient.shared.json(url)88        let obj = root.object ?? [:]89        let raw = obj[u.itemsKey]?.array90            ?? obj["items"]?.array ?? obj["results"]?.array ?? obj["hits"]?.array91            ?? root.array ?? []92        return raw.compactMap { $0.object.flatMap(map) }93    }9495    /// Total d'une requête (champ `total` de la réponse liste) — pour les alertes.96    static func total(_ u: Universe, query: String? = nil, params: [String: String] = [:]) async -> Int? {97        guard let url = listURL(u, query: query, limit: 1, params: params),98              let root = try? await APIClient.shared.json(url, ttl: 60) else { return nil }99        return root.object?.num("total", "count").map(Int.init)100    }101102    /// Items GÉOLOCALISÉS pour une région de carte.103    /// lou-ka / immo-ka / job-ka : endpoint geojson avec bbox RÉEL (vérifié).104    /// Autres univers : liste standard filtrée client sur la région.105    static func mapItems(_ u: Universe, west: Double, south: Double, east: Double, north: Double,106                         limit: Int = 150) async -> [KAItem] {107        let bboxCapable = ["lou-ka": "/api/listings.geojson",108                           "immo-ka": "/api/listings.geojson",109                           "job-ka": "/api/jobs.geojson"]110        if let path = bboxCapable[u.id], let map = u.map {111            var comps = URLComponents(string: "https://\(u.domain)\(path)")!112            comps.queryItems = [113                .init(name: "bbox", value: "\(west),\(south),\(east),\(north)"),114                .init(name: "limit", value: String(limit)),115            ]116            guard let url = comps.url,117                  let root = try? await APIClient.shared.json(url, ttl: 90),118                  let features = root.object?["features"]?.array else { return [] }119            return features.compactMap { f -> KAItem? in120                guard let fo = f.object,121                      var props = fo["properties"]?.object,122                      let coords = fo["geometry"]?.object?["coordinates"]?.array,123                      coords.count >= 2, let lon = coords[0].number, let lat = coords[1].number124                else { return nil }125                props["lat"] = .number(lat)126                props["lng"] = .number(lon)127                return map(props)128            }129        }130        // repli : liste + filtre client131        let items = (try? await fetch(u, limit: limit)) ?? []132        return items.filter { it in133            guard let la = it.latitude, let lo = it.longitude else { return false }134            return la >= south && la <= north && lo >= west && lo <= east135        }136    }137138    /// Couche VRAI-PRIX de la carte : les unités d'évaluation du Québec139    /// (3 747 008 au total) dans la ZONE VISIBLE seulement, via /api/nearby140    /// (bbox plafonné serveur ~9 km, cache APIClient). La carte ne l'appelle141    /// qu'assez zoomée — jamais de chargement massif.142    static func vraiPrixUnits(west: Double, south: Double, east: Double, north: Double,143                              limit: Int = 1000) async -> [KAItem] {144        var comps = URLComponents(string: "https://www.vrai-prix.com/api/nearby")!145        comps.queryItems = [146            .init(name: "lat", value: "\((south + north) / 2)"),147            .init(name: "lng", value: "\((west + east) / 2)"),148            .init(name: "halfLat", value: "\(min((north - south) / 2, 0.08))"),149            .init(name: "halfLng", value: "\(min((east - west) / 2, 0.12))"),150            .init(name: "limit", value: String(limit)),151        ]152        guard let url = comps.url,153              let root = try? await APIClient.shared.json(url, ttl: 300),154              let results = root.object?["results"]?.array else { return [] }155        return results.compactMap { r -> KAItem? in156            guard let o = r.object, let id = o.str("id"),157                  let lat = o.num("lat"), let lng = o.num("lng"),158                  let est = o.num("est2026"), est > 0 else { return nil }159            let apt = o.str("apt").flatMap { $0.isEmpty ? nil : $0 }160            return KAItem(161                id: "vp:\(id)",162                universeID: "vrai-prix",163                title: [o.str("adresse"), apt.map { "app. \($0)" }]164                    .compactMap { $0 }.joined(separator: " "),165                subtitle: o.str("typeProp"),166                priceLabel: "\(est.compact)$",167                city: o.str("municipalite"),168                url: URL(string: "https://www.vrai-prix.com/estimation/\(id)"),169                imageURL: nil,170                latitude: lat,171                longitude: lng,172                detail: nil,173                facts: [.init(label: "Estimation Vrai-Prix 2026", value: est.money0)]174            )175        }176    }177178    /// Total « en direct » depuis /api/stats (clé selon l'univers, chemins a.b acceptés)179    static func liveTotal(_ u: Universe) async -> Int? {180        let path = u.id == "trouve-ka" ? "/api/status" : "/api/stats"181        guard let url = URL(string: "https://\(u.domain)\(path)"),182              let root = try? await APIClient.shared.json(url, ttl: 300) else { return nil }183        for key in u.statTotalKeys {184            var cur: JSONValue? = root185            for part in key.split(separator: ".") {186                cur = cur?.object?[String(part)]187            }188            if let n = cur?.number { return Int(n) }189        }190        return nil191    }192}193194// MARK: - KA Agent (SSE)195196struct AgentEvent { let kind: Kind; enum Kind { case delta(String), tool(String), done, error(String) } }197198enum AgentService {199    static let endpoint = URL(string: "https://www.api-ka.com/api/agent/chat")!200201    static func stream(site: String, messages: [[String: String]]) -> AsyncThrowingStream<AgentEvent, Error> {202        AsyncThrowingStream { continuation in203            let task = Task {204                var req = URLRequest(url: endpoint)205                req.httpMethod = "POST"206                req.setValue("application/json", forHTTPHeaderField: "Content-Type")207                req.timeoutInterval = 90208                req.httpBody = try JSONSerialization.data(withJSONObject: ["site": site, "messages": messages])209                let (bytes, resp) = try await URLSession.shared.bytes(for: req)210                guard (resp as? HTTPURLResponse)?.statusCode == 200 else { throw URLError(.badServerResponse) }211                var event = ""212                for try await line in bytes.lines {213                    if line.hasPrefix("event: ") { event = String(line.dropFirst(7)) }214                    else if line.hasPrefix("data: ") {215                        let data = Data(line.dropFirst(6).utf8)216                        let obj = (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:]217                        switch event {218                        case "delta": if let t = obj["text"] as? String { continuation.yield(.init(kind: .delta(t))) }219                        case "tool": continuation.yield(.init(kind: .tool(obj["name"] as? String ?? "recherche")))220                        case "done": continuation.yield(.init(kind: .done)); continuation.finish(); return221                        case "error": continuation.yield(.init(kind: .error(obj["message"] as? String ?? "erreur")))222                        default: break223                        }224                    }225                }226                continuation.finish()227            }228            continuation.onTermination = { _ in task.cancel() }229        }230    }231}232