Skip to content

Commit dcf37c2

Browse files
committed
feat: add sync snapshot — single-call pull for Apple clients (276 tests)
1 parent da104a1 commit dcf37c2

File tree

7 files changed

+363
-1
lines changed

7 files changed

+363
-1
lines changed

CortexOSApp/Shared/Models/DecisionResult.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,43 @@ extension DecisionResult {
5757
/// Whether this item contradicts a prior assumption.
5858
var isContradiction: Bool { contradictionOrConfirmation == "contradicts" }
5959
}
60+
61+
// MARK: - Context mutation requests
62+
63+
struct DecisionCreateRequest: Codable {
64+
let decision: String
65+
let reason: String
66+
var project: String = ""
67+
var assumptions: [String] = []
68+
}
69+
70+
struct OutcomeCreateRequest: Codable {
71+
let decisionId: String
72+
let outcome: String
73+
var impactScore: Double = 0.0
74+
75+
enum CodingKeys: String, CodingKey {
76+
case outcome
77+
case decisionId = "decision_id"
78+
case impactScore = "impact_score"
79+
}
80+
}
81+
82+
struct InsightCreateRequest: Codable {
83+
let title: String
84+
var summary: String = ""
85+
var whyItMatters: String = ""
86+
var architecturalImplication: String = ""
87+
var nextAction: String = ""
88+
var confidence: Double = 0.5
89+
var tags: [String] = []
90+
var relatedProject: String = ""
91+
92+
enum CodingKeys: String, CodingKey {
93+
case title, summary, confidence, tags
94+
case whyItMatters = "why_it_matters"
95+
case architecturalImplication = "architectural_implication"
96+
case nextAction = "next_action"
97+
case relatedProject = "related_project"
98+
}
99+
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
//
2+
// SyncSnapshot.swift
3+
// CortexOS
4+
//
5+
// Single-call sync model — everything a client needs in one pull.
6+
// Backend is source of truth. Clients decode this on launch.
7+
//
8+
9+
import Foundation
10+
11+
// MARK: - Snapshot
12+
13+
struct SyncSnapshot: Codable {
14+
let profile: SyncProfile
15+
let activeProject: ProjectContext?
16+
let priorities: PriorityBrief?
17+
let recentDecisions: [SyncDecision]
18+
let insights: [SyncInsight]
19+
let signals: [SyncSignal]
20+
let workingMemory: SyncWorkingMemory
21+
let syncedAt: String
22+
23+
enum CodingKeys: String, CodingKey {
24+
case profile, priorities, insights, signals
25+
case activeProject = "active_project"
26+
case recentDecisions = "recent_decisions"
27+
case workingMemory = "working_memory"
28+
case syncedAt = "synced_at"
29+
}
30+
}
31+
32+
// MARK: - Profile (subset for sync)
33+
34+
struct SyncProfile: Codable {
35+
let name: String
36+
let role: String
37+
let goals: [String]
38+
let interests: [String]
39+
let currentProjects: [String]
40+
let ignoredTopics: [String]
41+
42+
enum CodingKeys: String, CodingKey {
43+
case name, role, goals, interests
44+
case currentProjects = "current_projects"
45+
case ignoredTopics = "ignored_topics"
46+
}
47+
}
48+
49+
// MARK: - Project
50+
51+
struct ProjectContext: Codable {
52+
let projectName: String
53+
let currentMilestone: String
54+
let activeBlockers: [String]
55+
let recentDecisions: [String]
56+
let architectureNotes: [String]
57+
let openQuestions: [String]
58+
59+
enum CodingKeys: String, CodingKey {
60+
case projectName = "project_name"
61+
case currentMilestone = "current_milestone"
62+
case activeBlockers = "active_blockers"
63+
case recentDecisions = "recent_decisions"
64+
case architectureNotes = "architecture_notes"
65+
case openQuestions = "open_questions"
66+
}
67+
}
68+
69+
// MARK: - Priority Brief
70+
71+
struct SyncPriority: Codable, Identifiable {
72+
var id: String { title }
73+
let rank: Int
74+
let title: String
75+
let whyItMatters: String
76+
let nextStep: String
77+
let source: String
78+
let relevanceScore: Double
79+
let tags: [String]
80+
81+
enum CodingKeys: String, CodingKey {
82+
case rank, title, source, tags
83+
case whyItMatters = "why_it_matters"
84+
case nextStep = "next_step"
85+
case relevanceScore = "relevance_score"
86+
}
87+
}
88+
89+
struct PriorityBrief: Codable {
90+
let date: String
91+
let priorities: [SyncPriority]
92+
let ignored: [String]
93+
let emergingSignals: [String]
94+
let changesSinceYesterday: [String]
95+
96+
enum CodingKeys: String, CodingKey {
97+
case date, priorities, ignored
98+
case emergingSignals = "emerging_signals"
99+
case changesSinceYesterday = "changes_since_yesterday"
100+
}
101+
}
102+
103+
// MARK: - Decision
104+
105+
struct SyncDecision: Codable, Identifiable {
106+
let id: String
107+
let decision: String
108+
let reason: String
109+
let project: String
110+
let assumptions: [String]
111+
let contextTags: [String]
112+
let createdAt: String
113+
let outcome: String
114+
let impactScore: Double
115+
116+
enum CodingKeys: String, CodingKey {
117+
case id, decision, reason, project, assumptions, outcome
118+
case contextTags = "context_tags"
119+
case createdAt = "created_at"
120+
case impactScore = "impact_score"
121+
}
122+
}
123+
124+
// MARK: - Insight
125+
126+
struct SyncInsight: Codable, Identifiable {
127+
let id: String
128+
let title: String
129+
let summary: String
130+
let whyItMatters: String
131+
let architecturalImplication: String
132+
let nextAction: String
133+
let confidence: Double
134+
let tags: [String]
135+
let relatedProject: String
136+
let createdAt: String
137+
138+
enum CodingKeys: String, CodingKey {
139+
case id, title, summary, confidence, tags
140+
case whyItMatters = "why_it_matters"
141+
case architecturalImplication = "architectural_implication"
142+
case nextAction = "next_action"
143+
case relatedProject = "related_project"
144+
case createdAt = "created_at"
145+
}
146+
}
147+
148+
// MARK: - Signal
149+
150+
struct SyncSignal: Codable, Identifiable {
151+
let id: String
152+
let topic: String
153+
let frequency: Int
154+
let strength: Double
155+
let status: String // emerging, confirmed, fading, archived
156+
let firstSeen: String
157+
let lastSeen: String
158+
let sourceTitles: [String]
159+
160+
enum CodingKeys: String, CodingKey {
161+
case id, topic, frequency, strength, status
162+
case firstSeen = "first_seen"
163+
case lastSeen = "last_seen"
164+
case sourceTitles = "source_titles"
165+
}
166+
}
167+
168+
// MARK: - Working Memory
169+
170+
struct SyncWorkingMemory: Codable {
171+
let date: String
172+
let todaysPriorities: [String]
173+
let currentlyExploring: [String]
174+
let temporaryNotes: [String]
175+
176+
enum CodingKeys: String, CodingKey {
177+
case date
178+
case todaysPriorities = "todays_priorities"
179+
case currentlyExploring = "currently_exploring"
180+
case temporaryNotes = "temporary_notes"
181+
}
182+
}

CortexOSApp/Shared/Services/APIService.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,26 @@ final class APIService: ObservableObject {
203203
func evaluateWhy(_ body: WhyEvaluateRequest) async throws -> DecisionResult {
204204
try await request("POST", path: "/why/evaluate", body: body)
205205
}
206+
207+
// MARK: - Sync
208+
209+
func fetchSnapshot() async throws -> SyncSnapshot {
210+
try await request("GET", path: "/sync/snapshot")
211+
}
212+
213+
// MARK: - Context (mutations)
214+
215+
func recordDecision(_ body: DecisionCreateRequest) async throws -> SyncDecision {
216+
try await request("POST", path: "/context/decision", body: body)
217+
}
218+
219+
func recordOutcome(_ body: OutcomeCreateRequest) async throws -> SyncDecision {
220+
try await request("POST", path: "/context/outcome", body: body)
221+
}
222+
223+
func storeInsight(_ body: InsightCreateRequest) async throws -> SyncInsight {
224+
try await request("POST", path: "/context/insight", body: body)
225+
}
206226
}
207227

208228
// MARK: - Helpers

cortex_core/api/routes/sync.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""
2+
Sync API route
3+
--------------
4+
GET /sync/snapshot — single-call pull of everything a Swift client needs.
5+
6+
One endpoint. One model. Backend is source of truth.
7+
Clients pull this on launch and on-demand refresh.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from fastapi import APIRouter
13+
14+
from cortex_core.api.server import get_engine
15+
16+
router = APIRouter(prefix="/sync", tags=["sync"])
17+
18+
19+
@router.get("/snapshot")
20+
async def get_snapshot() -> dict:
21+
"""Return a single sync snapshot for Apple clients.
22+
23+
Bundles profile, active project, priorities, recent decisions,
24+
insights, signals, and working memory into one response.
25+
"""
26+
engine = get_engine()
27+
return engine.build_sync_snapshot()

cortex_core/api/server.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,12 @@ def create_app() -> FastAPI:
5656
)
5757

