Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
43 changes: 43 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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> <Key>`
及設定畫面會使用同一份 mapping,不需另外維護供應商清單。

備援、冷卻、Key 加密、中文 prompt、schema、驗證及確認流程均共用。
請求使用剩餘的共用等待預算,不為每組 Key 固定切成 6 秒;最多三組 Key,
預算由 `AI_TIME_BUDGET_SECONDS` 統一定義。`urllib` 的 socket timeout 並非嚴格的整次執行期限。
錯誤分類與使用者文案共用 `PROVIDER_ERRORS`;只持久化錯誤代碼至 `ai_keys.last_error`,
成功後清除,不保存供應商原始錯誤。HTTP 429 僅依結構化 quota 資訊區分每日額度與速率,
無法辨識時保留 `quota`;不得從逾時推斷額度耗盡。憑證或權限錯誤停用該 Key,
需在修正問題後刪除重加;額度、速率、逾時、連線及服務暫時異常則冷卻後可再嘗試。
`AiGateway` 回傳實際使用的供應商、模型 ID 與顯示名稱,語意解析器依賴 `SemanticGenerator` Protocol,
提醒 service 依賴 `SemanticParsing` Protocol;Bot 設定處理器由入口注入,不從解析器取出 Key store。

每組 Key 都綁定供應商;未知或未註冊的供應商不會收到請求,Key 也不會送給其他供應商。
同一個使用者不能把同一組 Key 重複綁定;要改所屬供應商須刪除後重加。
Schema v7 只新增供應商欄位,原有 v6 Key 標記為 `gemini`,加密內容、排序及冷卻狀態保留。
Schema v8 保存 AI 編輯時間的追問狀態;schema 與版本紀錄必須在同一交易內提交。
Schema v9 新增 `ai_keys.last_error`;既有 Key 保留,舊紀錄的失敗原因預設為 `NULL`。
第二供應商的隔離與備援透過測試替身驗證;不代表已正式支援另一個雲端服務。
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,19 @@ Remindly 的重要變更會記錄在這裡。

## Unreleased

- 尚未有未發布變更。
- 修正單組 AI Key 提前在 6 秒逾時:請求改用剩餘共用等待預算,仍最多嘗試三組 Key,預算 18 秒。
- AI 供應商失敗原因依 Key 分類顯示並持久化(schema v9),供 `/ai` 查詢;成功後清除,不保存上游錯誤原文。
- AI 供應商介面、共用備援與標準 JSON Schema;Gemini adapter 獨立負責 API 格式與錯誤轉換。
- API Key 綁定供應商,支援 `/ai add [供應商] <Key>`;schema v7 保留既有 Key 及冷卻狀態。
- 個人 Gemini 3.5 Flash-Lite 中文語意解析,支援建立、草稿追問/修正及修改下次時間,沿用確認建立流程。
- 私聊 `/ai` 管理加密保存的多組 API Key、開關、排序及刪除;個人 Key 隔離,有限備援與持久化冷卻。
- 新增 `AI_ENCRYPTION_KEY` 與 runtime `cryptography` 依賴,Docker 以有 hashes 的 requirements 安裝。
- 固定週期排程測試時鐘,避免執行日期改變造成測試失敗;確認時拒絕已過去的提醒時間。
- AI 修改時間的追問狀態持久化(schema v8),補充時保留已修改的日期與問題。
- 草稿替換、確認建立與 migration 使用交易;寫入失敗可回滾,舊確認不會重複建立。
- 移除基本草稿流程中無法到達的 AI 分支,統一由 router 處理 AI 對話。
- 確認卡以標籤顯示解析來源;模型顯示名稱由供應商 metadata 取得並快取,查不到時顯示 AI。
- `/remind` 保存新草稿成功後清理舊對話;AI 週期首次觸發時間也檢查夏令時間歧義。

## v0.4.0 - 2026-07-11

Expand Down
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ ENV PYTHONPATH=/app/src

WORKDIR /app

COPY requirements.txt ./requirements.txt
RUN pip install --no-cache-dir --require-hashes -r requirements.txt

RUN addgroup --system app \
&& adduser --system --ingroup app app \
&& mkdir -p /app/data \
Expand Down
72 changes: 68 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<img alt="uv" src="https://img.shields.io/badge/uv-ready-111827?style=flat-square" />
<img alt="SQLite" src="https://img.shields.io/badge/SQLite-persistent-003B57?style=flat-square&logo=sqlite&logoColor=white" />
<img alt="Docker" src="https://img.shields.io/badge/Docker-Compose-2496ED?style=flat-square&logo=docker&logoColor=white" />
<img alt="Runtime dependencies" src="https://img.shields.io/badge/runtime%20deps-stdlib%20only-22C55E?style=flat-square" />
<img alt="Key encryption" src="https://img.shields.io/badge/key%20encryption-Fernet-22C55E?style=flat-square" />
</p>

<p align="center">
Expand All @@ -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` 依建立者分組,並支援今天、本週、我的、全部篩選。週期性提醒會以 `[重複]` 前綴標示。

