cctally 1.110.0 → 1.112.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +5 -6
  3. package/bin/_cctally_cache.py +14 -7
  4. package/bin/_cctally_core.py +12 -1
  5. package/bin/_cctally_dashboard.py +60 -154
  6. package/bin/_cctally_dashboard_envelope.py +7 -6
  7. package/bin/_cctally_dashboard_sources.py +90 -55
  8. package/bin/_cctally_doctor.py +73 -27
  9. package/bin/_cctally_forecast.py +2 -1
  10. package/bin/_cctally_journal.py +147 -6
  11. package/bin/_cctally_milestones.py +29 -3
  12. package/bin/_cctally_quota.py +10 -1
  13. package/bin/_cctally_record.py +136 -4
  14. package/bin/_cctally_rederive.py +311 -8
  15. package/bin/_cctally_source_analytics.py +20 -8
  16. package/bin/_cctally_tui.py +546 -45
  17. package/bin/_cctally_update.py +420 -14
  18. package/bin/_lib_cache_report.py +7 -10
  19. package/bin/_lib_codex_conversation_query.py +40 -4
  20. package/bin/_lib_codex_metadata.py +57 -11
  21. package/bin/_lib_cost_provenance.py +303 -0
  22. package/bin/_lib_dashboard_sources.py +263 -28
  23. package/bin/_lib_doctor.py +2 -2
  24. package/bin/_lib_ingest_frontier.py +137 -38
  25. package/bin/_lib_pricing.py +24 -263
  26. package/bin/_lib_rederive.py +704 -36
  27. package/bin/_lib_snapshot_cache.py +332 -9
  28. package/bin/cctally +18 -5
  29. package/dashboard/static/assets/{ConversationsView-BB9vUiL6.js → ConversationsView-CEZ-BUyi.js} +1 -1
  30. package/dashboard/static/assets/{DoctorModal-DFY2iPDf.js → DoctorModal-CE1oH_Zy.js} +1 -1
  31. package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +1 -0
  32. package/dashboard/static/assets/{ProjectsDrillPanel-DAv39uSo.js → ProjectsDrillPanel-B3DZoCeD.js} +1 -1
  33. package/dashboard/static/assets/{SourceDetailModal-DSAWo2Fp.js → SourceDetailModal-CHp9EgLP.js} +1 -1
  34. package/dashboard/static/assets/{UpdateModal-R_9dOVnh.js → UpdateModal-5iKST6jo.js} +1 -1
  35. package/dashboard/static/assets/{index-DdU62sov.js → index-Cm0VTxJC.js} +2 -2
  36. package/dashboard/static/assets/index-Dt4tqf43.css +1 -0
  37. package/dashboard/static/assets/{outlineNavigation-BOCLcRa6.js → outlineNavigation-DkXGL8J6.js} +1 -1
  38. package/dashboard/static/dashboard.html +2 -2
  39. package/package.json +2 -1
  40. package/dashboard/static/assets/ModalRoot-DvRKHbQE.js +0 -1
  41. package/dashboard/static/assets/index-COhxe3vU.css +0 -1
@@ -14,6 +14,7 @@ import datetime as dt
14
14
  import fcntl
15
15
  import hashlib
16
16
  import json
17
+ import math
17
18
  import os
18
19
  import shutil
19
20
  import signal
@@ -102,7 +103,7 @@ def _cache_contract(cache_conn: sqlite3.Connection) -> dict[str, set[str]]:
102
103
  }
103
104
 
104
105
 
105
- def _cache_fingerprint(cache_conn: sqlite3.Connection) -> str:
106
+ def _cache_fingerprint(cache_conn: sqlite3.Connection, *, include_codex=False) -> str:
106
107
  """Hash every cost-bearing Claude cache row and its project metadata."""
107
108
  entry_columns = (
108
109
  "source_path", "line_offset", "timestamp_utc", "model", "input_tokens",
@@ -120,7 +121,276 @@ def _cache_fingerprint(cache_conn: sqlite3.Connection) -> str:
120
121
  "SELECT " + ",".join(file_columns)
121
122
  + " FROM session_files ORDER BY path")
122
123
  ]
