Browse Source

client: 修助手闹钟的「同一条两张卡」与「日期落在昨天」

两处都是真机实测踩到的(2026-09-19):

- 拾忆页重复卡片:双写迁移期同一条闹钟本地与服务端各有一份,投影层只信本地
  的 synced 标志,而那只是服务端事实的影子拷贝——上传成功后进程被杀、重装、
  清缓存都会把它打回 unsynced,于是凭空多一条(一张能编辑、点另一张说没同步
  到服务器)。改用服务端自己带的答案:client_key / source_id 存的就是指令 id,
  对上了就不再投影(pendingLocalReminders,两个键都认,历史数据里有只写一个的)。
  顺带补上 _uploadReminder 成功后的 reminders.refresh()——改的是 RxList 元素
  内部字段,不通知没人知道,syncPending() 那条补传路径本来就 refresh 了。
- 闹钟定到过去:模型下发的 date 偶尔早一天(会话 Start 没告诉百炼今天几号),
  客户端原样信了,闹钟落到昨天那一页、而且永远不会响。rollForwardPastDate 把
  过去的一次性闹钟挪到就近的未来。只管一次性的——重复闹钟的 date 是锚点,落在
  过去是正常的,挪了会把第一次触发推迟一周。治本要在百炼控制台注入当前时间。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 3 weeks ago
parent
commit
b61c4c91d7
  1. 83
      apps/client/lib/data/models/assistant_directive.dart
  2. 7
      apps/client/lib/data/services/assistant_directive_service.dart
  3. 18
      apps/client/lib/modules/home/controllers/calendar_controller.dart
  4. 139
      apps/client/test/assistant_directive_test.dart

83
apps/client/lib/data/models/assistant_directive.dart

@ -354,7 +354,12 @@ class AssistantReminder {
id: d.id,
content: (d.args['content'] ?? '').toString().trim(),
time: time,
date: _normalizeDate((d.args['date'] ?? '').toString()),
date: rollForwardPastDate(
_normalizeDate((d.args['date'] ?? '').toString()),
time,
d.createdAt,
parsed.$1,
),
repeat: parsed.$1,
repeatRaw: rawRepeat,
weekday: parsed.$2,
@ -364,6 +369,52 @@ class AssistantReminder {
);
}
/// 模型给的 `date` 落在过去时,挪到就近的未来。
///
/// 起因是真机实测(2026-09-19 09:25 定「下午2点去恒玄公司」,服务端 id=35):
/// 模型下发的 `date` 是 **2026-09-18**,比当天早一天。客户端原样信了,于是
/// 闹钟落在昨天那一页 —— 助手说"设好了",拾忆页翻到今天却空空如也,
/// 而且**永远不会响**(服务端 remind 只排未来的,那条的 next_remind_at=0)。
///
/// 根因在模型侧:会话 Start 里没有告诉百炼"今天是几号"(见
/// BailianMultimodalService 的 parameters),模型只能自己猜,偶尔差一天
/// (同一张表里 9-14 那条也差了一天,9-15 的两条又是对的)。
/// 正解是在百炼应用里注入当前时间,但那是控制台配置、不在本仓库;
/// 在那之前这道防线兜住最难受的表现。
///
/// 口径:**过去的一次性闹钟没有意义**,所以
/// - 今天这个点还没到 → 挪到今天;
/// - 已经过了 → 挪到明天。
///
/// ⚠️ 只管一次性的。重复闹钟的 `date` 是锚点,落在过去是正常的
/// ("每周一"从上周一起算),挪了反而会把第一次触发推迟一周。
///
/// ⚠️ 只在**确实早于今天**时才动。模型把"明天"算成今天这类偏差
/// 客户端分辨不了(时间仍在未来),不猜。
static String? rollForwardPastDate(
String? date,
String time,
DateTime now,
RepeatRule repeat,
) {
if (date == null) return null;
if (repeat != RepeatRule.once && repeat != RepeatRule.unknown) return date;
final parsed = DateTime.tryParse(date);
if (parsed == null) return date;
final today = DateTime(now.year, now.month, now.day);
if (!parsed.isBefore(today)) return date;
final parts = time.split(':');
final at = DateTime(now.year, now.month, now.day,
int.parse(parts[0]), int.parse(parts[1]));
final target = at.isAfter(now) ? today : today.add(const Duration(days: 1));
return '${target.year.toString().padLeft(4, '0')}-'
'${target.month.toString().padLeft(2, '0')}-'
'${target.day.toString().padLeft(2, '0')}';
}
/// 把 12 小时制的歧义时间按回复文案校正到 24 小时制。
///
/// 起因是实测拿到的这一帧:用户说 "3:30pm",模型回 "已经帮你设定了明天下午
@ -480,6 +531,36 @@ class AssistantReminder {
}
/// 本地提醒里**还需要投影到拾忆页**的那些:服务端已经有的排除掉。
///
/// 双写迁移期,同一条闹钟可能同时存在于两处——本地 reminders 与服务端
/// memory_item。页面两边都投影就会出现「同一个闹钟两张卡,一张能编辑、
/// 点另一张说没同步到服务器」(2026-09-19 真机实测,服务端 id=36/37 各只有
/// 一行,重复纯在客户端)。
///
/// ⚠️ **不能只信本地的 `synced` 标志**。它是服务端事实的一份影子拷贝,
/// 丢了就会凭空多一条:上传成功后进程被杀(标志还没落盘)、重装、清缓存
/// 都会把一条服务端已有的闹钟打回 unsynced。服务端那份数据自己就带着答案——
/// `client_key` / `source_id` 存的正是指令 id(见 [AssistantReminderToMemory]),
/// 对上了就说明已经在服务端了。两个键都认:历史数据里有只写了其中一个的。
List<AssistantReminder> pendingLocalReminders(
Iterable<AssistantReminder> reminders,
Iterable<MemoryItem> serverItems,
) {
final uploaded = <String>{};
for (final it in serverItems) {
if (it.clientKey.isNotEmpty) uploaded.add(it.clientKey);
if (it.sourceId.isNotEmpty) uploaded.add(it.sourceId);
}
return [
for (final r in reminders)
if (!r.synced &&
!(r.directiveId.isNotEmpty && uploaded.contains(r.directiveId)) &&
!(r.id.isNotEmpty && uploaded.contains(r.id)))
r,
];
}
/// 提醒 → 记忆项的换算。
///
/// 收敛在这里而不是各写一份:上传服务端(AssistantDirectiveService._uploadReminder)