5858
# Register routers
59-
from cortex_core.api.routes import context, digest, focus, health, knowledge, pipeline, posts, profile, why
59+
from cortex_core.api.routes import context, digest, focus, health, knowledge, pipeline, posts, profile, sync, why
6060

6161
app.include_router(health.router)
6262
app.include_router(focus.router) # primary feature
6363
app.include_router(why.router) # why engine — per-item intelligence
64+
app.include_router(sync.router) # single-call sync for Apple clients
6465
app.include_router(context.router) # agent context API
6566
app.include_router(profile.router)
6667
app.include_router(knowledge.router)

cortex_core/engine.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,46 @@ def get_full_context(self) -> dict:
484484
"""Return complete memory state for agent consumption."""
485485
return self.memory.full_context()
486486

487+
# ── Sync ────────────────────────────────────────────────────
488+
489+
def build_sync_snapshot(self) -> dict:
490+
"""Single-call snapshot of everything Apple clients need.
491+
492+
Bundles profile, active project, priorities, decisions,
493+
insights, signals, and working memory into one dict.
494+
Backend is source of truth — clients pull this on launch.
495+
"""
496+
from datetime import UTC, datetime
497+
498+
profile = self.memory.profile
499+
500+
# Active project context (first project, if any)
501+
active_project: dict | None = None
502+
if profile.current_projects:
503+
pm = self.memory.get_project(profile.current_projects[0])
504+
active_project = pm.to_dict()
505+
506+
# Priority brief (may not exist yet)
507+
priorities = self.decision_engine.get_previous_brief()
508+
509+
return {
510+
"profile": {
511+
"name": profile.name,
512+
"role": profile.role,
513+
"goals": profile.goals,
514+
"interests": profile.interests,
515+
"current_projects": profile.current_projects,
516+
"ignored_topics": profile.ignored_topics,
517+
},
518+
"active_project": active_project,
519+
"priorities": priorities,
520+
"recent_decisions": self.get_recent_decisions(10),
521+
"insights": self.get_insights(limit=10),
522+
"signals": self.get_signals(),
523+
"working_memory": self.memory.working.to_dict(),
524+
"synced_at": datetime.now(UTC).isoformat(),
525+
}
526+
487527
# ── Why Engine ──────────────────────────────────────────────
488528

