Skip to content
Open
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
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

## 擴充 AI 供應商

目前正式啟用 Gemini。供應商透過 `ai/contracts.py` 的 `AiProvider` Protocol 接入,
目前註冊 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)`。
Expand Down Expand Up @@ -40,4 +40,5 @@
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 實測。
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ Remindly 的重要變更會記錄在這裡。

## Unreleased

- 修正週期提醒的本機時間約束:只有時鐘時依週期計算首次日期,避免每週一、三、五被覆寫成週六;保留明確起始日期。

- Groq 請求明確設定 User-Agent,避免預設 Python urllib 被 Cloudflare 以 HTTP 403/1010 擋下。

- 新增 Groq adapter,固定 GPT-OSS 120B 與 strict JSON Schema;沿用個人 Key、冷卻、確認及 18 秒共用預算,Gemini 仍為預設供應商。

- 修正單組 AI Key 提前在 6 秒逾時:請求改用剩餘共用等待預算,仍最多嘗試三組 Key,預算 18 秒。
- AI 供應商失敗原因依 Key 分類顯示並持久化(schema v9),供 `/ai` 查詢;成功後清除,不保存上游錯誤原文。
- AI 供應商介面、共用備援與標準 JSON Schema;Gemini adapter 獨立負責 API 格式與錯誤轉換。
Expand Down
21 changes: 12 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

- 🔎 **自然語言建立提醒**:支援「明天下午三點提醒我倒垃圾」這類中文輸入,拆出提醒時間、提醒事項與參與者。

- 🧠 **自帶 Gemini 語意解析**:個人可設定多組 API Key,以 Gemini 3.5 Flash-Lite 理解口語、不同日期及草稿修正;不明確時追問,仍需確認才建立。未設定或關閉 AI 時使用原本規則解析。
- 🧠 **自帶 Key 的 AI 語意解析**:個人可設定多組 Gemini/Groq API Key,以 Gemini 3.5 Flash-Lite 或 GPT-OSS 120B 理解口語、不同日期及草稿修正;不明確時追問,仍需確認才建立。未設定或關閉 AI 時使用原本規則解析。

- 🔁 **週期性提醒**:`每天 09:00`、`每週一 09:00`、`每個月 1, 18, 25 號 09:00`、`每年 12/25 08:00`、`每 15 分鐘 / 每 2 小時` 都能一句話建立,到期後自動排下一次。內建解析遇到無效語法會直接拒絕;AI 解析遇到缺漏或歧義則追問。

Expand Down Expand Up @@ -78,33 +78,36 @@ Telegram Update
| `/cancel R-8F3K` | 取消提醒。 |
| `/timezone Asia/Taipei` | 設定時區。 |
| `/groupmode on\|off\|status` | 查看或切換群組自然語言模式。 |
| `/ai` | 私聊管理個人 Gemini Key、開關及備援順序。 |
| `/ai` | 私聊管理個人 Gemini/Groq Key、開關及備援順序。 |

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

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

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

```text
/ai add <你的 Gemini API Key>
/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 <另一組 Key>` |
| 新增備援 Key | `/ai add gemini <Key>` / `/ai add groq <Key>` |
| 將某組 Key 優先使用 | `/ai move <Key ID> 1` |
| 刪除 Key | `/ai delete <Key ID>` |

- **費用自己掌握:** 依你的 Google 專案計費;付費專案可能直接收費,不會先跳出付費確認。同專案多組 Key 共用額度。
- **資料會傳出:** Key 經 Telegram 傳至 Bot 加密保存;啟用後,一般私聊文字、群組觸發訊息及必要上下文會送至 Google。
- **費用自己掌握:** 依你的供應商帳號/專案計費;付費帳號可能直接收費,不會先跳出付費確認。同帳號/專案多組 Key 可能共用額度。
- **資料會傳出:** Key 經 Telegram 傳至 Bot 加密保存;啟用後,一般私聊文字、群組觸發訊息及必要上下文會送至實際使用的供應商(Google 或 Groq);備援時可能依序送至兩者。
- **時間與事項約束:** 可明確辨識的時間及事項區由共用本機規則定案,事項保留原文;只補時間時保留草稿日期、事項、對象與週期。已確定欄位的多餘追問會移除,未解條件則保留;無效或已過的時間會追問,不接受模型換成其他日期。其他口語歧義仍須確認。
- **AI 暫時不能用:** 草稿會保留;稍後重試,或 `/ai off` 後重送,改用內建解析。
- **等待時間:** 最多嘗試三組 Key,共用 18 秒等待預算;逾時不代表 Key 失效或免費額度用盡。
Expand Down Expand Up @@ -196,7 +199,7 @@ 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_ENCRYPTION_KEY` | AI 設定必填 | 無 | Fernet 主金鑰,加密個人 AI Key;需固定並另行備份。 |

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

