248 lines
11 KiB
Swift
248 lines
11 KiB
Swift
import Foundation
|
|
import CocottePlatformModels
|
|
import CocottePlatformAPIClient
|
|
|
|
// Adapter: drives the shared V4 platform client (CocottePlatformAPIClient →
|
|
// platform.api on black:3060) and maps its wire entities onto the cockpit's
|
|
// UI-facing models. The Kit owns its own presentation vocabulary (Surface,
|
|
// DropState, ContentClass); this layer is the ONLY place that knows the platform
|
|
// wire types. Endpoints platform.api doesn't expose yet throw `.unavailable`;
|
|
// CockpitModel.refresh keeps prior data per failed endpoint (partial backends
|
|
// degrade gracefully).
|
|
|
|
public struct LiveCockpitAPI: CockpitAPI {
|
|
private let client: PlatformAPIClient
|
|
private let scope: TenantScope
|
|
|
|
/// - Parameters:
|
|
/// - baseURL: platform.api host root (e.g. `http://black:3060`); the client adds `/api/v1`.
|
|
/// - auth: Bearer-token source (`InMemoryAuthProvider` for CLI/tests; Keychain-backed in prod).
|
|
/// - scope: tenant scope (person-first; org optional), sent as `x-user-id` / `x-org-id`.
|
|
public init(baseURL: URL, auth: any AuthProvider, scope: TenantScope, session: URLSession = .shared) {
|
|
self.client = PlatformAPIClient(baseURL: baseURL, auth: auth, session: session)
|
|
self.scope = scope
|
|
}
|
|
|
|
// MARK: - Reads
|
|
|
|
public func fetchDrops() async throws -> [ContentDrop] {
|
|
try await client
|
|
.sendList(Endpoint.contentDrops(), scope: scope, as: CocottePlatformModels.ContentDrop.self)
|
|
.map { Self.kitDrop($0) }
|
|
}
|
|
|
|
public func fetchPending() async throws -> [PendingApproval] {
|
|
try await client
|
|
.sendList(Endpoint.contentPosts(approvalState: .pending), scope: scope, as: ContentPost.self)
|
|
.map(Self.kitApproval)
|
|
}
|
|
|
|
public func fetchAssets() async throws -> [Asset] {
|
|
// Newest slice only — the library live-polls every few seconds; the full
|
|
// 11k-asset library never needs to cross the wire for the cockpit grid.
|
|
try await client
|
|
.sendList(Endpoint.contentAssets(limit: 120), scope: scope, as: ContentAsset.self)
|
|
.enumerated()
|
|
.map { Self.kitAsset($0.element, index: $0.offset) }
|
|
}
|
|
|
|
public func fetchActions() async throws -> [AgentAction] {
|
|
try await client
|
|
.sendList(Endpoint.agentActions(), scope: scope, as: CocottePlatformModels.AgentAction.self)
|
|
.map(Self.kitAction)
|
|
}
|
|
|
|
public func fetchSpecialists() async throws -> [Specialist] {
|
|
try await client
|
|
.sendList(Endpoint.specialists(), scope: scope, as: SpecialistSummary.self)
|
|
.map(Self.kitSpecialist)
|
|
}
|
|
|
|
public func fetchMetrics() async throws -> [SurfaceMetric] {
|
|
// Surfaces with no Kit equivalent (tiktok/youtube/…) are dropped; the table
|
|
// is empty until adapters write rows, so today this returns [].
|
|
try await client
|
|
.sendList(Endpoint.surfaceMetrics(), scope: scope, as: SurfaceMetricSummary.self)
|
|
.compactMap(Self.kitMetric)
|
|
}
|
|
|
|
public func fetchIngestStatus() async throws -> IngestStatus {
|
|
let wire = try await client.send(Endpoint.ingestionStatus(), scope: scope, as: IngestState.self)
|
|
return Self.kitIngest(wire)
|
|
}
|
|
|
|
// MARK: - Writes
|
|
|
|
public func controlIngestion(_ action: IngestControlAction) async throws -> IngestStatus {
|
|
let wire = try await client.send(
|
|
Endpoint.ingestionControl(action: action.rawValue), scope: scope, as: IngestState.self)
|
|
return Self.kitIngest(wire)
|
|
}
|
|
|
|
public func approve(_ approvalId: UUID, edited: Bool) async throws {
|
|
// `edited` is a client-side receipt distinction (no backing column); the
|
|
// server flips approval_state→approved and stamps the SSO user. No body.
|
|
try await client.sendVoid(Endpoint.approveContentPost(id: approvalId), scope: scope)
|
|
}
|
|
|
|
public func setAside(_ approvalId: UUID) async throws {
|
|
try await client.sendVoid(Endpoint.setAsideContentPost(id: approvalId), scope: scope)
|
|
}
|
|
|
|
/// Create the composed drop, then link the selected assets. The drop is created
|
|
/// first (POST content-drops); asset links (POST :id/assets) are best-effort —
|
|
/// the drop exists regardless, and the returned count reflects only links that
|
|
/// actually landed (never an optimistic number). `created_by_specialist` is
|
|
/// `ai-copilot`: the human authored it through the copilot surface.
|
|
public func createDrop(title: String, arc: String, assetIds: [UUID], dropAt: Date) async throws -> ContentDrop {
|
|
let body = CreateContentDropBody(
|
|
userId: scope.userId, orgId: scope.orgId, title: title, arc: arc,
|
|
state: .scheduled, clusterSource: "composed", dropAt: dropAt,
|
|
createdBySpecialist: "ai-copilot")
|
|
let created = try await client.send(
|
|
Endpoint.createContentDrop(body), scope: scope, as: CocottePlatformModels.ContentDrop.self)
|
|
var linked = 0
|
|
for (i, assetId) in assetIds.enumerated() {
|
|
do {
|
|
try await client.sendVoid(
|
|
Endpoint.addDropAsset(dropId: created.id, AddDropAssetBody(assetId: assetId, sortOrder: i)),
|
|
scope: scope)
|
|
linked += 1
|
|
} catch {
|
|
// A failed link is not fatal — the drop is already persisted.
|
|
}
|
|
}
|
|
return Self.kitDrop(created, assetCount: linked)
|
|
}
|
|
|
|
public func fetchImageData(for asset: Asset) async -> Data? {
|
|
guard let assetId = asset.assetId else { return nil } // mock/composed — no backing media
|
|
return try? await client.sendData(Endpoint.contentAssetImage(id: assetId), scope: scope)
|
|
}
|
|
|
|
public func fetchBumpScreenshot(surface: String) async -> Data? {
|
|
// 404 (none captured yet) surfaces as a thrown APIError → nil; the card hides.
|
|
try? await client.sendData(Endpoint.bumpScreenshotLatest(surface: surface), scope: scope)
|
|
}
|
|
|
|
// MARK: - Platform wire → Kit model mapping
|
|
|
|
private static func kitDrop(_ d: CocottePlatformModels.ContentDrop, assetCount: Int = 0) -> ContentDrop {
|
|
ContentDrop(
|
|
title: d.title,
|
|
arc: d.arc,
|
|
assetCount: assetCount, // legs/asset count live in a separate table, not on the drop row
|
|
clusterSource: d.clusterSource ?? "",
|
|
legs: [],
|
|
teaseAt: d.teaseAt,
|
|
dropAt: d.dropAt ?? Date(timeIntervalSince1970: 0),
|
|
followupAt: d.followupAt,
|
|
state: kitDropState(d.state))
|
|
}
|
|
|
|
private static func kitApproval(_ p: ContentPost) -> PendingApproval {
|
|
let surface = kitSurface(p.surface) ?? .x
|
|
return PendingApproval(
|
|
postId: p.id,
|
|
title: "Proposed \(surface.label) post",
|
|
surfaces: [surface],
|
|
kind: "content-post",
|
|
stakes: .medium,
|
|
confidence: p.confidence,
|
|
why: "Proposed by content-social.",
|
|
legPreviews: [(surface, "", .ok)])
|
|
}
|
|
|
|
private static func kitAsset(_ a: ContentAsset, index: Int) -> Asset {
|
|
Asset(
|
|
assetId: a.id,
|
|
label: a.mediaRef.split(separator: "/").last.map(String.init) ?? a.source,
|
|
contentClass: a.isExplicit ? .explicit : .sfw, // Kit rating ≠ platform hot/stocked; derive from is_explicit
|
|
isExplicit: a.isExplicit,
|
|
qualityScore: a.qualityScore ?? 0,
|
|
capturedAt: a.createdAt,
|
|
hue: Double((index * 37) % 100) / 100,
|
|
scheduled: false)
|
|
}
|
|
|
|
/// Platform `ingest_state` wire row → Kit's `IngestStatus` UI model.
|
|
private static func kitIngest(_ s: IngestState) -> IngestStatus {
|
|
IngestStatus(
|
|
enabled: s.enabled,
|
|
runRequested: s.runRequested,
|
|
state: IngestStatus.RunState(rawValue: s.state.rawValue) ?? .idle,
|
|
totalPhotos: s.totalPhotos,
|
|
processed: s.processed,
|
|
hotCount: s.hotCount,
|
|
stockedCount: s.stockedCount,
|
|
explicitCount: s.explicitCount,
|
|
failedCount: s.failedCount,
|
|
lastError: s.lastError,
|
|
backfillVideos: s.backfillVideos ?? false)
|
|
}
|
|
|
|
/// Fleet roster row → Kit Specialist. `mode` maps 1:1 to the status the UI
|
|
/// renders; `trust` carries through (nil when the specialist has no actions);
|
|
/// `note` has no backing data (kept nil — never fabricated).
|
|
private static func kitSpecialist(_ s: SpecialistSummary) -> Specialist {
|
|
let status: SpecialistStatus
|
|
switch s.mode {
|
|
case .auto: status = .auto
|
|
case .draftOnly: status = .draftOnly
|
|
case .idle: status = .idle
|
|
}
|
|
return Specialist(
|
|
name: s.specialistId, status: status, trust: s.trust,
|
|
countToday: s.countToday, countLabel: "today", note: nil)
|
|
}
|
|
|
|
/// Per-surface metric rollup → Kit SurfaceMetric. Surfaces with no Kit `Surface`
|
|
/// case (directory / long-tail surfaces) are dropped by the caller's compactMap.
|
|
private static func kitMetric(_ m: SurfaceMetricSummary) -> SurfaceMetric? {
|
|
guard let surface = Surface(rawValue: m.surface) else { return nil }
|
|
return SurfaceMetric(
|
|
surface: surface, posts: m.posts, impressions: m.impressions,
|
|
engagementPct: m.engagementPct, conversions: m.conversions)
|
|
}
|
|
|
|
private static func kitAction(_ a: CocottePlatformModels.AgentAction) -> AgentAction {
|
|
AgentAction(
|
|
time: hm.string(from: a.createdAt),
|
|
surface: nil, // agent_actions has no surface column
|
|
summary: "\(a.specialistId) · \(a.actionType)",
|
|
ok: true)
|
|
}
|
|
|
|
/// Platform surface → cockpit Surface. The cockpit is a content-only UI, so
|
|
/// directory + long-tail content surfaces (tiktok/youtube/twitch/facebook/…) have
|
|
/// no Kit equivalent and map to nil (caller substitutes a default where needed).
|
|
private static func kitSurface(_ s: SurfaceKind) -> Surface? {
|
|
switch s {
|
|
case .onlyfans: return .onlyfans
|
|
case .fansly: return .fansly
|
|
case .x: return .x
|
|
case .instagram: return .instagram
|
|
case .threads: return .threads
|
|
case .bluesky: return .bluesky
|
|
case .reddit: return .reddit
|
|
default: return nil
|
|
}
|
|
}
|
|
|
|
private static func kitDropState(_ s: ContentDropState) -> DropState {
|
|
switch s {
|
|
case .clustering: return .clustering
|
|
case .arcDraft: return .arcDraft
|
|
case .derived: return .derived
|
|
case .scheduled: return .scheduled
|
|
case .dispatched: return .dispatched
|
|
case .partial: return .partial
|
|
case .done: return .done
|
|
case .cancelled: return .scheduled // Kit has no 'cancelled'; nearest neutral state
|
|
}
|
|
}
|
|
}
|
|
|
|
extension LiveCockpitAPI {
|
|
static let hm: DateFormatter = { let f = DateFormatter(); f.dateFormat = "HH:mm"; return f }()
|
|
}
|