123
- return _fingerprint({"sessionEntries": entries, "sessionFiles": files})
124
+ basis = {"sessionEntries": entries, "sessionFiles": files}
125
+ if include_codex:
126
+ if "codex_session_entries" not in _cache_contract(cache_conn):
127
+ raise _lib_rederive.RederiveDataGap(
128
+ "missing cache.db table codex_session_entries"
129
+ )
130
+ basis["codexEntries"] = [
131
+ list(row) for row in cache_conn.execute(
132
+ "SELECT source_path,line_offset,timestamp_utc,session_id,model,"
133
+ "input_tokens,cached_input_tokens,output_tokens,"
134
+ "reasoning_output_tokens,account_key "
135
+ "FROM codex_session_entries ORDER BY source_path,line_offset"
136
+ )
137
+ ]
138
+ return _fingerprint(basis)
139
+
140
+
141
+ def _split_matches(parts, total) -> bool:
142
+ """A recorded cost split agrees with its total up to summation order.
143
+
144
+ The at-fire writer sums one interleaved entry stream into the total and
145
+ into per-model buckets, and the two association orders round differently
146
+ as spend grows. `fsum` removes the split's own rounding; the relative bound
147
+ absorbs the total's, while a material mismatch still fails (#869 F8).
148
+ """
149
+ return math.isclose(
150
+ math.fsum(parts), total, rel_tol=1e-10, abs_tol=1e-9,
151
+ )
152
+
153
+
154
+ def _desired_codex_budget_costs(selection, cache_conn, *, uncertain=None):
155
+ """Correct only journaled fallback facts with an unchanged source set.
156
+
157
+ ``uncertain`` collects the ids of crossings kept unchanged because their
158
+ retained source population no longer matches the recorded one.
159
+ """
160
+ import _cctally_cache
161
+ import _lib_cost_provenance
162
+ import _lib_pricing
163
+ out = []
164
+ for selected in selection.by_id.values():
165
+ record = selected.record
166
+ if selected.status != "active" or record is None:
167
+ continue
168
+ payload = record.get("payload") or {}
169
+ if payload.get("kind") != "budget" or payload.get("vendor") != "codex":
170
+ continue
171
+ old = payload.get("_pricing") or {}
172
+ if old.get("version") != 1:
173
+ continue # legacy evidence is insufficient for automatic repricing
174
+ missing = old.get("fallbackModels") or []
175
+ if not missing:
176
+ out.append(record) # a genuinely priced at-fire fact is immutable
177
+ continue
178
+ old_model_costs = old.get("modelCostsUsd")
179
+ spent_value = payload.get("spent_usd")
180
+ if (
181
+ not isinstance(missing, list)
182
+ or not missing
183
+ or any(not isinstance(model, str) or not model for model in missing)
184
+ or len(set(missing)) != len(missing)
185
+ or not isinstance(old_model_costs, dict)
186
+ or any(
187
+ not isinstance(value, (int, float))
188
+ or not math.isfinite(value) or value < 0
189
+ for value in old_model_costs.values()
190
+ )
191
+ or any(model not in old_model_costs for model in missing)
192
+ or not isinstance(old.get("directCostUsd"), (int, float))
193
+ or not math.isfinite(old["directCostUsd"])
194
+ or old["directCostUsd"] < 0
195
+ or old.get("speed") not in ("standard", "fast")
196
+ or not isinstance(spent_value, (int, float))
197
+ or not math.isfinite(spent_value)
198
+ or not _split_matches(old_model_costs.values(), spent_value)
199
+ or not _split_matches(
200
+ [old["directCostUsd"],
201
+ *(old_model_costs[model] for model in missing)],
202
+ spent_value,
203
+ )
204
+ ):
205
+ raise _lib_rederive.RederiveDataGap(
206
+ f"Codex budget {record['id']} has incomplete at-fire "
207
+ "pricing provenance"
208
+ )
209
+ if any(_lib_pricing._is_codex_fallback(model) for model in missing):
210
+ # #869 F1: a direct card has not shipped for every fallback model,
211
+ # so nothing about this crossing can change yet. Decided before
212
+ # the population comparison: drift in a fact this run cannot
213
+ # correct must not refuse every other correction in the family.
214
+ out.append(record)
215
+ continue
216
+ try:
217
+ start = dt.datetime.fromisoformat(
218
+ payload["period_start_at"].replace("Z", "+00:00")
219
+ )
220
+ end = dt.datetime.fromisoformat(
221
+ payload["crossed_at_utc"].replace("Z", "+00:00")
222
+ )
223
+ if start.tzinfo is None or end.tzinfo is None or start >= end:
224
+ raise ValueError("invalid budget cost bounds")
225
+ speed = old["speed"]
226
+ threshold = float(payload["threshold"])
227
+ target = float(payload["budget_usd"])
228
+ scope = payload.get("account_key")
229
+ if scope == "*":
230
+ scope = None
231
+ source = _cctally_cache.iter_codex_entries(
232
+ cache_conn, start, end, account_key=scope,
233
+ )
234
+ cost, newer = _lib_cost_provenance.codex_cost_with_provenance(
235
+ source, end=end, speed=speed,
236
+ )
237
+ except (KeyError, TypeError, ValueError, sqlite3.DatabaseError) as exc:
238
+ raise _lib_rederive.RederiveDataGap(
239
+ f"Codex budget {record['id']} lacks retained cost inputs: {exc}"
240
+ ) from exc
241
+ if (
242
+ newer["sourceHash"] != old.get("sourceHash")
243
+ or newer["entryCount"] != old.get("entryCount")
244
+ ):
245
+ # A changed population cannot license a new amount, but it does
246
+ # not invalidate the recorded crossing either: keep it exactly as
247
+ # fired and disclose it (#869 F1/Q2).
248
+ if uncertain is not None:
249
+ uncertain.add(record["id"])
250
+ out.append(record)
251
+ continue
252
+ if any(model not in newer["modelCostsUsd"] for model in missing):
253
+ raise _lib_rederive.RederiveDataGap(
254
+ f"Codex budget {record['id']} lacks a complete at-fire "
255
+ "priced-cost split"
256
+ )
257
+ if any(
258
+ model in newer["fallbackModels"]
259
+ for model in old_model_costs if model not in missing
260
+ ):
261
+ # #869 V5: a model priced by a direct card at fire has lost that
262
+ # card, so this run cannot price the crossing truthfully. That is
263
+ # this crossing's gap alone: keep it exactly as fired and disclose
264
+ # it, rather than refusing every other correction in the family.
265
+ if uncertain is not None:
266
+ uncertain.add(record["id"])
267
+ out.append(record)
268
+ continue
269
+ # Only the fallback-priced models change. The direct-card contribution
270
+ # is a real at-fire fact even when those cards are revised later.
271
+ cost = math.fsum([
272
+ float(old["directCostUsd"]),
273
+ *(newer["modelCostsUsd"][model] for model in missing),
274
+ ])
275
+ consumption_pct = cost / target * 100.0 if target > 0 else 0.0
276
+ if consumption_pct + 1e-9 < threshold:
277
+ # #869 F6: the fallback overpriced this crossing and the real card
278
+ # puts it below its threshold. Omitting it from the desired set
279
+ # tombstones it in the correction batch, so the natural key is
280
+ # free for a genuine later crossing (which re-fires under a new
281
+ # incarnation id, `bm2:`).
282
+ continue
283
+ revised = copy.deepcopy(record)
284
+ revised_payload = revised["payload"]
285
+ revised_payload["spent_usd"] = cost
286
+ revised_payload["consumption_pct"] = consumption_pct
287
+ revised_pricing = copy.deepcopy(newer)
288
+ revised_pricing["modelCostsUsd"] = {
289
+ model: (
290
+ newer["modelCostsUsd"][model] if model in missing else value
291
+ )
292
+ for model, value in old_model_costs.items()
293
+ }
294
+ revised_pricing["directCostUsd"] = cost
295
+ revised_payload["_pricing"] = revised_pricing
296
+ out.append(revised)
297
+ return out
298
+
299
+
300
+ def _historical_close_candidates(selection) -> set:
301
+ """(account_key, window key) of each unmarked close with a $0 model."""
302
+ import _lib_accounts
303
+
304
+ out = set()
305
+ for selected in selection.by_id.values():
306
+ record = selected.record
307
+ if selected.status != "active" or record is None:
308
+ continue
309
+ payload = record.get("payload") or {}
310
+ if (
311
+ payload.get("kind") != "five_hour_block_close"
312
+ or payload.get("_pricing")
313
+ or payload.get("pricing_provenance_json") is not None
314
+ or not _lib_rederive.close_cost_is_uncertain(payload)
315
+ ):
316
+ continue
317
+ try:
318
+ out.add((
319
+ payload.get("account_key") or _lib_accounts.UNATTRIBUTED,
320
+ int(payload["five_hour_window_key"]),
321
+ ))
322
+ except (KeyError, TypeError, ValueError):
323
+ continue
324
+ return out
325
+
326
+
327
+ def _historical_close_markers(selection, desired_events, block_sources):
328
+ """event id -> correction marker for each provable historical close (#869).
329
+
330
+ An unmarked close predates computation-time pricing evidence. It is
331
+ provable only from the retained entries the scratch replay assigned to the
332
+ same block, with the same account, no provider cost, and a positive current
333
+ card rate for every token class those entries used. The planner still
334
+ compares the replayed child populations before adopting the correction.
335
+ """
336
+ import _lib_accounts
337
+ import _lib_cost_provenance
338
+
339
+ desired_closes = {
340
+ event.get("id"): event for event in desired_events
341
+ if (event.get("payload") or {}).get("kind") == "five_hour_block_close"
342
+ }
343
+ markers = {}
344
+ for event_id, selected in selection.by_id.items():
345
+ record = selected.record
346
+ if (
347
+ selected.status != "active"
348
+ or record is None
349
+ or event_id not in desired_closes
350
+ ):
351
+ continue
352
+ payload = record.get("payload") or {}
353
+ if payload.get("kind") != "five_hour_block_close":
354
+ continue
355
+ desired = desired_closes[event_id].get("payload") or {}
356
+ key = (
357
+ desired.get("account_key") or _lib_accounts.UNATTRIBUTED,
358
+ desired.get("five_hour_window_key"),
359
+ )
360
+ try:
361
+ key = (key[0], int(key[1]))
362
+ except (TypeError, ValueError):
363
+ continue
364
+ marker = _lib_cost_provenance.historical_close_marker(
365
+ payload, block_sources.get(key),
366
+ unattributed=_lib_accounts.UNATTRIBUTED,
367
+ )
368
+ if marker is not None:
369
+ markers[event_id] = marker
370
+ return markers
371
+
372
+
373
+ def _uncertain_cost_facts(selection) -> set:
374
+ """Ids of retained Codex monetary events lacking proof of their inputs.
375
+
376
+ Closed Claude blocks are judged by the plan itself, because whether one
377
+ stays uncertain depends on the correction this plan makes to it.
378
+ """
379
+ out = set()
380
+ for event_id, selected in selection.by_id.items():
381
+ if selected.status != "active" or selected.record is None:
382
+ continue
383
+ payload = selected.record.get("payload") or {}
384
+ kind = payload.get("kind")
385
+ if (kind == "budget" and payload.get("vendor") == "codex"
386
+ and "_pricing" not in payload):
387
+ out.add(event_id)
388
+ elif (kind == "projected"
389
+ and payload.get("metric") == "codex_budget_usd"):
390
+ # Historical projection inputs/config are not retained, so this
391
+ # at-fire alert value cannot be recomputed from a new rate card.
392
+ out.add(event_id)
393
+ return out
124
394
 