Expand All @@ -57,7 +59,7 @@ Telegram Update
-> BotRouter
-> CommandHandlers / CallbackHandlers
-> ReminderService
-> ReminderParser / DraftStore / ReminderRepository
-> ReminderParser / SemanticParser / DraftStore / ReminderRepository
-> SQLite
-> ReminderScheduler
-> TelegramClient
Expand All @@ -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 <Key ID> 1` |
| 刪除 Key | `/ai delete <Key ID>` |

- **費用自己掌握:** 依你的 Google 專案計費;付費專案可能直接收費,不會先跳出付費確認。同專案多組 Key 共用額度。
- **資料會傳出:** Key 經 Telegram 傳至 Bot 加密保存;啟用後,一般私聊文字、群組觸發訊息及必要上下文會送至 Google。
- **時間與事項約束:** 可明確辨識的時間及事項區由共用本機規則定案,事項保留原文;只補時間時保留草稿日期、事項、對象與週期。已確定欄位的多餘追問會移除,未解條件則保留;無效或已過的時間會追問,不接受模型換成其他日期。其他口語歧義仍須確認。
- **AI 暫時不能用:** 草稿會保留;稍後重試,或 `/ai off` 後重送,改用內建解析。
- **等待時間:** 最多嘗試三組 Key,共用 18 秒等待預算;逾時不代表 Key 失效或免費額度用盡。
- **失敗原因:** 供應商呼叫失敗時,依 Key 顯示憑證、權限、額度/速率、連線、逾時或服務異常;
`/ai` 也會顯示最近失敗原因,重啟後保留,呼叫成功後清除。HTTP 429 只有在供應商提供
配額資訊時才區分每日額度與每分鐘/每秒速率,否則標示無法確認;不顯示上游錯誤原文。
憑證或權限錯誤會停用該 Key,修正問題後請刪除並重新新增。

### 自然語言範例

Expand Down Expand Up @@ -160,6 +196,20 @@ cp .env.example .env
| `SCHEDULER_INTERVAL_SECONDS` | 否 | `10` | 到期提醒掃描間隔。 |
| `DRAFT_TTL_MINUTES` | 否 | `10` | 草稿與修改 session 過期時間。 |
| `LOG_LEVEL` | 否 | `INFO` | Logging level。 |
| `AI_ENCRYPTION_KEY` | AI 設定必填 | 無 | Fernet 主金鑰,加密個人 Gemini Key;需固定並另行備份。 |

### 啟用個人 AI(選用)

若要讓使用者自行設定 AI Key,伺服器部署前需先產生加密主金鑰,填入 `.env` 或部署環境的
`AI_ENCRYPTION_KEY`,再重啟。這是伺服器用來保護已存 Key 的加密金鑰,並非供應商 API Key;
一般使用者不需要產生或設定它。

```sh
uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```

這把主金鑰要固定保存,並在資料庫以外另外備份。換掉或遺失後,原有 API Key 無法解密;
不會自動以新主金鑰覆蓋。未設定時 Bot 仍可使用內建規則解析,但不能新增或使用 AI Key。

### Docker Compose

Expand Down Expand Up @@ -213,6 +263,9 @@ uv run python -m remindly
make check
```

時間解析的固定回歸案例放在 `tests/fixtures/time_regressions.jsonl`,會隨 `make check`
與 CI 執行;新增格式、誤解析或對話案例請參考 [資料集說明](tests/fixtures/README.md)。

等同於:

```sh
Expand All @@ -238,6 +291,7 @@ Smoke test 會模擬:
| 路徑 | 職責 |
| --- | --- |
| `src/remindly/bot/` | Telegram update routing、指令、callback、訊息回覆。 |
| `src/remindly/ai/` | 供應商 adapter、Key 加密與備援、語意解析及模型顯示名稱。 |
| `src/remindly/reminders/` | 提醒 domain model、parser、service、renderer、scheduler。 |
| `src/remindly/storage/` | SQLite repository、session stores、versioned migrations。 |
| `src/remindly/telegram/` | Telegram Bot API client 與 DTO。 |
Expand All @@ -251,6 +305,16 @@ main -> dev -> feature/<name> -> PR -> dev -> PR -> main

Feature branch 合併到 `dev` 必須開 PR;`dev` 驗證通過後再以 release PR 合併回 `main`。

Runtime 新增 `cryptography` 供 Key 加密。更新 runtime dependencies 後同步 Docker 安裝清單:

```sh
uv export --locked --no-dev --no-emit-project --format requirements-txt --output-file requirements.txt
```

`tests/test_ai.py` 與 `tests/test_ai_flow.py` 使用模擬 Gemini 回應及暫存 SQLite,檢查 REST
請求、結構化驗證、Key 備援與 Telegram 完整流程,不消耗真實 API 額度。這些檢查不代表
已量測模型中文準確率;實際模型表現需另用真實 Key 驗證。

### Release Flow

1. 更新 `pyproject.toml`、`src/remindly/__init__.py`、`uv.lock` 的版本號。
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ services:
SCHEDULER_INTERVAL_SECONDS: ${SCHEDULER_INTERVAL_SECONDS:-10}
DRAFT_TTL_MINUTES: ${DRAFT_TTL_MINUTES:-10}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
AI_ENCRYPTION_KEY: ${AI_ENCRYPTION_KEY:-}
volumes:
- remindly-data:/app/data
restart: unless-stopped
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ version = "0.4.0"
description = "Remindly"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []
dependencies = [
"cryptography>=46,<47",
]

[build-system]
requires = ["hatchling"]
Expand Down
Loading
Loading