Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ba08bdb
feat(ai): add optional personal AI parsing
HeiTang Oct 22, 2026
ce3641a
fix(ai): share timeout budget and report failures
HeiTang Oct 23, 2026
ac8f527
fix(parser): unify reminder time parsing
HeiTang Oct 23, 2026
d1aee01
fix(parser): add date corpus and hyphen support
HeiTang Oct 23, 2026
1c9b67e
fix(ai): enforce time and follow-up constraints
HeiTang Oct 23, 2026
50bd4bc
fix(parser): validate time boundaries consistently
HeiTang Oct 23, 2026
ffc930a
fix(ai): constrain content and follow-up questions
HeiTang Oct 23, 2026
641dfd1
fix(ai): preserve unresolved clarification
HeiTang Oct 24, 2026
4f10101
fix(ai): constrain follow-ups and local prompts
HeiTang Oct 24, 2026
22b285d
fix(ai): preserve correction and question guards
HeiTang Oct 24, 2026
1b1f22d
Merge pull request #31 from HeiTang/feature/gemini-semantic-parser
HeiTang Oct 30, 2026
c27f435
feat(ai): add Groq GPT-OSS adapter
HeiTang Oct 30, 2026
5f4824e
fix(ai): correct Groq requests and first fire
HeiTang Oct 30, 2026
37664d4
test(ai): add Groq live regression evaluation
HeiTang Oct 30, 2026
5ca733f
fix(parser): share guards and check time phrases
HeiTang Nov 4, 2026
1aa8250
fix(parser): constrain weekdays and new requests
HeiTang Nov 4, 2026
b29ca3d
fix(parser): resolve redundant content questions
HeiTang Nov 4, 2026
61f83de
fix(parser): preserve guards and recurrence dates
HeiTang Nov 5, 2026
5fe3c83
Merge pull request #32 from HeiTang/feature/groq-adapter
HeiTang Nov 5, 2026
f947a53
chore(release): prepare v0.5.0
HeiTang Nov 5, 2026
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=
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,45 @@ jobs:

- name: Run checks
run: make check

docker:
name: Docker build
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Build image
run: docker build --tag remindly:ci .

- name: Check image without network
run: |
release_version=$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')
docker run --rm --interactive --network none --env EXPECTED_VERSION="$release_version" --entrypoint python remindly:ci - <<'PY'
import os
import tempfile
from pathlib import Path

from cryptography.fernet import Fernet
from remindly import __version__
from remindly.ai.keys import ApiKeyStore
from remindly.app import COMMANDS
from remindly.storage.migrations import latest_schema_version
from remindly.storage.sqlite import ReminderRepository

assert __version__ == os.environ["EXPECTED_VERSION"], __version__
assert any(command.command == "ai" for command in COMMANDS)
cipher = Fernet(Fernet.generate_key())
assert cipher.decrypt(cipher.encrypt(b"container-check")) == b"container-check"
with tempfile.TemporaryDirectory() as directory:
repository = ReminderRepository(Path(directory) / "check.db")
repository.migrate()
repository.migrate()
assert not ApiKeyStore(repository, "").configured
with repository.connect() as connection:
version = connection.execute("select max(version) from schema_migrations").fetchone()[0]
assert version == latest_schema_version(), version
assert connection.execute("pragma integrity_check").fetchone()[0] == "ok"
print(f"Container checks passed: v{__version__}, encryption, schema v{version}, AI optional")
PY
44 changes: 44 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Remindly 開發規範

遵循上層工作區規範;以下為本專案 AI 擴充的約束。

## AI 產品邊界

- 不區分免費/付費用戶,AI 為選用;未提供 Key 或關閉 AI 時使用內建規則解析。
- 平台不提供共用 Key,也不代付費用;只使用請求者的個人 Key,設定僅限私聊。
- AI 啟用後,一般私聊文字、群組觸發訊息及必要上下文會送至供應商;說明不得宣稱只傳已辨識的提醒。
- 建立提醒仍須使用者確認;確認卡從草稿解析紀錄顯示「使用 … 解析」,不依當下開關推測來源。

## 擴充 AI 供應商

目前註冊 Gemini 與 Groq(固定 `openai/gpt-oss-120b`)。供應商透過 `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`。
Groq 使用 strict JSON Schema,沒有可讀模型名稱 metadata,回傳 `None` 並顯示共用 AI 標籤。
供應商隔離、備援與確認流程透過模擬 HTTP 回應驗證;不代表已完成真實 Groq Key 實測。
34 changes: 33 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,39 @@ Remindly 的重要變更會記錄在這裡。

## Unreleased

- 尚未有未發布變更。
## v0.5.0 - 2026-11-05

### Added

- **選用個人 AI 解析**:支援 Gemini 3.5 Flash-Lite 與 Groq GPT-OSS 120B,理解中文口語、建立提醒、草稿追問/修正及修改下次時間;建立提醒仍須確認。
- **個人 Key 管理**:私聊 `/ai` 管理加密保存的 Key、供應商、開關及備援順序;平台不提供共用 Key。未設定 Key 或關閉 AI 時使用內建規則解析。
- **供應商備援與診斷**:最多嘗試三組 Key,共用 18 秒等待預算;依錯誤種類處理停用/冷卻,僅保存固定錯誤代碼,成功後清除。
- **解析來源標籤**:確認卡顯示實際解析紀錄中的模型名稱;名稱由供應商 metadata 取得,無名稱時顯示「使用 AI 解析」。
- **回歸與實測工具**:新增固定時間資料集、Gemini/Groq 錄製回應重播與手動 Groq API 評測;CI 不讀個人 Key 或呼叫 AI。
- **Docker 驗證**:CI 建置實際映像,在無網路容器驗證版本、加密依賴與 SQLite 初始化,不連線 Telegram 或 AI 供應商。