125
395
 
126
396
  def _validate_cache_rows(cache_conn: sqlite3.Connection,
@@ -180,7 +450,7 @@ def _joined_entries(cache_conn, range_start, range_end, *,
180
450
  "SELECT se.timestamp_utc, se.model, se.input_tokens, se.output_tokens, "
181
451
  "se.cache_create_tokens, se.cache_read_tokens, se.source_path, "
182
452
  "sf.session_id, sf.project_path, se.cost_usd_raw, se.speed, "
183
- "se.cache_create_1h_tokens "
453
+ "se.cache_create_1h_tokens, se.line_offset, se.account_key "
184
454
  "FROM session_entries se "
185
455
  "LEFT JOIN session_files sf ON sf.path = se.source_path "
186
456
  "WHERE se.timestamp_utc >= ? AND se.timestamp_utc <= ?"
@@ -203,7 +473,7 @@ def _joined_entries(cache_conn, range_start, range_end, *,
203
473
  sql += " ORDER BY se.timestamp_utc ASC, se.id ASC"
204
474
  out = []
205
475
  for row in cache_conn.execute(sql, params):
206
- out.append(cache._JoinedClaudeEntry(
476
+ entry = cache._JoinedClaudeEntry(
207
477
  timestamp=dt.datetime.fromisoformat(row[0]),
208
478
  model=row[1],
209
479
  input_tokens=int(row[2] or 0),
@@ -216,7 +486,13 @@ def _joined_entries(cache_conn, range_start, range_end, *,
216
486
  cost_usd=row[9],
217
487
  usage_extra=({"speed": row[10]} if row[10] else None),
218
488
  cache_1h_tokens=(None if row[11] is None else int(row[11])),
219
- ))
489
+ )
490
+ # #869: the retained source identity and account of the entry. The
491
+ # cost path never reads these; the historical-close proof fingerprints
492
+ # the exact population and requires every entry to be the close's.
493
+ entry.source_line_offset = row[12]
494
+ entry.source_account_key = row[13]
495
+ out.append(entry)
220
496
  return out
221
497
 
222
498
 
@@ -691,7 +967,8 @@ def _normalize_legacy_accounts(records: list[dict]) -> None:
691
967
 
692
968
  def _derive_desired_events(records: list[dict], cache_conn: sqlite3.Connection,
693
969
  scratch_dir: Path, *,
694
- held_weekly_observation_ids=frozenset()) -> list[dict]:
970
+ held_weekly_observation_ids=frozenset(),
971
+ block_sources: "dict | None" = None) -> list[dict]:
695
972
  # #386: this replays the whole ingest pipeline into a PRIVATE scratch index
696
973
  # and is reached from `db rederive`'s PREVIEW, which takes no locks by
697
974
  # design (its contract is zero persistent writes to the live family). The
@@ -705,12 +982,14 @@ def _derive_desired_events(records: list[dict], cache_conn: sqlite3.Connection,
705
982
  with _cctally_store.stats_write_scope("rederive-derive"):
706
983
  return _derive_desired_events_into(
707
984
  records, cache_conn, str(scratch_path),
708
- held_weekly_observation_ids=held_weekly_observation_ids)
985
+ held_weekly_observation_ids=held_weekly_observation_ids,
986
+ block_sources=block_sources)
709
987
 
710
988
 
711
989
  def _derive_desired_events_into(
712
990
  records, cache_conn, scratch_path, *,
713
991
  held_weekly_observation_ids=frozenset(),
992
+ block_sources: "dict | None" = None,
714
993
  ) -> list[dict]:
715
994
  raw_records = _rederivable_raw_records(records)
716
995
  held_ids = frozenset(held_weekly_observation_ids)
@@ -747,6 +1026,7 @@ def _derive_desired_events_into(
747
1026
  held_weekly_observation_ids=held_ids,
748
1027
  reviewed_weekly_basis_by_account=(
749
1028
  reviewed_weekly_basis_by_account),
1029
+ block_source_evidence=block_sources,
750
1030
  )
751
1031
  conn.execute("BEGIN IMMEDIATE")
752
1032
  try:
@@ -811,7 +1091,15 @@ def plan_claude_usage(
811
1091
  _lib_rederive.validate_claude_cache_contract(_cache_contract(cache_conn))
812
1092
  raw_records = _rederivable_raw_records(records)
813
1093
  _validate_cache_rows(cache_conn, raw_records)
814
- cache_fingerprint = _cache_fingerprint(cache_conn)
1094
+ codex_pricing_records = any(
1095
+ (record.get("payload") or {}).get("vendor") == "codex"
1096
+ and (record.get("payload") or {}).get("kind") == "budget"
1097
+ and (record.get("payload") or {}).get("_pricing")
1098
+ for record in records
1099
+ )
1100
+ cache_fingerprint = _cache_fingerprint(
1101
+ cache_conn, include_codex=codex_pricing_records,
1102
+ )
815
1103
  held_ids, _accepted_ids, identity_pairs, reviewed_ops = (
816
1104
  _reviewed_weekly_state(records)
817
1105
  )
@@ -855,11 +1143,21 @@ def plan_claude_usage(
855
1143
  raise _lib_rederive.RederiveConflict(
856
1144
  "journal contains tainted correction batch(es): " + summary
857
1145
  )
1146
+ # Only an unmarked close with a zero-cost model can need the historical
1147
+ # proof, so the scratch replay keeps retained entries for those blocks
1148
+ # alone rather than for every block in the journal.
1149
+ block_sources = dict.fromkeys(_historical_close_candidates(selection))
858
1150
  with tempfile.TemporaryDirectory(prefix="cctally-rederive-") as tmp:
859
1151
  desired = _derive_desired_events(
860
1152
  records, cache_conn, Path(tmp),
861
1153
  held_weekly_observation_ids=held_ids,
1154
+ block_sources=block_sources,
862
1155
  )
1156
+ historical_markers = _historical_close_markers(
1157
+ selection, desired, block_sources)
1158
+ codex_uncertain: set = set()
1159
+ desired.extend(_desired_codex_budget_costs(
1160
+ selection, cache_conn, uncertain=codex_uncertain))
863
1161
  # #426: the scratch replay only sees RETAINED sources, so it can never
864
1162
  # reproduce the pre-cutover rows the journal exported as `b:<table>:<rowid>`
865
1163
  # evt lines. Hold them out of the diff instead of retiring them. Evidence is
@@ -883,6 +1181,9 @@ def plan_claude_usage(
883
1181
  raw_observations=raw_records,
884
1182
  snapshot_identity_decisions=identity_pairs,
885
1183
  reviewed_weekly_hold_ids=held_ids,
1184
+ uncertain_cost_fact_ids=(
1185
+ _uncertain_cost_facts(selection) | codex_uncertain),
1186
+ historical_close_markers=historical_markers,
886
1187
  enforce_guard=enforce_guard,
887
1188
  )
888
1189
 
@@ -1885,6 +2186,8 @@ def _command_payload(
1885
2186
  }
1886
2187
  if reviewed_decision_id is not None:
1887
2188
  body["reviewedWeeklyDecisionId"] = reviewed_decision_id
2189
+ if plan is not None and plan.uncertain_cost_fact_count:
2190
+ body["uncertainCostFactCount"] = plan.uncertain_cost_fact_count
1888
2191
  if preview is not None and preview.baseline_plan is not None:
1889
2192
  body["baselinePlanHash"] = preview.baseline_plan.plan_hash
1890
2193
  body["baselineActionCounts"] = dict(preview.baseline_plan.counts)
@@ -204,21 +204,33 @@ def _codex_alias_inventory_sql(identity_count: int) -> str:
204
204
  )
205
205
 
206
206
 
207
+ def _sqlite_order_key(value: object) -> tuple:
208
+ """One scalar's SQLite default ascending storage-class order."""
209
+ if value is None:
210
+ return (0, 0)
211
+ if isinstance(value, (int, float)):
212
+ return (1, value)
213
+ if isinstance(value, str):
214
+ return (2, value)
215
+ if isinstance(value, (bytes, bytearray, memoryview)):
216
+ return (3, bytes(value))
217
+ raise TypeError(f"unsupported SQLite value type: {type(value).__name__}")
218
+
219
+
207
220
  def _alias_winner_sort_key(last_seen_utc: object, conversation_key: object) -> tuple:
208
221
  """Reproduce ``ORDER BY last_seen_utc DESC, conversation_key DESC``.
209
222
 
210
223
  The retired statement ordered its joined rows that way and the qualifier
211
224
  kept the FIRST row per ``(source_root_key, path)``, so the winner is the
212
- maximum under this key. SQLite sorts NULL FIRST ascending, hence LAST
213
- descending, so a NULL ``last_seen_utc`` loses to every stored value; the
214
- leading flag is what reproduces that, and ``max`` is what reproduces
215
- "first row wins".
225
+ maximum under this key. SQLite orders ``NULL < INTEGER/REAL < TEXT <
226
+ BLOB`` before comparing values inside a class. Both columns may carry any
227
+ storage class despite their declared affinity, so stringifying them changes
228
+ mixed-class winners. The pair below preserves ordinary TEXT ordering while
229
+ reproducing every storage-class boundary.
216
230
  """
217
- has_last_seen = 0 if last_seen_utc is None else 1
218
231
  return (
219
- has_last_seen,
220
- "" if last_seen_utc is None else str(last_seen_utc),
221
- str(conversation_key or ""),
232
+ _sqlite_order_key(last_seen_utc),
233
+ _sqlite_order_key(conversation_key),
222
234
  )
223
235
 
224
236