From 596b7e91528d04b5843c5270ea591236181b5b48 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 17:51:40 +0000 Subject: [PATCH 1/2] Cut Gemini cost: default to flash-lite, add ENABLE_DRAFT_REPLIES flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Input-token cost on the every-5-minutes email pipeline was dominated by the premium gemini-3.5-flash model. Two changes drastically reduce it: - Default GEMINI_MODEL to gemini-2.0-flash-lite (~10x cheaper input tokens than gemini-3.5-flash) for both analyze_email and standup. The env var still overrides for higher accuracy when needed. - Add ENABLE_DRAFT_REPLIES (default true, mirrors ENABLE_ACTION_ITEMS). When false, the large REPLY section is dropped from the analyze_email prompt so the model never classifies replies or drafts one — cutting both input and output tokens — and no Gmail drafts are created. Calendar events, action items, and context updates are unaffected. Adds deterministic unit tests for prompt-section gating and updates README and .env.example. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CSnZNqUEh8uDyMCPDmzBTa --- .env.example | 13 ++++++++-- README.md | 12 +++++++--- assistant/event_extractor.py | 21 ++++++++++++---- main.py | 12 ++++++---- tests/test_prompt_building.py | 45 +++++++++++++++++++++++++++++++++++ 5 files changed, 90 insertions(+), 13 deletions(-) create mode 100644 tests/test_prompt_building.py diff --git a/.env.example b/.env.example index 5cc84d3..5cc675d 100644 --- a/.env.example +++ b/.env.example @@ -10,8 +10,17 @@ CALENDAR_ID=primary # Google Tasks list to add action items to ("@default" = My Tasks) TASKS_LIST_ID=@default -# Gemini model to use for email analysis and reply drafting -GEMINI_MODEL=gemini-3.5-flash +# Extract action items and create Google Tasks (default: true). Set false to +# drop the ACTION ITEMS prompt section and reduce Gemini cost. +ENABLE_ACTION_ITEMS=true + +# Draft reply emails and create Gmail drafts (default: true). Set false to drop +# the REPLY prompt section and reduce Gemini cost (input + output tokens). +ENABLE_DRAFT_REPLIES=true + +# Gemini model to use for email analysis and reply drafting. Defaults to the +# cheap gemini-2.0-flash-lite; set gemini-3.5-flash for higher accuracy at cost. +GEMINI_MODEL=gemini-2.0-flash-lite # ---- Local development only (not used in Cloud Run) ---- diff --git a/README.md b/README.md index edcb5ed..87ce860 100644 --- a/README.md +++ b/README.md @@ -47,13 +47,17 @@ Each email triggers up to two Gemini calls: | Call | Model | Purpose | Max output | |------|-------|---------|------------| -| `analyze_email` | `gemini-3.5-flash` | Classify event/reply (and, optionally, action items), signal context-worthiness | 1,024 tokens | +| `analyze_email` | `gemini-2.0-flash-lite` | Classify event/reply (and, optionally, action items), signal context-worthiness | 1,024 tokens | | `update_user_context` | `gemini-2.0-flash-lite` | Extract personal facts into persistent profile | dynamic (estimated tokens + 512, range 1,024–4,096) | +Both calls default to `gemini-2.0-flash-lite` — the lightest tier — because classification and fact-extraction don't need a premium model. `analyze_email`'s model is overridable via `GEMINI_MODEL` if you want more accuracy at higher cost (the premium `gemini-3.5-flash` was the previous default, but its input-token pricing dominated the bill). + The context update call only fires when `analyze_email` returns `has_context_update=true` — a signal the first call sets when the email contains facts worth remembering (new contact details, family news, stated preferences) versus newsletters, receipts, or automated notifications. This keeps the second call from running on ~50–80% of inbox traffic. **Action item extraction is on by default** and gated by `ENABLE_ACTION_ITEMS`. Set `ENABLE_ACTION_ITEMS=false` to disable it as a cost-saving measure: the ACTION ITEMS section is then dropped from the `analyze_email` prompt entirely, so the model never generates action items — cutting both prompt and output tokens on every email — and no Google Tasks are created. +**Reply drafting is on by default** and gated by `ENABLE_DRAFT_REPLIES`. Set `ENABLE_DRAFT_REPLIES=false` to disable it: the (large) REPLY section is dropped from the `analyze_email` prompt entirely, so the model is never asked to classify replies or draft one — cutting both prompt (input) and output tokens on every email — and no Gmail drafts are created. Calendar events, action items, and context updates still work. + ### Persistent user context `user_context.json` in GCS stores a freeform JSON profile that grows automatically as facts are extracted from emails. It is injected into the `analyze_email` prompt so drafts are personalized. The profile survives Cloud Run restarts and can be manually edited at any time: @@ -82,7 +86,8 @@ Several layers prevent runaway billing: - **Output caps**: `analyze_email` capped at 1,024 output tokens; `update_user_context` uses a dynamic cap (`max(1024, profile_chars / 3 + 512)`) bounded at 4,096. Without caps, unconstrained generation was the root cause of a 15× billing spike. - **Conditional context updates**: `has_context_update` field on `EmailAnalysis` lets the first LLM call gate the second. Junk email never reaches `update_user_context`. - **Input token reduction**: quoted reply chains are stripped from email bodies before they reach the model (threaded emails can have 2–3× more quoted history than new content). User context is serialized as compact JSON (no indentation). The context update prompt truncates the email body to 1,500 chars — fact extraction doesn't need the full body. -- **Model tiering**: email analysis uses the full-quality model; context extraction (a simpler task) uses `gemini-2.0-flash-lite` (~10× cheaper per token). +- **Model tiering**: both analysis and context extraction default to `gemini-2.0-flash-lite` (~10× cheaper per token than the premium `gemini-3.5-flash`), since classification and fact-extraction don't need a premium model. Override `GEMINI_MODEL` to trade cost for accuracy. +- **Optional feature gating**: reply drafting (`ENABLE_DRAFT_REPLIES`) and action-item extraction (`ENABLE_ACTION_ITEMS`) can each be turned off. When off, their prompt section is dropped entirely, cutting input *and* output tokens on every email. - **Token logging**: every Gemini response logs prompt/output/total token counts tagged by call type. Query in Cloud Logging: ``` resource.type="cloud_run_revision" @@ -313,7 +318,8 @@ curl -X POST http://localhost:8080/availability-sync # mirror busy time | `CALENDAR_ID` | Calendar to read/write events (`primary` = default) | | `TASKS_LIST_ID` | Google Tasks list for action items (`@default` = My Tasks). Only used when `ENABLE_ACTION_ITEMS` is enabled | | `ENABLE_ACTION_ITEMS` | Extract action items and create Google Tasks (default `true`). Set to `false` to disable and reduce Gemini API cost | -| `GEMINI_MODEL` | Gemini model for all AI tasks (default `gemini-3.5-flash`) | +| `ENABLE_DRAFT_REPLIES` | Draft reply emails and create Gmail drafts (default `true`). Set to `false` to disable and reduce Gemini API cost | +| `GEMINI_MODEL` | Gemini model for all AI tasks (default `gemini-2.0-flash-lite`; set `gemini-3.5-flash` for higher accuracy at higher cost) | | `STANDUP_LOOKBACK_DAYS` | Days of mail history to scan for stakeholder discovery (default `30`) | | `STANDUP_THREAD_DAYS` | Days of thread context per stakeholder included in standup (default `7`) | | `AVAILABILITY_CALENDAR_ID` | Target calendar for detail-free "Busy" blocks (required for `/availability-sync`) | diff --git a/assistant/event_extractor.py b/assistant/event_extractor.py index 196d948..b4738c8 100644 --- a/assistant/event_extractor.py +++ b/assistant/event_extractor.py @@ -9,7 +9,7 @@ logger = logging.getLogger(__name__) -GEMINI_MODEL = "gemini-3.5-flash" +GEMINI_MODEL = "gemini-2.0-flash-lite" CONTEXT_UPDATE_MODEL = "gemini-2.0-flash-lite" CONTEXT_UPDATE_BODY_CHARS = 1500 @@ -80,14 +80,23 @@ """ -def build_system_prompt(extract_action_items: bool = True) -> str: +def build_system_prompt( + extract_action_items: bool = True, + draft_replies: bool = True, +) -> str: """Assemble the analysis system prompt. When extract_action_items is False, the ACTION ITEMS section is omitted so the model never generates action_items — cutting output tokens (and API cost) on every email. + + When draft_replies is False, the REPLY section is omitted so the model is + never asked to classify replies or draft one — cutting both the (large) + REPLY instructions from the input prompt and the draft from the output. """ - sections = [CALENDAR_SECTION, REPLY_SECTION] + sections = [CALENDAR_SECTION] + if draft_replies: + sections.append(REPLY_SECTION) if extract_action_items: sections.append(ACTION_ITEMS_SECTION) sections.append(CONTEXT_SECTION) @@ -153,12 +162,16 @@ def analyze_email( model: str = GEMINI_MODEL, user_context: Optional[dict] = None, extract_action_items: bool = True, + draft_replies: bool = True, ) -> EmailAnalysis: """Analyze an email for schedulable events and whether a reply is needed. When extract_action_items is False, the model is not asked to produce action items, reducing output tokens and API cost. + When draft_replies is False, the model is not asked to classify replies or + draft one, reducing both prompt (input) and output tokens. + Returns EmailAnalysis with is_event=False and needs_reply=False on failure. """ context_section = ( @@ -166,7 +179,7 @@ def analyze_email( if user_context else "" ) - system_prompt = build_system_prompt(extract_action_items) + system_prompt = build_system_prompt(extract_action_items, draft_replies) prompt = ( f"{system_prompt}{context_section}\n\n" f"Today's date: {date.today().isoformat()}\n\n" diff --git a/main.py b/main.py index 550f2be..b51dd31 100644 --- a/main.py +++ b/main.py @@ -62,8 +62,9 @@ def run(): calendar_id = os.getenv("CALENDAR_ID", "primary") tasks_list_id = os.getenv("TASKS_LIST_ID", "@default") gemini_api_key = os.environ["GEMINI_API_KEY"] - gemini_model = os.getenv("GEMINI_MODEL", "gemini-3.5-flash") + gemini_model = os.getenv("GEMINI_MODEL", "gemini-2.0-flash-lite") action_items_enabled = os.getenv("ENABLE_ACTION_ITEMS", "true").lower() == "true" + draft_replies_enabled = os.getenv("ENABLE_DRAFT_REPLIES", "true").lower() == "true" creds = _get_credentials() gmail_service = get_gmail_service(creds) @@ -78,7 +79,7 @@ def run(): logger.info("Loaded user context (%d top-level keys).", len(user_context)) label_id = get_or_create_label(gmail_service) - self_email = get_user_email(gmail_service) + self_email = get_user_email(gmail_service) if draft_replies_enabled else "" message_ids = list_unread_message_ids(gmail_service) new_ids = [mid for mid in message_ids if mid not in processed_ids] @@ -111,6 +112,7 @@ def run(): gemini_model, user_context, extract_action_items=action_items_enabled, + draft_replies=draft_replies_enabled, ) if analysis.has_context_update: user_context = update_user_context(gemini_client, subject, body, sender, user_context) @@ -136,7 +138,9 @@ def run(): else: logger.info("No schedulable event found.") - if analysis.needs_reply: + if not draft_replies_enabled: + logger.debug("Reply drafting disabled; skipping drafts.") + elif analysis.needs_reply: if analysis.draft_body: draft_url = create_draft_reply( gmail_service, @@ -204,7 +208,7 @@ def standup(): calendar_id = os.getenv("CALENDAR_ID", "primary") tasks_list_id = os.getenv("TASKS_LIST_ID", "@default") gemini_api_key = os.environ["GEMINI_API_KEY"] - gemini_model = os.getenv("GEMINI_MODEL", "gemini-3.5-flash") + gemini_model = os.getenv("GEMINI_MODEL", "gemini-2.0-flash-lite") lookback_days = int(os.getenv("STANDUP_LOOKBACK_DAYS", "30")) thread_days = int(os.getenv("STANDUP_THREAD_DAYS", "7")) diff --git a/tests/test_prompt_building.py b/tests/test_prompt_building.py new file mode 100644 index 0000000..20c7fd4 --- /dev/null +++ b/tests/test_prompt_building.py @@ -0,0 +1,45 @@ +"""Unit tests for analyze_email system-prompt assembly. + +These are deterministic (no Gemini API needed) and verify that the +cost-saving feature flags actually drop their prompt sections. +""" +from assistant.event_extractor import build_system_prompt + + +def test_full_prompt_includes_all_sections(): + prompt = build_system_prompt(extract_action_items=True, draft_replies=True) + assert "CALENDAR:" in prompt + assert "REPLY:" in prompt + assert "ACTION ITEMS:" in prompt + assert "CONTEXT:" in prompt + + +def test_draft_replies_off_drops_reply_section(): + prompt = build_system_prompt(extract_action_items=True, draft_replies=False) + assert "REPLY:" not in prompt + # Everything else is retained. + assert "CALENDAR:" in prompt + assert "ACTION ITEMS:" in prompt + assert "CONTEXT:" in prompt + + +def test_action_items_off_drops_action_section(): + prompt = build_system_prompt(extract_action_items=False, draft_replies=True) + assert "ACTION ITEMS:" not in prompt + assert "REPLY:" in prompt + + +def test_both_off_leaves_calendar_and_context_only(): + prompt = build_system_prompt(extract_action_items=False, draft_replies=False) + assert "REPLY:" not in prompt + assert "ACTION ITEMS:" not in prompt + assert "CALENDAR:" in prompt + assert "CONTEXT:" in prompt + + +def test_sections_are_renumbered_contiguously(): + # With both optional sections off, the two remaining sections must be + # numbered "1." and "2." — no gaps that would confuse the model. + prompt = build_system_prompt(extract_action_items=False, draft_replies=False) + assert "1. CALENDAR:" in prompt + assert "2. CONTEXT:" in prompt From 24fa48867c4026dba6d6ac7d175c4126d9b9b2c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 17:53:25 +0000 Subject: [PATCH 2/2] Default needs_reply=False so drafts-off schema needs no REPLY instructions When ENABLE_DRAFT_REPLIES=false drops the REPLY section from the prompt, needs_reply as a required schema field still forced the model to emit it with no guiding instructions. Give it a default of False so the model can omit it cleanly; main.py already gates draft creation on the flag. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CSnZNqUEh8uDyMCPDmzBTa --- assistant/event_extractor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assistant/event_extractor.py b/assistant/event_extractor.py index b4738c8..f4049f0 100644 --- a/assistant/event_extractor.py +++ b/assistant/event_extractor.py @@ -119,7 +119,7 @@ class EmailAnalysis(BaseModel): description: Optional[str] = None timezone: Optional[str] = None urls: Optional[list[str]] = None - needs_reply: bool + needs_reply: bool = False draft_body: Optional[str] = None action_items: Optional[list[ActionItem]] = None has_context_update: bool = False