### Changed

- 建立、追問、編輯與 AI 約束共用時間文法,支援連字號日期與「這個/下個星期」等變形,統一無效、過去及歧義時間的處理。
- 新增 runtime `cryptography` 依賴;Docker 依有 hashes 的 `requirements.txt` 安裝。
- AI 編輯追問狀態持久化;補充輸入保留已確定的日期、事項、對象、週期及未解條件。

### Fixed

- 不完整時間片語不再以合法前綴覆寫模型完整時間;仍驗證明確日期、額外條件、多筆提醒與週期,防止漏掉條件或擅加重複提醒。
- 週期首次日期依週期計算;保留明確起始日期及「明早/明晚/後晚」,避免錯排日期或提前一天。
- 明確另建提醒時隔離舊草稿,避免模型誤標更新而沿用舊時間、週期或條件。
- 事項保留可可靠辨識的原文;消除已解決的多餘追問,保留必要條件與否定修正,對外追問使用本機文案。
- 草稿替換、確認建立及 migration 使用交易;寫入失敗可回滾,舊確認不能重複建立,確認時拒絕已過期時間。
- `/remind` 保存新草稿成功後才清理舊對話;週期首次時間檢查日光節約時間歧義。
- Groq 請求設定 User-Agent,避免預設 urllib 被拒絕;截斷回應轉為連線錯誤,沿用冷卻/備援。
- 固定週期排程測試時鐘,避免測試隨執行日期失敗。

### Upgrade notes

- SQLite 啟動時自動由 schema v5 升至 v9;部署前備份資料庫。升級保留既有提醒、草稿與設定,新增個人 AI Key、供應商、編輯追問及錯誤代碼欄位。
- 啟用 AI 須設定固定的 `AI_ENCRYPTION_KEY`,並在資料庫以外另外備份;遺失或更換後,原有 API Key 無法解密。未設定時仍可使用內建規則解析。
- 啟用 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
75 changes: 71 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 分鐘`)會單輪拒絕並具體說明原因。
- 🧠 **自帶 Key 的 AI 語意解析**:個人可設定多組 Gemini/Groq API Key,以 Gemini 3.5 Flash-Lite 或 GPT-OSS 120B 理解口語、不同日期及草稿修正;不明確時追問,仍需確認才建立。未設定或關閉 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,43 @@ Telegram Update
| `/cancel R-8F3K` | 取消提醒。 |
| `/timezone Asia/Taipei` | 設定時區。 |
| `/groupmode on\|off\|status` | 查看或切換群組自然語言模式。 |
| `/ai` | 私聊管理個人 Gemini/Groq Key、開關及備援順序。 |

### 個人 AI 設定(選用)

**不設定也能用。** 所有使用者功能相同,不分免費/付費;預設使用內建規則解析。
想用 AI 理解口語提醒,可自備 Gemini 或 Groq API Key,平台不提供 Key。

**開啟:** 與 Bot 私聊,輸入:

```text
/ai add gemini <你的 Gemini API Key>
/ai add groq <你的 Groq API Key>
```

選一個供應商新增即可;省略供應商仍預設 Gemini。Groq 固定使用 `openai/gpt-oss-120b`,不提供模型切換。
首次新增後自動開啟;若之前手動關閉過,請再輸入 `/ai on`。
接著直接說「明晚八點叫我拿包裹」,確認後才會建立提醒。
確認卡會標示「使用 … 解析」,方便核對本次使用的模型或內建規則。
Groq 模型 metadata 沒有可讀名稱,確認卡顯示「使用 AI 解析」;供應商與模型 ID 仍存於解析紀錄。

| 想做什麼 | 指令 |
| --- | --- |
| 查看狀態與 Key ID | `/ai` |
| 關閉/重新開啟 AI | `/ai off` / `/ai on` |
| 新增備援 Key | `/ai add gemini <Key>` / `/ai add groq <Key>` |
| 將某組 Key 優先使用 | `/ai move <Key ID> 1` |
| 刪除 Key | `/ai delete <Key ID>` |

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

### 自然語言範例

Expand Down Expand Up @@ -160,6 +199,20 @@ cp .env.example .env
| `SCHEDULER_INTERVAL_SECONDS` | 否 | `10` | 到期提醒掃描間隔。 |
| `DRAFT_TTL_MINUTES` | 否 | `10` | 草稿與修改 session 過期時間。 |
| `LOG_LEVEL` | 否 | `INFO` | Logging level。 |
| `AI_ENCRYPTION_KEY` | AI 設定必填 | 無 | Fernet 主金鑰,加密個人 AI 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 +266,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 +294,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 +308,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_groq.py` 與 `tests/test_ai_flow.py` 使用模擬供應商回應及暫存 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
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
[project]
name = "remindly"
version = "0.4.0"
version = "0.5.0"
description = "Remindly"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []
dependencies = [
"cryptography>=46,<47",
]

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