489529
def evaluate_why(self, item_data: dict) -> dict:

tests/test_api.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,55 @@ def test_run_pipeline(self, client, tmp_data_dir, sample_digest_text):
154154
assert resp.status_code == 200
155155
data = resp.json()
156156
assert "success" in data
157+
158+
159+
# ── Sync ────────────────────────────────────────────────────────
160+
161+
162+
class TestSyncEndpoints:
163+
def test_snapshot_returns_200(self, client):
164+
resp = client.get("/sync/snapshot")
165+
assert resp.status_code == 200
166+
167+
def test_snapshot_has_required_keys(self, client):
168+
data = client.get("/sync/snapshot").json()
169+
for key in ("profile", "active_project", "priorities",
170+
"recent_decisions", "insights", "signals",
171+
"working_memory", "synced_at"):
172+
assert key in data, f"missing key: {key}"
173+
174+
def test_snapshot_profile_shape(self, client):
175+
profile = client.get("/sync/snapshot").json()["profile"]
176+
assert isinstance(profile["goals"], list)
177+
assert isinstance(profile["interests"], list)
178+
assert isinstance(profile["current_projects"], list)
179+
assert "name" in profile
180+
assert "role" in profile
181+
182+
def test_snapshot_synced_at_is_iso(self, client):
183+
data = client.get("/sync/snapshot").json()
184+
# Should be a valid ISO timestamp
185+
assert "T" in data["synced_at"]
186+
187+
def test_snapshot_recent_decisions_is_list(self, client):
188+
data = client.get("/sync/snapshot").json()
189+
assert isinstance(data["recent_decisions"], list)
190+
191+
def test_snapshot_signals_is_list(self, client):
192+
data = client.get("/sync/snapshot").json()
193+
assert isinstance(data["signals"], list)
194+
195+
def test_snapshot_working_memory_shape(self, client):
196+
wm = client.get("/sync/snapshot").json()["working_memory"]
197+
assert "date" in wm
198+
assert isinstance(wm["todays_priorities"], list)
199+
200+
def test_snapshot_after_decision_includes_it(self, client):
201+
client.post("/context/decision", json={
202+
"decision": "Use rule-based scoring for v1",
203+
"reason": "Simplicity over accuracy at this stage",
204+
"project": "CortexOS",
205+
})
206+
decisions = client.get("/sync/snapshot").json()["recent_decisions"]
207+
texts = [d["decision"] for d in decisions]
208+
assert "Use rule-based scoring for v1" in texts

0 commit comments

Comments
 (0)