Expand Down Expand Up @@ -311,7 +314,7 @@ Runtime 新增 `cryptography` 供 Key 加密。更新 runtime dependencies 後
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
`tests/test_ai.py`、`tests/test_groq.py` 與 `tests/test_ai_flow.py` 使用模擬供應商回應及暫存 SQLite,檢查 REST
請求、結構化驗證、Key 備援與 Telegram 完整流程,不消耗真實 API 額度。這些檢查不代表
已量測模型中文準確率;實際模型表現需另用真實 Key 驗證。

Expand Down
221 changes: 221 additions & 0 deletions scripts/groq_live_eval.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
from __future__ import annotations

import argparse
import hashlib
import json
import os
import time
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo

from remindly.ai.contracts import AiUnavailable, GeneratedResponse, ProviderError
from remindly.ai.groq import GroqProvider
from remindly.ai.parser import SCHEMA, SYSTEM, SemanticParser, validate_result
from remindly.reminders.models import MentionKind, ParseResult, Participant, ReminderDraft
from remindly.settings import load_dotenv

ROOT = Path(__file__).resolve().parents[1]
CASES = ROOT / "tests/fixtures/groq_live_cases.jsonl"
PERSON = Participant("Orange", MentionKind.TEXT_MENTION, user_id=7, username="orange")
ALICE = Participant("Alice", MentionKind.TEXT_MENTION, user_id=8, username="alice")


def summarize(result: ParseResult) -> dict:
return {
"title": result.title,
"at": result.remind_at.isoformat() if result.remind_at else None,
"missing": sorted(result.missing_fields),
"complete": bool(result.title and result.remind_at and not result.missing_fields),
"participants": sorted(p.stable_key for p in result.participants),
"recurrence": json.loads(json.dumps(asdict(result.recurrence)))
if result.recurrence
else None,
"question": result.raw.get("question"),
"intent": result.raw.get("intent"),
}


def differences(actual: dict, expected: dict) -> dict:
failures = {}
for field, wanted in expected.items():
value = actual.get(field)
if field == "recurrence" and wanted is not None and isinstance(value, dict):
value = {key: value.get(key) for key in wanted}
if field in {"missing", "participants"}:
wanted = sorted(wanted)
if value != wanted:
failures[field] = {"expected": wanted, "actual": value}
return failures


def context_for(case: dict) -> ReminderDraft | None:
seed = case.get("seed")
if not seed:
return None
return ReminderDraft(
id="seed",
chat_id=100,
chat_type="private",
creator_user_id=7,
timezone=case["timezone"],
source_text=seed["source"],
title=seed["title"],
remind_at=datetime.fromisoformat(seed["at"]) if seed.get("at") else None,
participants=[PERSON],
missing_fields=seed["missing"] + (["clarification"] if seed.get("condition") else []),
parse_result={
"grain": "ai",
"question": seed.get("question"),
"condition_question": seed.get("question"),
},
)


class RecordingGenerator:
def __init__(self, key: str):
self.key = key
self.provider = GroqProvider()
self.raw = None

def enabled(self, user_id: int) -> bool:
return True

def generate(self, user_id, system, prompt, schema):
self.raw = self.provider.generate(self.key, system, prompt, schema)
return GeneratedResponse(self.raw, self.provider.id, self.provider.model)


