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
13 changes: 11 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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) ----

Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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`) |
Expand Down
23 changes: 18 additions & 5 deletions assistant/event_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -110,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
Expand Down Expand Up @@ -153,20 +162,24 @@ def analyze_email(
model: str = GEMINI_MODEL,
user_context: Optional[dict] = None,
extract_action_items: bool = True,
draft_replies: bool = True,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When draft_replies is set to False, the REPLY section is omitted from the system prompt. However, the EmailAnalysis Pydantic model (used as the response_schema for Gemini) defines needs_reply as a required boolean field:

class EmailAnalysis(BaseModel):
    ...
    needs_reply: bool

Because it is required, the Gemini model is forced to output needs_reply in the JSON response, even though the system prompt contains no instructions or context on how to evaluate it. This can lead to unpredictable model behavior, validation errors, or arbitrary/hallucinated values.

To resolve this, please update the EmailAnalysis class (around line 122) to provide a default value for needs_reply:

    needs_reply: bool = False

) -> 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 = (
f"\n\nUser context:\n{json.dumps(user_context)}"
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"
Expand Down
12 changes: 8 additions & 4 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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]
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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"))

Expand Down
45 changes: 45 additions & 0 deletions tests/test_prompt_building.py
Original file line number Diff line number Diff line change
@@ -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
Loading