diff --git a/.env.example b/.env.example
index 783bb03..3fe20be 100644
--- a/.env.example
+++ b/.env.example
@@ -6,3 +6,6 @@ POLL_TIMEOUT_SECONDS=25
SCHEDULER_INTERVAL_SECONDS=10
DRAFT_TTL_MINUTES=10
LOG_LEVEL=INFO
+# Optional: generate with `uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`
+# Keep this stable and backed up separately; changing it makes saved API keys unreadable.
+AI_ENCRYPTION_KEY=
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..4152a4c
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,43 @@
+# Remindly 開發規範
+
+遵循上層工作區規範;以下為本專案 AI 擴充的約束。
+
+## AI 產品邊界
+
+- 不區分免費/付費用戶,AI 為選用;未提供 Key 或關閉 AI 時使用內建規則解析。
+- 平台不提供共用 Key,也不代付費用;只使用請求者的個人 Key,設定僅限私聊。
+- AI 啟用後,一般私聊文字、群組觸發訊息及必要上下文會送至供應商;說明不得宣稱只傳已辨識的提醒。
+- 建立提醒仍須使用者確認;確認卡從草稿解析紀錄顯示「使用 … 解析」,不依當下開關推測來源。
+
+## 擴充 AI 供應商
+
+目前正式啟用 Gemini。供應商透過 `ai/contracts.py` 的 `AiProvider` Protocol 接入,
+不繼承 Gemini 實作。新增時只需實作 adapter 並在 `app.py` 註冊:
+
+1. 提供 `id`、`name`、`model`,實作 `generate(key, system, prompt, schema, *, timeout)`。
+ 另實作 `model_display_name(key, *, timeout)`,由供應商 metadata 取得可讀名稱並快取;
+ 查詢失敗回傳 `None`,不得中斷已成功的解析。沒有查詢介面時也回傳 `None`。
+ API 模型 ID 僅供請求與內部紀錄,不用作使用者標籤;renderer 不維護模型名稱對照表。
+ 名稱沿 `GeneratedResponse.model_display_name` 存入解析紀錄;缺少名稱時顯示「使用 AI 解析」。
+2. 接收共用 JSON Schema(小寫 `type`、含 `null` 的型別陣列),自行轉成供應商 API 格式。
+ 成功回傳解析後的 JSON object;上游錯誤轉成固定的 `ProviderError` 類別,禁止帶入
+ Key、請求 URL 或上游錯誤原文。
+3. 在 composition root 的供應商 mapping 加入新實例。之後 `/ai add <供應商ID>
-
+
@@ -28,9 +28,11 @@
- 🔎 **自然語言建立提醒**:支援「明天下午三點提醒我倒垃圾」這類中文輸入,拆出提醒時間、提醒事項與參與者。
-- 🔁 **週期性提醒**:`每天 09:00`、`每週一 09:00`、`每個月 1, 18, 25 號 09:00`、`每年 12/25 08:00`、`每 15 分鐘 / 每 2 小時` 都能一句話建立,到期後自動排下一次。無效語法(`每個月 45 號 / 每 1 分鐘`)會單輪拒絕並具體說明原因。
+- 🧠 **自帶 Gemini 語意解析**:個人可設定多組 API Key,以 Gemini 3.5 Flash-Lite 理解口語、不同日期及草稿修正;不明確時追問,仍需確認才建立。未設定或關閉 AI 時使用原本規則解析。
-- 💬 **缺漏資訊追問**:時間不足時主動追問,並提供 09:00、12:00、15:00、18:00 快捷按鈕。
+- 🔁 **週期性提醒**:`每天 09:00`、`每週一 09:00`、`每個月 1, 18, 25 號 09:00`、`每年 12/25 08:00`、`每 15 分鐘 / 每 2 小時` 都能一句話建立,到期後自動排下一次。內建解析遇到無效語法會直接拒絕;AI 解析遇到缺漏或歧義則追問。
+
+- 💬 **缺漏資訊追問**:缺少資訊時主動追問;內建解析在日期明確但缺少時間時,提供 09:00、12:00、15:00、18:00 快捷按鈕。
- 🛠️ **互動式提醒管理**:`/list` 依建立者分組,並支援今天、本週、我的、全部篩選。週期性提醒會以 `[重複]` 前綴標示。
@@ -57,7 +59,7 @@ Telegram Update
-> BotRouter
-> CommandHandlers / CallbackHandlers
-> ReminderService
- -> ReminderParser / DraftStore / ReminderRepository
+ -> ReminderParser / SemanticParser / DraftStore / ReminderRepository
-> SQLite
-> ReminderScheduler
-> TelegramClient
@@ -76,6 +78,40 @@ Telegram Update
| `/cancel R-8F3K` | 取消提醒。 |
| `/timezone Asia/Taipei` | 設定時區。 |
| `/groupmode on\|off\|status` | 查看或切換群組自然語言模式。 |
+| `/ai` | 私聊管理個人 Gemini Key、開關及備援順序。 |
+
+### 個人 AI 設定(選用)
+
+**不設定也能用。** 所有使用者功能相同,不分免費/付費;預設使用內建規則解析。
+想用 AI 理解口語提醒,可自備 Gemini API Key,平台不提供 Key。
+
+**開啟:** 與 Bot 私聊,輸入:
+
+```text
+/ai add <你的 Gemini API Key>
+```
+
+首次新增後自動開啟;若之前手動關閉過,請再輸入 `/ai on`。
+接著直接說「明晚八點叫我拿包裹」,確認後才會建立提醒。
+確認卡會標示「使用 … 解析」,方便核對本次使用的模型或內建規則。
+
+| 想做什麼 | 指令 |
+| --- | --- |
+| 查看狀態與 Key ID | `/ai` |
+| 關閉/重新開啟 AI | `/ai off` / `/ai on` |
+| 新增備援 Key | `/ai add <另一組 Key>` |
+| 將某組 Key 優先使用 | `/ai move 使用 {html_escape(parser_name)} 解析"])
return "\n".join(lines)
def render_created(self, reminder: Reminder) -> str:
diff --git a/src/remindly/reminders/repositories.py b/src/remindly/reminders/repositories.py
index 202c46f..3bf4c81 100644
--- a/src/remindly/reminders/repositories.py
+++ b/src/remindly/reminders/repositories.py
@@ -68,7 +68,7 @@ def get_user_display_name(self, user_id: int) -> str | None:
"""取得建立者顯示名稱,用於 /list 依建立者分組。"""
...
- def create_reminder(self, reminder: Reminder, participants: list[Participant]) -> None:
+ def create_reminder(self, reminder: Reminder, participants: list[Participant]) -> bool:
"""建立正式提醒與提醒對象。"""
...
diff --git a/src/remindly/reminders/service.py b/src/remindly/reminders/service.py
index 5478e66..37796d9 100644
--- a/src/remindly/reminders/service.py
+++ b/src/remindly/reminders/service.py
@@ -2,10 +2,11 @@
import secrets
from dataclasses import dataclass
-from datetime import datetime, timedelta
+from datetime import UTC, datetime, timedelta
from enum import StrEnum
from zoneinfo import ZoneInfo
+from remindly.ai.parser import SemanticParsing, followup_question
from remindly.reminders.drafts import (
EditSession,
ReminderDraftStore,
@@ -19,7 +20,7 @@
ReminderDraft,
ReminderStatus,
)
-from remindly.reminders.parser import ReminderParser
+from remindly.reminders.parser import ReminderParser, dedupe_participants
from remindly.reminders.recurrence import next_fire
from remindly.reminders.repositories import ReminderRepository
from remindly.telegram.models import (
@@ -95,6 +96,7 @@ class SnoozeResult:
@dataclass(frozen=True)
class ExpiredPrompt:
"""Sweep 掃出來的過期互動訊息,交給 scheduler 用 editMessageText 標記。"""
+
chat_id: int
message_id: int
text: str
@@ -120,12 +122,45 @@ def __init__(
draft_store: ReminderDraftStore,
edit_store: ReminderEditSessionStore,
default_timezone: str,
+ ai_parser: SemanticParsing | None = None,
) -> None:
self._repository = repository
self._parser = parser
self._draft_store = draft_store
self._edit_store = edit_store
self._default_timezone = default_timezone
+ self.ai_parser = ai_parser
+
+ def ai_enabled(self, user_id: int) -> bool:
+ return self.ai_parser is not None and self.ai_parser.enabled(user_id)
+
+ def parse_ai(
+ self,
+ text: str,
+ message: TelegramMessage,
+ now: datetime,
+ *,
+ context: ReminderDraft | None = None,
+ purpose: str = "reminder",
+ ) -> ParseResult:
+ creator = require_user(message)
+ timezone = (
+ context.timezone
+ if context
+ else self._repository.get_user_timezone(creator.id, self._default_timezone)
+ )
+ candidates = self._parser.extract_participants("提醒我 " + text, message)
+ if context:
+ candidates = dedupe_participants(candidates + context.participants)
+ result = self.ai_parser.parse(
+ text,
+ creator.id,
+ now.astimezone(ZoneInfo(timezone)),
+ candidates,
+ context=context,
+ purpose=purpose,
+ )
+ return result
def record_message_context(self, message: TelegramMessage, now: datetime) -> None:
self._repository.upsert_chat(
@@ -178,6 +213,8 @@ def preview_parse(
`begin_create` 也可以透過 `parse_result` kwarg 重用此結果避免重複解析。"""
creator = require_user(message)
timezone = self._repository.get_user_timezone(creator.id, self._default_timezone)
+ if self.ai_enabled(creator.id):
+ return self.parse_ai(text, message, now)
return self._parser.parse(text, message, now=now.astimezone(ZoneInfo(timezone)))
def begin_create(
@@ -191,11 +228,11 @@ def begin_create(
creator = require_user(message)
timezone = self._repository.get_user_timezone(creator.id, self._default_timezone)
if parse_result is None:
- parse_result = self._parser.parse(
- text, message, now=now.astimezone(ZoneInfo(timezone))
- )
+ parse_result = self.preview_parse(text, message, now)
if parse_result.recurrence_error is not None:
return RecurrenceRejected(error=parse_result.recurrence_error)
+ if parse_result.raw.get("grain") == "ai":
+ timezone = str(parse_result.raw["timezone"])
draft = ReminderDraft(
id=new_id("draft"),
chat_id=message.chat.id,
@@ -210,6 +247,9 @@ def begin_create(
parse_result=parse_result.raw,
recurrence=parse_result.recurrence,
)
+ if parse_result.raw.get("intent") == "not_reminder":
+ draft.missing_fields = ["title", "time"]
+ draft.parse_result["question"] = "請描述要建立的提醒,例如明天 15:00 叫我拿包裹。"
if draft.is_complete:
self._draft_store.save(draft, now)
@@ -219,7 +259,11 @@ def begin_create(
return DraftPrompt(
draft=draft,
question=self._question_for(draft),
- wants_quick_time=draft.missing_fields == ["time"] and draft.remind_at is not None,
+ wants_quick_time=(
+ draft.missing_fields == ["time"]
+ and draft.remind_at is not None
+ and draft.parse_result.get("grain") != "ai"
+ ),
)
def continue_draft(
@@ -255,11 +299,14 @@ def apply_quick_time(
if not draft or draft.creator_user_id != user_id or draft.remind_at is None:
return None
- hour, minute = [int(part) for part in hour_minute.split(":", 1)]
- remind_at = draft.remind_at.replace(hour=hour, minute=minute, second=0, microsecond=0)
- draft.remind_at = remind_at
+ parsed = self._parser.parse_time_only(
+ hour_minute, now.astimezone(ZoneInfo(draft.timezone)), existing_date=draft.remind_at
+ )
+ if parsed.remind_at:
+ draft.remind_at = parsed.remind_at
+ draft.parse_result["grain"] = parsed.grain
draft.missing_fields = [field for field in draft.missing_fields if field != "time"]
- if remind_at <= now.astimezone(remind_at.tzinfo):
+ if not parsed.remind_at or parsed.missing_time or parsed.is_past:
draft.missing_fields.append("time")
self._draft_store.save(draft, now)
@@ -271,6 +318,10 @@ def confirm(self, draft_id: str, user_id: int, now: datetime) -> CreateResult |
draft = self._draft_store.get_by_id(draft_id, now)
if not draft or draft.creator_user_id != user_id or not draft.is_complete:
return None
+ if require_datetime(draft.remind_at).astimezone(UTC) <= now.astimezone(UTC):
+ draft.missing_fields = ["time"]
+ self._draft_store.save(draft, now)
+ return None
reminder = Reminder(
id=new_id("rmd"),
@@ -288,8 +339,8 @@ def confirm(self, draft_id: str, user_id: int, now: datetime) -> CreateResult |
updated_at=now,
recurrence=draft.recurrence,
)
- self._repository.create_reminder(reminder, draft.participants)
- self._draft_store.delete(draft_id)
+ if not self._draft_store.confirm(draft, reminder):
+ return None
return CreateResult(reminder)
def discard_draft(self, draft_id: str, user_id: int, now: datetime) -> bool:
@@ -299,25 +350,19 @@ def discard_draft(self, draft_id: str, user_id: int, now: datetime) -> bool:
self._draft_store.delete(draft_id)
return True
- def peek_draft(
- self, chat_id: int, user_id: int, now: datetime
- ) -> ReminderDraft | None:
+ def peek_draft(self, chat_id: int, user_id: int, now: datetime) -> ReminderDraft | None:
"""純讀當前未完成 draft,不做任何合併或狀態改變。"""
return self._draft_store.get_for_context(chat_id, user_id, now)
- def peek_edit_session(
- self, chat_id: int, user_id: int, now: datetime
- ) -> EditSession | None:
+ def peek_edit_session(self, chat_id: int, user_id: int, now: datetime) -> EditSession | None:
"""純讀當前未完成 edit session,不做狀態改變。"""
return self._edit_store.get_for_context(chat_id, user_id, now)
- def clear_pending_conversation(
- self, chat_id: int, user_id: int
+ def discard_conversation(
+ self, draft: ReminderDraft | None, existing_edit: EditSession | None
) -> list[ExpiredPrompt]:
- """建立新提醒前呼叫:清掉舊 draft/edit session。
- 回傳每個仍需 editMessage 標記的舊 prompt(含 prompt_message_id 的才會回傳)。"""
+ """清理指定的舊對話,允許先保存替代草稿再清理舊按鈕。"""
cancelled: list[ExpiredPrompt] = []
- draft = self._draft_store.get_for_context(chat_id, user_id, self._min_datetime())
if draft:
if draft.prompt_message_id is not None:
cancelled.append(
@@ -328,8 +373,6 @@ def clear_pending_conversation(
)
)
self._draft_store.delete(draft.id)
- # peek EditSession 是否存在 — 用 min 時間避開過期檢查
- existing_edit = self._edit_store.get_for_context(chat_id, user_id, self._min_datetime())
if existing_edit:
if existing_edit.prompt_message_id is not None:
cancelled.append(
@@ -339,16 +382,14 @@ def clear_pending_conversation(
text=CANCELLED_BY_NEW_EDIT_TEXT,
)
)
- self._edit_store.delete(chat_id, user_id)
+ self._edit_store.delete(existing_edit.chat_id, existing_edit.user_id)
return cancelled
def set_draft_prompt_message_id(self, draft_id: str, message_id: int) -> None:
"""在 Telegram 送出追問/確認訊息後綁定 message_id,供 sweep 時 editMessage 用。"""
self._draft_store.set_prompt_message_id(draft_id, message_id)
- def set_edit_prompt_message_id(
- self, chat_id: int, user_id: int, message_id: int
- ) -> None:
+ def set_edit_prompt_message_id(self, chat_id: int, user_id: int, message_id: int) -> None:
self._edit_store.set_prompt_message_id(chat_id, user_id, message_id)
def sweep_expired_prompts(self, now: datetime) -> list[ExpiredPrompt]:
@@ -377,10 +418,6 @@ def sweep_expired_prompts(self, now: datetime) -> list[ExpiredPrompt]:
self._edit_store.delete(session.chat_id, session.user_id)
return expired
- def _min_datetime(self) -> datetime:
- """給 store 用的極小 datetime,避免因 TTL 過期而讀不到 pending 資料。"""
- return datetime.min.replace(tzinfo=ZoneInfo(self._default_timezone))
-
def get_details(self, chat_id: int, short_id: str) -> ReminderDetails | None:
reminder = self._repository.get_by_short_id(chat_id, short_id)
if not reminder or reminder.status != ReminderStatus.PENDING:
@@ -483,6 +520,49 @@ def continue_edit(
return self._edit_result(updated, "已更新提醒內容。")
if session.field == "time":
+ if self.ai_enabled(actor.id):
+ context = ReminderDraft(
+ id=reminder.id,
+ chat_id=reminder.chat_id,
+ chat_type=reminder.chat_type,
+ creator_user_id=actor.id,
+ timezone=reminder.timezone,
+ source_text=reminder.source_text,
+ title=reminder.title,
+ remind_at=reminder.remind_at,
+ recurrence=reminder.recurrence,
+ participants=self._repository.list_participants(reminder.id),
+ )
+ if session.ai_context is not None:
+ pending = session.ai_context
+ context.remind_at = (
+ datetime.fromisoformat(str(pending["remind_at"]))
+ if pending["remind_at"]
+ else None
+ )
+ context.missing_fields = list(pending["missing_fields"])
+ context.parse_result = {
+ "question": pending["question"],
+ "condition_question": pending.get("condition_question"),
+ }
+ parsed = self.parse_ai(
+ message.text, message, now, context=context, purpose="edit_time"
+ )
+ if parsed.missing_fields or parsed.raw.get("intent") == "not_reminder":
+ if parsed.raw.get("intent") != "not_reminder":
+ session.ai_context = {
+ "remind_at": parsed.remind_at.isoformat() if parsed.remind_at else None,
+ "missing_fields": list(parsed.missing_fields),
+ "question": parsed.raw.get("question"),
+ "condition_question": parsed.raw.get("condition_question"),
+ }
+ self._edit_store.save(session, now)
+ return EditPrompt(reminder, followup_question(parsed.missing_fields))
+ updated = self._repository.update_remind_at(
+ message.chat.id, session.short_id, actor.id, parsed.remind_at, now
+ )
+ self._edit_store.delete(message.chat.id, actor.id)
+ return self._edit_result(updated, "已更新下次提醒時間。")
parsed_time = self._parser.parse_time_only(
message.text,
now.astimezone(ZoneInfo(reminder.timezone)),
@@ -638,7 +718,8 @@ def _merge_answer(
message: TelegramMessage,
now: datetime,
) -> None:
- missing = list(draft.missing_fields)
+ missing = [field for field in draft.missing_fields if field != "clarification"]
+ draft.parse_result.pop("question", None)
parsed_answer = self._parser.parse(
answer,
message,
@@ -651,6 +732,9 @@ def _merge_answer(
now.astimezone(ZoneInfo(draft.timezone)),
existing_date=draft.remind_at,
)
+ draft.parse_result["grain"] = parsed_time.grain
+ if parsed_time.remind_at and parsed_time.missing_time:
+ draft.remind_at = parsed_time.remind_at
if parsed_time.remind_at and not parsed_time.missing_time and not parsed_time.is_past:
draft.remind_at = parsed_time.remind_at
missing.remove("time")
@@ -668,7 +752,13 @@ def _merge_answer(
draft.missing_fields = missing
def _question_for(self, draft: ReminderDraft) -> str:
+ if draft.parse_result.get("grain") == "ai" or draft.parse_result.get("provider"):
+ return followup_question(draft.missing_fields)
+ if draft.parse_result.get("question"):
+ return str(draft.parse_result["question"])
if "time" in draft.missing_fields:
+ if draft.parse_result.get("grain") == "ambiguous":
+ return "時間有歧義或遇到日光節約時間切換,請指定單一日期與24小時制時間。"
return "什麼時候提醒?" if draft.remind_at is None else "那天幾點?"
if "title" in draft.missing_fields:
return "要提醒什麼?"
diff --git a/src/remindly/settings.py b/src/remindly/settings.py
index 1559a68..7589e89 100644
--- a/src/remindly/settings.py
+++ b/src/remindly/settings.py
@@ -1,7 +1,7 @@
from __future__ import annotations
import os
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from pathlib import Path
from zoneinfo import ZoneInfo
@@ -17,6 +17,7 @@ class Settings:
draft_ttl_minutes: int
confirming_ttl_minutes: int
log_level: str
+ ai_encryption_key: str = field(default="", repr=False)
@property
def default_zone(self) -> ZoneInfo:
@@ -55,4 +56,5 @@ def load_settings() -> Settings:
draft_ttl_minutes=int(os.getenv("DRAFT_TTL_MINUTES", "10")),
confirming_ttl_minutes=int(os.getenv("CONFIRMING_TTL_MINUTES", "15")),
log_level=os.getenv("LOG_LEVEL", "INFO"),
+ ai_encryption_key=os.getenv("AI_ENCRYPTION_KEY", "").strip(),
)
diff --git a/src/remindly/storage/migrations.py b/src/remindly/storage/migrations.py
index 7c4502a..061ed79 100644
--- a/src/remindly/storage/migrations.py
+++ b/src/remindly/storage/migrations.py
@@ -136,6 +136,44 @@ class Migration:
Migration(version=3, name="prompt_message_id", sql=PROMPT_MESSAGE_ID_SQL),
Migration(version=4, name="recurrence", sql=RECURRENCE_SQL),
Migration(version=5, name="draft_recurrence", sql=DRAFT_RECURRENCE_SQL),
+ Migration(
+ version=6,
+ name="personal_ai_keys",
+ sql="""
+ create table ai_settings (
+ user_id integer primary key,
+ enabled integer not null default 1
+ );
+ create table ai_keys (
+ id text primary key,
+ user_id integer not null,
+ encrypted_key blob not null,
+ fingerprint text not null,
+ position integer not null,
+ status text not null default 'ready',
+ cooldown_until text,
+ unique(user_id, fingerprint)
+ );
+ create index idx_ai_keys_user on ai_keys(user_id, position);
+ """,
+ ),
+ Migration(
+ version=7,
+ name="ai_key_providers",
+ sql="""
+ alter table ai_keys add column provider text not null default 'gemini';
+ """,
+ ),
+ Migration(
+ version=8,
+ name="ai_edit_context",
+ sql="alter table edit_sessions add column ai_context text;",
+ ),
+ Migration(
+ version=9,
+ name="ai_key_last_error",
+ sql="alter table ai_keys add column last_error text;",
+ ),
)
@@ -169,15 +207,28 @@ def load_applied_versions(connection: sqlite3.Connection) -> set[int]:
def apply_migration(connection: sqlite3.Connection, migration: Migration) -> None:
- """執行單一 migration,成功後寫入 ledger。"""
- connection.executescript(migration.sql)
- connection.execute(
- """
- insert into schema_migrations (version, name, applied_at)
- values (?, ?, ?)
- """,
- (migration.version, migration.name, datetime.now().astimezone().isoformat()),
- )
+ """Schema 與 ledger 同時成功或回滾,且不隱式提交呼叫端的交易。"""
+ connection.execute("savepoint schema_migration")
+ try:
+ statement = ""
+ # executescript 會先 commit,改用 SQLite 自己判斷 statement 邊界。
+ for char in migration.sql:
+ statement += char
+ if char == ";" and sqlite3.complete_statement(statement):
+ connection.execute(statement)
+ statement = ""
+ if statement.strip():
+ connection.execute(statement)
+ connection.execute(
+ """insert into schema_migrations (version, name, applied_at)
+ values (?, ?, ?)""",
+ (migration.version, migration.name, datetime.now().astimezone().isoformat()),
+ )
+ except Exception:
+ connection.execute("rollback to schema_migration")
+ raise
+ finally:
+ connection.execute("release schema_migration")
def latest_schema_version() -> int:
diff --git a/src/remindly/storage/session_stores.py b/src/remindly/storage/session_stores.py
index 051616b..0e355b7 100644
--- a/src/remindly/storage/session_stores.py
+++ b/src/remindly/storage/session_stores.py
@@ -4,7 +4,7 @@
from datetime import datetime, timedelta
from remindly.reminders.drafts import EditSession
-from remindly.reminders.models import MentionKind, Participant, ReminderDraft
+from remindly.reminders.models import MentionKind, Participant, Reminder, ReminderDraft
from remindly.reminders.recurrence import deserialize_rule, serialize_rule
from remindly.storage.sqlite import ReminderRepository
@@ -24,11 +24,14 @@ def __init__(
self._repository = repository
def save(self, draft: ReminderDraft, now: datetime) -> ReminderDraft:
- self.delete_for_context(draft.chat_id, draft.creator_user_id)
# 每次 save 都以當下時間重算 TTL,讓活躍對話不會因初始時戳而過期;
# 也讓 draft 從 asking 進入 confirming 時能升級到較長的 TTL。
draft.expires_at = now + self._default_ttl(draft)
with self._repository.connect() as connection:
+ connection.execute(
+ "delete from reminder_drafts where chat_id = ? and creator_user_id = ?",
+ (draft.chat_id, draft.creator_user_id),
+ )
connection.execute(
"""
insert into reminder_drafts (
@@ -60,6 +63,9 @@ def save(self, draft: ReminderDraft, now: datetime) -> ReminderDraft:
)
return draft
+ def confirm(self, draft: ReminderDraft, reminder: Reminder) -> bool:
+ return self._repository.create_reminder(reminder, draft.participants, draft_id=draft.id)
+
def get_by_id(self, draft_id: str, now: datetime) -> ReminderDraft | None:
with self._repository.connect() as connection:
row = connection.execute(
@@ -134,14 +140,15 @@ def save(self, session: EditSession, now: datetime) -> EditSession:
connection.execute(
"""
insert into edit_sessions (
- chat_id, user_id, short_id, field, expires_at, prompt_message_id
+ chat_id, user_id, short_id, field, expires_at, prompt_message_id, ai_context
)
- values (?, ?, ?, ?, ?, ?)
+ values (?, ?, ?, ?, ?, ?, ?)
on conflict(chat_id, user_id) do update set
short_id = excluded.short_id,
field = excluded.field,
expires_at = excluded.expires_at,
- prompt_message_id = excluded.prompt_message_id
+ prompt_message_id = excluded.prompt_message_id,
+ ai_context = excluded.ai_context
""",
(
session.chat_id,
@@ -150,6 +157,9 @@ def save(self, session: EditSession, now: datetime) -> EditSession:
session.field,
session.expires_at.isoformat() if session.expires_at else None,
session.prompt_message_id,
+ json.dumps(session.ai_context, ensure_ascii=False)
+ if session.ai_context is not None
+ else None,
),
)
return session
@@ -179,9 +189,7 @@ def delete(self, chat_id: int, user_id: int) -> None:
(chat_id, user_id),
)
- def set_prompt_message_id(
- self, chat_id: int, user_id: int, message_id: int
- ) -> None:
+ def set_prompt_message_id(self, chat_id: int, user_id: int, message_id: int) -> None:
with self._repository.connect() as connection:
connection.execute(
"""
@@ -247,6 +255,7 @@ def row_to_edit_session(row) -> EditSession:
field=str(row["field"]),
expires_at=datetime.fromisoformat(str(row["expires_at"])) if row["expires_at"] else None,
prompt_message_id=_optional_int(row, "prompt_message_id"),
+ ai_context=json.loads(row["ai_context"]) if _optional_str(row, "ai_context") else None,
)
diff --git a/src/remindly/storage/sqlite.py b/src/remindly/storage/sqlite.py
index bc3c369..6adb36d 100644
--- a/src/remindly/storage/sqlite.py
+++ b/src/remindly/storage/sqlite.py
@@ -128,8 +128,18 @@ def get_user_display_name(self, user_id: int) -> str | None:
return str(row["first_name"])
return None
- def create_reminder(self, reminder: Reminder, participants: list[Participant]) -> None:
+ def create_reminder(
+ self, reminder: Reminder, participants: list[Participant], *, draft_id: str | None = None
+ ) -> bool:
with self.connect() as connection:
+ if draft_id is not None:
+ consumed = connection.execute(
+ """delete from reminder_drafts
+ where id = ? and chat_id = ? and creator_user_id = ?""",
+ (draft_id, reminder.chat_id, reminder.creator_user_id),
+ )
+ if consumed.rowcount != 1:
+ return False
connection.execute(
"""
insert into reminders (
@@ -176,6 +186,7 @@ def create_reminder(self, reminder: Reminder, participants: list[Participant]) -
for index, participant in enumerate(participants)
],
)
+ return True
def list_pending(self, chat_id: int, limit: int = 10) -> list[Reminder]:
with self.connect() as connection:
diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md
new file mode 100644
index 0000000..3c4cd75
--- /dev/null
+++ b/tests/fixtures/README.md
@@ -0,0 +1,109 @@
+# 時間解析回歸資料集
+
+`time_regressions.jsonl` 每行是一個獨立 JSON 案例,可直接新增一行;預期結果由人核對,
+不要執行解析器後把輸出自動存成預期值。實際使用遇到誤解析時,先補一個會失敗的案例再修正。
+
+資料集由 `tests/test_time_corpus.py` 執行,既有 `make test`、`make check` 與 CI 會自動納入。
+全部使用固定時間與臨時 SQLite,不讀正式資料庫、不連 Telegram 或 AI,也不需要 Key。
+
+只跑資料集:
+
+```sh
+uv run python -B -m unittest discover -s tests -p 'test_time_corpus.py' -v
+```
+
+失敗訊息會顯示案例 `id`,以及解析入口、來源句或對話輪次。
+
+## 案例格式
+
+每筆必填 `kind`、唯一的 `id`、含 UTC offset 的 `now` 與 IANA `timezone`。
+`at` 使用含 offset 的 ISO 日期時間;無法解析則填 `null`。
+
+| kind | 用途與必要欄位 |
+| --- | --- |
+| `time` | `text`、`at`、`missing_time`、`past`、`existing_date`(可為 `null`)、`cross_entry_points`。檢查單獨補時間;開啟跨入口時,再檢查「時間提醒我買湯」、「提醒我在時間買湯」、「時間買湯」。有既有日期或倒裝時間的案例可關閉跨入口。 |
+| `reminder` | 完整句子的 `text`、`title`、`at`、`missing`、`recurrence`、`error`、`participants`。事項必須完全相符;週期規則與參與者也要相符。無週期填 `null`,`error` 表示是否應拒絕無效週期。 |
+| `conversation` | `turns` 陣列,各輪包含 `text`、`state`(`prompt`/`confirmation`)、`title`、`at`、`missing`、`question`(確認卡填 `null`)。逐輪檢查草稿保存,最後確認並檢查資料庫;確認前不得建立提醒。 |
+| `edit` | `initial`(建立原提醒的完整句子)、修改輸入 `text`、`state`(`updated`/`prompt`)、修改後資料庫的 `at` 與 `title`。被拒絕的修改必須保留整筆原提醒。 |
+
+測試使用者固定為 `@orange`,私聊 ID 為 `100`。對話各輪固定在該案例的 `now`,
+用途是驗證解析與狀態合併;TTL 與時間經過由原有 session 測試負責。
+
+日期追問案例包含 `9/17 23:55`、`09/17 23:55`、`09-17` → `23:55`、
+`2026/09/17` → `23:55`,以及只回答 `23:55`。預期規則如下:
+
+- 未指定年份的月日若已過,沿用下一年的規則;有指定年份時不改年份。
+- 只給日期時繼續追問時分,下一輪保留該日期。
+- 只給時分且沒有草稿日期時,取下一次該時刻;已有草稿日期則保留日期。
+- 明確的過去日期不能進入確認建立流程,必須重新提供有效時間。
+
+新增時間案例的範例:
+
+```json
+{"kind":"time","id":"tomorrow-afternoon-clock","now":"2026-10-23T12:10:00+08:00","timezone":"Asia/Taipei","text":"明天下午三點","at":"2026-10-24T15:00:00+08:00","missing_time":false,"past":false,"existing_date":null,"cross_entry_points":true}
+```
+
+## 維護原則
+
+- 保留真實回報的完整句子與對話,不只抽出日期時間。
+- 正常格式、缺漏追問、非法輸入、事項中的日期/時段文字都要涵蓋。
+- 日期與時區要明寫,避免依賴執行測試當天或作業系統時區。
+- 只有產品規則確定改變時才調整預期值,並說明理由;不能為了讓測試通過而改答案。
+- 這份資料集驗證內建規則;不代表任意口語或 Gemini 回應的準確率。
+
+`time_regressions.required_ids.txt` 保存原本 107 筆案例的 ID;測試會檢查它們全部存在,
+避免刪除舊案例後用等量新案例取代。新增案例直接附加至 JSONL,不需改這份基準清單。
+
+時間格式必須完整有效;超長數字、不完整冒號及目前不支援的秒數都需重新輸入,
+不能只取合法前綴。早上/上午十二點是 00:00,中午十二點是 12:00;晚上十二點
+需追問單一日期與 24 小時制時間。時間片語間以「或/或者/還是」列出選項時需追問,
+事項中的日期與「或」則保留。DST 不存在或重複的牆上時間需追問;
+明確經過分鐘/小時的相對時間以 UTC 計算,仍可跨越 DST。
+
+`tests/test_time_boundaries.py` 另外以共用案例交叉驗證建立、追問、編輯與 AI 約束,
+並覆蓋跨午夜、草稿重載、確認時已過期與快捷時間。AI 使用刻意不一致的模型回應替身,
+驗證本機約束與確認流程;這些測試不呼叫 Gemini。
+
+## Gemini 原始回應回歸
+
+`gemini_content_regressions.jsonl` 保留 2026-10-24 使用 `gemini-3.5-flash-lite`
+三輪實測中失敗的 7 筆解析 JSON,包含模型實際產生的錯誤年份、事項與追問,
+不修改 `response` 欄位來配合程式。`expected` 引用已人工核對的時間資料集預期,
+不是把模型或解析器輸出當成答案。`tests/test_ai_content.py` 會離線重播原始回應,
+檢查事項、必要追問、確認前不建立,以及確認後保存的資料。
+另追加曆法詞作為一般事項卻觸發定義追問的實測回應;只有原句未帶條件結構、
+其他時間或多筆提醒時,才可清除這類多餘定義追問。
+事項被模型刪詞或混入提醒時間的兩種原始回應也保留,驗證本機仍保存完整原文事項。
+
+本機只對可明確切出的時間/事項區定案;未辨識的口語仍交由語意解析。
+純事項區只清除可由本機已驗證欄位解決的追問,其餘未解語意問題保留。
+條件依完整語句結構辨識,不能只因事項以「前/到/內」或「工作日/假日」開頭就新增追問;
+等待條件與括號內條件仍須保留。時間無效與語意問題同時存在時,兩者分別保存,
+補時分不會解除尚未回答的語意問題。
+
+`gemini_question_regressions.jsonl` 保存同日實測的亂碼追問原始 JSON。
+`tests/test_ai_flow.py` 驗證模型原文留在草稿中,但建立、編輯與非提醒回覆只顯示本機文案;
+缺少事項或尚有必要條件時仍不可確認建立。這不是靠中文常用字比例猜測亂碼,
+也不會刪除模型原始追問來讓草稿變成完整。
+
+`gemini_followup_regressions.jsonl` 保存帶亂碼草稿上下文後,模型將補充事項改寫成錯字的原始回應。
+待補事項的草稿收到一般內容時,沿用既有時間、對象與週期,以使用者回覆作為事項,
+並套用相同的條件守衛;補時間或另開提醒時不走這條路徑。
+待補事項時的變更指令先交給時間文法;可辨識的日期與時分直接約束,
+否定、模糊時段或尚未能確認的週期變更則繼續追問,不可填成事項或沿用舊時間定案。
+「提早寄出報表」等一般動作不因單一動詞而視為改時間。
+曆法定義追問也不能蓋過同一追問內的截止、前後界線或其他必要條件。
+
+`gemini_correction_regressions.jsonl` 保存 2026-10-25 實測發現的原始回應與追問前草稿:
+一般事項被判成非提醒、已有事項的時間修正清掉工作日追問,以及「時間前+動作」漏問。
+補事項由草稿狀態與原文時間文法判定,不讓模型的非提醒標籤否決;已有事項的時間修正
+同樣受本機日期約束,未解條件在共同出口保留,不依賴是否走過本機時間解析。
+「前交報表/後再領藥」按界線與動作組合保留追問;「前往/前置/後續/後製」仍是一般事項。
+測試逐筆重播原始回應,核對必要欄位、追問與確認後保存的事項;不需要 Key 或網路。
+另保留一筆模型把時間輸出成 `HH:MM:SS` 的格式違規回應,標記 `rejected`;
+測試必須拒絕該回應並保留原草稿,不能放寬 `HH:MM` 契約或當成成功解析。
+否定句中的時間不能作為替代時間:例如「不是11/17 23:55」必須繼續追問,
+不能刪掉否定詞後套用日期。測試涵蓋已有/缺少事項、既有條件與不同模型意圖,
+並確認舊確認按鈕與未完成的新草稿都不能建立提醒。
+有草稿的變更指令即使模型誤標 `create`/`not_reminder`,仍是更新;未解條件必須保留。
+明確另開提醒的文字不套用這項更新約束。
diff --git a/tests/fixtures/gemini_content_regressions.jsonl b/tests/fixtures/gemini_content_regressions.jsonl
new file mode 100644
index 0000000..6f2482c
--- /dev/null
+++ b/tests/fixtures/gemini_content_regressions.jsonl
@@ -0,0 +1,11 @@
+{"id": "morning-twelve-round-1", "text": "提醒我 明天早上十二點 買湯", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "response": {"intent": "create", "title": "買湯", "date": "2025-10-25", "time": "12:00", "relative_minutes": null, "question": "早上十二點是指中午十二點嗎?請確認時間。", "recurrence": null, "participant_indices": [0]}, "expected": {"text": "提醒我 明天早上十二點 買湯", "at": "2026-10-25T00:00:00+08:00", "offset": 0, "complete": true, "title": "買湯"}}
+{"id": "alternative-in-subject-round-1", "text": "提醒我11/17 23:55買湯或11/18要用的湯匙", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "response": {"intent": "create", "title": null, "date": null, "time": null, "relative_minutes": null, "question": "一句話包含多個提醒,請使用者一次建立一筆,請問您要先建立哪一個提醒?", "recurrence": null, "participant_indices": [0]}, "expected": {"text": "提醒我11/17 23:55買湯或11/18要用的湯匙", "at": "2026-11-17T23:55:00+08:00", "offset": 0, "complete": true, "title": "買湯或11/18要用的湯匙"}}
+{"id": "explicit-today-past-round-2", "text": "提醒我 今天09:00 買湯", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "response": {"intent": "create", "title": "耆東", "date": "2026-10-24", "time": "09:00", "relative_minutes": null, "question": null, "recurrence": null, "participant_indices": [0]}, "expected": {"text": "提醒我 今天09:00 買湯", "at": "2026-10-24T09:00:00+08:00", "offset": 0, "complete": false, "title": "買湯"}}
+{"id": "exact-current-clock-rolls-round-2", "text": "提醒我 12:10 買湯", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "response": {"intent": "create", "title": "買湯", "date": "2026-10-24", "time": "12:10", "relative_minutes": null, "question": "您想在今天還是明天的 12:10 提醒您呢?", "recurrence": null, "participant_indices": [0]}, "expected": {"text": "提醒我 12:10 買湯", "at": "2026-10-25T12:10:00+08:00", "offset": 0, "complete": true, "title": "買湯"}}
+{"id": "chinese-date-extra-day-digit-round-2", "text": "提醒我 11月170日 23:55 買湯", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "response": {"intent": "create", "title": "老锆", "date": null, "time": null, "relative_minutes": null, "question": "您輸入的日期有誤(11月沒有 170 日),請問您想在哪一天收到揕醒?", "recurrence": null, "participant_indices": [0]}, "expected": {"text": "提醒我 11月170日 23:55 買湯", "at": null, "offset": 0, "complete": false, "title": "買湯"}}
+{"id": "exact-current-clock-rolls-round-3", "text": "提醒我 12:10 買湯", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "response": {"intent": "create", "title": "買湯", "date": "2026-10-24", "time": "12:10", "relative_minutes": null, "question": "您指定的時間是今天中午 12 點 10 分,但現在時間已經是 12 點 10 分了,請問是要將時間改到明天,或是其他特定的時間呢?", "recurrence": null, "participant_indices": [0]}, "expected": {"text": "提醒我 12:10 買湯", "at": "2026-10-25T12:10:00+08:00", "offset": 0, "complete": true, "title": "買湯"}}
+{"id": "alternative-in-subject-round-3", "text": "提醒我11/17 23:55買湯或11/18要用的湯匙", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "response": {"intent": "create", "title": null, "date": null, "time": null, "relative_minutes": null, "question": "這是一句包含多個提醒的訊息,請一次建立一筆,請問您要先建立哪一個提醒?", "recurrence": null, "participant_indices": [0]}, "expected": {"text": "提醒我11/17 23:55買湯或11/18要用的湯匙", "at": "2026-11-17T23:55:00+08:00", "offset": 0, "complete": true, "title": "買湯或11/18要用的湯匙"}}
+{"id":"recorded-calendar-noun-question","text":"提醒我明天15:00工作日行事曆","now":"2026-10-24T12:10:00+08:00","timezone":"Asia/Taipei","response":{"intent":"create","title":"工作日行事曆","date":"2026-10-25","time":"15:00","relative_minutes":null,"question":"請問「工作日」是指週一至週五嗎?","recurrence":null,"participant_indices":[0]},"expected":{"title":"工作日行事曆","at":"2026-10-25T15:00:00+08:00","complete":true}}
+{"id":"recorded-calendar-definition-reworded","text":"提醒我明天15:00工作日行事曆","now":"2026-10-24T12:10:00+08:00","timezone":"Asia/Taipei","response":{"intent":"create","title":"工作日行事曆","date":"2026-10-25","time":"15:00","relative_minutes":null,"question":"職務日是指週一至週五嗎?","recurrence":null,"participant_indices":[0]},"expected":{"title":"工作日行事曆","at":"2026-10-25T15:00:00+08:00","complete":true}}
+{"id":"recorded-literal-title-rewritten-round-1","text":"提醒我明天15:00之前的會議紀錄","now":"2026-10-24T12:10:00+08:00","timezone":"Asia/Taipei","response":{"intent":"create","title":"會議紀錄","date":"2026-10-25","time":"15:00","relative_minutes":null,"question":null,"recurrence":null,"participant_indices":[0]},"expected":{"title":"之前的會議紀錄","at":"2026-10-25T15:00:00+08:00","complete":true}}
+{"id":"recorded-literal-title-rewritten-round-2","text":"提醒我明天15:00之前的會議紀錄","now":"2026-10-24T12:10:00+08:00","timezone":"Asia/Taipei","response":{"intent":"create","title":"明天15:00之前的會議紀錄","date":"2026-10-25","time":"15:00","relative_minutes":null,"question":null,"recurrence":null,"participant_indices":[0]},"expected":{"title":"之前的會議紀錄","at":"2026-10-25T15:00:00+08:00","complete":true}}
diff --git a/tests/fixtures/gemini_correction_regressions.jsonl b/tests/fixtures/gemini_correction_regressions.jsonl
new file mode 100644
index 0000000..7b343d5
--- /dev/null
+++ b/tests/fixtures/gemini_correction_regressions.jsonl
@@ -0,0 +1,4 @@
+{"id":"ordinary-7-round-1","text":"延後寄包裹","now":"2026-10-25T12:10:00+08:00","timezone":"Asia/Taipei","seed":{"title":null,"at":"2026-10-26T15:00:00+08:00","missing":["title"],"question":null,"source":"提醒我明天15:00","condition":false},"response":{"intent":"not_reminder","title":null,"date":null,"time":null,"relative_minutes":null,"question":"請問您想把寄包裹的提醒延後到什麼時候呢?","recurrence":null,"participant_indices":[0]},"expected":{"text":"延後寄包裹","at":"2026-10-26T15:00:00+08:00","offset":0,"complete":true,"title":"延後寄包裹","missing":[]}}
+{"id":"known-title-existing-condition-round-1","text":"改成明天九點","now":"2026-10-25T12:10:00+08:00","timezone":"Asia/Taipei","seed":{"title":"只在工作日喝水","at":"2026-10-26T15:00:00+08:00","missing":[],"question":"是否只限週一至週五?","source":"只在工作日,明天15:00提醒我","condition":true},"response":{"intent":"update","title":"只在工作日喝水","date":"2026-10-26","time":"09:00","relative_minutes":null,"question":null,"recurrence":null,"participant_indices":[0]},"expected":{"text":"改成明天九點","at":"2026-10-26T09:00:00+08:00","offset":0,"complete":false,"title":"只在工作日喝水","missing":["clarification"]}}
+{"id":"deadline-definition-round-1","text":"提醒我明天下午三點前交報表","now":"2026-10-25T12:10:00+08:00","timezone":"Asia/Taipei","seed":null,"response":{"intent":"create","title":"交報表","date":"2026-10-26","time":"15:00","relative_minutes":null,"question":null,"recurrence":null,"participant_indices":[0]},"expected":{"text":"提醒我明天下午三點前交報表","at":"2026-10-26T15:00:00+08:00","offset":0,"complete":false,"title":"前交報表","missing":["clarification"]}}
+{"text": "不對稱花瓶", "now": "2026-10-25T12:10:00+08:00", "timezone": "Asia/Taipei", "seed": {"title": null, "at": "2026-10-26T15:00:00+08:00", "missing": ["title"], "question": null, "source": "提醒我明天15:00", "condition": false}, "expected": {"text": "不對稱花瓶", "at": "2026-10-26T15:00:00+08:00", "offset": 0, "complete": true, "title": "不對稱花瓶", "missing": []}, "id": "ordinary-invalid-clock-format", "response": {"intent": "update", "title": "不對稱花瓶", "date": "2026-10-26", "time": "15:00:00", "relative_minutes": null, "question": null, "recurrence": null, "participant_indices": [0]}, "rejected": true}
diff --git a/tests/fixtures/gemini_followup_regressions.jsonl b/tests/fixtures/gemini_followup_regressions.jsonl
new file mode 100644
index 0000000..9888cdc
--- /dev/null
+++ b/tests/fixtures/gemini_followup_regressions.jsonl
@@ -0,0 +1 @@
+{"id":"recorded-garbled-context-title-rewrite","text":"寄出年度報表","now":"2026-10-24T12:10:00+08:00","timezone":"Asia/Taipei","context":{"text":"提醒我明天15:00","response":{"intent":"create","title":null,"date":"2026-10-25","time":"15:00","relative_minutes":null,"question":"職耇與䐂蕩耇䐂略耇䐂耇䐂","recurrence":null,"participant_indices":[0]}},"response":{"intent":"update","title":"匄出年度報表","date":"2026-10-25","time":"15:00","relative_minutes":null,"question":null,"recurrence":null,"participant_indices":[0]},"expected":{"title":"寄出年度報表","at":"2026-10-25T15:00:00+08:00"}}
diff --git a/tests/fixtures/gemini_question_regressions.jsonl b/tests/fixtures/gemini_question_regressions.jsonl
new file mode 100644
index 0000000..6f1f0a2
--- /dev/null
+++ b/tests/fixtures/gemini_question_regressions.jsonl
@@ -0,0 +1 @@
+{"id":"recorded-garbled-missing-title","text":"提醒我明天15:00","now":"2026-10-24T12:10:00+08:00","timezone":"Asia/Taipei","response":{"intent":"create","title":null,"date":"2026-10-25","time":"15:00","relative_minutes":null,"question":"職耇與䐂蕩耇䐂略耇䐂耇䐂","recurrence":null,"participant_indices":[0]},"expected_question":"要提醒什麼?"}
diff --git a/tests/fixtures/time_regressions.jsonl b/tests/fixtures/time_regressions.jsonl
new file mode 100644
index 0000000..c1c1067
--- /dev/null
+++ b/tests/fixtures/time_regressions.jsonl
@@ -0,0 +1,134 @@
+{"kind": "time", "id": "slash-date", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "11/17 23:55", "at": "2026-11-17T23:55:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "chinese-date", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "十一月十七號晚上九點", "at": "2026-11-17T21:00:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "chinese-numeric-date", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "11月17日 23:55", "at": "2026-11-17T23:55:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "iso-date", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "2026-11-17 23:55", "at": "2026-11-17T23:55:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "year-date-chinese-clock", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "2026/11/17 下午三點", "at": "2026-11-17T15:00:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "tomorrow-alias", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "明日 23:55", "at": "2026-10-24T23:55:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "day-after-tomorrow", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "後天早上九點", "at": "2026-10-25T09:00:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "next-monday", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "下週一下午三點", "at": "2026-10-26T15:00:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "weekday-number-boundary", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "下週一九點", "at": "2026-10-26T09:00:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "next-next-monday", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "下下週一九點", "at": "2026-11-02T09:00:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "weekday-without-prefix", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "週一九點", "at": "2026-10-26T09:00:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "bare-hour-rollover", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "五點", "at": "2026-10-24T05:00:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "partial-minute", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "19 分", "at": "2026-10-23T12:19:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "relative-hours", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "三小時後", "at": "2026-10-23T15:10:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "relative-half-hour", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "半小時後", "at": "2026-10-23T12:40:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "relative-weeks", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "3週後", "at": "2026-11-13T12:10:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "morning-clock", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "明早九點", "at": "2026-10-24T09:00:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "evening-clock", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "明晚九點", "at": "2026-10-24T21:00:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "tonight-clock", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "今晚九點", "at": "2026-10-23T21:00:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "morning-default", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "明早", "at": "2026-10-24T08:00:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "midnight", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "凌晨十二點", "at": "2026-10-24T00:00:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "noon", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "中午十二點", "at": "2026-10-24T12:00:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "half-hour-clock", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "下午三點半", "at": "2026-10-23T15:30:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "fullwidth-colon", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "17:30", "at": "2026-10-23T17:30:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "time-suffix", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "下午五點的時候", "at": "2026-10-23T17:00:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "tomorrow-date-only", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "明天", "at": "2026-10-24T12:10:00+08:00", "missing_time": true, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "slash-date-only", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "11/17", "at": "2026-11-17T00:00:00+08:00", "missing_time": true, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "weekday-imprecise-period", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "週五下午", "at": "2026-10-23T15:00:00+08:00", "missing_time": true, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "invalid-month", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "13/17 23:55", "at": null, "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "invalid-day", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "11/31 23:55", "at": null, "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "invalid-chinese-day", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "二月三十日 23:55", "at": null, "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "invalid-iso-day", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "2026-02-30 23:55", "at": null, "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "invalid-hour", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "25:00", "at": null, "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "invalid-minute", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "17:60", "at": null, "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "invalid-partial-minute", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "99 分", "at": null, "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "explicit-past", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "2026-10-22 09:00", "at": "2026-10-22T09:00:00+08:00", "missing_time": false, "past": true, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "past-date-only", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "2026-10-22", "at": "2026-10-22T00:00:00+08:00", "missing_time": true, "past": true, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "this-week-past", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "本週一九點", "at": "2026-10-19T09:00:00+08:00", "missing_time": false, "past": true, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "year-rollover", "now": "2026-12-31T23:00:00+08:00", "timezone": "Asia/Taipei", "text": "1/1 09:00", "at": "2027-01-01T09:00:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "month-end", "now": "2027-01-31T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "1個月後", "at": "2027-02-28T12:10:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "leap-day", "now": "2028-02-28T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "2/29 09:00", "at": "2028-02-29T09:00:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "partial-minute-rollover", "now": "2026-10-23T12:25:00+08:00", "timezone": "Asia/Taipei", "text": "19分", "at": "2026-10-23T13:19:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "new-york", "now": "2026-10-23T23:00:00-04:00", "timezone": "America/New_York", "text": "明早九點", "at": "2026-10-24T09:00:00-04:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "existing-date-clock", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "09:00", "at": "2026-11-17T09:00:00+08:00", "missing_time": false, "past": false, "existing_date": "2026-11-17T23:55:00+08:00", "cross_entry_points": false}
+{"kind": "time", "id": "existing-date-partial", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "19分", "at": "2026-11-17T23:19:00+08:00", "missing_time": false, "past": false, "existing_date": "2026-11-17T23:55:00+08:00", "cross_entry_points": false}
+{"kind": "time", "id": "existing-date-past-clock", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "09:00", "at": "2026-10-23T09:00:00+08:00", "missing_time": false, "past": true, "existing_date": "2026-10-23T08:00:00+08:00", "cross_entry_points": false}
+{"kind": "time", "id": "explicit-date-overrides-existing", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "11/17 23:55", "at": "2026-11-17T23:55:00+08:00", "missing_time": false, "past": false, "existing_date": "2026-10-24T09:00:00+08:00", "cross_entry_points": false}
+{"kind": "time", "id": "clock-before-date", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "23:55 明天", "at": "2026-10-24T23:55:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": false}
+{"kind": "reminder", "id": "beef-soup-report", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "提醒我在五點的時候買兩份清燉牛肉湯", "title": "買兩份清燉牛肉湯", "at": "2026-10-24T05:00:00+08:00", "missing": [], "recurrence": null, "error": false, "participants": ["@orange"]}
+{"kind": "reminder", "id": "suffix-in-title", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "提醒我在五點的時候買喝湯的時候要用的湯匙", "title": "買喝湯的時候要用的湯匙", "at": "2026-10-24T05:00:00+08:00", "missing": [], "recurrence": null, "error": false, "participants": ["@orange"]}
+{"kind": "reminder", "id": "split-date-clock-title", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "明天買湯 23:55", "title": "買湯", "at": "2026-10-24T23:55:00+08:00", "missing": [], "recurrence": null, "error": false, "participants": ["@orange"]}
+{"kind": "reminder", "id": "split-clock-date-title", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "23:55 買湯 明天", "title": "買湯", "at": "2026-10-24T23:55:00+08:00", "missing": [], "recurrence": null, "error": false, "participants": ["@orange"]}
+{"kind": "reminder", "id": "title-period", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "五點買晚上要用的湯匙", "title": "買晚上要用的湯匙", "at": "2026-10-24T05:00:00+08:00", "missing": [], "recurrence": null, "error": false, "participants": ["@orange"]}
+{"kind": "reminder", "id": "title-date-slash", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "7/30 21:00提醒我08/01要去家樂福", "title": "08/01要去家樂福", "at": "2027-07-30T21:00:00+08:00", "missing": [], "recurrence": null, "error": false, "participants": ["@orange"]}
+{"kind": "reminder", "id": "title-date-chinese", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "提醒我7/30 21:00 8月1號要去家樂福", "title": "8月1號要去家樂福", "at": "2027-07-30T21:00:00+08:00", "missing": [], "recurrence": null, "error": false, "participants": ["@orange"]}
+{"kind": "reminder", "id": "multiple-participants", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "明天下午三點提醒我和 @alice 倒垃圾", "title": "倒垃圾", "at": "2026-10-24T15:00:00+08:00", "missing": [], "recurrence": null, "error": false, "participants": ["@orange", "@alice"]}
+{"kind": "reminder", "id": "missing-title", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "提醒我明天下午三點", "title": null, "at": "2026-10-24T15:00:00+08:00", "missing": ["title"], "recurrence": null, "error": false, "participants": ["@orange"]}
+{"kind": "reminder", "id": "breakfast", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "提醒我買早餐", "title": "買早餐", "at": null, "missing": ["time"], "recurrence": null, "error": false, "participants": ["@orange"]}
+{"kind": "reminder", "id": "daily-breakfast", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "每天提醒我買早餐", "title": "買早餐", "at": null, "missing": ["time"], "recurrence": null, "error": false, "participants": ["@orange"]}
+{"kind": "reminder", "id": "dinner", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "提醒我買晚餐", "title": "買晚餐", "at": null, "missing": ["time"], "recurrence": null, "error": false, "participants": ["@orange"]}
+{"kind": "reminder", "id": "daily-dinner", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "每天提醒我買晚餐", "title": "買晚餐", "at": null, "missing": ["time"], "recurrence": null, "error": false, "participants": ["@orange"]}
+{"kind": "reminder", "id": "morning-bread", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "提醒我買早上的麵包", "title": "買早上的麵包", "at": null, "missing": ["time"], "recurrence": null, "error": false, "participants": ["@orange"]}
+{"kind": "reminder", "id": "daily-morning-bread", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "每天提醒我買早上的麵包", "title": "買早上的麵包", "at": null, "missing": ["time"], "recurrence": null, "error": false, "participants": ["@orange"]}
+{"kind": "reminder", "id": "night-ticket", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "提醒我買晚上的車票", "title": "買晚上的車票", "at": null, "missing": ["time"], "recurrence": null, "error": false, "participants": ["@orange"]}
+{"kind": "reminder", "id": "daily-night-ticket", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "每天提醒我買晚上的車票", "title": "買晚上的車票", "at": null, "missing": ["time"], "recurrence": null, "error": false, "participants": ["@orange"]}
+{"kind": "reminder", "id": "daily", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "每天九點提醒我買湯", "title": "買湯", "at": "2026-10-24T09:00:00+08:00", "missing": [], "recurrence": {"period": "daily", "hour": 9, "minute": 0}, "error": false, "participants": ["@orange"]}
+{"kind": "reminder", "id": "weekly", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "每週一、三、五九點提醒我買湯", "title": "買湯", "at": "2026-10-26T09:00:00+08:00", "missing": [], "recurrence": {"period": "weekly", "hour": 9, "minute": 0, "weekdays": [0, 2, 4]}, "error": false, "participants": ["@orange"]}
+{"kind": "reminder", "id": "monthly", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "每月1號九點提醒我買湯", "title": "買湯", "at": "2026-11-01T09:00:00+08:00", "missing": [], "recurrence": {"period": "monthly", "hour": 9, "minute": 0, "month_days": [1]}, "error": false, "participants": ["@orange"]}
+{"kind": "reminder", "id": "yearly", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "每年11/17九點提醒我買湯", "title": "買湯", "at": "2026-11-17T09:00:00+08:00", "missing": [], "recurrence": {"period": "yearly", "hour": 9, "minute": 0, "year_month": 11, "year_day": 17}, "error": false, "participants": ["@orange"]}
+{"kind": "reminder", "id": "daily-morning-default", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "每天早上提醒我買湯", "title": "買湯", "at": "2026-10-24T08:00:00+08:00", "missing": [], "recurrence": {"period": "daily", "hour": 8, "minute": 0}, "error": false, "participants": ["@orange"]}
+{"kind": "reminder", "id": "interval", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "每15分鐘提醒我喝水", "title": "喝水", "at": "2026-10-23T12:25:00+08:00", "missing": [], "recurrence": {"period": "interval", "interval_seconds": 900}, "error": false, "participants": ["@orange"]}
+{"kind": "reminder", "id": "daily-invalid-clock", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "每天25:00提醒我買湯", "title": null, "at": null, "missing": [], "recurrence": null, "error": true, "participants": ["@orange"]}
+{"kind": "reminder", "id": "interval-zero", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "每0分鐘提醒我喝水", "title": null, "at": null, "missing": [], "recurrence": null, "error": true, "participants": ["@orange"]}
+{"kind": "reminder", "id": "yearly-invalid-date", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "每年2/30九點提醒我買湯", "title": null, "at": null, "missing": [], "recurrence": null, "error": true, "participants": ["@orange"]}
+{"kind": "conversation", "id": "shrimp-coins-report", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "turns": [{"text": "提醒我要領蝦幣", "state": "prompt", "title": "要領蝦幣", "at": null, "missing": ["time"], "question": "什麼時候提醒?"}, {"text": "11/17 23:55", "state": "confirmation", "title": "要領蝦幣", "at": "2026-11-17T23:55:00+08:00", "missing": [], "question": null}]}
+{"kind": "conversation", "id": "date-then-clock", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "turns": [{"text": "提醒我買湯", "state": "prompt", "title": "買湯", "at": null, "missing": ["time"], "question": "什麼時候提醒?"}, {"text": "11/17", "state": "prompt", "title": "買湯", "at": "2026-11-17T00:00:00+08:00", "missing": ["time"], "question": "那天幾點?"}, {"text": "23:55", "state": "confirmation", "title": "買湯", "at": "2026-11-17T23:55:00+08:00", "missing": [], "question": null}]}
+{"kind": "conversation", "id": "invalid-then-valid-time", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "turns": [{"text": "提醒我買湯", "state": "prompt", "title": "買湯", "at": null, "missing": ["time"], "question": "什麼時候提醒?"}, {"text": "11/31 23:55", "state": "prompt", "title": "買湯", "at": null, "missing": ["time"], "question": "什麼時候提醒?"}, {"text": "明晚九點", "state": "confirmation", "title": "買湯", "at": "2026-10-24T21:00:00+08:00", "missing": [], "question": null}]}
+{"kind": "conversation", "id": "title-follow-up", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "turns": [{"text": "提醒我明天下午三點", "state": "prompt", "title": null, "at": "2026-10-24T15:00:00+08:00", "missing": ["title"], "question": "要提醒什麼?"}, {"text": "買早餐", "state": "confirmation", "title": "買早餐", "at": "2026-10-24T15:00:00+08:00", "missing": [], "question": null}]}
+{"kind": "conversation", "id": "new-york-follow-up", "now": "2026-10-23T23:00:00-04:00", "timezone": "America/New_York", "turns": [{"text": "提醒我買湯", "state": "prompt", "title": "買湯", "at": null, "missing": ["time"], "question": "什麼時候提醒?"}, {"text": "明早九點", "state": "confirmation", "title": "買湯", "at": "2026-10-24T09:00:00-04:00", "missing": [], "question": null}]}
+{"kind": "edit", "id": "edit-month-day", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "initial": "提醒我11/17 23:55買湯", "text": "11/18 09:00", "state": "updated", "at": "2026-11-18T09:00:00+08:00", "title": "買湯"}
+{"kind": "edit", "id": "edit-clock-only", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "initial": "提醒我11/17 23:55買湯", "text": "09:00", "state": "updated", "at": "2026-11-17T09:00:00+08:00", "title": "買湯"}
+{"kind": "edit", "id": "edit-partial-minute", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "initial": "提醒我11/17 23:55買湯", "text": "19分", "state": "updated", "at": "2026-11-17T23:19:00+08:00", "title": "買湯"}
+{"kind": "edit", "id": "edit-explicit-tomorrow", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "initial": "提醒我11/17 23:55買湯", "text": "明早九點", "state": "updated", "at": "2026-10-24T09:00:00+08:00", "title": "買湯"}
+{"kind": "edit", "id": "edit-invalid-date", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "initial": "提醒我11/17 23:55買湯", "text": "11/31 23:55", "state": "prompt", "at": "2026-11-17T23:55:00+08:00", "title": "買湯"}
+{"kind": "edit", "id": "edit-invalid-clock", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "initial": "提醒我11/17 23:55買湯", "text": "25:00", "state": "prompt", "at": "2026-11-17T23:55:00+08:00", "title": "買湯"}
+{"kind": "edit", "id": "edit-past-date", "now": "2026-10-23T12:10:00+08:00", "timezone": "Asia/Taipei", "initial": "提醒我11/17 23:55買湯", "text": "2026-10-22 09:00", "state": "prompt", "at": "2026-11-17T23:55:00+08:00", "title": "買湯"}
+{"kind": "time", "id": "single-digit-month-follow-up-format", "now": "2026-09-01T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "9/17 23:55", "at": "2026-09-17T23:55:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "padded-month-follow-up-format", "now": "2026-09-01T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "09/17 23:55", "at": "2026-09-17T23:55:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "hyphen-date-only", "now": "2026-09-01T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "09-17", "at": "2026-09-17T00:00:00+08:00", "missing_time": true, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "year-slash-date-only", "now": "2026-09-01T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "2026/09/17", "at": "2026-09-17T00:00:00+08:00", "missing_time": true, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "clock-only-tonight", "now": "2026-09-01T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "23:55", "at": "2026-09-01T23:55:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "hyphen-date-clock", "now": "2026-09-01T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "09-17 23:55", "at": "2026-09-17T23:55:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "hyphen-date-next-year", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "09-17", "at": "2027-09-17T00:00:00+08:00", "missing_time": true, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "short-date-clock-next-year", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "9/17 23:55", "at": "2027-09-17T23:55:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "explicit-year-slash-past", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "2026/09/17", "at": "2026-09-17T00:00:00+08:00", "missing_time": true, "past": true, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "invalid-hyphen-date", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "09-31 23:55", "at": null, "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "conversation", "id": "reply-single-month", "now": "2026-09-01T12:10:00+08:00", "timezone": "Asia/Taipei", "turns": [{"text": "提醒我要領蝦幣", "state": "prompt", "title": "要領蝦幣", "at": null, "missing": ["time"], "question": "什麼時候提醒?"}, {"text": "9/17 23:55", "state": "confirmation", "title": "要領蝦幣", "at": "2026-09-17T23:55:00+08:00", "missing": [], "question": null}]}
+{"kind": "conversation", "id": "reply-padded-month", "now": "2026-09-01T12:10:00+08:00", "timezone": "Asia/Taipei", "turns": [{"text": "提醒我要領蝦幣", "state": "prompt", "title": "要領蝦幣", "at": null, "missing": ["time"], "question": "什麼時候提醒?"}, {"text": "09/17 23:55", "state": "confirmation", "title": "要領蝦幣", "at": "2026-09-17T23:55:00+08:00", "missing": [], "question": null}]}
+{"kind": "conversation", "id": "reply-hyphen-date-then-clock", "now": "2026-09-01T12:10:00+08:00", "timezone": "Asia/Taipei", "turns": [{"text": "提醒我要領蝦幣", "state": "prompt", "title": "要領蝦幣", "at": null, "missing": ["time"], "question": "什麼時候提醒?"}, {"text": "09-17", "state": "prompt", "title": "要領蝦幣", "at": "2026-09-17T00:00:00+08:00", "missing": ["time"], "question": "那天幾點?"}, {"text": "23:55", "state": "confirmation", "title": "要領蝦幣", "at": "2026-09-17T23:55:00+08:00", "missing": [], "question": null}]}
+{"kind": "conversation", "id": "reply-year-date-then-clock", "now": "2026-09-01T12:10:00+08:00", "timezone": "Asia/Taipei", "turns": [{"text": "提醒我要領蝦幣", "state": "prompt", "title": "要領蝦幣", "at": null, "missing": ["time"], "question": "什麼時候提醒?"}, {"text": "2026/09/17", "state": "prompt", "title": "要領蝦幣", "at": "2026-09-17T00:00:00+08:00", "missing": ["time"], "question": "那天幾點?"}, {"text": "23:55", "state": "confirmation", "title": "要領蝦幣", "at": "2026-09-17T23:55:00+08:00", "missing": [], "question": null}]}
+{"kind": "conversation", "id": "reply-clock-without-date", "now": "2026-09-01T12:10:00+08:00", "timezone": "Asia/Taipei", "turns": [{"text": "提醒我要領蝦幣", "state": "prompt", "title": "要領蝦幣", "at": null, "missing": ["time"], "question": "什麼時候提醒?"}, {"text": "23:55", "state": "confirmation", "title": "要領蝦幣", "at": "2026-09-01T23:55:00+08:00", "missing": [], "question": null}]}
+{"kind": "conversation", "id": "reply-hyphen-date-next-year", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "turns": [{"text": "提醒我要領蝦幣", "state": "prompt", "title": "要領蝦幣", "at": null, "missing": ["time"], "question": "什麼時候提醒?"}, {"text": "09-17", "state": "prompt", "title": "要領蝦幣", "at": "2027-09-17T00:00:00+08:00", "missing": ["time"], "question": "那天幾點?"}, {"text": "23:55", "state": "confirmation", "title": "要領蝦幣", "at": "2027-09-17T23:55:00+08:00", "missing": [], "question": null}]}
+{"kind": "conversation", "id": "reply-explicit-past-year-does-not-roll", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "turns": [{"text": "提醒我要領蝦幣", "state": "prompt", "title": "要領蝦幣", "at": null, "missing": ["time"], "question": "什麼時候提醒?"}, {"text": "2026/09/17", "state": "prompt", "title": "要領蝦幣", "at": "2026-09-17T00:00:00+08:00", "missing": ["time"], "question": "那天幾點?"}, {"text": "23:55", "state": "prompt", "title": "要領蝦幣", "at": "2026-09-17T00:00:00+08:00", "missing": ["time"], "question": "那天幾點?"}, {"text": "2027/09/17 23:55", "state": "confirmation", "title": "要領蝦幣", "at": "2027-09-17T23:55:00+08:00", "missing": [], "question": null}]}
+{"kind": "conversation", "id": "reply-clock-rolls-after-cutoff", "now": "2026-09-01T23:56:00+08:00", "timezone": "Asia/Taipei", "turns": [{"text": "提醒我要領蝦幣", "state": "prompt", "title": "要領蝦幣", "at": null, "missing": ["time"], "question": "什麼時候提醒?"}, {"text": "23:55", "state": "confirmation", "title": "要領蝦幣", "at": "2026-09-02T23:55:00+08:00", "missing": [], "question": null}]}
+{"kind":"time","id":"year-hyphen-date-only","now":"2026-09-01T12:10:00+08:00","timezone":"Asia/Taipei","text":"2026-09-17","at":"2026-09-17T00:00:00+08:00","missing_time":true,"past":false,"existing_date":null,"cross_entry_points":true}
+{"kind":"edit","id":"edit-hyphen-month-day","now":"2026-09-01T12:10:00+08:00","timezone":"Asia/Taipei","initial":"提醒我11/17 23:55買湯","text":"09-17 23:55","state":"updated","at":"2026-09-17T23:55:00+08:00","title":"買湯"}
+{"kind":"reminder","id":"hyphen-date-in-title","now":"2026-09-01T12:10:00+08:00","timezone":"Asia/Taipei","text":"提醒我11/17 23:55 09-17要出國","title":"09-17要出國","at":"2026-11-17T23:55:00+08:00","missing":[],"recurrence":null,"error":false,"participants":["@orange"]}
+{"kind": "time", "id": "clock-extra-minute-digit", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "23:555", "at": null, "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "clock-extra-hour-digit", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "123:55", "at": null, "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "clock-with-seconds", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "11/17 23:55:30", "at": null, "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "clock-missing-minute", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "23:", "at": null, "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "morning-twelve", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "明天早上十二點", "at": "2026-10-25T00:00:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "am-twelve", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "明天上午十二點", "at": "2026-10-25T00:00:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "evening-twelve-ambiguous", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "明天晚上十二點", "at": null, "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "alternative-dates", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "11/17 09:00或11/18 10:00", "at": null, "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "alternative-clock", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "明天09:00還是10:00", "at": null, "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "dst-nonexistent", "now": "2027-03-13T12:00:00-05:00", "timezone": "America/New_York", "text": "明天02:30", "at": null, "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "dst-ambiguous", "now": "2027-11-06T12:00:00-04:00", "timezone": "America/New_York", "text": "明天01:30", "at": null, "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "dst-before-gap", "now": "2027-03-13T12:00:00-05:00", "timezone": "America/New_York", "text": "明天01:30", "at": "2027-03-14T01:30:00-05:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "dst-after-gap", "now": "2027-03-13T12:00:00-05:00", "timezone": "America/New_York", "text": "明天03:30", "at": "2027-03-14T03:30:00-04:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "dst-after-fold", "now": "2027-11-06T12:00:00-04:00", "timezone": "America/New_York", "text": "明天02:30", "at": "2027-11-07T02:30:00-05:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "fullwidth-clock-invalid", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "23:555", "at": null, "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "explicit-today-past", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "今天09:00", "at": "2026-10-24T09:00:00+08:00", "missing_time": false, "past": true, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "exact-current-clock-rolls", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "12:10", "at": "2026-10-25T12:10:00+08:00", "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "reminder", "id": "carrefour-july-report", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "提醒我 7/31 11點要去家樂福", "title": "要去家樂福", "at": "2027-07-31T11:00:00+08:00", "missing": [], "recurrence": null, "error": false, "participants": ["@orange"]}
+{"kind": "time", "id": "short-date-extra-day-digit", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "11/170 23:55", "at": null, "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "short-date-extra-month-digit", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "111/17 23:55", "at": null, "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "absolute-date-extra-day-digit", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "2026/09/170 23:55", "at": null, "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "absolute-date-leading-zero-day", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "2026/09/017 23:55", "at": null, "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "absolute-date-extra-year-digit", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "12026/09/17 23:55", "at": null, "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "absolute-date-short-year", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "26/09/17 23:55", "at": null, "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "chinese-date-extra-day-digit", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "11月170日 23:55", "at": null, "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "time", "id": "partial-minute-extra-digit", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "119分", "at": null, "missing_time": false, "past": false, "existing_date": null, "cross_entry_points": true}
+{"kind": "reminder", "id": "alternative-in-subject", "now": "2026-10-24T12:10:00+08:00", "timezone": "Asia/Taipei", "text": "提醒我11/17 23:55買湯或11/18要用的湯匙", "title": "買湯或11/18要用的湯匙", "at": "2026-11-17T23:55:00+08:00", "missing": [], "recurrence": null, "error": false, "participants": ["@orange"]}
diff --git a/tests/fixtures/time_regressions.required_ids.txt b/tests/fixtures/time_regressions.required_ids.txt
new file mode 100644
index 0000000..998c599
--- /dev/null
+++ b/tests/fixtures/time_regressions.required_ids.txt
@@ -0,0 +1,107 @@
+slash-date
+chinese-date
+chinese-numeric-date
+iso-date
+year-date-chinese-clock
+tomorrow-alias
+day-after-tomorrow
+next-monday
+weekday-number-boundary
+next-next-monday
+weekday-without-prefix
+bare-hour-rollover
+partial-minute
+relative-hours
+relative-half-hour
+relative-weeks
+morning-clock
+evening-clock
+tonight-clock
+morning-default
+midnight
+noon
+half-hour-clock
+fullwidth-colon
+time-suffix
+tomorrow-date-only
+slash-date-only
+weekday-imprecise-period
+invalid-month
+invalid-day
+invalid-chinese-day
+invalid-iso-day
+invalid-hour
+invalid-minute
+invalid-partial-minute
+explicit-past
+past-date-only
+this-week-past
+year-rollover
+month-end
+leap-day
+partial-minute-rollover
+new-york
+existing-date-clock
+existing-date-partial
+existing-date-past-clock
+explicit-date-overrides-existing
+clock-before-date
+beef-soup-report
+suffix-in-title
+split-date-clock-title
+split-clock-date-title
+title-period
+title-date-slash
+title-date-chinese
+multiple-participants
+missing-title
+breakfast
+daily-breakfast
+dinner
+daily-dinner
+morning-bread
+daily-morning-bread
+night-ticket
+daily-night-ticket
+daily
+weekly
+monthly
+yearly
+daily-morning-default
+interval
+daily-invalid-clock
+interval-zero
+yearly-invalid-date
+shrimp-coins-report
+date-then-clock
+invalid-then-valid-time
+title-follow-up
+new-york-follow-up
+edit-month-day
+edit-clock-only
+edit-partial-minute
+edit-explicit-tomorrow
+edit-invalid-date
+edit-invalid-clock
+edit-past-date
+single-digit-month-follow-up-format
+padded-month-follow-up-format
+hyphen-date-only
+year-slash-date-only
+clock-only-tonight
+hyphen-date-clock
+hyphen-date-next-year
+short-date-clock-next-year
+explicit-year-slash-past
+invalid-hyphen-date
+reply-single-month
+reply-padded-month
+reply-hyphen-date-then-clock
+reply-year-date-then-clock
+reply-clock-without-date
+reply-hyphen-date-next-year
+reply-explicit-past-year-does-not-roll
+reply-clock-rolls-after-cutoff
+year-hyphen-date-only
+edit-hyphen-month-day
+hyphen-date-in-title
diff --git a/tests/test_ai.py b/tests/test_ai.py
new file mode 100644
index 0000000..5de5957
--- /dev/null
+++ b/tests/test_ai.py
@@ -0,0 +1,776 @@
+from __future__ import annotations
+
+import io
+import json
+import tempfile
+import unittest
+import urllib.error
+from copy import deepcopy
+from datetime import datetime, timedelta
+from pathlib import Path
+from unittest.mock import patch
+from zoneinfo import ZoneInfo
+
+from cryptography.fernet import Fernet
+
+from remindly.ai.contracts import (
+ AI_TIME_BUDGET_SECONDS,
+ PROVIDER_ERRORS,
+ AiUnavailable,
+ GeneratedResponse,
+ ProviderError,
+)
+from remindly.ai.gateway import AiGateway
+from remindly.ai.gemini import GeminiProvider, gemini_schema, quota_cooldown
+from remindly.ai.keys import ApiKeyStore
+from remindly.ai.parser import SCHEMA, SemanticParser, validate_result
+from remindly.reminders.models import MentionKind, Participant, RecurrencePeriod, RecurrenceRule
+from remindly.storage.sqlite import ReminderRepository
+
+NOW = datetime(2026, 10, 22, 12, tzinfo=ZoneInfo("Asia/Taipei"))
+SECRET = "AIza" + "a" * 35
+SECOND = "AIza" + "b" * 35
+PERSON = Participant("Orange", MentionKind.TEXT_MENTION, user_id=7)
+
+
+def parsed(**changes) -> dict[str, object]:
+ return {
+ "intent": "create",
+ "title": "後天要交報告",
+ "date": "2026-10-23",
+ "time": "15:00",
+ "relative_minutes": None,
+ "question": None,
+ "recurrence": None,
+ "participant_indices": [0],
+ **changes,
+ }
+
+
+def rule(**changes) -> dict[str, object]:
+ return {
+ "period": "daily",
+ "hour": 9,
+ "minute": 0,
+ "weekdays": [],
+ "month_days": [],
+ "year_month": None,
+ "year_day": None,
+ "interval_seconds": None,
+ **changes,
+ }
+
+
+class ScriptedProvider:
+ id = "gemini"
+ name = "Google Gemini"
+ model = "test-gemini"
+
+ def __init__(self, responses: list[object], *, provider_id: str = "gemini") -> None:
+ self.id = provider_id
+ self.name = provider_id
+ self.model = f"test-{provider_id}"
+ self.display_name = f"Test {provider_id}"
+ self.responses = responses
+ self.calls: list[tuple[str, dict[str, object]]] = []
+ self.schemas: list[dict[str, object]] = []
+ self.timeouts: list[float] = []
+
+ def model_display_name(self, key, *, timeout):
+ return self.display_name
+
+ def generate(self, key, system, prompt, schema, *, timeout=AI_TIME_BUDGET_SECONDS):
+ self.calls.append((key, prompt))
+ self.timeouts.append(timeout)
+ self.schemas.append(schema)
+ response = self.responses.pop(0)
+ if isinstance(response, Exception):
+ raise response
+ return response
+
+
+class KeyStoreTest(unittest.TestCase):
+ def setUp(self) -> None:
+ self.tmp = tempfile.TemporaryDirectory()
+ self.repo = ReminderRepository(Path(self.tmp.name) / "test.db")
+ self.repo.migrate()
+ self.master = Fernet.generate_key().decode()
+ self.keys = ApiKeyStore(self.repo, self.master)
+
+ def tearDown(self) -> None:
+ self.tmp.cleanup()
+
+ def test_encryption_survives_reopen_and_scopes_every_operation(self) -> None:
+ first = self.keys.add(7, provider="gemini", key=SECRET)
+ self.assertTrue(self.keys.enabled(7))
+ self.assertFalse(self.keys.enabled(8))
+ self.assertEqual([], self.keys.list(8))
+ self.assertFalse(self.keys.delete(8, first))
+ with self.assertRaises(ValueError):
+ self.keys.move(8, first, 1)
+ with self.repo.connect() as db:
+ encrypted = bytes(db.execute("select encrypted_key from ai_keys").fetchone()[0])
+ self.assertNotIn(SECRET.encode(), encrypted)
+ self.assertNotIn(SECRET, repr(self.keys.list(7)))
+ reopened = ApiKeyStore(self.repo, self.master)
+ self.assertEqual(SECRET, reopened.decrypt(reopened.list(7)[0]))
+ self.assertTrue(reopened.delete(7, first))
+ self.assertFalse(reopened.enabled(7))
+
+ def test_order_duplicates_switch_and_invalid_input(self) -> None:
+ first = self.keys.add(7, provider="gemini", key=SECRET)
+ second = self.keys.add(7, provider="gemini", key=SECOND)
+ self.keys.move(7, second, 1)
+ self.assertEqual([second, first], [k.id for k in self.keys.list(7)])
+ with self.assertRaises(ValueError):
+ self.keys.add(7, provider="gemini", key=SECRET)
+ with self.assertRaises(ValueError):
+ self.keys.add(7, provider="gemini", key="bad\nkey")
+ self.keys.set_enabled(7, False)
+ self.keys.add(7, provider="gemini", key="AIza" + "c" * 35)
+ self.assertFalse(self.keys.enabled(7))
+ self.keys.set_enabled(7, True)
+ self.assertTrue(self.keys.enabled(7))
+
+ def test_missing_or_changed_master_key_fails_closed(self) -> None:
+ unavailable = ApiKeyStore(self.repo, "")
+ self.assertFalse(unavailable.enabled(7))
+ with self.assertRaises(ValueError):
+ unavailable.add(7, provider="gemini", key=SECRET)
+ self.keys.add(7, provider="gemini", key=SECRET)
+ changed = ApiKeyStore(self.repo, Fernet.generate_key().decode())
+ with self.assertRaises(AiUnavailable) as error:
+ changed.decrypt(changed.list(7)[0])
+ self.assertNotIn(SECRET, str(error.exception))
+
+ def test_quota_falls_back_and_cooldown_survives_restart(self) -> None:
+ first = self.keys.add(7, provider="gemini", key=SECRET)
+ self.keys.add(7, provider="gemini", key=SECOND)
+ client = ScriptedProvider([ProviderError("quota", 3600), parsed()])
+ gateway = AiGateway(self.keys, {"gemini": client}, default_provider="gemini")
+ self.assertEqual(parsed(), gateway.generate(7, "system", {}, SCHEMA).data)
+ self.assertEqual([SECRET, SECOND], [call[0] for call in client.calls])
+ reopened = ApiKeyStore(self.repo, self.master)
+ self.assertEqual([SECOND], [reopened.decrypt(k) for k in reopened.available(7)])
+ self.assertEqual("cooldown", next(k for k in reopened.list(7) if k.id == first).status)
+
+ def test_invalid_key_skips_future_calls_and_never_borrows_another_users_key(self) -> None:
+ self.keys.add(7, provider="gemini", key=SECRET)
+ self.keys.add(8, provider="gemini", key=SECOND)
+ client = ScriptedProvider([ProviderError("invalid")])
+ gateway = AiGateway(self.keys, {"gemini": client}, default_provider="gemini")
+ for _ in range(2):
+ with self.assertRaises(AiUnavailable):
+ gateway.generate(7, "system", {}, SCHEMA)
+ self.assertEqual(1, len(client.calls))
+ self.assertEqual("invalid", self.keys.list(7)[0].status)
+ self.assertEqual(1, len(self.keys.available(8)))
+
+ def test_failure_reasons_survive_reopen_and_success_clears_them(self) -> None:
+ key_id = self.keys.add(7, provider="gemini", key=SECRET)
+ for kind, label in PROVIDER_ERRORS.items():
+ with self.subTest(kind=kind):
+ self.keys.mark(7, key_id, "unchecked")
+ client = ScriptedProvider([ProviderError(kind, 60)])
+ gateway = AiGateway(self.keys, {"gemini": client}, default_provider="gemini")
+ with self.assertRaises(AiUnavailable) as error:
+ gateway.generate(7, "system", {}, SCHEMA)
+ self.assertIn(label, str(error.exception))
+ self.assertNotIn(SECRET, str(error.exception))
+ reopened = ApiKeyStore(self.repo, self.master)
+ self.assertEqual(kind, reopened.list(7)[0].last_error)
+ if kind not in {"request", "response"}:
+ with self.assertRaises(AiUnavailable) as retry:
+ AiGateway(reopened, {"gemini": client}, default_provider="gemini").generate(
+ 7, "system", {}, SCHEMA
+ )
+ self.assertIn(label, str(retry.exception))
+ self.assertEqual(1, len(client.calls))
+ self.keys.mark(7, key_id, "unchecked", last_error="timeout")
+ gateway = AiGateway(
+ self.keys, {"gemini": ScriptedProvider([parsed()])}, default_provider="gemini"
+ )
+ gateway.generate(7, "system", {}, SCHEMA)
+ self.assertIsNone(self.keys.list(7)[0].last_error)
+
+ def test_attempts_are_bounded_and_bad_response_does_not_rotate(self) -> None:
+ for i in range(5):
+ self.keys.add(7, provider="gemini", key="AIza" + str(i) * 35)
+ client = ScriptedProvider([ProviderError("temporary")] * 3)
+ with self.assertRaises(AiUnavailable):
+ AiGateway(self.keys, {"gemini": client}, default_provider="gemini").generate(
+ 7, "system", {}, SCHEMA
+ )
+ self.assertEqual(3, len(client.calls))
+ client = ScriptedProvider([ProviderError("response")])
+ with self.assertRaises(AiUnavailable):
+ AiGateway(self.keys, {"gemini": client}, default_provider="gemini").generate(
+ 7, "system", {}, SCHEMA
+ )
+ self.assertEqual(1, len(client.calls))
+
+ def test_no_new_attempt_starts_after_time_budget(self) -> None:
+ self.keys.add(7, provider="gemini", key=SECRET)
+ self.keys.add(7, provider="gemini", key=SECOND)
+ client = ScriptedProvider([ProviderError("temporary")])
+ with (
+ patch("remindly.ai.gateway.time.monotonic", side_effect=[0, 0, 19]),
+ self.assertRaises(AiUnavailable),
+ ):
+ AiGateway(self.keys, {"gemini": client}, default_provider="gemini").generate(
+ 7, "system", {}, SCHEMA
+ )
+ self.assertEqual(1, len(client.calls))
+
+ def test_single_key_can_wait_longer_than_six_seconds(self) -> None:
+ class SlowProvider(ScriptedProvider):
+ def generate(self, key, system, prompt, schema, *, timeout):
+ if timeout < 8:
+ raise ProviderError("timeout")
+ return super().generate(key, system, prompt, schema, timeout=timeout)
+
+ self.keys.add(7, provider="gemini", key=SECRET)
+ client = SlowProvider([parsed()])
+ gateway = AiGateway(self.keys, {"gemini": client}, default_provider="gemini")
+ with patch("remindly.ai.gateway.time.monotonic", return_value=0):
+ self.assertEqual(parsed(), gateway.generate(7, "system", {}, SCHEMA).data)
+ self.assertEqual([18], client.timeouts)
+ self.assertEqual("ready", self.keys.list(7)[0].status)
+
+ def test_fallback_uses_only_remaining_shared_time(self) -> None:
+ self.keys.add(7, provider="gemini", key=SECRET)
+ self.keys.add(7, provider="gemini", key=SECOND)
+ client = ScriptedProvider([ProviderError("quota"), parsed()])
+ gateway = AiGateway(self.keys, {"gemini": client}, default_provider="gemini")
+ with patch("remindly.ai.gateway.time.monotonic", side_effect=[0, 0, 4, 9]):
+ self.assertEqual(parsed(), gateway.generate(7, "system", {}, SCHEMA).data)
+ self.assertEqual([18, 14], client.timeouts)
+ self.assertEqual([SECRET, SECOND], [call[0] for call in client.calls])
+
+ def test_short_remaining_time_is_not_extended_for_generation_or_metadata(self) -> None:
+ self.keys.add(7, provider="gemini", key=SECRET)
+ client = ScriptedProvider([parsed()])
+ gateway = AiGateway(self.keys, {"gemini": client}, default_provider="gemini")
+ with (
+ patch("remindly.ai.gateway.time.monotonic", side_effect=[0, 16, 18]),
+ patch.object(client, "model_display_name") as metadata,
+ ):
+ self.assertEqual(parsed(), gateway.generate(7, "system", {}, SCHEMA).data)
+ metadata.assert_not_called()
+ self.assertEqual([2], client.timeouts)
+
+ def test_cooldown_expiry_makes_key_available(self) -> None:
+ key_id = self.keys.add(7, provider="gemini", key=SECRET)
+ self.keys.mark(7, key_id, "cooldown", 60)
+ self.assertEqual([], self.keys.available(7))
+ with self.repo.connect() as db:
+ db.execute(
+ "update ai_keys set cooldown_until = ?", ((NOW - timedelta(days=1)).isoformat(),)
+ )
+ self.assertEqual(1, len(self.keys.available(7)))
+
+ def test_second_provider_reuses_parser_without_receiving_gemini_key(self) -> None:
+ self.keys.add(7, SECRET, provider="gemini")
+ key_id = self.keys.add(7, SECOND, provider="other")
+ self.keys.move(7, key_id, 1)
+ gemini = ScriptedProvider([])
+ other = ScriptedProvider([parsed()], provider_id="other")
+ gateway = AiGateway(
+ self.keys, {"gemini": gemini, "other": other}, default_provider="gemini"
+ )
+ result = SemanticParser(gateway).parse("明天下午三點提醒我後天交報告", 7, NOW, [PERSON])
+ self.assertEqual("other", result.raw["provider"])
+ self.assertEqual("test-other", result.raw["model"])
+ self.assertEqual("Test other", result.raw["model_display_name"])
+ self.assertEqual(SECOND, other.calls[0][0])
+ self.assertEqual([], gemini.calls)
+ self.assertEqual("object", other.schemas[0]["type"])
+ self.assertEqual(["string", "null"], other.schemas[0]["properties"]["title"]["type"])
+ self.assertEqual(15, result.remind_at.hour)
+
+ def test_cross_provider_fallback_shares_policy_and_reports_actual_model(self) -> None:
+ self.keys.add(7, SECRET, provider="gemini")
+ self.keys.add(7, SECOND, provider="other")
+ gemini = ScriptedProvider([ProviderError("quota")])
+ other = ScriptedProvider([parsed()], provider_id="other")
+ gateway = AiGateway(
+ self.keys, {"gemini": gemini, "other": other}, default_provider="gemini"
+ )
+ result = gateway.generate(7, "system", {}, SCHEMA)
+ self.assertEqual("other", result.provider)
+ self.assertEqual("test-other", result.model)
+ self.assertEqual("Test other", result.model_display_name)
+ self.assertEqual(SECRET, gemini.calls[0][0])
+ self.assertEqual(SECOND, other.calls[0][0])
+
+ def test_removed_or_unknown_provider_never_receives_another_providers_key(self) -> None:
+ self.keys.add(7, SECRET, provider="unregistered")
+ gemini = ScriptedProvider([])
+ gateway = AiGateway(self.keys, {"gemini": gemini}, default_provider="gemini")
+ with self.assertRaises(AiUnavailable):
+ gateway.generate(7, "system", {}, SCHEMA)
+ self.assertEqual([], gemini.calls)
+ with self.assertRaises(ValueError):
+ gateway.add_key(7, SECOND, "unregistered")
+ with self.assertRaises(ValueError):
+ AiGateway(self.keys, {"wrong": gemini}, default_provider="wrong")
+
+
+class GeminiClientTest(unittest.TestCase):
+ def test_schema_conversion_is_recursive_and_does_not_modify_shared_schema(self) -> None:
+ before = deepcopy(SCHEMA)
+ converted = gemini_schema(SCHEMA)
+ self.assertEqual("OBJECT", converted["type"])
+ self.assertEqual(True, converted["properties"]["recurrence"]["nullable"])
+ hour = converted["properties"]["recurrence"]["properties"]["hour"]
+ self.assertEqual({"type": "INTEGER", "nullable": True}, hour)
+ self.assertEqual(SCHEMA, before)
+
+ def test_real_http_request_contract_and_thinking_parts(self) -> None:
+ captured = []
+
+ def opener(request, *, timeout):
+ captured.append(request)
+ return io.BytesIO(
+ json.dumps(
+ {
+ "candidates": [
+ {
+ "finishReason": "STOP",
+ "content": {
+ "parts": [
+ {"thought": True, "text": "reasoning"},
+ {"text": json.dumps(parsed())},
+ ]
+ },
+ }
+ ]
+ }
+ ).encode()
+ )
+
+ result = GeminiProvider(opener=opener).generate(
+ SECRET, "system", {"text": "提醒我"}, SCHEMA
+ )
+ self.assertEqual(parsed(), result)
+ request = captured[0]
+ self.assertIn(GeminiProvider.model, request.full_url)
+ self.assertNotIn(SECRET, request.full_url)
+ self.assertEqual(SECRET, request.get_header("X-goog-api-key"))
+ body = json.loads(request.data)
+ self.assertEqual(gemini_schema(SCHEMA), body["generationConfig"]["responseSchema"])
+ self.assertNotIn(SECRET, request.data.decode())
+
+ def test_model_display_name_uses_metadata_and_caches_by_model(self) -> None:
+ captured = []
+
+ def opener(request, *, timeout):
+ captured.append(request)
+ self.assertEqual("GET", request.get_method())
+ self.assertEqual(SECRET, request.get_header("X-goog-api-key"))
+ self.assertNotIn(SECRET, request.full_url)
+ self.assertEqual(2, timeout)
+ return io.BytesIO(
+ json.dumps({"displayName": f"Readable Model {len(captured)}"}).encode()
+ )
+
+ provider = GeminiProvider(opener=opener)
+ self.assertEqual("Readable Model 1", provider.model_display_name(SECRET, timeout=2))
+ self.assertEqual("Readable Model 1", provider.model_display_name(SECOND, timeout=2))
+ self.assertEqual(1, len(captured))
+ provider.model = "another-model"
+ self.assertEqual("Readable Model 2", provider.model_display_name(SECRET, timeout=2))
+ self.assertIn("/models/another-model", captured[-1].full_url)
+
+ def test_metadata_failure_or_missing_name_is_optional(self) -> None:
+ for body in (b"bad", b"[]", b"{}", b'{"displayName": " "}', b'{"displayName": 42}'):
+ with self.subTest(body=body):
+ provider = GeminiProvider(opener=lambda *a, body=body, **kw: io.BytesIO(body))
+ self.assertIsNone(provider.model_display_name(SECRET, timeout=2))
+
+ def opener(*args, **kwargs):
+ raise urllib.error.HTTPError(SECRET, 403, SECRET, {}, io.BytesIO(b"denied"))
+
+ self.assertIsNone(GeminiProvider(opener=opener).model_display_name(SECRET, timeout=2))
+
+ def test_errors_do_not_echo_upstream_credentials(self) -> None:
+ cases = [
+ (401, {}, "invalid"),
+ (403, {}, "permission"),
+ (500, {}, "temporary"),
+ (429, {}, "quota"),
+ (
+ 429,
+ {"error": {"details": [{"violations": [{"quotaId": "RequestsPerDay"}]}]}},
+ "daily_quota",
+ ),
+ (
+ 429,
+ {"error": {"details": [{"violations": [{"quotaId": "RequestsPerMinute"}]}]}},
+ "rate",
+ ),
+ (400, {"error": {"details": [{"reason": "API_KEY_INVALID"}]}}, "invalid"),
+ (400, {"error": None}, "request"),
+ ]
+ for code, details, kind in cases:
+ with self.subTest(code=code, kind=kind):
+
+ def opener(request, *, timeout, code=code, details=details):
+ raise urllib.error.HTTPError(
+ SECRET, code, SECRET, {}, io.BytesIO(json.dumps(details).encode())
+ )
+
+ with self.assertRaises(ProviderError) as error:
+ GeminiProvider(opener=opener).generate(SECRET, "system", {}, SCHEMA)
+ self.assertEqual(kind, error.exception.kind)
+ self.assertNotIn(SECRET, str(error.exception))
+ self.assertTrue(error.exception.__suppress_context__)
+
+ def test_timeout_and_malformed_or_truncated_outputs(self) -> None:
+ for output in (
+ b"bad",
+ b"[]",
+ b'{"candidates": []}',
+ b'{"candidates": [null]}',
+ b'{"candidates": [{"finishReason": "STOP", "content": {"parts": [null]}}]}',
+ json.dumps(
+ {"candidates": [{"finishReason": "MAX_TOKENS", "content": {"parts": []}}]}
+ ).encode(),
+ ):
+ with self.subTest(output=output), self.assertRaises(ProviderError):
+ GeminiProvider(opener=lambda *a, output=output, **kw: io.BytesIO(output)).generate(
+ SECRET, "system", {}, SCHEMA
+ )
+
+ def timeout(*args, **kwargs):
+ raise TimeoutError(SECRET)
+
+ with self.assertRaises(ProviderError) as error:
+ GeminiProvider(opener=timeout).generate(SECRET, "system", {}, SCHEMA)
+ self.assertEqual("timeout", error.exception.kind)
+
+ def test_connection_and_wrapped_timeout_are_distinct(self) -> None:
+ for failure, kind in (
+ (urllib.error.URLError(TimeoutError(SECRET)), "timeout"),
+ (urllib.error.URLError(SECRET), "connection"),
+ (OSError(SECRET), "connection"),
+ ):
+
+ def opener(*args, failure=failure, **kwargs):
+ raise failure
+
+ with self.subTest(kind=kind), self.assertRaises(ProviderError) as error:
+ GeminiProvider(opener=opener).generate(SECRET, "system", {}, SCHEMA)
+ self.assertEqual(kind, error.exception.kind)
+ self.assertNotIn(SECRET, str(error.exception))
+
+ def test_retry_and_daily_reset_are_distinguished(self) -> None:
+ self.assertEqual(60, quota_cooldown({}))
+ self.assertEqual(121, quota_cooldown({"error": {"details": [{"retryDelay": "120s"}]}}))
+ with patch("remindly.ai.gemini.datetime") as clock:
+ clock.now.return_value = NOW.astimezone(ZoneInfo("America/Los_Angeles"))
+ delay = quota_cooldown(
+ {
+ "error": {
+ "details": [
+ {
+ "violations": [
+ {"quotaId": "GenerateRequestsPerDayPerProjectPerModel-FreeTier"}
+ ]
+ }
+ ]
+ }
+ }
+ )
+ self.assertGreater(delay, 60)
+ self.assertLessEqual(delay, 86401)
+
+ def test_daily_quota_handles_25_hour_pacific_day(self) -> None:
+ with patch("remindly.ai.gemini.datetime") as clock:
+ clock.now.return_value = datetime(2026, 11, 1, tzinfo=ZoneInfo("America/Los_Angeles"))
+ delay = quota_cooldown(
+ {"error": {"details": [{"violations": [{"quotaId": "RequestsPerDayPerModel"}]}]}}
+ )
+ self.assertEqual(90001, delay)
+
+
+class SemanticTimeGuardTest(unittest.TestCase):
+ def test_date_then_clock_cannot_clear_pending_condition(self):
+ context = self.draft(
+ missing_fields=["time", "clarification"],
+ parse_result={"question": "是否只限工作日?"},
+ )
+ for text in ("11/17", "23:55"):
+ with self.subTest(text=text):
+ result = self.parse(parsed(intent="update"), text, context=context)
+ self.assertIn("clarification", result.missing_fields)
+ self.assertIn("是否只限工作日?", result.raw["question"])
+ context.remind_at = result.remind_at
+ context.missing_fields = list(result.missing_fields)
+ context.parse_result = result.raw
+ self.assertEqual("是否只限工作日?", result.raw["question"])
+
+ def test_unrequested_or_changed_recurrence_requires_confirmation(self):
+ for text, recurrence in (
+ ("提醒我明天下午三點買湯", rule()),
+ ("提醒我明天下午三點每週一吃藥", rule()),
+ ):
+ with self.subTest(text=text):
+ result = self.parse(parsed(recurrence=recurrence), text)
+ self.assertIsNone(result.recurrence)
+ self.assertIn("clarification", result.missing_fields)
+ self.assertIn("週期", result.raw["question"])
+
+ def test_missing_recipients_are_not_guessed_from_multiple_candidates(self):
+ other = Participant("Alice", MentionKind.TEXT_MENTION, user_id=8)
+ result = self.parse(
+ parsed(date="2030-09-17", participant_indices=[]),
+ "提醒我09-17 23:55和 @alice 買湯",
+ participants=[PERSON, other],
+ )
+ self.assertIn("participants", result.missing_fields)
+ self.assertEqual((), result.participants)
+
+ def parse(self, data, text, *, context=None, now=NOW, participants=None, purpose="reminder"):
+ class Gateway:
+ def generate(self, *args):
+ return GeneratedResponse(data, "test", "variable-model")
+
+ return SemanticParser(Gateway()).parse(
+ text, 7, now, participants or [PERSON], context=context, purpose=purpose
+ )
+
+ def draft(self, **changes):
+ from remindly.reminders.models import ReminderDraft
+
+ values = dict(
+ id="guard",
+ chat_id=100,
+ chat_type="private",
+ creator_user_id=7,
+ timezone="Asia/Taipei",
+ source_text="提醒我要領蝦幣",
+ title="要領蝦幣",
+ participants=[PERSON],
+ missing_fields=["time"],
+ )
+ values.update(changes)
+ return ReminderDraft(**values)
+
+ def test_explicit_date_is_independent_of_model_year(self):
+ for text in ("提醒我09-17 23:55領蝦幣", "9/17 23:55提醒我領蝦幣"):
+ for year in (2025, 2026, 2027, 2030):
+ with self.subTest(text=text, year=year):
+ result = self.parse(parsed(date=f"{year}-10-24", time="09:00"), text)
+ self.assertEqual(
+ datetime(2027, 9, 17, 23, 55, tzinfo=NOW.tzinfo), result.remind_at
+ )
+ self.assertEqual((), result.missing_fields)
+ self.assertEqual("rules", result.raw["time_validation"])
+
+ def test_date_then_clock_cannot_change_date_or_other_fields(self):
+ context = self.draft()
+ first = self.parse(
+ parsed(intent="update", date="2026-09-17", time="12:00"), "09-17", context=context
+ )
+ self.assertEqual(datetime(2027, 9, 17, tzinfo=NOW.tzinfo), first.remind_at)
+ self.assertIn("time", first.missing_fields)
+ context.remind_at = first.remind_at
+ other = Participant("Alice", MentionKind.TEXT_MENTION, user_id=8)
+ for day in ("2026-10-22", "2026-09-17", "2030-01-01"):
+ for intent in ("create", "update", "not_reminder"):
+ with self.subTest(day=day, intent=intent):
+ result = self.parse(
+ parsed(
+ intent=intent,
+ date=day,
+ time="01:00",
+ title="轉帳",
+ recurrence=rule(),
+ participant_indices=[1],
+ question="改成今天嗎?",
+ ),
+ "23:55",
+ context=context,
+ participants=[PERSON, other],
+ )
+ self.assertEqual(
+ datetime(2027, 9, 17, 23, 55, tzinfo=NOW.tzinfo), result.remind_at
+ )
+ self.assertEqual("要領蝦幣", result.title)
+ self.assertEqual((PERSON,), result.participants)
+ self.assertIsNone(result.recurrence)
+ self.assertEqual("update", result.raw["intent"])
+ self.assertEqual((), result.missing_fields)
+
+ def test_explicit_year_is_never_rolled_forward(self):
+ result = self.parse(parsed(date="2030-09-17"), "提醒我2026/09/17 23:55買湯")
+ self.assertEqual(datetime(2026, 9, 17, 23, 55, tzinfo=NOW.tzinfo), result.remind_at)
+ self.assertIn("time", result.missing_fields)
+
+ def test_invalid_date_cannot_be_replaced_with_valid_date(self):
+ for data in (
+ parsed(date="2030-09-30"),
+ parsed(
+ intent="not_reminder",
+ title=None,
+ date=None,
+ time=None,
+ question="九月沒有31日",
+ participant_indices=[],
+ ),
+ ):
+ with self.subTest(data=data):
+ result = self.parse(data, "提醒我09-31 23:55買湯")
+ self.assertIsNone(result.remind_at)
+ self.assertIn("time", result.missing_fields)
+ self.assertEqual("create", result.raw["intent"])
+ self.assertIsNotNone(result.raw["question"])
+ context = self.draft(
+ recurrence=RecurrenceRule(period=RecurrencePeriod.DAILY, hour=9, minute=0)
+ )
+ result = self.parse(parsed(recurrence=rule()), "09-31 23:55", context=context)
+ self.assertIsNone(result.remind_at)
+ self.assertIn("time", result.missing_fields)
+ self.assertEqual(context.recurrence, result.recurrence)
+
+ def test_subject_date_does_not_override_reminder_time(self):
+ result = self.parse(parsed(), "明天下午三點提醒我後天要交報告")
+ self.assertEqual(datetime(2026, 10, 23, 15, tzinfo=NOW.tzinfo), result.remind_at)
+ self.assertEqual("後天要交報告", result.title)
+ data = parsed(date=None, time=None, question="什麼時候提醒?")
+ result = self.parse(data, "提醒我買11/17要用的湯匙")
+ self.assertNotIn("time_validation", result.raw)
+
+ def test_relative_time_preserves_seconds_and_elapsed_time(self):
+ for now in (
+ NOW.replace(second=37),
+ datetime(2027, 3, 14, 1, 45, tzinfo=ZoneInfo("America/New_York")),
+ datetime(2027, 11, 7, 1, 45, tzinfo=ZoneInfo("America/New_York")),
+ ):
+ with self.subTest(now=now):
+ result = self.parse(
+ parsed(date=None, time=None, relative_minutes=30), "半小時後提醒我買湯", now=now
+ )
+ self.assertEqual(1800, result.remind_at.timestamp() - now.timestamp())
+ self.assertEqual(now.second, result.remind_at.second)
+ self.assertEqual((), result.missing_fields)
+
+ def test_dst_ambiguity_is_still_blocked(self):
+ now = datetime(2027, 11, 6, 12, tzinfo=ZoneInfo("America/New_York"))
+ result = self.parse(parsed(date="2027-11-07", time="01:30"), "提醒我明天01:30買湯", now=now)
+ self.assertIn("time", result.missing_fields)
+ self.assertIn("日光節約", result.raw["question"])
+
+ def test_unresolved_condition_is_not_cleared_by_clock_reply(self):
+ context = self.draft(
+ missing_fields=["time", "clarification"], parse_result={"question": "是否只限工作日?"}
+ )
+ result = self.parse(parsed(intent="update"), "23:55", context=context)
+ self.assertIn("clarification", result.missing_fields)
+ self.assertEqual("是否只限工作日?", result.raw["question"])
+
+ def test_pending_recurrence_uses_original_weekdays(self):
+ context = self.draft(source_text="每週一提醒我吃藥", title="吃藥")
+ result = self.parse(
+ parsed(intent="update", date=None, time=None, recurrence=rule()),
+ "早上九點",
+ context=context,
+ )
+ self.assertEqual(RecurrencePeriod.WEEKLY, result.recurrence.period)
+ self.assertEqual((0,), result.recurrence.weekdays)
+ self.assertEqual(datetime(2026, 10, 26, 9, tzinfo=NOW.tzinfo), result.remind_at)
+
+
+class SemanticValidationTest(unittest.TestCase):
+ def test_separate_dates_title_and_absolute_time(self) -> None:
+ result = validate_result(parsed(), NOW, [PERSON])
+ self.assertEqual("後天要交報告", result.title)
+ self.assertEqual(datetime(2026, 10, 23, 15, tzinfo=NOW.tzinfo), result.remind_at)
+ self.assertEqual((), result.missing_fields)
+
+ def test_relative_is_calculated_locally(self) -> None:
+ result = validate_result(parsed(date=None, time=None, relative_minutes=30), NOW, [PERSON])
+ self.assertEqual(NOW + timedelta(minutes=30), result.remind_at)
+
+ def test_relative_minutes_are_elapsed_time_across_dst(self) -> None:
+ now = datetime(2027, 3, 14, 1, 45, tzinfo=ZoneInfo("America/New_York"))
+ result = validate_result(parsed(date=None, time=None, relative_minutes=30), now, [PERSON])
+ self.assertEqual(1800, result.remind_at.timestamp() - now.timestamp())
+ self.assertEqual((3, 15), (result.remind_at.hour, result.remind_at.minute))
+
+ def test_missing_past_ambiguous_and_unknown_participant_require_followup(self) -> None:
+ cases = [
+ (parsed(time=None, question="那天幾點?"), "time"),
+ (parsed(date="2026-10-21"), "time"),
+ (parsed(question="是否只限週一到週五?"), "clarification"),
+ (parsed(participant_indices=[], question="請提供小明的 @username。"), "participants"),
+ ]
+ for data, missing in cases:
+ with self.subTest(missing=missing):
+ self.assertIn(missing, validate_result(data, NOW, [PERSON]).missing_fields)
+
+ def test_schema_and_value_validation_reject_hallucinated_fields(self) -> None:
+ cases = [
+ parsed(date="2026-02-30"),
+ parsed(time="25:00"),
+ parsed(relative_minutes=True),
+ parsed(relative_minutes=1),
+ parsed(participant_indices=[100]),
+ parsed(participant_indices=[True]),
+ parsed(user_id=999),
+ parsed(title="x" * 1001),
+ parsed(recurrence=rule(hour=24)),
+ parsed(recurrence=rule(period="weekly", weekdays=[7])),
+ parsed(recurrence=rule(period="interval", interval_seconds=60)),
+ parsed(recurrence=rule(period="interval", interval_seconds=601)),
+ parsed(recurrence=rule(period="yearly", year_month=2, year_day=30)),
+ ]
+ for data in cases:
+ with self.subTest(data=data), self.assertRaises((ValueError, TypeError)):
+ validate_result(data, NOW, [PERSON])
+
+ def test_supported_recurrence_uses_shared_next_fire(self) -> None:
+ for recurrence in (
+ rule(),
+ rule(period="weekly", weekdays=[0, 2, 4]),
+ rule(period="monthly", month_days=[1, 18, 25]),
+ rule(period="yearly", year_month=12, year_day=25),
+ rule(period="interval", hour=None, minute=None, interval_seconds=900),
+ ):
+ with self.subTest(rule=recurrence):
+ result = validate_result(
+ parsed(date=None, time=None, recurrence=recurrence), NOW, [PERSON]
+ )
+ self.assertGreater(result.remind_at, NOW)
+ self.assertEqual((), result.missing_fields)
+
+ def test_dst_invalid_or_ambiguous_time_is_not_silently_accepted(self) -> None:
+ now = NOW.astimezone(ZoneInfo("America/New_York"))
+ for day in ("2027-03-14", "2027-11-07"):
+ clock = "02:30" if day == "2027-03-14" else "01:30"
+ result = validate_result(parsed(date=day, time=clock), now, [PERSON])
+ self.assertIn("time", result.missing_fields)
+
+ def test_recurring_start_checks_dst_without_explicit_date(self) -> None:
+ zone = ZoneInfo("America/New_York")
+ for now, hour in (
+ (datetime(2027, 3, 14, tzinfo=zone), 2),
+ (datetime(2027, 11, 7, tzinfo=zone), 1),
+ ):
+ with self.subTest(now=now):
+ result = validate_result(
+ parsed(date=None, time=None, recurrence=rule(hour=hour, minute=30)),
+ now,
+ [PERSON],
+ )
+ self.assertIn("time", result.missing_fields)
+ self.assertIn("日光節約時間", result.raw["question"])
+
+ def test_semantic_parser_converts_bad_output_to_safe_error(self) -> None:
+ class Gateway:
+ def generate(self, *args):
+ return GeneratedResponse(parsed(date="wrong"), "test", "test-mini")
+
+ with self.assertRaises(AiUnavailable):
+ SemanticParser(Gateway()).parse("明天下午提醒我", 7, NOW, [PERSON])
diff --git a/tests/test_ai_content.py b/tests/test_ai_content.py
new file mode 100644
index 0000000..2e508db
--- /dev/null
+++ b/tests/test_ai_content.py
@@ -0,0 +1,627 @@
+from __future__ import annotations
+
+import json
+import tempfile
+import unittest
+from datetime import UTC, datetime
+from pathlib import Path
+from unittest.mock import Mock
+from zoneinfo import ZoneInfo
+
+from test_ai import NOW, PERSON, parsed, rule
+
+from remindly.ai.contracts import AiUnavailable, GeneratedResponse
+from remindly.ai.parser import SemanticParser
+from remindly.reminders.models import MentionKind, Participant, ReminderDraft
+from remindly.reminders.parser import ReminderParser
+from remindly.reminders.service import Confirmation, DraftPrompt, ReminderService
+from remindly.storage.session_stores import SqliteDraftStore, SqliteEditSessionStore
+from remindly.storage.sqlite import ReminderRepository
+from remindly.telegram.models import TelegramChat, TelegramMessage, TelegramUser
+
+
+class GeminiContentRegressionTest(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ fixture = Path(__file__).parent / "fixtures" / "gemini_content_regressions.jsonl"
+ cls.rows = [json.loads(line) for line in fixture.read_text().splitlines()]
+
+ def parse(self, text, response, *, context=None, participants=None, purpose="reminder"):
+ generator = Mock()
+ generator.generate.return_value = GeneratedResponse(response, "test", "variable")
+ return SemanticParser(generator).parse(
+ text, 7, NOW, participants or [PERSON], context=context, purpose=purpose
+ )
+
+ def test_literal_content_is_independent_of_model_title_and_question(self):
+ for text, title in (
+ ("提醒我明天下午三點寄出年度報表", "寄出年度報表"),
+ ("明天下午三點提醒我拿包裹和明天要用的資料", "拿包裹和明天要用的資料"),
+ ("11/31 23:55提醒我領藥", "領藥"),
+ ("提醒我2026/11/31 23:55領藥", "領藥"),
+ ("11月170日 23:55領藥", "領藥"),
+ ("明天下午三點寄出年度報表", "寄出年度報表"),
+ ):
+ for model_title in (None, "轉帳", "年度報表"):
+ with self.subTest(text=text, model_title=model_title):
+ result = self.parse(text, parsed(title=model_title, question="換個時間嗎?"))
+ self.assertEqual(title, result.title)
+ if result.remind_at and "time" not in result.missing_fields:
+ self.assertIsNone(result.raw["question"])
+
+ def test_missing_source_title_cannot_be_invented_by_model(self):
+ result = self.parse("提醒我明天下午三點", parsed(title="轉帳", question="明天幾點?"))
+ self.assertIsNone(result.title)
+ self.assertIn("title", result.missing_fields)
+ self.assertIsNone(result.raw["question"])
+
+ def test_semantic_conditions_and_multiple_reminders_keep_their_question(self):
+ for source in (
+ "提醒我明天下午三點只在工作日喝水",
+ "提醒我明天下午三點排除國定假日喝水",
+ "提醒我明天下午三點如果下雨就收衣服",
+ "提醒我明天下午三點之前交報表",
+ "提醒我明天下午三點前交報表",
+ "提醒我明天下午三點拿包裹,後天九點提醒我領藥",
+ "提醒我明天下午三點去市場,後天去醫院",
+ "提醒我明天下午三點週一至週五喝水",
+ "提醒我明天下午三點喝水但只在工作日",
+ ):
+ with self.subTest(source=source):
+ result = self.parse(source, parsed(question="請確認額外條件。"))
+ self.assertEqual("請確認額外條件。", result.raw["question"])
+ self.assertIn("clarification", result.missing_fields)
+
+ def test_unrequested_recurrence_and_missing_participants_are_not_finalized(self):
+ result = self.parse("提醒我明天下午三點拿包裹", parsed(recurrence=rule()))
+ self.assertIn("clarification", result.missing_fields)
+ other = Participant("Alice", MentionKind.TEXT_MENTION, user_id=8)
+ result = self.parse(
+ "提醒我明天下午三點和 @alice 拿包裹",
+ parsed(participant_indices=[], question="要提醒誰?"),
+ participants=[PERSON, other],
+ )
+ self.assertIn("participants", result.missing_fields)
+ self.assertEqual("要提醒誰?", result.raw["question"])
+
+ def test_model_cannot_omit_explicit_condition_or_multiple_reminder_question(self):
+ for source in (
+ "提醒我明天下午三點只在工作日喝水",
+ "提醒我明天下午三點排除國定假日喝水",
+ "提醒我明天下午三點如果下雨就收衣服",
+ "提醒我明天下午三點拿包裹,後天九點提醒我領藥",
+ "提醒我明天下午三點喝水但只在工作日",
+ ):
+ with self.subTest(source=source):
+ result = self.parse(source, parsed(question=None))
+ self.assertIn("clarification", result.missing_fields)
+ self.assertIsNotNone(result.raw["question"])
+
+ def test_literal_names_and_item_attributes_do_not_invent_conditions(self):
+ for title in (
+ "寄若晴的包裹",
+ "買只要五十元的湯",
+ "買假日要用的湯匙",
+ "前往車站",
+ "到家樂福買湯",
+ "內湖寄包裹",
+ "起司蛋糕",
+ "工作日行事曆",
+ "假日用的湯匙",
+ "週末報表",
+ "若晴的包裹",
+ "若晴的時鐘",
+ "等待回覆的信件",
+ "排除異常資料",
+ "不含麩質的麵包",
+ "之前的會議紀錄",
+ ):
+ with self.subTest(title=title):
+ for question in (None, "要改成明天嗎?"):
+ result = self.parse(f"提醒我明天下午三點{title}", parsed(question=question))
+ self.assertEqual(title, result.title)
+ self.assertEqual((), result.missing_fields)
+
+ def test_unknown_and_conditional_questions_are_not_cleared(self):
+ for title, question in (
+ ("有空才交報告", "有空是什麼時間?"),
+ ("等收到帳單再繳費", "收到帳單的具體時間?"),
+ ("收衣服(如果下雨)", "是否只在下雨時提醒?"),
+ ("寄出報表", "請確認尚未釐清的限制。"),
+ ("前交報表", "這是截止時間嗎?"),
+ ):
+ with self.subTest(title=title):
+ result = self.parse(f"提醒我明天下午三點{title}", parsed(question=question))
+ self.assertIn("clarification", result.missing_fields)
+ self.assertEqual(question, result.raw["question"])
+
+ def test_calendar_definition_questions_require_source_condition(self):
+ for calendar in ("工作日", "平日", "假日", "週末"):
+ question = f"「{calendar}」是指週一至週五嗎?"
+ with self.subTest(calendar=calendar):
+ result = self.parse(
+ f"提醒我明天下午三點買{calendar}用的筆記本", parsed(question=question)
+ )
+ self.assertEqual((), result.missing_fields)
+ result = self.parse(
+ f"提醒我明天下午三點只在{calendar}喝水", parsed(question=question)
+ )
+ self.assertIn("clarification", result.missing_fields)
+ self.assertEqual(question, result.raw["question"])
+
+ def test_calendar_definition_cannot_clear_deadline_question(self):
+ question = "「前」是指週一之前嗎?這是截止時間嗎?"
+ result = self.parse("提醒我明天下午三點前交報表", parsed(question=question))
+ self.assertIn("clarification", result.missing_fields)
+ self.assertEqual(question, result.raw["question"])
+
+ def test_pending_title_does_not_consume_time_corrections(self):
+ context = ReminderDraft(
+ id="correction",
+ chat_id=100,
+ chat_type="private",
+ creator_user_id=7,
+ timezone="Asia/Taipei",
+ source_text="提醒我明天下午三點",
+ title=None,
+ remind_at=NOW.replace(day=23, hour=15),
+ participants=[PERSON],
+ missing_fields=["title"],
+ parse_result={},
+ )
+ for text, expected in (
+ ("改成明天九點", NOW.replace(day=23, hour=9)),
+ ("時間改到11/17 23:55", NOW.replace(month=11, day=17, hour=23, minute=55)),
+ ("請把時間調整為後天18:30", NOW.replace(day=24, hour=18, minute=30)),
+ ("延後到後天18:30", NOW.replace(day=24, hour=18, minute=30)),
+ ):
+ with self.subTest(text=text):
+ result = self.parse(text, parsed(title="模型亂寫"), context=context)
+ self.assertIsNone(result.title)
+ self.assertIn("title", result.missing_fields)
+ self.assertEqual(expected, result.remind_at)
+ for title in (
+ "修改年度報表",
+ "更換印表機墨水",
+ "下班後要用的報表",
+ "月底前要交的資料",
+ "工作日行事曆",
+ "提早寄出報表",
+ "延後寄包裹",
+ "調整年度預算",
+ "不對稱花瓶",
+ ):
+ with self.subTest(title=title):
+ result = self.parse(title, parsed(title="模型亂寫"), context=context)
+ self.assertEqual(title, result.title)
+ self.assertEqual(context.remind_at, result.remind_at)
+ self.assertEqual((), result.missing_fields)
+ context.missing_fields.append("clarification")
+ context.parse_result = {"question": "是否只限工作日?"}
+ for text in (
+ "不是明天,是後天,時間一樣",
+ "改成下班後",
+ "時間改成有空時",
+ "改成每天下午三點",
+ "下班後",
+ "月底前",
+ "有空時",
+ "週末",
+ "延後半小時",
+ "提早30分鐘",
+ "不對,是明天",
+ ):
+ with self.subTest(text=text):
+ result = self.parse(text, parsed(title="模型亂寫"), context=context)
+ self.assertIsNone(result.title)
+ self.assertIn("time", result.missing_fields)
+ self.assertIn("title", result.missing_fields)
+ self.assertIn("clarification", result.missing_fields)
+ self.assertEqual("是否只限工作日?", result.raw["condition_question"])
+ result = self.parse("改成明天九點", parsed(title="模型亂寫"), context=context)
+ self.assertIsNone(result.title)
+ self.assertIn("clarification", result.missing_fields)
+ self.assertEqual("是否只限工作日?", result.raw["question"])
+
+ def test_explicit_waiting_and_nested_conditions_cannot_be_omitted(self):
+ for title in ("有空才交報告", "等收到帳單再繳費", "收衣服(如果下雨)"):
+ with self.subTest(title=title):
+ result = self.parse(f"提醒我明天下午三點{title}", parsed(question=None))
+ self.assertIn("clarification", result.missing_fields)
+
+ def test_existing_condition_survives_explicit_update_and_edit_keeps_title(self):
+ context = ReminderDraft(
+ id="condition",
+ chat_id=100,
+ chat_type="private",
+ creator_user_id=7,
+ timezone="Asia/Taipei",
+ source_text="工作日提醒我領藥",
+ title="領藥",
+ participants=[PERSON],
+ missing_fields=["time", "clarification"],
+ parse_result={"question": "是否只限週一至週五?"},
+ )
+ result = self.parse(
+ "提醒我明天下午三點領藥", parsed(intent="update", question=None), context=context
+ )
+ self.assertIn("clarification", result.missing_fields)
+ self.assertEqual("是否只限週一至週五?", result.raw["question"])
+ result = self.parse(
+ "提醒我明天下午三點寄報表",
+ parsed(intent="update", title="寄報表"),
+ context=context,
+ purpose="edit_time",
+ )
+ self.assertEqual("領藥", result.title)
+ result = self.parse(
+ "提醒我明天下午三點", parsed(intent="update", title="領藥"), context=context
+ )
+ self.assertEqual("領藥", result.title)
+ self.assertIn("clarification", result.missing_fields)
+
+ def test_source_condition_survives_invalid_time_and_clock_followup(self):
+ source = "提醒我明天25:00只在工作日喝水"
+ first = self.parse(source, parsed(question="是否只限週一至週五?"))
+ self.assertIn("time", first.missing_fields)
+ self.assertIn("clarification", first.missing_fields)
+ self.assertEqual("是否只限週一至週五?", first.raw["condition_question"])
+ context = ReminderDraft(
+ id="invalid-condition",
+ chat_id=100,
+ chat_type="private",
+ creator_user_id=7,
+ timezone="Asia/Taipei",
+ source_text=source,
+ title=first.title,
+ participants=[PERSON],
+ missing_fields=list(first.missing_fields),
+ parse_result=first.raw,
+ )
+ corrected = self.parse("23:55", parsed(intent="update", question=None), context=context)
+ self.assertIn("clarification", corrected.missing_fields)
+ self.assertEqual("是否只限週一至週五?", corrected.raw["question"])
+
+ def test_unknown_question_survives_invalid_time_and_followup(self):
+ source = "提醒我明天25:00寄出報表"
+ question = "請確認尚未釐清的限制。"
+ first = self.parse(source, parsed(question=question))
+ self.assertIn("time", first.missing_fields)
+ self.assertIn("clarification", first.missing_fields)
+ context = ReminderDraft(
+ id="unknown",
+ chat_id=100,
+ chat_type="private",
+ creator_user_id=7,
+ timezone="Asia/Taipei",
+ source_text=source,
+ title=first.title,
+ participants=[PERSON],
+ missing_fields=list(first.missing_fields),
+ parse_result=first.raw,
+ )
+ result = self.parse("23:55", parsed(intent="update", question=None), context=context)
+ self.assertIn("clarification", result.missing_fields)
+ self.assertEqual(question, result.raw["question"])
+
+ def test_resolved_title_question_does_not_become_pending_condition(self):
+ for question in ("請問要提醒您什麼事?", "這是什麼事項?"):
+ with self.subTest(question=question):
+ result = self.parse("提醒我明天下午三點寄出報表", parsed(question=question))
+ self.assertEqual((), result.missing_fields)
+ self.assertIsNone(result.raw["question"])
+
+ def test_original_gemini_responses_keep_source_content_and_required_questions(self):
+ self.assertGreaterEqual(len(self.rows), 7)
+ person = Participant("HeiTang", MentionKind.TEXT_MENTION, user_id=7)
+ for row in self.rows:
+ with self.subTest(case=row["id"]):
+ generator = Mock()
+ generator.generate.return_value = GeneratedResponse(
+ row["response"], "gemini", "recorded"
+ )
+ now = datetime.fromisoformat(row["now"]).astimezone(ZoneInfo(row["timezone"]))
+ result = SemanticParser(generator).parse(row["text"], 7, now, [person])
+ expected = row["expected"]
+ self.assertEqual(expected["title"], result.title)
+ at = datetime.fromisoformat(expected["at"]) if expected["at"] else None
+ self.assertEqual(
+ at.astimezone(UTC) if at else None,
+ result.remind_at.astimezone(UTC) if result.remind_at else None,
+ )
+ if expected["complete"]:
+ self.assertEqual((), result.missing_fields)
+ self.assertIsNone(result.raw["question"])
+ else:
+ self.assertIn("time", result.missing_fields)
+ self.assertIsNotNone(result.raw["question"])
+
+ def test_title_only_followup_preserves_literal_answer_and_context(self):
+ fixture = Path(__file__).parent / "fixtures" / "gemini_followup_regressions.jsonl"
+ for line in fixture.read_text().splitlines():
+ row = json.loads(line)
+ now = datetime.fromisoformat(row["now"])
+ gateway = Mock()
+ parser = SemanticParser(gateway)
+ gateway.generate.return_value = GeneratedResponse(
+ row["context"]["response"], "test", "recorded"
+ )
+ first = parser.parse(row["context"]["text"], 7, now, [PERSON])
+ context = ReminderDraft(
+ id="missing-title",
+ chat_id=100,
+ chat_type="private",
+ creator_user_id=7,
+ timezone=row["timezone"],
+ source_text=row["context"]["text"],
+ title=first.title,
+ remind_at=first.remind_at,
+ participants=[PERSON],
+ missing_fields=list(first.missing_fields),
+ parse_result=first.raw,
+ )
+ for response in (
+ row["response"],
+ {
+ **row["response"],
+ "intent": "create",
+ "title": None,
+ "date": "2027-01-01",
+ "time": "09:00",
+ "participant_indices": [],
+ },
+ ):
+ with self.subTest(response=response):
+ gateway.generate.return_value = GeneratedResponse(response, "test", "recorded")
+ result = parser.parse(row["text"], 7, now, [PERSON], context=context)
+ self.assertEqual(row["expected"]["title"], result.title)
+ self.assertEqual(first.remind_at, result.remind_at)
+ self.assertEqual((PERSON,), result.participants)
+ self.assertEqual((), result.missing_fields)
+ self.assertEqual("update", result.raw["intent"])
+ gateway.generate.return_value = GeneratedResponse(row["response"], "test", "recorded")
+ result = parser.parse("有空才交報告", 7, now, [PERSON], context=context)
+ self.assertEqual("有空才交報告", result.title)
+ self.assertIn("clarification", result.missing_fields)
+ result = parser.parse("11/17 23:55", 7, now, [PERSON], context=context)
+ self.assertIsNone(result.title)
+ self.assertIn("title", result.missing_fields)
+ self.assertEqual(
+ (11, 17, 23, 55),
+ (
+ result.remind_at.month,
+ result.remind_at.day,
+ result.remind_at.hour,
+ result.remind_at.minute,
+ ),
+ )
+ context.missing_fields = ["time", "title"]
+ context.remind_at = first.remind_at.replace(hour=0, minute=0)
+ result = parser.parse(row["text"], 7, now, [PERSON], context=context)
+ self.assertEqual(row["expected"]["title"], result.title)
+ self.assertEqual(context.remind_at, result.remind_at)
+ self.assertIn("time", result.missing_fields)
+ context.remind_at = now.replace(hour=0, minute=0, second=0, microsecond=0)
+ result = parser.parse(row["text"], 7, now, [PERSON], context=context)
+ self.assertEqual(context.remind_at, result.remind_at)
+ self.assertIn("time", result.missing_fields)
+ self.assertNotIn("過去", result.raw["question"])
+ context.remind_at = None
+ result = parser.parse(row["text"], 7, now, [PERSON], context=context)
+ self.assertEqual(row["expected"]["title"], result.title)
+ self.assertIsNone(result.remind_at)
+ self.assertIn("time", result.missing_fields)
+
+ def test_negated_time_cannot_be_used_as_replacement(self):
+ for title in (None, "寄報表"):
+ for condition in (False, True):
+ context = ReminderDraft(
+ id="negated-time",
+ chat_id=100,
+ chat_type="private",
+ creator_user_id=7,
+ timezone="Asia/Taipei",
+ source_text="提醒我明天15:00",
+ title=title,
+ remind_at=NOW.replace(day=23, hour=15),
+ participants=[PERSON],
+ missing_fields=(["title"] if title is None else [])
+ + (["clarification"] if condition else []),
+ parse_result={"question": "是否只限工作日?"} if condition else {},
+ )
+ for text in ("不是明天", "不是11/17 23:55", "請把時間不是明天九點"):
+ for intent in ("create", "update", "not_reminder"):
+ with self.subTest(
+ title=title, condition=condition, text=text, intent=intent
+ ):
+ result = self.parse(
+ text, parsed(intent=intent, title="模型改寫"), context=context
+ )
+ self.assertEqual(title, result.title)
+ self.assertIsNone(result.remind_at)
+ self.assertIn("time", result.missing_fields)
+ self.assertEqual("update", result.raw["intent"])
+ if condition:
+ self.assertIn("clarification", result.missing_fields)
+ self.assertEqual(
+ "是否只限工作日?", result.raw["condition_question"]
+ )
+
+ def test_recorded_corrections_preserve_fields_questions_and_confirmation(self):
+ fixture = Path(__file__).parent / "fixtures" / "gemini_correction_regressions.jsonl"
+ for line in fixture.read_text().splitlines():
+ row = json.loads(line)
+ with self.subTest(case=row["id"]), tempfile.TemporaryDirectory() as directory:
+ repo = ReminderRepository(Path(directory) / "recorded.db")
+ repo.migrate()
+ generator = Mock()
+ generator.enabled.return_value = True
+ generator.generate.return_value = GeneratedResponse(
+ row["response"], "gemini", "recorded"
+ )
+ service = ReminderService(
+ repository=repo,
+ parser=ReminderParser("Asia/Taipei"),
+ draft_store=SqliteDraftStore(10, repo),
+ edit_store=SqliteEditSessionStore(10, repo),
+ default_timezone="Asia/Taipei",
+ ai_parser=SemanticParser(generator),
+ )
+ now = datetime.fromisoformat(row["now"]).astimezone(ZoneInfo(row["timezone"]))
+ seed = row["seed"]
+ context = None
+ if seed:
+ context = ReminderDraft(
+ id="seed",
+ chat_id=100,
+ chat_type="private",
+ creator_user_id=7,
+ timezone=row["timezone"],
+ source_text=seed["source"],
+ title=seed["title"],
+ remind_at=datetime.fromisoformat(seed["at"]),
+ participants=[PERSON],
+ missing_fields=seed["missing"]
+ + (["clarification"] if seed["condition"] else []),
+ parse_result={
+ "grain": "ai",
+ "question": seed["question"],
+ "condition_question": seed["question"],
+ },
+ )
+ service._draft_store.save(context, now)
+ message = TelegramMessage(
+ id=1,
+ chat=TelegramChat(id=100, type="private"),
+ from_user=TelegramUser(id=7, first_name="HeiTang"),
+ text=row["text"],
+ entities=(),
+ )
+ if row.get("rejected"):
+ with self.assertRaises(AiUnavailable):
+ service.parse_ai(message.text, message, now, context=context)
+ self.assertEqual(context, service.peek_draft(100, 7, now))
+ self.assertEqual([], repo.list_pending(100))
+ continue
+ result = service.parse_ai(message.text, message, now, context=context)
+ expected = row["expected"]
+ self.assertEqual(expected["title"], result.title)
+ self.assertEqual(
+ datetime.fromisoformat(expected["at"]) if expected["at"] else None,
+ result.remind_at,
+ )
+ self.assertTrue(set(expected["missing"]).issubset(result.missing_fields))
+ if seed and seed["condition"]:
+ self.assertEqual(seed["question"], result.raw["condition_question"])
+ preview = service.begin_create(
+ context.source_text if context else message.text,
+ message,
+ now,
+ parse_result=result,
+ )
+ self.assertEqual([], repo.list_pending(100))
+ self.assertIsInstance(
+ preview, Confirmation if expected["complete"] else DraftPrompt
+ )
+ confirmed = service.confirm(preview.draft.id, 7, now)
+ if expected["complete"]:
+ self.assertIsNotNone(confirmed)
+ self.assertEqual(expected["title"], confirmed.reminder.title)
+ else:
+ self.assertIsNone(confirmed)
+ self.assertEqual([], repo.list_pending(100))
+
+ def test_boundary_actions_and_compound_titles_are_distinct(self):
+ for title in ("前交報表", "前完成繳費", "後再領藥", "後寄包裹"):
+ with self.subTest(title=title):
+ result = self.parse(f"提醒我明天下午三點{title}", parsed(question=None))
+ self.assertIn("clarification", result.missing_fields)
+ for title in ("前往車站", "前進營地", "前置作業", "後續報表", "後製影片"):
+ with self.subTest(title=title):
+ result = self.parse(f"提醒我明天下午三點{title}", parsed(question=None))
+ self.assertEqual(title, result.title)
+ self.assertEqual((), result.missing_fields)
+
+ def test_known_title_correction_uses_native_time_and_retains_question(self):
+ context = ReminderDraft(
+ id="known-title",
+ chat_id=100,
+ chat_type="private",
+ creator_user_id=7,
+ timezone="Asia/Taipei",
+ source_text="只在工作日提醒我寄報表",
+ title="寄報表",
+ remind_at=NOW.replace(day=23, hour=15),
+ participants=[PERSON],
+ missing_fields=["clarification"],
+ parse_result={"question": "是否只限週一至週五?"},
+ )
+ result = self.parse(
+ "改成明天九點",
+ parsed(intent="update", title="模型誤改", date="2027-01-01", time="18:00"),
+ context=context,
+ )
+ self.assertEqual("寄報表", result.title)
+ self.assertEqual(NOW.replace(day=23, hour=9), result.remind_at)
+ self.assertEqual("是否只限週一至週五?", result.raw["question"])
+ self.assertIn("clarification", result.missing_fields)
+ for intent in ("update", "create", "not_reminder"):
+ with self.subTest(semantic_intent=intent):
+ result = self.parse(
+ "不是明天,是後天,時間一樣",
+ parsed(intent=intent, title="寄報表", question=None),
+ context=context,
+ )
+ self.assertEqual("update", result.raw["intent"])
+ self.assertEqual("是否只限週一至週五?", result.raw["condition_question"])
+ self.assertIn("clarification", result.missing_fields)
+ for text in ("下班後", "改成有空時"):
+ with self.subTest(text=text):
+ result = self.parse(
+ text, parsed(intent="update", title="模型誤改"), context=context
+ )
+ self.assertEqual("寄報表", result.title)
+ self.assertIsNone(result.remind_at)
+ self.assertIn("time", result.missing_fields)
+ self.assertIn("clarification", result.missing_fields)
+
+ def test_recorded_responses_obey_confirmation_and_saved_title(self):
+ for row in self.rows:
+ with self.subTest(case=row["id"]), tempfile.TemporaryDirectory() as directory:
+ repository = ReminderRepository(Path(directory) / "recorded.db")
+ repository.migrate()
+ repository.set_user_timezone(7, row["timezone"])
+ generator = Mock()
+ generator.enabled.return_value = True
+ generator.generate.return_value = GeneratedResponse(
+ row["response"], "gemini", "recorded"
+ )
+ service = ReminderService(
+ repository=repository,
+ parser=ReminderParser("Asia/Taipei"),
+ draft_store=SqliteDraftStore(10, repository),
+ edit_store=SqliteEditSessionStore(10, repository),
+ default_timezone="Asia/Taipei",
+ ai_parser=SemanticParser(generator),
+ )
+ message = TelegramMessage(
+ id=1,
+ chat=TelegramChat(id=100, type="private"),
+ from_user=TelegramUser(id=7, first_name="HeiTang", username="HeiTang"),
+ text=row["text"],
+ entities=(),
+ )
+ now = datetime.fromisoformat(row["now"]).astimezone(ZoneInfo(row["timezone"]))
+ preview = service.begin_create(message.text, message, now)
+ self.assertEqual([], repository.list_pending(100))
+ expected = row["expected"]
+ self.assertIsInstance(
+ preview, Confirmation if expected["complete"] else DraftPrompt
+ )
+ confirmed = service.confirm(preview.draft.id, 7, now)
+ if expected["complete"]:
+ self.assertIsNotNone(confirmed)
+ self.assertEqual(expected["title"], repository.list_pending(100)[0].title)
+ else:
+ self.assertIsNone(confirmed)
+ self.assertEqual([], repository.list_pending(100))
diff --git a/tests/test_ai_flow.py b/tests/test_ai_flow.py
new file mode 100644
index 0000000..70e381f
--- /dev/null
+++ b/tests/test_ai_flow.py
@@ -0,0 +1,679 @@
+from __future__ import annotations
+
+import json
+import sqlite3
+import tempfile
+import unittest
+from datetime import datetime, timedelta
+from pathlib import Path
+from unittest.mock import patch
+
+from cryptography.fernet import Fernet
+from test_ai import NOW, SECOND, SECRET, ScriptedProvider, parsed, rule
+from test_bot_router import CHAT, GROUP_CHAT, USER, FakeTelegramClient
+
+from remindly.ai.contracts import ProviderError
+from remindly.ai.gateway import AiGateway
+from remindly.ai.keys import ApiKeyStore
+from remindly.ai.parser import SemanticParser
+from remindly.bot.ai_commands import AiCommands
+from remindly.bot.router import BotRouter
+from remindly.reminders.parser import ReminderParser
+from remindly.reminders.renderer import ReminderRenderer
+from remindly.reminders.service import ReminderService
+from remindly.storage.session_stores import SqliteDraftStore, SqliteEditSessionStore
+from remindly.storage.sqlite import ReminderRepository
+from remindly.telegram.client import TelegramApiError
+from remindly.telegram.models import (
+ TelegramCallbackQuery,
+ TelegramMessage,
+ TelegramUpdate,
+ TelegramUser,
+)
+
+
+class AiFlowTest(unittest.TestCase):
+ def test_pending_title_time_correction_then_literal_answer(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.extend(
+ [
+ parsed(title=None),
+ parsed(intent="update", title="模型誤填事項"),
+ parsed(intent="update", title="匄出報表", date="2027-01-01"),
+ ]
+ )
+ self.send("提醒我明天下午三點")
+ self.send("時間改到11/17 23:55")
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.assertIsNone(draft.title)
+ self.assertEqual("要提醒什麼?", self.telegram.messages[-1].text)
+ self.assertIsNone(self.service.confirm(draft.id, USER.id, NOW))
+ self.send("寄出報表")
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.assertTrue(draft.is_complete)
+ self.confirm(draft.id)
+ saved = self.repo.list_pending(CHAT.id)[0]
+ self.assertEqual("寄出報表", saved.title)
+ self.assertEqual(
+ (2026, 11, 17, 23, 55),
+ (
+ saved.remind_at.year,
+ saved.remind_at.month,
+ saved.remind_at.day,
+ saved.remind_at.hour,
+ saved.remind_at.minute,
+ ),
+ )
+
+ def test_recorded_garbled_question_is_not_displayed(self) -> None:
+ fixture = Path(__file__).parent / "fixtures" / "gemini_question_regressions.jsonl"
+ row = json.loads(fixture.read_text())
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.append(row["response"])
+ self.send(row["text"])
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.assertEqual(row["response"]["question"], draft.parse_result["question"])
+ self.assertEqual(row["expected_question"], self.telegram.messages[-1].text)
+ self.assertIn("title", draft.missing_fields)
+ self.assertIsNone(self.service.confirm(draft.id, USER.id, NOW))
+ self.gemini.responses.append(parsed(intent="update", title="寄出報表"))
+ self.send("寄出報表")
+ corrected = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.assertTrue(corrected.is_complete)
+ self.assertEqual("寄出報表", corrected.title)
+ self.assertNotIn(row["response"]["question"], self.telegram.messages[-1].text)
+
+ def test_garbled_question_cannot_leak_from_not_reminder_or_edit(self) -> None:
+ garbage = "\ufffd\u0001職耇與䐂蕩耇䐂略耇䐂耇䐂"
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.extend(
+ [
+ parsed(question=garbage),
+ parsed(intent="not_reminder", question=garbage),
+ parsed(),
+ parsed(intent="update", date=None, time=None, question=garbage),
+ ]
+ )
+ self.send("提醒我明天下午三點有空才交報告")
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.assertIn("提醒資訊未釐清", self.telegram.messages[-1].text)
+ self.assertNotIn(garbage, self.telegram.messages[-1].text)
+ self.send("還沒確定")
+ self.assertEqual(draft.id, self.service.peek_draft(CHAT.id, USER.id, NOW).id)
+ self.assertNotIn(garbage, self.telegram.messages[-1].text)
+ self.send("提醒我明天下午三點寄報表")
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.confirm(draft.id)
+ reminder = self.repo.list_pending(CHAT.id)[0]
+ self.service.begin_edit(CHAT.id, reminder.short_id, USER.id, "time", NOW)
+ self.send("等有空時")
+ self.assertNotIn(garbage, self.telegram.messages[-1].text)
+ self.assertIn("未來日期與時間", self.telegram.messages[-1].text)
+ self.assertEqual(reminder.remind_at, self.repo.list_pending(CHAT.id)[0].remind_at)
+ self.assertIsNotNone(self.service.peek_edit_session(CHAT.id, USER.id, NOW))
+
+ def test_missing_participant_uses_local_question_and_blocks_confirmation(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.append(parsed(participant_indices=[], question="\ufffd\u0001"))
+ self.send("提醒小明明天下午三點交報告")
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.assertIn("participants", draft.missing_fields)
+ self.assertEqual("要提醒誰?請提供 @username。", self.telegram.messages[-1].text)
+ self.assertIsNone(self.service.confirm(draft.id, USER.id, NOW))
+
+ def test_quick_time_keeps_condition_and_does_not_display_model_question(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.append(parsed(time=None, question="\ufffd\u0001"))
+ self.send("提醒我明天只在工作日喝水")
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ result = self.service.apply_quick_time(draft.id, "23:55", USER.id, NOW)
+ self.assertIn("clarification", result.draft.missing_fields)
+ self.assertIn("提醒資訊未釐清", result.question)
+ self.assertNotIn("\ufffd", result.question)
+ self.assertIsNone(self.service.confirm(draft.id, USER.id, NOW))
+
+ def test_edit_condition_survives_date_clock_and_store_reload(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.extend(
+ [
+ parsed(),
+ parsed(intent="update", question="是否只限工作日?"),
+ parsed(intent="update"),
+ parsed(intent="update"),
+ ]
+ )
+ self.send("明天下午三點提醒我交報告")
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.confirm(draft.id)
+ original = self.repo.list_pending(CHAT.id)[0]
+ self.service.begin_edit(CHAT.id, original.short_id, USER.id, "time", NOW)
+ self.send("改成工作日")
+ self.send("11/17")
+ self.service._edit_store = SqliteEditSessionStore(10, self.repo)
+ self.send("23:55")
+ self.assertIn("提醒資訊未釐清", self.telegram.messages[-1].text)
+ self.assertEqual(original.remind_at, self.repo.list_pending(CHAT.id)[0].remind_at)
+ self.assertIsNotNone(self.service.peek_edit_session(CHAT.id, USER.id, NOW))
+
+ def test_date_and_clock_do_not_confirm_unresolved_condition(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.extend(
+ [
+ parsed(question="是否只限工作日?"),
+ parsed(intent="update", question=None),
+ parsed(intent="update", question=None),
+ ]
+ )
+ for text in ("提醒我工作日喝水", "11/17", "23:55"):
+ self.send(text)
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.assertIn("clarification", draft.missing_fields)
+ self.assertIn("提醒資訊未釐清", self.telegram.messages[-1].text)
+ self.assertIsNone(self.service.confirm(draft.id, USER.id, NOW))
+ self.assertEqual([], self.repo.list_pending(CHAT.id))
+
+ def test_unrequested_recurrence_cannot_be_confirmed(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.append(parsed(recurrence=rule()))
+ self.send("提醒我明天下午三點買湯")
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.assertIsNone(draft.recurrence)
+ self.assertIn("clarification", draft.missing_fields)
+ self.assertIsNone(self.service.confirm(draft.id, USER.id, NOW))
+ self.assertEqual([], self.repo.list_pending(CHAT.id))
+
+ def test_wrong_model_dates_cannot_change_followup_date(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.extend(
+ [
+ parsed(title="要領蝦幣", date=None, time=None, question="什麼時候提醒?"),
+ parsed(intent="update", date="2026-09-17", time=None, question="那天幾點?"),
+ parsed(intent="create", date="2026-10-22", time="23:55", title="其他事項"),
+ ]
+ )
+ for text in ("提醒我要領蝦幣", "09-17", "23:55"):
+ self.send(text)
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.assertEqual(datetime(2027, 9, 17, 23, 55, tzinfo=NOW.tzinfo), draft.remind_at)
+ self.assertEqual("要領蝦幣", draft.title)
+ self.assertTrue(draft.is_complete)
+ self.assertEqual([], self.repo.list_pending(CHAT.id))
+
+ def test_invalid_reminder_is_not_silently_dropped(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.append(
+ parsed(
+ intent="not_reminder",
+ title=None,
+ date=None,
+ time=None,
+ question="九月沒有31日",
+ participant_indices=[],
+ )
+ )
+ self.send("提醒我09-31 23:55買湯")
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.assertIsNotNone(draft)
+ self.assertFalse(draft.is_complete)
+ self.assertIn("未來日期與時間", self.telegram.messages[-1].text)
+ self.assertEqual([], self.repo.list_pending(CHAT.id))
+
+ def test_explicit_new_reminder_keeps_its_source(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.extend(
+ [
+ parsed(title="舊事項", date=None, time=None, question="什麼時候?"),
+ parsed(title="買湯", intent="create"),
+ ]
+ )
+ self.send("提醒我舊事項")
+ self.send("提醒我明天下午三點買湯")
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.assertEqual("提醒我明天下午三點買湯", draft.source_text)
+ self.assertEqual("買湯", draft.title)
+ self.assertEqual("create", draft.parse_result["intent"])
+
+ def test_not_reminder_question_is_shown_without_replacing_draft(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.append(parsed(time=None, question="幾點?"))
+ self.send("提醒我下班後買湯")
+ original = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.gemini.responses.append(
+ parsed(
+ intent="not_reminder",
+ date=None,
+ time=None,
+ question="請提供明確的時間。",
+ )
+ )
+ self.send("下班的時候")
+ self.assertIn("未來日期與時間", self.telegram.messages[-1].text)
+ self.assertEqual(original.id, self.service.peek_draft(CHAT.id, USER.id, NOW).id)
+
+ def setUp(self) -> None:
+ self.tmp = tempfile.TemporaryDirectory()
+ self.repo = ReminderRepository(Path(self.tmp.name) / "test.db")
+ self.repo.migrate()
+ self.keys = ApiKeyStore(self.repo, Fernet.generate_key().decode())
+ self.gemini = ScriptedProvider([])
+ self.telegram = FakeTelegramClient()
+ self.gateway = AiGateway(self.keys, {"gemini": self.gemini}, default_provider="gemini")
+ self.service = ReminderService(
+ repository=self.repo,
+ parser=ReminderParser("Asia/Taipei"),
+ draft_store=SqliteDraftStore(10, self.repo),
+ edit_store=SqliteEditSessionStore(10, self.repo),
+ default_timezone="Asia/Taipei",
+ ai_parser=SemanticParser(self.gateway),
+ )
+ self.router = BotRouter(
+ self.telegram,
+ self.service,
+ ReminderRenderer(),
+ bot_username="ReminderBot",
+ default_timezone="Asia/Taipei",
+ ai_commands=AiCommands(self.telegram, self.gateway, "ReminderBot"),
+ )
+ self.sequence = 0
+
+ def tearDown(self) -> None:
+ self.tmp.cleanup()
+
+ def send(self, text, *, chat=CHAT, user=USER):
+ self.sequence += 1
+ message = TelegramMessage(
+ id=self.sequence,
+ chat=chat,
+ from_user=user,
+ text=text,
+ entities=(),
+ )
+ with patch("remindly.bot.router.datetime") as clock:
+ clock.now.return_value = NOW
+ self.router.handle_update(TelegramUpdate(id=self.sequence, message=message))
+
+ def confirm(self, draft_id, *, user=USER):
+ from remindly.reminders.callback_data import reminder_callback
+
+ self.sequence += 1
+ callback = TelegramCallbackQuery(
+ id=str(self.sequence),
+ from_user=user,
+ data=reminder_callback("confirm", draft_id),
+ message=TelegramMessage(id=1000, chat=CHAT, from_user=USER, text="確認", entities=()),
+ )
+ with patch("remindly.bot.router.datetime") as clock:
+ clock.now.return_value = NOW
+ self.router.handle_update(TelegramUpdate(id=self.sequence, callback_query=callback))
+
+ def test_private_settings_add_order_delete_and_do_not_echo_key(self) -> None:
+ self.send("/ai")
+ self.send(f"/ai add {SECRET}")
+ self.send(f"/ai add {SECOND}")
+ first, second = self.keys.list(USER.id)
+ self.send(f"/ai move {second.id} 1")
+ self.assertEqual(second.id, self.keys.list(USER.id)[0].id)
+ self.send("/ai off")
+ self.assertFalse(self.keys.enabled(USER.id))
+ self.send("/ai on")
+ self.assertTrue(self.keys.enabled(USER.id))
+ self.send(f"/ai delete {first.id}")
+ self.assertEqual([second.id], [k.id for k in self.keys.list(USER.id)])
+ self.assertIn(2, self.telegram.deleted_messages)
+ self.assertIn(3, self.telegram.deleted_messages)
+ self.assertNotIn(SECRET, repr(self.telegram.messages))
+ self.assertNotIn(SECOND, repr(self.telegram.messages))
+ self.assertEqual([], self.gemini.calls)
+
+ def test_group_key_setup_is_rejected_and_secret_message_deleted(self) -> None:
+ self.send(f"/ai add {SECRET}", chat=GROUP_CHAT)
+ self.assertEqual([], self.keys.list(USER.id))
+ self.assertIn(1, self.telegram.deleted_messages)
+ self.assertIn("只能", self.telegram.messages[-1].text)
+
+ def test_explicit_provider_setup_and_unregistered_provider_are_safe(self) -> None:
+ self.send(f"/ai add gemini {SECRET}")
+ self.assertEqual("gemini", self.keys.list(USER.id)[0].provider)
+ self.send(f"/ai add unregistered {SECOND}")
+ self.assertEqual(1, len(self.keys.list(USER.id)))
+ self.assertNotIn(SECOND, repr(self.telegram.messages))
+ self.assertIn("尚未啟用", self.telegram.messages[-1].text)
+
+ def test_registered_second_provider_works_through_unchanged_bot_confirmation(self) -> None:
+ other = ScriptedProvider([parsed(title="拿包裹")], provider_id="other")
+ self.gateway.providers["other"] = other
+ self.send(f"/ai add other {SECOND}")
+ self.assertIn("other", self.telegram.messages[-1].text)
+ self.send("明天下午三點叫我去拿包裹")
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.assertEqual("other", draft.parse_result["provider"])
+ self.assertEqual([], self.repo.list_pending(CHAT.id))
+ self.assertIn("使用 Test other 解析", self.telegram.messages[-1].text)
+ self.confirm(draft.id)
+ self.assertEqual("拿包裹", self.repo.list_pending(CHAT.id)[0].title)
+ self.assertEqual(SECOND, other.calls[0][0])
+ self.assertEqual([], self.gemini.calls)
+
+ def test_failed_key_message_deletion_is_reported_without_echoing(self) -> None:
+ with patch.object(
+ self.telegram, "delete_message", side_effect=TelegramApiError("deleteMessage", "no")
+ ):
+ self.send(f"/ai add {SECRET}")
+ self.assertIn("手動刪除", self.telegram.messages[-1].text)
+ self.assertNotIn(SECRET, self.telegram.messages[-1].text)
+
+ def test_ai_without_keyword_only_creates_after_confirmation(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.append(parsed(title="拿包裹", time="20:00"))
+ self.send("明晚八點叫我去拿包裹")
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.assertIsNotNone(draft)
+ self.assertEqual([], self.repo.list_pending(CHAT.id))
+ self.assertIn("確認建立提醒", self.telegram.messages[-1].text)
+ self.confirm(draft.id, user=TelegramUser(id=99, first_name="Other"))
+ self.assertEqual([], self.repo.list_pending(CHAT.id))
+ self.confirm(draft.id)
+ self.assertEqual("拿包裹", self.repo.list_pending(CHAT.id)[0].title)
+
+ def test_negated_time_replaces_confirmation_with_question(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.extend(
+ [parsed(title="寄報表"), parsed(intent="update", title="寄報表")]
+ )
+ self.send("提醒我明天下午三點寄報表")
+ old = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.send("不是11/17 23:55")
+ new = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.assertEqual("寄報表", new.title)
+ self.assertIsNone(new.remind_at)
+ self.assertIn("time", new.missing_fields)
+ self.assertIn("未來日期與時間", self.telegram.messages[-1].text)
+ self.confirm(old.id)
+ self.confirm(new.id)
+ self.assertEqual([], self.repo.list_pending(CHAT.id))
+
+ def test_followup_replaces_confirmation_and_old_button_cannot_create(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.extend(
+ [
+ parsed(),
+ parsed(intent="update", date="2026-10-24"),
+ ]
+ )
+ self.send("明天下午三點提醒我後天要交報告")
+ old = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.send("不是明天,是後天,時間一樣")
+ new = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.assertNotEqual(old.id, new.id)
+ self.assertEqual(24, new.remind_at.day)
+ self.assertEqual("後天要交報告", new.title)
+ context = self.gemini.calls[-1][1]["context"]
+ self.assertEqual(old.title, context["title"])
+ self.assertEqual(old.source_text, new.source_text)
+ self.assertEqual(old.source_text, context["source_text"])
+ self.assertEqual([0], context["participant_indices"])
+ self.confirm(old.id)
+ self.assertEqual([], self.repo.list_pending(CHAT.id))
+ self.confirm(new.id)
+ self.assertEqual(24, self.repo.list_pending(CHAT.id)[0].remind_at.day)
+
+ def test_ambiguity_followup_and_recurrence_survive_persistence(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.extend(
+ [
+ parsed(date=None, time=None, question="你幾點下班?"),
+ parsed(intent="update", time="18:00", question=None),
+ ]
+ )
+ self.send("下班後提醒我買牛奶")
+ self.assertIn("未來日期與時間", self.telegram.messages[-1].text)
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.assertFalse(draft.is_complete)
+ self.send("明天十八點")
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.assertTrue(draft.is_complete)
+ self.gemini.responses.append(parsed(date=None, time=None, recurrence=rule()))
+ self.send("每天九點叫我吃藥")
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.confirm(draft.id)
+ self.assertIsNotNone(self.repo.list_pending(CHAT.id)[0].recurrence)
+
+ def test_errors_preserve_draft_and_do_not_silently_use_basic_parser(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.extend([parsed(), ProviderError("temporary")])
+ self.send("明天下午叫我交報告")
+ old = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.send("改成後天")
+ self.assertEqual(old.id, self.service.peek_draft(CHAT.id, USER.id, NOW).id)
+ self.assertEqual([], self.repo.list_pending(CHAT.id))
+ self.assertIn("/ai off", self.telegram.messages[-1].text)
+ self.assertNotIn(SECRET, repr(self.telegram.messages))
+
+ def test_non_reminder_keeps_draft_and_no_key_keeps_existing_parser(self) -> None:
+ self.send("明天下午三點提醒我倒垃圾")
+ old = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.assertEqual([], self.gemini.calls)
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.append(parsed(intent="not_reminder"))
+ self.send("你好,謝謝")
+ self.assertEqual(old.id, self.service.peek_draft(CHAT.id, USER.id, NOW).id)
+
+ def test_explicit_command_uses_ai_once_and_respects_user_timezone(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.service.set_timezone(USER.id, "Asia/Tokyo")
+ self.gemini.responses.append(parsed())
+ self.send("/remind 明天下午三點叫我交報告")
+ self.assertEqual(1, len(self.gemini.calls))
+ self.assertEqual("Asia/Tokyo", self.gemini.calls[0][1]["timezone"])
+ self.assertEqual("Asia/Tokyo", self.service.peek_draft(CHAT.id, USER.id, NOW).timezone)
+
+ def test_group_trigger_and_keys_are_scoped_to_actor(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.send("明晚八點叫我拿包裹", chat=GROUP_CHAT)
+ self.assertEqual([], self.gemini.calls)
+ other = TelegramUser(id=8, first_name="Other")
+ self.send("@ReminderBot 明天三點提醒我喝水", chat=GROUP_CHAT, user=other)
+ self.assertEqual([], self.gemini.calls)
+ self.gemini.responses.append(parsed())
+ self.send("@ReminderBot 明天三點叫我喝水", chat=GROUP_CHAT)
+ self.assertEqual(SECRET, self.gemini.calls[0][0])
+
+ def test_time_edit_passes_context_and_preserves_series(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.append(parsed(date=None, time=None, recurrence=rule()))
+ self.send("每天九點叫我吃藥")
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.confirm(draft.id)
+ reminder = self.repo.list_pending(CHAT.id)[0]
+ self.service.begin_edit(CHAT.id, reminder.short_id, USER.id, "time", NOW)
+ self.gemini.responses.append(parsed(intent="update", time="10:00", recurrence=rule()))
+ self.send("明天晚一小時,同樣那件事")
+ updated = self.repo.list_pending(CHAT.id)[0]
+ self.assertEqual(10, updated.remind_at.hour)
+ self.assertEqual(9, updated.recurrence.hour)
+ self.assertEqual("edit_time", self.gemini.calls[-1][1]["purpose"])
+
+ def test_remind_command_replaces_pending_edit_in_both_modes(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ for enabled in (True, False):
+ with self.subTest(ai_enabled=enabled):
+ self.keys.set_enabled(USER.id, enabled)
+ if enabled:
+ self.gemini.responses.extend([parsed(), parsed(title="拿包裹")])
+ self.send("/remind 明天下午三點提醒我交報告")
+ first = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ original = self.service.confirm(first.id, USER.id, NOW).reminder
+ self.service.begin_edit(CHAT.id, original.short_id, USER.id, "time", NOW)
+ self.send("/remind 明天下午三點提醒我拿包裹")
+ replacement = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.assertNotEqual(first.id, replacement.id)
+ self.assertIn("拿包裹", replacement.title)
+ self.assertIsNone(self.service.peek_edit_session(CHAT.id, USER.id, NOW))
+
+ def test_rejected_remind_command_keeps_pending_edit(self) -> None:
+ self.send("/remind 明天下午三點提醒我交報告")
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ original = self.service.confirm(draft.id, USER.id, NOW).reminder
+ self.service.begin_edit(CHAT.id, original.short_id, USER.id, "time", NOW)
+ self.send("/remind 每個月45號09:00提醒我拿包裹")
+ self.assertIsNotNone(self.service.peek_edit_session(CHAT.id, USER.id, NOW))
+ self.assertIsNone(self.service.peek_draft(CHAT.id, USER.id, NOW))
+
+ def test_confirmation_does_not_create_past_reminder(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.append(parsed())
+ self.send("明天下午三點提醒我交報告")
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ draft.remind_at = NOW - timedelta(minutes=1)
+ self.service._draft_store.save(draft, NOW)
+ self.confirm(draft.id)
+ self.assertEqual([], self.repo.list_pending(CHAT.id))
+
+ def test_time_edit_keeps_partial_date_and_question_after_store_reload(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.extend(
+ [
+ parsed(),
+ parsed(intent="update", date="2026-10-24", time=None, question="後天幾點?"),
+ parsed(intent="update", date="2026-10-24", time="09:00"),
+ ]
+ )
+ self.send("明天下午三點叫我交報告")
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.confirm(draft.id)
+ original = self.repo.list_pending(CHAT.id)[0]
+ self.service.begin_edit(CHAT.id, original.short_id, USER.id, "time", NOW)
+ self.send("延到後天")
+ self.assertEqual(original.remind_at, self.repo.list_pending(CHAT.id)[0].remind_at)
+ self.service._edit_store = SqliteEditSessionStore(10, self.repo)
+ self.send("早上九點")
+ context = self.gemini.calls[-1][1]["context"]
+ self.assertEqual("2026-10-24", context["remind_at"][:10])
+ self.assertEqual(["time"], context["missing_fields"])
+ self.assertEqual("那天幾點?", context["question"])
+ updated = self.repo.list_pending(CHAT.id)[0]
+ self.assertEqual((24, 9), (updated.remind_at.day, updated.remind_at.hour))
+ self.assertIsNone(self.service.peek_edit_session(CHAT.id, USER.id, NOW))
+
+ def test_confirmation_rolls_back_on_draft_or_participant_write_failure(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ for operation in ("delete on reminder_drafts", "insert on reminder_participants"):
+ with self.subTest(operation=operation):
+ before = len(self.repo.list_pending(CHAT.id))
+ self.gemini.responses.append(parsed())
+ self.send("明天下午三點叫我交報告")
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ with self.repo.connect() as connection:
+ connection.execute(
+ f"""create trigger reject_write before {operation}
+ begin select raise(abort, 'injected failure'); end"""
+ )
+ with self.assertRaisesRegex(sqlite3.IntegrityError, "injected failure"):
+ self.service.confirm(draft.id, USER.id, NOW)
+ self.assertEqual(before, len(self.repo.list_pending(CHAT.id)))
+ self.assertEqual(draft.id, self.service.peek_draft(CHAT.id, USER.id, NOW).id)
+ with self.repo.connect() as connection:
+ connection.execute("drop trigger reject_write")
+ result = self.service.confirm(draft.id, USER.id, NOW)
+ self.assertIsNotNone(result)
+ self.assertEqual(before + 1, len(self.repo.list_pending(CHAT.id)))
+ self.assertIsNone(self.service.confirm(draft.id, USER.id, NOW))
+ # 另一個請求已讀到的舊草稿也只能消耗一次。
+ self.assertFalse(self.service._draft_store.confirm(draft, result.reminder))
+
+ def test_failed_draft_replacement_keeps_previous_confirmation(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.extend([parsed(), parsed(intent="update", time="16:00")])
+ for enabled in (True, False):
+ with self.subTest(ai_enabled=enabled):
+ self.keys.set_enabled(USER.id, enabled)
+ self.send("明天下午三點提醒我交報告")
+ original = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ with self.repo.connect() as connection:
+ connection.execute(
+ """create trigger reject_draft before insert on reminder_drafts
+ begin select raise(abort, 'injected failure'); end"""
+ )
+ with self.assertRaisesRegex(sqlite3.IntegrityError, "injected failure"):
+ self.send("明天下午四點提醒我交報告")
+ preserved = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.assertEqual(original.id, preserved.id)
+ self.assertEqual(original.remind_at, preserved.remind_at)
+ with self.repo.connect() as connection:
+ connection.execute("drop trigger reject_draft")
+
+ def test_no_master_key_preserves_basic_mode(self) -> None:
+ self.keys._cipher = None
+ self.send("/ai")
+ self.assertIn("AI_ENCRYPTION_KEY", self.telegram.messages[-1].text)
+ self.send("明天下午三點提醒我倒垃圾")
+ self.assertIsNotNone(self.service.peek_draft(CHAT.id, USER.id, NOW))
+ self.assertEqual([], self.gemini.calls)
+
+ def test_ai_status_shows_saved_timeout_reason(self) -> None:
+ key_id = self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.append(ProviderError("timeout"))
+ self.send("明天下午三點提醒我倒垃圾")
+ self.assertIn("AI 請求逾時", self.telegram.messages[-1].text)
+ self.assertIn(key_id, self.telegram.messages[-1].text)
+ self.send("/ai")
+ self.assertIn("冷卻至", self.telegram.messages[-1].text)
+ self.assertIn("最近失敗:AI 請求逾時", self.telegram.messages[-1].text)
+ self.assertNotIn(SECRET, self.telegram.messages[-1].text)
+
+ def test_ai_off_can_finish_preserved_ambiguous_draft_with_basic_parser(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.append(parsed(question="要不要排除假日?"))
+ self.send("工作日提醒我喝水")
+ self.send("/ai off")
+ self.send("明天下午三點")
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.assertTrue(draft.is_complete)
+ self.assertNotIn("question", draft.parse_result)
+
+ def test_update_keeps_other_participants_and_original_timezone(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.extend(
+ [parsed(participant_indices=[1]), parsed(intent="update", participant_indices=[1])]
+ )
+ self.send("明天十五點叫 @alice 交報告")
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.assertEqual("alice", draft.participants[0].username)
+ self.service.set_timezone(USER.id, "Asia/Tokyo")
+ self.send("日期一樣,晚點再做")
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.assertEqual("Asia/Taipei", draft.timezone)
+ self.assertEqual("alice", draft.participants[0].username)
+
+ def test_failed_time_edit_preserves_edit_session_and_reminder(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.extend([parsed(), ProviderError("temporary")])
+ self.send("明天十五點提醒我交報告")
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.confirm(draft.id)
+ original = self.repo.list_pending(CHAT.id)[0]
+ self.service.begin_edit(CHAT.id, original.short_id, USER.id, "time", NOW)
+ self.send("延到後天")
+ self.assertIsNotNone(self.service.peek_edit_session(CHAT.id, USER.id, NOW))
+ self.assertEqual(original.remind_at, self.repo.list_pending(CHAT.id)[0].remind_at)
+
+ def test_lost_master_key_still_allows_removing_saved_credentials(self) -> None:
+ key_id = self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.keys._cipher = None
+ self.send(f"/ai delete {key_id}")
+ self.assertEqual([], self.keys.list(USER.id))
+
+ def test_missing_recurrence_time_preserves_source_for_followup(self) -> None:
+ self.keys.add(USER.id, provider="gemini", key=SECRET)
+ self.gemini.responses.extend(
+ [
+ parsed(date=None, time=None, question="每天幾點吃藥?"),
+ parsed(intent="update", date=None, time=None, recurrence=rule()),
+ ]
+ )
+ self.send("每天叫我吃藥")
+ self.send("早上九點")
+ draft = self.service.peek_draft(CHAT.id, USER.id, NOW)
+ self.assertEqual("每天叫我吃藥", self.gemini.calls[-1][1]["context"]["source_text"])
+ self.assertEqual(9, draft.recurrence.hour)
+ self.assertTrue(draft.is_complete)
diff --git a/tests/test_migrations.py b/tests/test_migrations.py
index be4e412..5fe4d85 100644
--- a/tests/test_migrations.py
+++ b/tests/test_migrations.py
@@ -3,10 +3,16 @@
import sqlite3
import tempfile
import unittest
+from collections.abc import Iterator
+from contextlib import closing, contextmanager
from pathlib import Path
from remindly.storage.migrations import (
INITIAL_SCHEMA_SQL,
+ MIGRATIONS,
+ Migration,
+ apply_migration,
+ ensure_migration_table,
latest_schema_version,
migrate_sqlite_database,
)
@@ -24,11 +30,13 @@ def test_migrate_records_latest_version_and_is_idempotent(self) -> None:
tables = table_names(connection)
self.assertEqual(expected_migration_rows(), rows)
- self.assertEqual(5, latest_schema_version())
+ self.assertEqual(9, latest_schema_version())
self.assertIn("reminders", tables)
self.assertIn("reminder_drafts", tables)
self.assertIn("edit_sessions", tables)
self.assertIn("chat_settings", tables)
+ self.assertIn("ai_keys", tables)
+ self.assertIn("ai_settings", tables)
def test_migrate_adopts_unversioned_existing_schema(self) -> None:
with tempfile.TemporaryDirectory() as directory:
@@ -41,6 +49,61 @@ def test_migrate_adopts_unversioned_existing_schema(self) -> None:
self.assertEqual(expected_migration_rows(), rows)
+ def test_provider_migration_preserves_existing_encrypted_keys_and_state(self) -> None:
+ with (
+ tempfile.TemporaryDirectory() as directory,
+ connect(Path(directory) / "test.db") as connection,
+ ):
+ ensure_migration_table(connection)
+ for migration in MIGRATIONS[:6]:
+ apply_migration(connection, migration)
+ connection.execute(
+ """insert into ai_keys
+ (id, user_id, encrypted_key, fingerprint, position, status, cooldown_until)
+ values ('key1', 7, ?, 'fingerprint', 2, 'cooldown',
+ '2027-01-01T00:00:00+00:00')""",
+ (b"encrypted-credential",),
+ )
+ before = dict(connection.execute("select * from ai_keys").fetchone())
+ migrate_sqlite_database(connection)
+ migrate_sqlite_database(connection)
+ after = dict(connection.execute("select * from ai_keys").fetchone())
+ self.assertEqual("gemini", after.pop("provider"))
+ self.assertIsNone(after.pop("last_error"))
+ self.assertEqual(before, after)
+
+ def test_ledger_failure_rolls_back_schema_and_allows_retry(self) -> None:
+ with connect(Path(":memory:")) as connection:
+ ensure_migration_table(connection)
+ for migration in MIGRATIONS[:6]:
+ apply_migration(connection, migration)
+ connection.execute(
+ """create trigger reject_version before insert on schema_migrations
+ when new.version = 7
+ begin select raise(abort, 'ledger unavailable'); end"""
+ )
+ with self.assertRaisesRegex(sqlite3.IntegrityError, "ledger unavailable"):
+ migrate_sqlite_database(connection)
+ columns = {row["name"] for row in connection.execute("pragma table_info(ai_keys)")}
+ self.assertNotIn("provider", columns)
+ self.assertEqual(6, migration_rows(connection)[-1][0])
+ connection.execute("drop trigger reject_version")
+ migrate_sqlite_database(connection)
+ self.assertEqual(expected_migration_rows(), migration_rows(connection))
+
+ def test_partial_ddl_failure_and_caller_transaction_are_atomic(self) -> None:
+ with connect(Path(":memory:")) as connection:
+ ensure_migration_table(connection)
+ with self.assertRaises(sqlite3.OperationalError):
+ apply_migration(connection, Migration(99, "broken", "create table probe (x); bad;"))
+ self.assertNotIn("probe", table_names(connection))
+ connection.execute("begin")
+ apply_migration(connection, Migration(99, "probe", "create table probe (x);"))
+ self.assertTrue(connection.in_transaction)
+ connection.rollback()
+ self.assertNotIn("probe", table_names(connection))
+ self.assertEqual([], migration_rows(connection))
+
def expected_migration_rows() -> list[tuple[int, str]]:
return [
@@ -49,13 +112,19 @@ def expected_migration_rows() -> list[tuple[int, str]]:
(3, "prompt_message_id"),
(4, "recurrence"),
(5, "draft_recurrence"),
+ (6, "personal_ai_keys"),
+ (7, "ai_key_providers"),
+ (8, "ai_edit_context"),
+ (9, "ai_key_last_error"),
]
-def connect(database_path: Path) -> sqlite3.Connection:
- connection = sqlite3.connect(database_path)
- connection.row_factory = sqlite3.Row
- return connection
+@contextmanager
+def connect(database_path: Path) -> Iterator[sqlite3.Connection]:
+ with closing(sqlite3.connect(database_path)) as connection:
+ connection.row_factory = sqlite3.Row
+ with connection:
+ yield connection
def migration_rows(connection: sqlite3.Connection) -> list[tuple[int, str]]:
@@ -66,7 +135,5 @@ def migration_rows(connection: sqlite3.Connection) -> list[tuple[int, str]]:
def table_names(connection: sqlite3.Connection) -> set[str]:
- rows = connection.execute(
- "select name from sqlite_master where type = 'table'"
- ).fetchall()
+ rows = connection.execute("select name from sqlite_master where type = 'table'").fetchall()
return {str(row["name"]) for row in rows}
diff --git a/tests/test_parser.py b/tests/test_parser.py
index a64b08f..3e4012c 100644
--- a/tests/test_parser.py
+++ b/tests/test_parser.py
@@ -102,6 +102,164 @@ def test_parse_same_weekday_can_target_today(self) -> None:
self.assertEqual("開會", result.title)
self.assertEqual((), result.missing_fields)
+ def test_parse_time_suffix_is_not_part_of_title(self) -> None:
+ for time_text in ("五點", "下午五點", "17:00", "三小時後", "明天五點"):
+ for suffix in ("的時候", " 的時候 ", "時候", " 的 時候 "):
+ with self.subTest(time_text=time_text, suffix=suffix):
+ prefix = f"提醒我在{time_text}"
+ expected = self.parser.parse(
+ f"{prefix}買兩份清燉牛肉湯", self.message, self.now
+ )
+ result = self.parser.parse(
+ f"{prefix}{suffix}買兩份清燉牛肉湯", self.message, self.now
+ )
+
+ self.assertEqual("買兩份清燉牛肉湯", result.title)
+ self.assertEqual(expected.remind_at, result.remind_at)
+ self.assertEqual((), result.missing_fields)
+ self.assertTrue(result.raw["consumed_time_text"].endswith(suffix.strip()))
+
+ def test_time_suffix_is_consumed_across_parse_paths(self) -> None:
+ for text in (
+ "提醒我在2026-10-24 17:00的時候買湯",
+ "提醒我在今晚的時候買湯",
+ "提醒我在19分的時候買湯",
+ "下午五點的時候提醒我買湯",
+ "下午五點的時候買湯",
+ "每天下午五點的時候提醒我買湯",
+ ):
+ with self.subTest(text=text):
+ result = self.parser.parse(text, self.message, self.now)
+ self.assertEqual("買湯", result.title)
+ self.assertEqual((), result.missing_fields)
+
+ def test_parse_preserves_time_suffix_inside_title(self) -> None:
+ title = "買喝湯的時候要用的湯匙"
+ result = self.parser.parse(f"提醒我在五點的時候{title}", self.message, self.now)
+
+ self.assertEqual(title, result.title)
+
+ def test_time_only_respects_explicit_month_day(self) -> None:
+ now = datetime(2026, 10, 23, 12, 0, tzinfo=self.zone)
+ for text in ("11/17 23:55", "11月17日 23:55", "十一月十七號 23:55"):
+ for existing_date in (None, now + timedelta(days=1)):
+ with self.subTest(text=text, existing_date=existing_date):
+ result = self.parser.parse_time_only(text, now, existing_date)
+ self.assertEqual(
+ datetime(2026, 11, 17, 23, 55, tzinfo=self.zone), result.remind_at
+ )
+ self.assertFalse(result.missing_time)
+ self.assertFalse(result.is_past)
+
+ def test_time_only_month_day_uses_existing_year_rollover_rule(self) -> None:
+ now = datetime(2026, 12, 23, 12, 0, tzinfo=self.zone)
+ result = self.parser.parse_time_only("11/17 23:55", now)
+ self.assertEqual(datetime(2027, 11, 17, 23, 55, tzinfo=self.zone), result.remind_at)
+
+ def test_invalid_datetime_never_falls_back_to_today(self) -> None:
+ for text in (
+ "13/17 23:55",
+ "11/31 23:55",
+ "二月三十日 23:55",
+ "2026-02-30 23:55",
+ "25:00",
+ "17:60",
+ "99 分",
+ ):
+ with self.subTest(text=text):
+ self.assertIsNone(self.parser.parse_time_only(text, self.now).remind_at)
+ for source in (f"{text}提醒我買湯", f"提醒我在{text}買湯", f"{text}買湯"):
+ parsed = self.parser.parse(source, self.message, self.now)
+ self.assertIn("time", parsed.missing_fields)
+
+ def test_time_only_preserves_existing_date_without_rolling_forward(self) -> None:
+ for text, hour, minute in (("09:00", 9, 0), ("19 分", 8, 19)):
+ existing = self.now.replace(hour=8)
+ parsed = self.parser.parse_time_only(text, self.now, existing)
+ self.assertEqual(existing.replace(hour=hour, minute=minute), parsed.remind_at)
+ self.assertTrue(parsed.is_past)
+
+ def test_past_date_only_keeps_missing_clock_flag(self) -> None:
+ parsed = self.parser.parse_time_only("2026-06-02", self.now)
+ self.assertTrue(parsed.is_past)
+ self.assertTrue(parsed.missing_time)
+
+ def test_time_only_and_creation_use_callers_timezone(self) -> None:
+ zone = ZoneInfo("America/New_York")
+ now = datetime(2026, 10, 23, 23, 0, tzinfo=zone)
+ expected = datetime(2026, 10, 24, 9, 0, tzinfo=zone)
+ self.assertEqual(expected, self.parser.parse_time_only("明早九點", now).remind_at)
+ self.assertEqual(
+ expected, self.parser.parse("提醒我明早九點買湯", self.message, now).remind_at
+ )
+
+ def test_search_can_read_clock_before_date(self) -> None:
+ result = self.parser.parse_time_only("23:55 明天", self.now)
+ self.assertEqual(self.now.replace(day=4, hour=23, minute=55), result.remind_at)
+
+ def test_content_period_does_not_override_clock(self) -> None:
+ parsed = self.parser.parse("五點買晚上要用的湯匙", self.message, self.now)
+ self.assertEqual(5, parsed.remind_at.hour)
+ self.assertEqual("買晚上要用的湯匙", parsed.title)
+
+ def test_search_preserves_content_between_date_and_clock(self) -> None:
+ for text, title, hour, minute in (
+ ("明天買湯 23:55", "買湯", 23, 55),
+ ("23:55 買湯 明天", "買湯", 23, 55),
+ ("明天買晚餐 晚上九點", "買晚餐", 21, 0),
+ ):
+ with self.subTest(text=text):
+ parsed = self.parser.parse(text, self.message, self.now)
+ self.assertEqual(title, parsed.title)
+ self.assertEqual(
+ (self.now + timedelta(days=1)).replace(hour=hour, minute=minute),
+ parsed.remind_at,
+ )
+ self.assertEqual((), parsed.missing_fields)
+
+ def test_meal_names_do_not_supply_a_time(self) -> None:
+ for title in ("買早餐", "買晚餐", "買早上的麵包", "買晚上的車票"):
+ for prefix in ("提醒我", "每天提醒我"):
+ with self.subTest(title=title, prefix=prefix):
+ parsed = self.parser.parse(prefix + title, self.message, self.now)
+ self.assertIn("time", parsed.missing_fields)
+ self.assertEqual(title, parsed.title)
+ self.assertIsNone(parsed.recurrence)
+ parsed = self.parser.parse("提醒我明天早餐買湯", self.message, self.now)
+ self.assertEqual("早餐買湯", parsed.title)
+ self.assertIn("time", parsed.missing_fields)
+
+ def test_week_prefixes_use_calendar_weeks(self) -> None:
+ now = datetime(2026, 10, 23, 12, 0, tzinfo=self.zone) # 週五
+ for text, day, month, past in (
+ ("週一九點", 26, 10, False),
+ ("下週一九點", 26, 10, False),
+ ("下下週一九點", 2, 11, False),
+ ("本週一九點", 19, 10, True),
+ ("這週一九點", 19, 10, True),
+ ):
+ with self.subTest(text=text):
+ parsed = self.parser.parse_time_only(text, now)
+ self.assertEqual(
+ datetime(2026, month, day, 9, 0, tzinfo=self.zone), parsed.remind_at
+ )
+ self.assertEqual(past, parsed.is_past)
+
+ def test_recurrence_uses_shared_clock_and_period_rules(self) -> None:
+ for time_text, hour in (("早上", 8), ("晚上九點", 21), ("17:00", 17)):
+ for marker in ("每天", "每週一", "每月1號", "每年11/17"):
+ with self.subTest(time_text=time_text, marker=marker):
+ parsed = self.parser.parse(
+ f"{marker}{time_text}提醒我買湯", self.message, self.now
+ )
+ self.assertIsNotNone(parsed.recurrence)
+ self.assertEqual(hour, parsed.recurrence.hour)
+ self.assertEqual("買湯", parsed.title)
+ for time_text in ("25:00", "17:60"):
+ with self.subTest(time_text=time_text):
+ parsed = self.parser.parse(f"每天{time_text}提醒我買湯", self.message, self.now)
+ self.assertIsNotNone(parsed.recurrence_error)
+
def test_parse_chinese_number(self) -> None:
self.assertEqual(9, parse_number("九"))
self.assertEqual(10, parse_number("十"))
diff --git a/tests/test_renderer.py b/tests/test_renderer.py
index 37f93e5..9ae6cfe 100644
--- a/tests/test_renderer.py
+++ b/tests/test_renderer.py
@@ -76,6 +76,34 @@ def test_one_off_shows_time_label(self) -> None:
self.assertIn("時間:", text)
self.assertNotIn("重複:", text)
self.assertNotIn("下次:", text)
+ self.assertTrue(text.endswith("\n\n使用 內建規則 解析"))
+
+ def test_ai_confirmation_labels_actual_parse_source(self) -> None:
+ draft = _draft(
+ parse_result={
+ "grain": "ai",
+ "provider": "gemini",
+ "model": "gemini-3.5-flash-lite",
+ "model_display_name": "Gemini 3.5 Flash-Lite",
+ }
+ )
+ text = self.renderer.render_confirmation(draft)
+ self.assertIn("使用 Gemini 3.5 Flash-Lite 解析", text)
+ self.assertNotIn("gemini-3.5-flash-lite", text)
+ self.assertNotIn("使用 內建規則 解析", text)
+ self.assertIn("時區:Asia/Taipei", text)
+
+ def test_ai_label_falls_back_and_escapes_metadata(self) -> None:
+ for metadata, expected in (
+ ({"provider": "other", "model": "internal-model-id"}, "AI"),
+ ({}, "AI"),
+ ({"model_display_name": "使用 {expected} 解析", text)
def test_recurring_shows_repeat_and_next(self) -> None:
draft = _draft(
@@ -103,11 +131,7 @@ def test_one_off_shows_time_label(self) -> None:
def test_recurring_shows_repeat_and_next(self) -> None:
text = self.renderer.render_created(
- _reminder(
- recurrence=RecurrenceRule(
- period=RecurrencePeriod.DAILY, hour=9, minute=0
- )
- )
+ _reminder(recurrence=RecurrenceRule(period=RecurrencePeriod.DAILY, hour=9, minute=0))
)
self.assertIn("重複:每天 09:00", text)
self.assertIn("下次:", text)
@@ -248,11 +272,7 @@ def test_keyboard_button_has_bracket_prefix_for_recurring(self) -> None:
)
]
markup = reminder_list_keyboard(groups, ReminderListFilter.ALL)
- button_texts = [
- button["text"]
- for row in markup["inline_keyboard"]
- for button in row
- ]
+ button_texts = [button["text"] for row in markup["inline_keyboard"] for button in row]
recurring_button = next(t for t in button_texts if "R-REC" in t)
oneoff_button = next(t for t in button_texts if "R-ONE" in t)
diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py
index 17f2fa5..7b55853 100644
--- a/tests/test_scheduler.py
+++ b/tests/test_scheduler.py
@@ -4,6 +4,7 @@
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Any
+from unittest.mock import patch
from zoneinfo import ZoneInfo
from remindly.reminders.models import Participant, Reminder, ReminderStatus
@@ -136,7 +137,6 @@ def test_delivery_message_contains_snooze_buttons(self) -> None:
]
self.assertEqual(["10 分鐘後", "1 小時後", "明天 09:00"], labels)
-
def test_tick_marks_expired_prompts_with_empty_keyboard(self) -> None:
now = datetime.now(ZoneInfo("Asia/Taipei"))
reminder = Reminder(
@@ -223,9 +223,7 @@ def test_tick_uses_reminder_timezone_for_next_fire(self) -> None:
# Mock `datetime.now` 讓 tick 時的 now 剛好是 2026-07-03 09:00 TP (= 01:00 UTC)
import unittest.mock
- with unittest.mock.patch(
- "remindly.reminders.scheduler.datetime"
- ) as mock_dt:
+ with unittest.mock.patch("remindly.reminders.scheduler.datetime") as mock_dt:
mock_dt.now.return_value = datetime(2026, 7, 3, 1, 0, tzinfo=scheduler_zone)
scheduler.tick()
@@ -364,7 +362,9 @@ def test_tick_reschedules_recurring_reminder_instead_of_marking_fired(self) -> N
interval_seconds=10,
)
- scheduler.tick()
+ with patch("remindly.reminders.scheduler.datetime") as clock:
+ clock.now.return_value = remind_at
+ scheduler.tick()
self.assertEqual([], repository.marked_fired) # 不標 FIRED
self.assertEqual(1, len(repository.rescheduled))
diff --git a/tests/test_service.py b/tests/test_service.py
index 314d756..4c889ce 100644
--- a/tests/test_service.py
+++ b/tests/test_service.py
@@ -2,6 +2,7 @@
import tempfile
import unittest
+from dataclasses import replace
from datetime import datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
@@ -36,7 +37,7 @@ def setUp(self) -> None:
self.service = ReminderService(
repository=self.repository,
parser=ReminderParser("Asia/Taipei"),
- draft_store=DraftStore(ttl_minutes=10),
+ draft_store=DraftStore(ttl_minutes=10, repository=self.repository),
edit_store=EditSessionStore(ttl_minutes=10),
default_timezone="Asia/Taipei",
)
@@ -178,7 +179,7 @@ def setUp(self) -> None:
self.service = ReminderService(
repository=self.repository,
parser=ReminderParser("Asia/Taipei"),
- draft_store=DraftStore(ttl_minutes=10),
+ draft_store=DraftStore(ttl_minutes=10, repository=self.repository),
edit_store=EditSessionStore(ttl_minutes=10),
default_timezone="Asia/Taipei",
)
@@ -336,7 +337,7 @@ def setUp(self) -> None:
self.service = ReminderService(
repository=self.repository,
parser=ReminderParser("Asia/Taipei"),
- draft_store=DraftStore(ttl_minutes=10),
+ draft_store=DraftStore(ttl_minutes=10, repository=self.repository),
edit_store=EditSessionStore(ttl_minutes=10),
default_timezone="Asia/Taipei",
)
@@ -396,9 +397,7 @@ def test_begin_create_rejects_too_frequent_interval(self) -> None:
)
self.assertIsInstance(result, RecurrenceRejected)
self.assertIn("太頻繁", result.error.reason)
- self.assertIsNone(
- self.service._draft_store.get_for_context(100, 7, self.now)
- )
+ self.assertIsNone(self.service._draft_store.get_for_context(100, 7, self.now))
def test_begin_create_returns_recurrence_rejected_for_invalid_marker(self) -> None:
"""/remind 之類的 entry point 不走 router preview_parse,begin_create 也要能
@@ -448,7 +447,7 @@ def setUp(self) -> None:
self.service = ReminderService(
repository=self.repository,
parser=ReminderParser("Asia/Taipei"),
- draft_store=DraftStore(ttl_minutes=10),
+ draft_store=DraftStore(ttl_minutes=10, repository=self.repository),
edit_store=EditSessionStore(ttl_minutes=10),
default_timezone="Asia/Taipei",
)
@@ -530,9 +529,7 @@ def test_skip_next_rejects_non_recurring_reminder(self) -> None:
def test_skip_next_rejects_non_creator(self) -> None:
self._persist_recurring(datetime(2026, 7, 18, 9, 0, tzinfo=self.zone))
- self.assertIsNone(
- self.service.skip_next_occurrence(100, "R-P4A", 999, self.now)
- )
+ self.assertIsNone(self.service.skip_next_occurrence(100, "R-P4A", 999, self.now))
def test_cancel_series_marks_cancelled(self) -> None:
self._persist_recurring(datetime(2026, 7, 18, 9, 0, tzinfo=self.zone))
@@ -556,9 +553,7 @@ def test_skip_next_returns_none_when_reminder_no_longer_pending(self) -> None:
("firing", "rmd_p4a"),
)
- self.assertIsNone(
- self.service.skip_next_occurrence(100, "R-P4A", 7, self.now)
- )
+ self.assertIsNone(self.service.skip_next_occurrence(100, "R-P4A", 7, self.now))
# 原本的 remind_at 沒被更改
stored = self.repository.get_by_short_id(100, "R-P4A")
self.assertEqual(
@@ -633,6 +628,136 @@ def _draft(self, draft_id: str, *, creator_user_id: int = 7) -> ReminderDraft:
parse_result={},
)
+ def test_create_follow_up_and_edit_share_datetime_formats(self) -> None:
+ from remindly.reminders.service import Confirmation, EditResult
+
+ now = datetime(2026, 10, 23, 12, 10, tzinfo=self.zone)
+ message = TelegramMessage(
+ id=1,
+ chat=TelegramChat(id=100, type="private"),
+ from_user=TelegramUser(id=7, first_name="Orange", username="orange"),
+ text="提醒我買湯",
+ entities=(),
+ )
+ for text, values in (
+ ("11/17 23:55", (2026, 11, 17, 23, 55)),
+ ("2026/11/17 下午三點", (2026, 11, 17, 15, 0)),
+ ("十一月十七號晚上九點", (2026, 11, 17, 21, 0)),
+ ("明早九點", (2026, 10, 24, 9, 0)),
+ ("19 分", (2026, 10, 23, 12, 19)),
+ ("下週一下午三點", (2026, 10, 26, 15, 0)),
+ ):
+ with self.subTest(text=text):
+ expected = datetime(*values, tzinfo=self.zone)
+ direct = self.service.begin_create(f"提醒我{text}買湯", message, now)
+ self.assertIsInstance(direct, Confirmation)
+ self.assertEqual(expected, direct.draft.remind_at)
+ self.service.begin_create(message.text, message, now)
+ answer = replace(message, text=text)
+ follow_up = self.service.continue_draft(answer, now)
+ self.assertIsInstance(follow_up, Confirmation)
+ self.assertEqual(expected, follow_up.draft.remind_at)
+ created = self.service.confirm(follow_up.draft.id, 7, now).reminder
+ self.service.begin_edit(100, created.short_id, 7, "time", now)
+ edited = self.service.continue_edit(answer, now)
+ self.assertIsInstance(edited, EditResult)
+ self.assertEqual(expected, edited.details.reminder.remind_at)
+
+ def test_invalid_or_past_edit_preserves_original_time(self) -> None:
+ from remindly.reminders.service import EditPrompt
+
+ message = TelegramMessage(
+ id=1,
+ chat=TelegramChat(id=100, type="private"),
+ from_user=TelegramUser(id=7, first_name="Orange", username="orange"),
+ text="提醒我明天九點買湯",
+ entities=(),
+ )
+ draft = self.service.begin_create(message.text, message, self.now).draft
+ original = self.service.confirm(draft.id, 7, self.now).reminder
+ self.service.begin_edit(100, original.short_id, 7, "time", self.now)
+ for text in ("11/31 23:55", "25:00", "2026-06-02 09:00"):
+ with self.subTest(text=text):
+ result = self.service.continue_edit(replace(message, text=text), self.now)
+ self.assertIsInstance(result, EditPrompt)
+ saved = self.repository.get_by_short_id(100, original.short_id)
+ self.assertEqual(original.remind_at, saved.remind_at)
+
+ def test_date_then_clock_follow_up_retains_date(self) -> None:
+ from remindly.reminders.service import Confirmation, DraftPrompt
+
+ message = TelegramMessage(
+ id=1,
+ chat=TelegramChat(id=100, type="private"),
+ from_user=TelegramUser(id=7, first_name="Orange", username="orange"),
+ text="提醒我買湯",
+ entities=(),
+ )
+ self.service.begin_create(message.text, message, self.now)
+ prompt = self.service.continue_draft(replace(message, text="11/17"), self.now)
+ self.assertIsInstance(prompt, DraftPrompt)
+ self.assertEqual("那天幾點?", prompt.question)
+ result = self.service.continue_draft(replace(message, text="23:55"), self.now)
+ self.assertIsInstance(result, Confirmation)
+ self.assertEqual(datetime(2026, 11, 17, 23, 55, tzinfo=self.zone), result.draft.remind_at)
+
+ def test_user_timezone_survives_creation_follow_up_and_edit(self) -> None:
+ from remindly.reminders.service import Confirmation, EditResult
+
+ zone = ZoneInfo("America/New_York")
+ self.repository.set_user_timezone(7, zone.key)
+ now = datetime(2026, 10, 23, 23, 0, tzinfo=zone)
+ expected = datetime(2026, 10, 24, 9, 0, tzinfo=zone)
+ message = TelegramMessage(
+ id=1,
+ chat=TelegramChat(id=100, type="private"),
+ from_user=TelegramUser(id=7, first_name="Orange", username="orange"),
+ text="提醒我買湯",
+ entities=(),
+ )
+ direct = self.service.begin_create("提醒我明早九點買湯", message, now)
+ self.assertEqual(expected, direct.draft.remind_at)
+ self.service.begin_create(message.text, message, now)
+ answer = replace(message, text="明早九點")
+ follow_up = self.service.continue_draft(answer, now)
+ self.assertIsInstance(follow_up, Confirmation)
+ self.assertEqual(expected, follow_up.draft.remind_at)
+ created = self.service.confirm(follow_up.draft.id, 7, now).reminder
+ self.service.begin_edit(100, created.short_id, 7, "time", now)
+ edited = self.service.continue_edit(answer, now)
+ self.assertIsInstance(edited, EditResult)
+ self.assertEqual(expected, edited.details.reminder.remind_at)
+
+ def test_follow_up_time_keeps_explicit_month_day(self) -> None:
+ from remindly.reminders.service import Confirmation, DraftPrompt
+
+ now = datetime(2026, 10, 23, 12, 0, tzinfo=self.zone)
+ message = TelegramMessage(
+ id=1,
+ chat=TelegramChat(id=100, type="private"),
+ from_user=TelegramUser(id=7, first_name="Orange", username="orange"),
+ text="提醒我要領蝦幣",
+ entities=(),
+ )
+ prompt = self.service.begin_create(message.text, message, now)
+ self.assertIsInstance(prompt, DraftPrompt)
+ self.assertEqual("什麼時候提醒?", prompt.question)
+
+ answer = TelegramMessage(
+ id=2,
+ chat=message.chat,
+ from_user=message.from_user,
+ text="11/17 23:55",
+ entities=(),
+ )
+ result = self.service.continue_draft(answer, now + timedelta(seconds=10))
+
+ self.assertIsInstance(result, Confirmation)
+ self.assertEqual("要領蝦幣", result.draft.title)
+ self.assertEqual(datetime(2026, 11, 17, 23, 55, tzinfo=self.zone), result.draft.remind_at)
+ saved = self.draft_store.get_by_id(result.draft.id, now + timedelta(seconds=11))
+ self.assertEqual(result.draft.remind_at, saved.remind_at)
+
def test_sweep_returns_only_prompts_with_message_id(self) -> None:
with_msg = self._draft("draft_with_msg", creator_user_id=7)
self.draft_store.save(with_msg, self.now)
diff --git a/tests/test_time_boundaries.py b/tests/test_time_boundaries.py
new file mode 100644
index 0000000..ac7b730
--- /dev/null
+++ b/tests/test_time_boundaries.py
@@ -0,0 +1,283 @@
+from __future__ import annotations
+
+import json
+import tempfile
+import unittest
+from dataclasses import replace
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+from unittest.mock import Mock
+from zoneinfo import ZoneInfo
+
+from test_ai import parsed
+
+from remindly.ai.contracts import GeneratedResponse
+from remindly.ai.parser import SemanticParser
+from remindly.reminders.models import ReminderDraft
+from remindly.reminders.parser import ReminderParser, participant_from_user
+from remindly.reminders.service import (
+ Confirmation,
+ DraftPrompt,
+ EditPrompt,
+ RecurrenceRejected,
+ ReminderService,
+)
+from remindly.storage.session_stores import SqliteDraftStore, SqliteEditSessionStore
+from remindly.storage.sqlite import ReminderRepository
+from remindly.telegram.models import TelegramChat, TelegramMessage, TelegramUser
+
+FIXTURES = Path(__file__).parent / "fixtures"
+
+
+class TimeBoundaryTest(unittest.TestCase):
+ def setUp(self):
+ self.directory = tempfile.TemporaryDirectory()
+ self.addCleanup(self.directory.cleanup)
+ self.repo = ReminderRepository(Path(self.directory.name) / "boundaries.db")
+ self.repo.migrate()
+ self.service = ReminderService(
+ repository=self.repo,
+ parser=ReminderParser("Asia/Taipei"),
+ draft_store=SqliteDraftStore(10, self.repo),
+ edit_store=SqliteEditSessionStore(10, self.repo),
+ default_timezone="Asia/Taipei",
+ )
+ self.message = TelegramMessage(
+ id=1,
+ chat=TelegramChat(id=100, type="private"),
+ from_user=TelegramUser(id=7, first_name="Orange", username="orange"),
+ text="",
+ entities=(),
+ )
+ self.now = datetime(2026, 10, 24, 23, 59, tzinfo=ZoneInfo("Asia/Taipei"))
+
+ def answer(self, text, now):
+ return self.service.continue_draft(replace(self.message, text=text), now)
+
+ def test_shared_formats_across_followup_edit_and_variable_ai(self):
+ ids = {
+ "slash-date",
+ "single-digit-month-follow-up-format",
+ "padded-month-follow-up-format",
+ "hyphen-date-clock",
+ "year-date-chinese-clock",
+ "fullwidth-colon",
+ "morning-twelve",
+ "clock-extra-minute-digit",
+ "clock-extra-hour-digit",
+ "clock-with-seconds",
+ "clock-missing-minute",
+ "evening-twelve-ambiguous",
+ "alternative-dates",
+ "alternative-clock",
+ "dst-nonexistent",
+ "dst-ambiguous",
+ "dst-before-gap",
+ "dst-after-gap",
+ "dst-after-fold",
+ "fullwidth-clock-invalid",
+ "short-date-extra-day-digit",
+ "short-date-extra-month-digit",
+ "absolute-date-extra-day-digit",
+ "absolute-date-leading-zero-day",
+ "absolute-date-extra-year-digit",
+ "absolute-date-short-year",
+ "chinese-date-extra-day-digit",
+ "partial-minute-extra-digit",
+ }
+ cases = [
+ json.loads(line)
+ for line in (FIXTURES / "time_regressions.jsonl").read_text().splitlines()
+ ]
+ selected = [case for case in cases if case["id"] in ids]
+ self.assertEqual(ids, {case["id"] for case in selected})
+ for case in selected:
+ now = datetime.fromisoformat(case["now"]).astimezone(ZoneInfo(case["timezone"]))
+ expected = datetime.fromisoformat(case["at"]) if case["at"] else None
+ self.repo.set_user_timezone(7, case["timezone"])
+ with self.subTest(case=case["id"], entry="followup"):
+ self.service.begin_create("提醒我買湯", self.message, now)
+ result = self.answer(case["text"], now)
+ self.assertIsInstance(result, Confirmation if expected else DraftPrompt)
+ self.assertEqual(expected, result.draft.remind_at)
+ self.assertEqual("買湯", result.draft.title)
+ with self.subTest(case=case["id"], entry="edit"):
+ initial = self.service.begin_create(
+ f"提醒我 {now:%Y-%m-%d} 23:59 買湯", self.message, now
+ )
+ original = self.service.confirm(initial.draft.id, 7, now).reminder
+ self.service.begin_edit(100, original.short_id, 7, "time", now)
+ result = self.service.continue_edit(replace(self.message, text=case["text"]), now)
+ saved = self.repo.get_by_short_id(100, original.short_id)
+ if expected:
+ self.assertEqual(expected, saved.remind_at)
+ else:
+ self.assertIsInstance(result, EditPrompt)
+ self.assertEqual(original, saved)
+ context = ReminderDraft(
+ id="guard",
+ chat_id=100,
+ chat_type="private",
+ creator_user_id=7,
+ timezone=case["timezone"],
+ source_text="提醒我買湯",
+ title="買湯",
+ participants=[participant_from_user(self.message.from_user)],
+ missing_fields=["time"],
+ )
+ for intent, day, clock in (
+ ("create", "2030-01-01", "01:00"),
+ ("update", "2025-12-31", "23:59"),
+ ("not_reminder", None, None),
+ ):
+ generator = Mock()
+ generator.generate.return_value = GeneratedResponse(
+ parsed(intent=intent, title="被模型改掉", date=day, time=clock),
+ "test",
+ "variable",
+ )
+ parser = SemanticParser(generator)
+ for entry, text, prior in (
+ ("ai-followup", case["text"], context),
+ ("ai-edit", case["text"], context),
+ ("ai-leading", f"{case['text']}買湯", None),
+ ("ai-prompt", f"提醒我{case['text']}買湯", None),
+ *([("ai-bare-invalid", case["text"], None)] if expected is None else []),
+ ):
+ with self.subTest(case=case["id"], entry=entry, intent=intent):
+ result = parser.parse(
+ text,
+ 7,
+ now,
+ context.participants,
+ context=prior,
+ purpose="edit_time" if entry == "ai-edit" else "reminder",
+ )
+ self.assertEqual(expected, result.remind_at)
+ self.assertEqual(expected is None, "time" in result.missing_fields)
+ if prior:
+ self.assertEqual("買湯", result.title)
+ self.assertEqual(tuple(context.participants), result.participants)
+ if entry == "ai-prompt":
+ generator.enabled.return_value = True
+ self.service.ai_parser = parser
+ try:
+ preview = self.service.begin_create(text, self.message, now)
+ self.assertIsInstance(
+ preview, Confirmation if expected else DraftPrompt
+ )
+ if expected is None:
+ self.assertIsNone(
+ self.service.confirm(preview.draft.id, 7, now)
+ )
+ finally:
+ self.service.ai_parser = None
+
+ def test_date_answer_survives_midnight_and_store_reload(self):
+ self.service.begin_create("提醒我買湯", self.message, self.now)
+ self.answer("明天", self.now)
+ self.service._draft_store = SqliteDraftStore(10, self.repo)
+ result = self.answer("23:55", self.now + timedelta(minutes=2))
+ self.assertIsInstance(result, Confirmation)
+ self.assertEqual(self.now.replace(day=25, hour=23, minute=55), result.draft.remind_at)
+ self.assertEqual([], self.repo.list_pending(100))
+
+ def test_ai_guard_does_not_replace_clock_before_date_with_today(self):
+ for text in ("23:55 明天買湯", "23:55 買湯 明天"):
+ with self.subTest(text=text):
+ generator = Mock()
+ generator.generate.return_value = GeneratedResponse(
+ parsed(date="2026-10-25", time="23:55"), "test", "variable"
+ )
+ person = participant_from_user(self.message.from_user)
+ result = SemanticParser(generator).parse(text, 7, self.now, [person])
+ self.assertEqual(self.now.replace(day=25, hour=23, minute=55), result.remind_at)
+
+ def test_recurrence_cannot_bypass_clock_or_dst_checks(self):
+ now = datetime(2027, 3, 13, 12, tzinfo=ZoneInfo("America/New_York"))
+ self.repo.set_user_timezone(7, str(now.tzinfo))
+ for clock in ("23:555", "23:55:30", "晚上十二點", "02:30"):
+ with self.subTest(clock=clock):
+ result = self.service.begin_create(f"每天{clock}提醒我買湯", self.message, now)
+ self.assertIsInstance(result, RecurrenceRejected)
+ generator = Mock()
+ generator.generate.return_value = GeneratedResponse(parsed(), "test", "variable")
+ for text in (f"每天{clock}提醒我買湯", f"提醒我每天{clock}買湯"):
+ person = participant_from_user(self.message.from_user)
+ guarded = SemanticParser(generator).parse(text, 7, now, [person])
+ self.assertIn("time", guarded.missing_fields)
+ self.assertIsNone(guarded.remind_at)
+ result = self.service.begin_create("每天03:30提醒我買湯", self.message, now)
+ self.assertIsInstance(result, Confirmation)
+
+ def test_oversized_clock_tokens_require_followup_without_raising(self):
+ for text in ("9" * 5000 + ":00", "9" * 5000 + "分"):
+ with self.subTest(suffix=text[-3:]):
+ result = self.service.begin_create(f"提醒我{text}買湯", self.message, self.now)
+ self.assertIsInstance(result, DraftPrompt)
+ self.assertIn("time", result.draft.missing_fields)
+
+ def test_ai_preserves_date_after_midnight_despite_changed_model_answers(self):
+ now = self.now + timedelta(minutes=2)
+ person = participant_from_user(self.message.from_user)
+ context = ReminderDraft(
+ id="midnight",
+ chat_id=100,
+ chat_type="private",
+ creator_user_id=7,
+ timezone="Asia/Taipei",
+ source_text="提醒我買湯",
+ title="買湯",
+ remind_at=self.now.replace(day=25, hour=0, minute=0),
+ participants=[person],
+ missing_fields=["time"],
+ )
+ for day in ("2026-10-24", "2026-10-25", "2030-01-01"):
+ with self.subTest(day=day):
+ generator = Mock()
+ generator.generate.return_value = GeneratedResponse(
+ parsed(date=day, time="01:00", title="其他事項"), "test", "variable"
+ )
+ result = SemanticParser(generator).parse("23:55", 7, now, [person], context=context)
+ self.assertEqual(self.now.replace(day=25, hour=23, minute=55), result.remind_at)
+ self.assertEqual("買湯", result.title)
+ self.assertEqual((), result.missing_fields)
+
+ def test_expired_date_is_not_reinterpreted_after_midnight(self):
+ self.service.begin_create("提醒我買湯", self.message, self.now)
+ self.answer("今天", self.now)
+ result = self.answer("23:55", self.now + timedelta(minutes=2))
+ self.assertIsInstance(result, DraftPrompt)
+ self.assertIn("time", result.draft.missing_fields)
+ self.assertIsNone(self.service.confirm(result.draft.id, 7, self.now + timedelta(minutes=2)))
+ corrected = self.answer("10/25 23:55", self.now + timedelta(minutes=3))
+ self.assertIsInstance(corrected, Confirmation)
+ self.assertEqual(self.now.replace(day=25, hour=23, minute=55), corrected.draft.remind_at)
+
+ def test_confirmation_cannot_create_time_that_passed_across_midnight(self):
+ now = self.now.replace(minute=58)
+ result = self.service.begin_create("提醒我23:59買湯", self.message, now)
+ self.assertIsInstance(result, Confirmation)
+ self.assertIsNone(self.service.confirm(result.draft.id, 7, now + timedelta(minutes=3)))
+ self.assertEqual([], self.repo.list_pending(100))
+
+ def test_quick_time_cannot_bypass_dst_or_invalid_clock(self):
+ now = datetime(2027, 3, 13, 12, tzinfo=ZoneInfo("America/New_York"))
+ self.repo.set_user_timezone(7, str(now.tzinfo))
+ for clock in ("02:30", "23:555", "23:55:30"):
+ with self.subTest(clock=clock):
+ draft = self.service.begin_create("提醒我明天買湯", self.message, now).draft
+ result = self.service.apply_quick_time(draft.id, clock, 7, now)
+ self.assertIsInstance(result, DraftPrompt)
+ self.assertIn("time", result.draft.missing_fields)
+ self.assertIsNone(self.service.confirm(draft.id, 7, now))
+
+ def test_relative_elapsed_time_remains_valid_in_repeated_hour(self):
+ now = datetime(2027, 11, 7, 1, 45, tzinfo=ZoneInfo("America/New_York"))
+ self.repo.set_user_timezone(7, str(now.tzinfo))
+ result = self.service.begin_create("半小時後提醒我買湯", self.message, now)
+ self.assertIsInstance(result, Confirmation)
+ self.assertEqual(
+ 1800, (result.draft.remind_at.astimezone(UTC) - now.astimezone(UTC)).total_seconds()
+ )
+ self.assertIsNotNone(self.service.confirm(result.draft.id, 7, now))
diff --git a/tests/test_time_corpus.py b/tests/test_time_corpus.py
new file mode 100644
index 0000000..da5145a
--- /dev/null
+++ b/tests/test_time_corpus.py
@@ -0,0 +1,189 @@
+from __future__ import annotations
+
+import json
+import tempfile
+import unittest
+from dataclasses import replace
+from datetime import datetime
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+from remindly.reminders.parser import ReminderParser
+from remindly.reminders.recurrence import serialize_rule
+from remindly.reminders.service import (
+ Confirmation,
+ DraftPrompt,
+ EditPrompt,
+ EditResult,
+ ReminderService,
+)
+from remindly.storage.session_stores import SqliteDraftStore, SqliteEditSessionStore
+from remindly.storage.sqlite import ReminderRepository
+from remindly.telegram.models import TelegramChat, TelegramMessage, TelegramUser
+
+CORPUS = Path(__file__).parent / "fixtures" / "time_regressions.jsonl"
+
+
+class TimeRegressionCorpusTest(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls) -> None:
+ cls.cases = [json.loads(line) for line in CORPUS.read_text().splitlines() if line.strip()]
+
+ def setUp(self) -> None:
+ self.parser = ReminderParser("Asia/Taipei")
+ self.message = TelegramMessage(
+ id=1,
+ chat=TelegramChat(id=100, type="private"),
+ from_user=TelegramUser(id=7, first_name="Orange", username="orange"),
+ text="",
+ entities=(),
+ )
+
+ def _reference(self, case: dict) -> datetime:
+ return datetime.fromisoformat(case["now"]).astimezone(ZoneInfo(case["timezone"]))
+
+ def _assert_time(self, expected: str | None, actual: datetime | None) -> None:
+ self.assertEqual(datetime.fromisoformat(expected) if expected is not None else None, actual)
+
+ def _service(
+ self, case: dict, directory: str
+ ) -> tuple[ReminderService, ReminderRepository, SqliteDraftStore]:
+ repository = ReminderRepository(Path(directory) / "corpus.db")
+ repository.migrate()
+ repository.set_user_timezone(7, case["timezone"])
+ drafts = SqliteDraftStore(ttl_minutes=10, repository=repository)
+ service = ReminderService(
+ repository=repository,
+ parser=self.parser,
+ draft_store=drafts,
+ edit_store=SqliteEditSessionStore(ttl_minutes=10, repository=repository),
+ default_timezone="Asia/Taipei",
+ )
+ return service, repository, drafts
+
+ def test_corpus_structure(self) -> None:
+ self.assertGreaterEqual(len(self.cases), 107, "既有 107 筆回歸案例不可縮減")
+ ids = [case["id"] for case in self.cases]
+ self.assertEqual(len(ids), len(set(ids)), "案例 id 不可重複")
+ required = CORPUS.with_suffix(".required_ids.txt").read_text().splitlines()
+ self.assertEqual(107, len(set(required)))
+ self.assertFalse(set(required) - set(ids), "原本的回歸案例不可被其他案例取代")
+ fields = {
+ "time": {"text", "at", "missing_time", "past", "existing_date", "cross_entry_points"},
+ "reminder": {"text", "title", "at", "missing", "recurrence", "error", "participants"},
+ "conversation": {"turns"},
+ "edit": {"initial", "text", "state", "at", "title"},
+ }
+ self.assertEqual(set(fields), {case["kind"] for case in self.cases})
+ for case in self.cases:
+ with self.subTest(case=case["id"]):
+ self.assertIn(case["kind"], fields)
+ self.assertEqual(
+ {"kind", "id", "now", "timezone"} | fields[case["kind"]], set(case)
+ )
+ self.assertIsNotNone(datetime.fromisoformat(case["now"]).tzinfo)
+ self._reference(case)
+
+ def test_time_formats_across_entry_points(self) -> None:
+ for case in self.cases:
+ if case["kind"] != "time":
+ continue
+ now = self._reference(case)
+ existing = case["existing_date"]
+ with self.subTest(case=case["id"], path="time_only"):
+ parsed = self.parser.parse_time_only(
+ case["text"], now, datetime.fromisoformat(existing) if existing else None
+ )
+ self._assert_time(case["at"], parsed.remind_at)
+ self.assertEqual(case["missing_time"], parsed.missing_time)
+ self.assertEqual(case["past"], parsed.is_past)
+ if parsed.remind_at is not None:
+ self.assertEqual(ZoneInfo(case["timezone"]), parsed.remind_at.tzinfo)
+ if not case["cross_entry_points"]:
+ continue
+ for source in (
+ f"{case['text']}提醒我買湯",
+ f"提醒我在{case['text']}買湯",
+ f"{case['text']}買湯",
+ ):
+ with self.subTest(case=case["id"], source=source):
+ parsed = self.parser.parse(source, self.message, now)
+ self._assert_time(case["at"], parsed.remind_at)
+ missing = case["at"] is None or case["missing_time"] or case["past"]
+ self.assertEqual(["time"] if missing else [], list(parsed.missing_fields))
+ if case["at"] is not None:
+ self.assertEqual("買湯", parsed.title)
+
+ def test_reminder_content_and_recurrence(self) -> None:
+ for case in self.cases:
+ if case["kind"] != "reminder":
+ continue
+ with self.subTest(case=case["id"]):
+ parsed = self.parser.parse(case["text"], self.message, self._reference(case))
+ self._assert_time(case["at"], parsed.remind_at)
+ self.assertEqual(case["title"], parsed.title)
+ self.assertEqual(case["missing"], list(parsed.missing_fields))
+ self.assertEqual(case["error"], parsed.recurrence_error is not None)
+ self.assertEqual(
+ case["recurrence"],
+ json.loads(serialize_rule(parsed.recurrence)) if parsed.recurrence else None,
+ )
+ self.assertEqual(
+ case["participants"], [p.display_name for p in parsed.participants]
+ )
+
+ def test_conversations_persist_each_answer(self) -> None:
+ for case in self.cases:
+ if case["kind"] != "conversation":
+ continue
+ with self.subTest(case=case["id"]), tempfile.TemporaryDirectory() as directory:
+ service, repository, drafts = self._service(case, directory)
+ now = self._reference(case)
+ for index, turn in enumerate(case["turns"]):
+ with self.subTest(turn=index, text=turn["text"]):
+ message = replace(self.message, id=index + 1, text=turn["text"])
+ result = (
+ service.begin_create(message.text, message, now)
+ if index == 0
+ else service.continue_draft(message, now)
+ )
+ self.assertIn(turn["state"], ("prompt", "confirmation"))
+ self.assertIsInstance(
+ result, DraftPrompt if turn["state"] == "prompt" else Confirmation
+ )
+ self.assertEqual(turn["question"], getattr(result, "question", None))
+ saved = drafts.get_by_id(result.draft.id, now)
+ self.assertIsNotNone(saved)
+ self.assertEqual(turn["title"], saved.title)
+ self._assert_time(turn["at"], saved.remind_at)
+ self.assertEqual(turn["missing"], saved.missing_fields)
+ self.assertEqual([], repository.list_pending(100))
+ self.assertIsInstance(result, Confirmation)
+ created = service.confirm(result.draft.id, 7, now)
+ self.assertIsNotNone(created)
+ saved = repository.get_by_short_id(100, created.reminder.short_id)
+ self._assert_time(turn["at"], saved.remind_at)
+ self.assertEqual(turn["title"], saved.title)
+ self.assertIsNone(drafts.get_by_id(result.draft.id, now))
+
+ def test_edits_update_or_preserve_stored_reminders(self) -> None:
+ for case in self.cases:
+ if case["kind"] != "edit":
+ continue
+ with self.subTest(case=case["id"]), tempfile.TemporaryDirectory() as directory:
+ service, repository, _ = self._service(case, directory)
+ now = self._reference(case)
+ draft = service.begin_create(case["initial"], self.message, now)
+ self.assertIsInstance(draft, Confirmation)
+ created = service.confirm(draft.draft.id, 7, now).reminder
+ self.assertIsNotNone(service.begin_edit(100, created.short_id, 7, "time", now))
+ result = service.continue_edit(replace(self.message, text=case["text"]), now)
+ self.assertIn(case["state"], ("updated", "prompt"))
+ self.assertIsInstance(
+ result, EditResult if case["state"] == "updated" else EditPrompt
+ )
+ saved = repository.get_by_short_id(100, created.short_id)
+ self._assert_time(case["at"], saved.remind_at)
+ self.assertEqual(case["title"], saved.title)
+ if case["state"] == "prompt":
+ self.assertEqual(created, saved)
diff --git a/uv.lock b/uv.lock
index 294aa7d..ea7f9d8 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2,10 +2,160 @@ version = 1
revision = 3
requires-python = ">=3.12"
+[[package]]
+name = "cffi"
+version = "2.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
+ { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
+ { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
+ { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
+ { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
+ { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
+ { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
+ { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
+ { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
+ { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
+ { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
+ { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
+ { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
+ { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
+ { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
+ { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
+ { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
+ { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
+ { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
+ { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
+ { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
+ { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
+ { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
+ { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
+ { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
+ { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
+ { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
+ { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
+ { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
+ { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
+]
+
+[[package]]
+name = "cryptography"
+version = "46.0.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
+ { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
+ { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
+ { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
+ { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
+ { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
+ { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
+ { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
+ { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
+ { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
+ { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
+ { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
+ { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
+ { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
+ { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
+ { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
+ { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
+]
+
+[[package]]
+name = "pycparser"
+version = "3.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz", hash = "sha256:d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc", size = 113796, upload-time = "2026-10-09T12:56:59.539Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl", hash = "sha256:51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80", size = 51178, upload-time = "2026-10-09T12:56:58.131Z" },
+]
+
[[package]]
name = "remindly"
version = "0.4.0"
source = { editable = "." }
+dependencies = [
+ { name = "cryptography" },
+]
[package.dev-dependencies]
dev = [
@@ -13,6 +163,7 @@ dev = [
]
[package.metadata]
+requires-dist = [{ name = "cryptography", specifier = ">=46,<47" }]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.15.10" }]