Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -97,5 +97,13 @@ enum class OpenAIModel(val slug: String, val blurb: String) {

companion object {
val DEFAULT = GPT_54

/** Slugs the Settings picker used to offer and no longer does (issue #77). A stored
* selection of one would keep failing with no visible way out, so it reads as [DEFAULT]. */
val RETIRED_SLUGS = setOf("gpt-4o", "gpt-4o-mini", "o4-mini")

/** [slug], or [DEFAULT]'s slug when it is blank or retired. */
fun normalize(slug: String): String =
if (slug.isBlank() || slug in RETIRED_SLUGS) DEFAULT.slug else slug
}
}
39 changes: 38 additions & 1 deletion app/src/main/java/com/pulseloop/coach/gemini/GeminiClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.pulseloop.coach.openai.ResponsesError
import com.pulseloop.coach.openai.ResponsesHttp
import com.pulseloop.coach.openai.ResponsesToolSpecs
import com.pulseloop.coach.openai.TextContent
import kotlinx.coroutines.delay
import kotlinx.serialization.json.*
import java.util.UUID

Expand Down Expand Up @@ -66,7 +67,7 @@ class GeminiClient(
val geminiBody = buildRequestBody(req)
val bodyBytes = json.encodeToString(JsonObject.serializer(), geminiBody).toByteArray()

val body = ResponsesHttp.post("$baseURL/$model:generateContent?key=$apiKey", bodyBytes)
val body = postWithOverloadRetry("$baseURL/$model:generateContent?key=$apiKey", bodyBytes)

val root = try {
json.parseToJsonElement(body).jsonObject
Expand All @@ -76,6 +77,42 @@ class GeminiClient(
return ingestResponse(root)
}

/**
* POST with a bounded retry on Gemini's **503 "high demand"** (issue #77). The
* `-latest` model aliases route to whatever pool is congested that hour, so a coach
* turn could fail on first contact for days while Google's own dashboard showed the
* account nowhere near quota. A 503 means the request was never served — nothing was
* generated and nothing billed — so a retry cannot double-charge, unlike a read
* timeout, which [ResponsesHttp] deliberately never retries for exactly that reason.
*
* A **429 is not retried**: it means the key's quota bucket said no, and on the free
* tier that bucket refills daily, not in seconds. [ResponsesHttp] raises it as-is and
* the turn fails with the provider's own message.
*
* [post] is a seam so a test can fail attempts without a network stack.
*/
internal suspend fun postWithOverloadRetry(
url: String,
bodyBytes: ByteArray,
backoffMs: Long = OVERLOAD_BACKOFF_MS,
post: suspend (String, ByteArray) -> String = { u, b -> ResponsesHttp.post(u, b) },
): String {
for (attempt in 0..MAX_OVERLOAD_RETRIES) {
try {
return post(url, bodyBytes)
} catch (e: ResponsesError.Http) {
if (e.status != 503 || attempt == MAX_OVERLOAD_RETRIES) throw e
delay(backoffMs shl attempt) // 2 s, then 4 s
}
}
throw IllegalStateException("overload retry loop never returned")
}

private companion object {
const val MAX_OVERLOAD_RETRIES = 2
const val OVERLOAD_BACKOFF_MS = 2_000L
}

// ── Request assembly (internal for unit tests) ───────────────────────

/** Translates one Responses-API request into the Gemini `generateContent`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,11 @@ object CoachResponseSchema {
"items" to JsonObject(mapOf("type" to JsonPrimitive("string"))),
)),
"chart" to JsonObject(mapOf(
"type" to JsonPrimitive("object"),
// Nullable: most answers carry no chart. Under `strict: true` OpenAI requires
// every property to be listed in `required`, with optionality expressed as a
// null union — not by omitting the key (issue #77: the 400 was the missing
// `additionalProperties`, and an incomplete `required` was the next one).
"type" to JsonArray(listOf(JsonPrimitive("object"), JsonPrimitive("null"))),
"properties" to JsonObject(mapOf(
"chart_type" to JsonObject(mapOf("type" to JsonPrimitive("string"))),
"title" to JsonObject(mapOf("type" to JsonPrimitive("string"))),
Expand All @@ -313,13 +317,21 @@ object CoachResponseSchema {
)),
)),
)),
"required" to JsonArray(listOf(
"chart_type", "title", "x_label", "y_label", "points",
).map { JsonPrimitive(it) }),
"additionalProperties" to JsonPrimitive(false),
)),
"confidence" to JsonObject(mapOf(
"type" to JsonPrimitive("string"),
"enum" to JsonArray(listOf("low", "medium", "high").map { JsonPrimitive(it) }),
)),
"safety_note" to JsonObject(mapOf("type" to JsonPrimitive("string"))),
"data_quality_note" to JsonObject(mapOf("type" to JsonPrimitive("string"))),
"safety_note" to JsonObject(mapOf(
"type" to JsonArray(listOf(JsonPrimitive("string"), JsonPrimitive("null"))),
)),
"data_quality_note" to JsonObject(mapOf(
"type" to JsonArray(listOf(JsonPrimitive("string"), JsonPrimitive("null"))),
)),
"sources" to JsonObject(mapOf(
"type" to JsonPrimitive("array"),
"items" to JsonObject(mapOf(
Expand All @@ -329,7 +341,9 @@ object CoachResponseSchema {
"url" to JsonObject(mapOf("type" to JsonPrimitive("string"))),
"publisher" to JsonObject(mapOf("type" to JsonPrimitive("string"))),
)),
"required" to JsonArray(listOf(JsonPrimitive("title"), JsonPrimitive("url"))),
"required" to JsonArray(listOf(
JsonPrimitive("title"), JsonPrimitive("url"), JsonPrimitive("publisher"),
)),
"additionalProperties" to JsonPrimitive(false),
)),
)),
Expand All @@ -342,8 +356,13 @@ object CoachResponseSchema {
"items" to JsonObject(mapOf("type" to JsonPrimitive("string"))),
)),
)),
// OpenAI strict structured outputs (issue #77): `required` must list **every**
// property — optionality is a null union, not a missing key — and every object
// needs `additionalProperties: false`. CoachResponseParser still only demands the
// four load-bearing keys, so a compliant reply decodes with the rest defaulted.
"required" to JsonArray(listOf(
"response_type", "title", "summary", "confidence"
"response_type", "title", "summary", "bullets", "chart", "safety_note",
"data_quality_note", "sources", "follow_up_chips", "actions_taken", "confidence",
).map { JsonPrimitive(it) }),
"additionalProperties" to JsonPrimitive(false),
))
Expand Down
167 changes: 167 additions & 0 deletions app/src/main/java/com/pulseloop/data/SleepRecordDeletion.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package com.pulseloop.data

import androidx.room.withTransaction
import com.pulseloop.data.entity.SleepSessionEntity
import com.pulseloop.data.entity.SleepStageBlockEntity
import com.pulseloop.ring.SleepStage

/**
* Deleting one ring sleep record (issue #78) — the sleep sibling of [ActivityBucketDeletion].
*
* A night can arrive as several ring records minutes apart, merged into one stored session because
* the merge is the night (#63). Mostly that merge is right — but the ring opens a record on a still
* wrist, so an evening on the sofa can land as a full phantom "session" before the wearer is even
* in bed, and the night's total runs an hour high. The wearer knows which run is wrong; the sensor
* cannot. This is the escape hatch.
*
* Three rules, mirroring the activity deletion's, and all of them live here rather than in any
* caller:
*
* * **Tombstone the blocks, not the session row.** Sleep's write path never upserts a session by
* id — `EventPersistenceSubscriber` re-derives the whole waking day from the raw stage blocks
* the ring re-sends, so deleting the row alone would let the next sync of that night rebuild it
* exactly. A block is minute-grid RLE over the record's stages, so the same record re-sent
* reproduces its blocks with the same `startAt` values; `MeasurementDeletionDao.sleepBlockId`
* keys on the waking day plus block start — the same stable-identity role the activity
* tombstone's `startEpoch` plays. The re-derive path must consult those tombstones before
* writing (it does — see `upsertSleepSessionAtomic`).
* * **Re-derive, never hand-patch.** The surviving session's bounds, asleep minutes and score are
* recomputed from the blocks that remain — the same numbers `reconcileWakingDay` produces — so
* what the UI shows after a delete is what the next sync would agree with. If nothing remains,
* the session row goes entirely and the day reads as unslept rather than reverting to the ring's
* figure on the next pass.
* * **The merge reseals itself.** The survivors are still distinct records with their own gap, so
* `sleepRecordRuns` re-splits the remaining blocks and the RECORDS card shows the shorter list
* with no stored "these were one record" state to maintain.
*/
object SleepRecordDeletion {

/**
* Delete the ring record whose blocks carry [recordStartAt] from session [sessionId], remember
* the blocks, and restate the session from what remains.
*
* Returns true when a record was actually removed.
*/
suspend fun delete(
db: PulseLoopDatabase,
sessionId: String,
recordStartAt: Long,
): Boolean = db.withTransaction {
val session = db.sleepSessionDao().byId(sessionId) ?: return@withTransaction false
val blocks = db.sleepStageBlockDao().forSession(sessionId)
// The run the user pointed at is the stretch of blocks stamped with that record's declared
// start (issue #68). `0` means a pre-#68 row with no stamp: fall back to "everything from
// the run boundary until the next stamped start", the same fallback `sleepRecordRuns` uses
// to split those nights for display.
val targets = if (recordStartAt == 0L) {
emptyList()
} else {
blocks.filter { it.recordStartAt == recordStartAt }.ifEmpty {
blocks.filter { it.recordStartAt == 0L && it.startAt >= recordStartAt }
.takeWhile { it.startAt < nextStampedStart(blocks, recordStartAt) }
}
}
if (targets.isEmpty()) return@withTransaction false

// The waking day the tombstone keys under is this session's own date: every record the day
// reconciles is assigned that same `date`, so a re-send of the record lands on the same key.
val day = session.date
db.measurementDeletionDao().recordSleepBlocks(day, targets.map { it.startAt })
targets.forEach { db.sleepStageBlockDao().deleteByStart(sessionId, it.startAt) }

restateSession(db, session, blocks - targets.toSet())
true
}

/** The next stamped record boundary after [after], or end-of-night — bounds the legacy fallback. */
private fun nextStampedStart(blocks: List<SleepStageBlockEntity>, after: Long): Long =
blocks.map { it.recordStartAt }.filter { it > after }.minOrNull() ?: Long.MAX_VALUE

/**
* Rewrite [session] from the blocks it still has — bounds, asleep minutes and score, the same
* formulas `reconcileWakingDay` applies (blocks re-keyed to the new bounds the same way). Empty
* means gone: the row is deleted and the day reads as unslept.
*
* The survivors are re-segmented by [com.pulseloop.service.SleepSegmentation] first, exactly as
* the next sync would do it. Deleting a *middle* record can leave an hour-plus hole — which the
* write path reads as two sessions, not one night with a long waking. Restating it as a single
* row would both disagree with the next sync and let `awakeMinutes` count the hole as a
* between-record waking (issue #81), turning a deleted 00:25–03:19 into three hours awake.
*/
private suspend fun restateSession(
db: PulseLoopDatabase,
session: SleepSessionEntity,
remaining: List<SleepStageBlockEntity>,
) {
val plan = planRestate(session, remaining)
if (plan.isEmpty()) {
db.sleepSessionDao().deleteById(session.id)
return
}
db.sleepStageBlockDao().deleteBySession(session.id)
val now = System.currentTimeMillis()
for (row in plan) {
val totalMin = com.pulseloop.service.asleepMinutes(row.blocks)
val deepMin = row.blocks.filter { it.stageRaw == SleepStage.DEEP.name }
.sumOf { it.durationMinutes }
// Parent session BEFORE its blocks (FK sleep_stage_blocks -> sleep_sessions.id).
db.sleepSessionDao().upsert(
session.copy(
id = row.id,
startAt = row.startAt,
endAt = row.endAt,
totalMinutes = totalMin,
score = com.pulseloop.service.sleepStageScore(deepMin, totalMin),
updatedAt = now,
),
)
// Re-key the surviving blocks to the restated bounds: `startMinute` is relative to the
// session start, so it moves with the new first block.
row.blocks.forEach {
db.sleepStageBlockDao().insert(
it.copy(
id = java.util.UUID.randomUUID().toString(),
sessionId = row.id,
startMinute = ((it.startAt - row.startAt) / 60_000L).toInt().coerceAtLeast(0),
),
)
}
}
}

/** One session row the restate writes: its id, bounds and blocks (sorted). */
internal data class RestatedRow(
val id: String,
val startAt: Long,
val endAt: Long,
val blocks: List<SleepStageBlockEntity>,
)

/**
* Split [remaining] the way `reconcileWakingDay` would and assign row ids: the segment that
* overlaps [session]'s old bounds most keeps its id (so the Day view stays on the same row);
* any other segment gets the id the write path mints for a new segment, `sleep-<day>-<start>`,
* so the next sync matches it rather than inserting a twin. Empty in → empty out.
*/
internal fun planRestate(
session: SleepSessionEntity,
remaining: List<SleepStageBlockEntity>,
): List<RestatedRow> {
val segments = com.pulseloop.service.SleepSegmentation.segment(remaining).map { g ->
val sorted = g.sortedBy { it.startAt }
RestatedRow(
id = "",
startAt = sorted.first().startAt,
endAt = sorted.maxOf { it.startAt + it.durationMinutes * 60_000L },
blocks = sorted,
)
}
if (segments.isEmpty()) return emptyList()
fun overlap(r: RestatedRow) =
maxOf(0L, minOf(r.endAt, session.endAt) - maxOf(r.startAt, session.startAt))
val keeper = segments.maxBy { overlap(it) }
return segments.map {
it.copy(id = if (it === keeper) session.id else "sleep-${session.date}-${it.startAt}")
}
}
}
41 changes: 41 additions & 0 deletions app/src/main/java/com/pulseloop/data/dao/Daos.kt
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ interface ActivityDailyDao {
@Query("SELECT * FROM activity_daily WHERE source NOT IN ('demo','mock') ORDER BY date DESC LIMIT :limit")
suspend fun recentReal(limit: Int = 7): List<ActivityDailyEntity>

/** Earliest tracked day key (local midnight millis) — bounds how far Activity day navigation can page back (issue #76). */
@Query("SELECT MIN(date) FROM activity_daily")
suspend fun earliestDay(): Long?

/** Whether the ring has ever synced a day. */
@Query("SELECT EXISTS(SELECT 1 FROM activity_daily WHERE source NOT IN ('demo','mock'))")
suspend fun hasReal(): Boolean
Expand Down Expand Up @@ -318,6 +322,9 @@ interface SleepSessionDao {
@Query("SELECT * FROM sleep_sessions WHERE date = :day ORDER BY (sourceRaw = 'demo') ASC, totalMinutes DESC LIMIT 1")
suspend fun byDay(day: Long): SleepSessionEntity?

@Query("SELECT * FROM sleep_sessions WHERE id = :id LIMIT 1")
suspend fun byId(id: String): SleepSessionEntity?

/** All sessions for a day, earliest first — feeds the Day-view carousel. */
@Query("SELECT * FROM sleep_sessions WHERE date = :day ORDER BY startAt ASC")
suspend fun allByDay(day: Long): List<SleepSessionEntity>
Expand Down Expand Up @@ -408,6 +415,10 @@ interface SleepStageBlockDao {
@Query("DELETE FROM sleep_stage_blocks WHERE sessionId = :sessionId")
suspend fun deleteBySession(sessionId: String)

/** Remove one run-length block — the delete affordance on the sleep RECORDS card (issue #78). */
@Query("DELETE FROM sleep_stage_blocks WHERE sessionId = :sessionId AND startAt = :startAt")
suspend fun deleteByStart(sessionId: String, startAt: Long)

@Query("DELETE FROM sleep_stage_blocks")
suspend fun clear()

Expand Down Expand Up @@ -715,6 +726,28 @@ interface MeasurementDeletionDao {
@Query("SELECT EXISTS(SELECT 1 FROM measurement_deletions WHERE measurementId = :id)")
suspend fun isActivityBucketDeleted(id: String): Boolean

/**
* Remember a deleted ring sleep record (issue #78) — one tombstone per stage block.
*
* Sleep's write path never upserts a session by id: [com.pulseloop.service.EventPersistenceSubscriber]
* re-derives the whole waking day from the raw stage blocks the ring re-sends, so deleting a
* session row alone would let the next sync of that night rebuild it exactly. A block is
* minute-grid RLE over the record's stages, so the same record re-sent reproduces its blocks
* with the same `startAt` values — which is the stable identity, the same role the bucket
* startEpoch plays above. Keyed by the waking day as well, because the same wall-clock minute
* legitimately recurs on different days.
*/
suspend fun recordSleepBlocks(dayStart: Long, startAts: List<Long>) {
if (startAts.isEmpty()) return
insertAll(startAts.map {
MeasurementDeletionEntity(
measurementId = sleepBlockId(dayStart, it),
kindRaw = SLEEP_RECORD_KIND,
timestamp = it,
)
})
}

companion object {
/** The prefix `EventPersistenceSubscriber.historyMeasurementId` builds its stable ids from.
* A measurement whose id starts with this is one the ring can hand us again. */
Expand All @@ -731,7 +764,15 @@ interface MeasurementDeletionDao {
* are not measurements — so it is deliberately a name no kind can collide with. */
const val ACTIVITY_KIND = "ACTIVITY_BUCKET"

/** Key prefix for a deleted ring sleep record's blocks (issue #78) — see [recordSleepBlocks]. */
const val SLEEP_ID_PREFIX = "sleep:"
/** `kindRaw` for a sleep-record tombstone — a name no measurement kind can collide with. */
const val SLEEP_RECORD_KIND = "SLEEP_RECORD_BLOCK"

/** The tombstone key for the bucket starting at [startEpoch]. */
fun activityBucketId(startEpoch: Long): String = "$ACTIVITY_ID_PREFIX$startEpoch"

/** The tombstone key for the sleep stage block starting at [startAt] on waking day [dayStart]. */
fun sleepBlockId(dayStart: Long, startAt: Long): String = "$SLEEP_ID_PREFIX$dayStart:$startAt"
}
}
Loading
Loading