7
apps/client/lib/data/services/assistant_directive_service.dart

@ -232,6 +232,13 @@ class AssistantDirectiveService extends GetxService {
if (saved != null) {
r.synced = true;
_persist();
// ⚠️ 必须 refresh:改的是 RxList **元素内部**的字段,列表本身没变,
// 不通知就没人知道。拾忆页的 ever(reminders) 因此不会触发,而
// ever(items) 那一次在 add() 返回前就已经跑完了(那时 synced 还是
// false)——于是服务端那条已经上屏、本地这条也还在,同一个闹钟两张卡,
// 一直挂到下次因为别的原因重投影为止(2026-09-19 真机实测)。
// syncPending() 那条补传路径本来就 refresh 了,这里是漏的。
reminders.refresh();
}
} catch (e) {
// 上传失败不影响本地闹钟——迁移期本地那份仍然是可用的

18
apps/client/lib/modules/home/controllers/calendar_controller.dart

@ -162,11 +162,25 @@ class CalendarController extends GetxController
/// 助手答应了、指令也落地了,拾忆页却空空如也(真机实测踩到)。
///
/// 已同步的跳过:那时 MemoryService 里的才是权威,两边都投影会出现两条。
///
/// ⚠️ **判「已同步」不能只信本地那个 bool**。它是服务端事实的一份影子拷贝,
/// 丢了就会凭空多出一条:上传成功后进程被杀(flag 还没落盘)、重装、清缓存,
/// 都会让一条服务端已有的闹钟在本地重新变回 unsynced。真机实测就是这样出现
/// 「同一条闹钟两张卡,一张能编辑、点另一张说没同步到服务器」
/// (2026-09-19,服务端 memory_item id=36/37 各只有一行,重复纯在客户端投影层)。
///
/// 权威判据是服务端那份数据自己说的:`client_key` / `source_id` 存的就是
/// 指令 id(见 AssistantReminderToMemory.toMemoryItem),对上了就说明
/// 这条已经在服务端了,本地那份不该再投影。
List<CalendarEvent> _localReminderEvents() {
if (!Get.isRegistered<AssistantDirectiveService>()) return const [];
final serverItems = Get.isRegistered<MemoryService>()
? MemoryService.to.items
: const <MemoryItem>[];
final out = <CalendarEvent>[];
for (final r in AssistantDirectiveService.to.reminders) {
if (r.synced) continue;
// 判据收敛在 pendingLocalReminders(纯函数、有测试),别在这里再写一份
for (final r in pendingLocalReminders(
AssistantDirectiveService.to.reminders, serverItems)) {
// id 前缀区分来源:本地项的 memoryId 是 0,点开编辑时要能识别出
// 「这条还没上服务端」而不是去 MemoryService 里查一个不存在的 id。
out.addAll(_expandItem(r.toMemoryItem(), idPrefix: 'local:${r.id}:'));

139
apps/client/test/assistant_directive_test.dart

@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:eaimar/data/models/assistant_directive.dart';
import 'package:eaimar/data/models/memory_item.dart';
import 'package:flutter_test/flutter_test.dart';
/// 用例里的 payload 全部是 2026-08-29 从真机上抓的原始帧(见 scratchpad 的
@ -28,7 +29,15 @@ void main() {
final r = AssistantReminder.fromSetClock(ds.first)!;
expect(r.time, '17:00');
expect(r.date, '2026-08-29');
// ⚠️ 这里**不能**断言 '2026-08-29':帧是那天抓的,重放时早已是过去,
// 会被 rollForwardPastDate 挪到就近的未来(见下面那个 group)。
// 原始值仍由上面的 args['date'] 守着。
final today = DateTime.now();
expect(
DateTime.parse(r.date!).isBefore(DateTime(today.year, today.month, today.day)),
isFalse,
reason: '一次性闹钟不该落在过去',
);
expect(r.repeat, RepeatRule.once);
expect(r.content, isEmpty); // 这一轮模型没给 content
});
@ -98,6 +107,125 @@ void main() {
});
});
/// 真机实测(2026-09-19 09:25 说「下午2点去恒玄公司」):模型下发的 date 是
/// 2026-09-18,比当天早一天,闹钟落到昨天那页、且永远不会响。
/// 见 AssistantReminder.rollForwardPastDate 的注释。
group('模型给了过去的日期', () {
// 2026-09-19 09:25,正是那次真机复现的时刻
final now = DateTime(2026, 9, 19, 9, 25);
test('时间点还没到 → 挪到今天', () {
expect(
AssistantReminder.rollForwardPastDate(
'2026-09-18', '14:00', now, RepeatRule.once),
'2026-09-19',
);
});
test('时间点已经过了 → 挪到明天', () {
expect(
AssistantReminder.rollForwardPastDate(
'2026-09-18', '08:00', now, RepeatRule.once),
'2026-09-20',
);
});
test('今天或以后的日期原样不动', () {
for (final d in ['2026-09-19', '2026-09-20', '2026-12-31']) {
expect(
AssistantReminder.rollForwardPastDate(d, '08:00', now, RepeatRule.once),
d,
reason: '$d 不在过去,不该被改',
);
}
});
test('重复闹钟的锚点即使在过去也不动', () {
// 「每周一」的锚点本来就可能是上周一,挪了会把第一次触发推迟一周
for (final rule in [
RepeatRule.daily,
RepeatRule.weekly,
RepeatRule.weekdays,
RepeatRule.weekend,
]) {
expect(
AssistantReminder.rollForwardPastDate('2026-09-01', '08:00', now, rule),
'2026-09-01',
);
}
});
test('没有日期就还是没有日期', () {
expect(
AssistantReminder.rollForwardPastDate(null, '08:00', now, RepeatRule.once),
isNull,
);
});
});
/// 拾忆页的「本地兜底」投影要不要出这一条。
///
/// 真机实测(2026-09-19):同一个闹钟出现两张卡,一张能编辑(服务端那份),
/// 点另一张提示「没有同步到服务器」(本地那份)。服务端 memory_item
/// id=36/37 各只有一行,重复纯在客户端投影层。
group('本地提醒去重(pendingLocalReminders)', () {
AssistantReminder mk(String id, {bool synced = false}) => AssistantReminder(
id: id,
content: '送员工到恒玄公司',
time: '17:00',
date: '2026-09-19',
repeat: RepeatRule.once,
repeatRaw: '',
weekday: null,
createdAt: DateTime(2026, 9, 19, 9, 50),
directiveId: id,
roundId: 'r',
synced: synced,
);
// 真机那条:client_key 就是指令 id
MemoryItem server(String key, {bool byClientKey = true}) => MemoryItem(
id: 37,
title: '送员工到恒玄公司',
happenDate: '2026-09-19',
happenTime: '17:00',
clientKey: byClientKey ? key : '',
sourceId: byClientKey ? '' : key,
);
const k = '79a34c5913ad4fddba0c91e8bcb031b6#0';
test('服务端已有(client_key 对上)→ 本地那份不投影', () {
expect(pendingLocalReminders([mk(k)], [server(k)]), isEmpty);
});
test('只写了 source_id 的历史数据也认', () {
expect(
pendingLocalReminders([mk(k)], [server(k, byClientKey: false)]),
isEmpty,
);
});
test('⚠️ synced 标志丢了也不该重复 —— 这正是真机上出现两张卡的原因', () {
// 上传成功后进程被杀 / 重装 / 清缓存,本地 synced 会退回 false,
// 但服务端那行还在。只看 synced 就会又投影一条出来。
final r = mk(k, synced: false);
expect(pendingLocalReminders([r], [server(k)]), isEmpty);
});
test('服务端确实没有 → 必须投影,否则本地闹钟没有任何展示出口', () {
expect(pendingLocalReminders([mk(k)], const []), hasLength(1));
expect(
pendingLocalReminders([mk(k)], [server('别的指令#0')]),
hasLength(1),
);
});
test('本地已标记 synced 的不投影', () {
expect(pendingLocalReminders([mk(k, synced: true)], const []), isEmpty);
});
});
group('中文重复规则', () {
test('每天 / 每日', () {
expect(AssistantReminder.parseRepeat('每天').$1, RepeatRule.daily);
@ -157,7 +285,14 @@ void main() {
AssistantDirective.parseFromOutput(output, source: 'emai').single)!;
expect(r.time, '15:30');
expect(r.content, '项目 review');
expect(r.date, '2026-08-30');
// 同上:帧里的 2026-08-30 重放时已是过去,会被挪到就近的未来。
// 这条用例守的是 15:30 那个校正,日期由专门的 group 守。
final today = DateTime.now();
expect(
DateTime.parse(r.date!)
.isBefore(DateTime(today.year, today.month, today.day)),
isFalse,
);
});
});

Loading…
Cancel
Save