def evaluate(case: dict, generator: RecordingGenerator) -> dict:
now = datetime.fromisoformat(case["now"]).astimezone(ZoneInfo(case["timezone"]))
participants = [PERSON, ALICE] if case.get("alice") else [PERSON]
generator.raw = None
local_error = None
try:
local = summarize(
SemanticParser(generator).parse(
case["text"], 7, now, participants, context=context_for(case)
)
)
except AiUnavailable:
local, local_error = None, "validation"
raw_error = None
try:
raw = summarize(validate_result(generator.raw, now, participants))
except (ValueError, TypeError, KeyError, OverflowError):
raw, raw_error = None, "validation"
return {
"raw": generator.raw,
"raw_result": raw,
"local_result": local,
"raw_failures": differences(raw, case["expected"]) if raw else {"error": raw_error},
"local_failures": differences(local, case["expected"]) if local else {"error": local_error},
}


def wait_until(deadline: float) -> None:
while (remaining := deadline - time.time()) > 0:
print(f"cooldown: {remaining:.0f}s remaining", flush=True)
time.sleep(min(30, remaining))


def main() -> None:
parser = argparse.ArgumentParser(
description="Groq live regression evaluation; uses personal quota"
)
parser.add_argument(
"--output", type=Path, required=True, help="Append-only JSONL; resumes this run"
)
args = parser.parse_args()
load_dotenv(ROOT / ".env")
key = os.environ.get("GROQ_API_KEY", "").strip()
if not key:
raise SystemExit("GROQ_API_KEY missing")
cases = [json.loads(line) for line in CASES.read_text().splitlines() if line.strip()]
digest = hashlib.sha256(CASES.read_bytes() + SYSTEM.encode() + json.dumps(SCHEMA).encode())
for relative in (
"ai/groq.py",
"ai/parser.py",
"reminders/parser.py",
"reminders/recurrence.py",
):
digest.update((ROOT / "src/remindly" / relative).read_bytes())
meta = {
"event": "meta",
"signature": digest.hexdigest(),
"model": GroqProvider.model,
"cases": len(cases),
"requests": sum(case["repeats"] for case in cases),
}
previous = (
[json.loads(line) for line in args.output.read_text().splitlines()]
if args.output.exists()
else []
)
if previous and previous[0] != meta:
raise SystemExit("Suite or code changed; choose a new output path")
args.output.parent.mkdir(parents=True, exist_ok=True)
generator = RecordingGenerator(key)
with args.output.open("a") as stream:

def save(row):
stream.write(json.dumps(row, ensure_ascii=False).replace(key, "[REDACTED]") + "\n")
stream.flush()

if not previous:
save(meta)
done = {(row["id"], row["run"]) for row in previous if row["event"] == "result"}
if previous and previous[-1]["event"] == "retry":
wait_until(previous[-1]["retry_at"])
for case in cases:
for run in range(1, case["repeats"] + 1):
if (case["id"], run) in done:
continue
while True:
started = time.monotonic()
try:
result = evaluate(case, generator)
except ProviderError as exc:
row = {
"event": "retry",
"id": case["id"],
"run": run,
"kind": exc.kind,
"cooldown": exc.cooldown,
"retry_at": time.time() + exc.cooldown,
"seconds": round(time.monotonic() - started, 3),
}
if exc.kind not in {"quota", "rate", "daily_quota"}:
row["event"] = "fatal"
save(row)
raise SystemExit(f"Stopped: {exc.kind}; progress saved") from None
save(row)
print(f"429 {exc.kind}: cooling down {exc.cooldown}s", flush=True)
wait_until(row["retry_at"])
continue
row = {
"event": "result",
"id": case["id"],
"run": run,
"source": case["source"],
"text": case["text"],
"now": case["now"],
"expected": case["expected"],
"seconds": round(time.monotonic() - started, 3),
**result,
}
save(row)
done.add((case["id"], run))
print(
f"{len(done)}/{meta['requests']} {case['id']} #{run}: "
f"raw={'FAIL' if result['raw_failures'] else 'PASS'} "
f"local={'FAIL' if result['local_failures'] else 'PASS'} "
f"{row['seconds']}s",
flush=True,
)
break


if __name__ == "__main__":
main()
Loading
Loading