mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-11 07:58:40 -04:00
fix(email): route summaries through shared LLM adapter (#5841)
* fix(email): route summaries through shared llm adapter * chore(ci): refresh PR checks * fix(email): preserve scheduled summary safeguards --------- Co-authored-by: Matyas Fenyves <16389204+uhhgoat@users.noreply.github.com>
This commit is contained in:
@@ -247,6 +247,7 @@ import re as _re_reply
|
|||||||
_REPLY_OPEN_RE = _re_reply.compile(r"<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+", _re_reply.I)
|
_REPLY_OPEN_RE = _re_reply.compile(r"<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+", _re_reply.I)
|
||||||
_REPLY_CLOSE_RE = _re_reply.compile(r"<<<\s*END\s*>>+", _re_reply.I)
|
_REPLY_CLOSE_RE = _re_reply.compile(r"<<<\s*END\s*>>+", _re_reply.I)
|
||||||
_REPLY_ROLE_MARKER_RE = _re_reply.compile(r"</?\|(?:assistant|assistan|user|system|tool)\|>?|</\|end\|>?", _re_reply.I)
|
_REPLY_ROLE_MARKER_RE = _re_reply.compile(r"</?\|(?:assistant|assistan|user|system|tool)\|>?|</\|end\|>?", _re_reply.I)
|
||||||
|
_SUMMARY_BULLET_RE = _re_reply.compile(r"^(?:[-*\u2022]\s+|\d+[.)]\s+)")
|
||||||
|
|
||||||
|
|
||||||
def _extract_reply(text: str) -> str:
|
def _extract_reply(text: str) -> str:
|
||||||
@@ -277,6 +278,125 @@ def _extract_reply(text: str) -> str:
|
|||||||
return _strip_think(t).strip()
|
return _strip_think(t).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_email_summary_messages(sender: str, subject: str, body_for_llm: str) -> list[dict[str, str]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
"You are an email summarizer. Format: 1-3 short bullet points "
|
||||||
|
"(use '- '). Cover: main point, action items, deadlines. If the "
|
||||||
|
"email has attachments (marked '--- ATTACHMENTS ---'), USE THEIR "
|
||||||
|
"CONTENTS - pull invoice totals, deadlines, key clauses, concrete "
|
||||||
|
"numbers/dates from PDFs/docs into the bullets. Be terse.\n\n"
|
||||||
|
"OUTPUT FORMAT: Put ONLY the bullet points between these exact "
|
||||||
|
"markers, each on its own line:\n"
|
||||||
|
"<<<SUMMARY>>>\n"
|
||||||
|
"- ...\n"
|
||||||
|
"<<<END>>>\n"
|
||||||
|
"Any reasoning must come BEFORE <<<SUMMARY>>> (ideally inside "
|
||||||
|
"<think>...</think>). Only the text between the markers is kept."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
f"From: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}"
|
||||||
|
"\n\n---\n\nSummarize the email. Output the bullets between "
|
||||||
|
"<<<SUMMARY>>> and <<<END>>>."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_email_summary(
|
||||||
|
url: str,
|
||||||
|
model: str,
|
||||||
|
sender: str,
|
||||||
|
subject: str,
|
||||||
|
body_for_llm: str,
|
||||||
|
*,
|
||||||
|
headers: dict | None = None,
|
||||||
|
max_tokens: int = 8192,
|
||||||
|
timeout: int = 180,
|
||||||
|
) -> str:
|
||||||
|
"""Generate an interactive email summary through the shared LLM adapter."""
|
||||||
|
from src.llm_core import llm_call_async
|
||||||
|
|
||||||
|
raw = await llm_call_async(
|
||||||
|
url=url,
|
||||||
|
model=model,
|
||||||
|
messages=_build_email_summary_messages(sender, subject, body_for_llm),
|
||||||
|
temperature=0.3,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
headers=headers,
|
||||||
|
timeout=timeout,
|
||||||
|
workload="foreground",
|
||||||
|
)
|
||||||
|
return _normalize_email_summary(raw)
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_scheduled_email_summary(
|
||||||
|
url: str,
|
||||||
|
model: str,
|
||||||
|
sender: str,
|
||||||
|
subject: str,
|
||||||
|
body_for_llm: str,
|
||||||
|
*,
|
||||||
|
headers: dict | None = None,
|
||||||
|
owner: str | None = None,
|
||||||
|
max_tokens: int = 8192,
|
||||||
|
timeout: int = 180,
|
||||||
|
) -> str:
|
||||||
|
"""Generate a scheduled summary through the background task candidate chain."""
|
||||||
|
from src.task_endpoint import task_llm_call_async
|
||||||
|
|
||||||
|
raw = await task_llm_call_async(
|
||||||
|
messages=_build_email_summary_messages(sender, subject, body_for_llm),
|
||||||
|
fallback_url=url,
|
||||||
|
fallback_model=model,
|
||||||
|
fallback_headers=headers,
|
||||||
|
owner=owner,
|
||||||
|
temperature=0.3,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
return _normalize_email_summary(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_email_summary(raw) -> str:
|
||||||
|
"""Extract a stable cache/UI summary from provider output."""
|
||||||
|
raw_text = raw or ""
|
||||||
|
if _REPLY_OPEN_RE.search(raw_text):
|
||||||
|
summary = _extract_reply(raw_text)
|
||||||
|
if summary:
|
||||||
|
return summary
|
||||||
|
|
||||||
|
cleaned = _strip_think(raw_text).strip()
|
||||||
|
bullets = [
|
||||||
|
line.strip()
|
||||||
|
for line in cleaned.splitlines()
|
||||||
|
if _SUMMARY_BULLET_RE.match(line.strip())
|
||||||
|
]
|
||||||
|
if bullets:
|
||||||
|
return "\n".join(bullets)
|
||||||
|
return cleaned.strip()
|
||||||
|
|
||||||
|
|
||||||
|
EMAIL_SUMMARY_ERROR_CODE = "email_summary_unavailable"
|
||||||
|
EMAIL_SUMMARY_ERROR_MESSAGE = "Failed to summarize"
|
||||||
|
|
||||||
|
|
||||||
|
def _email_summary_failure_log_detail(exc: BaseException) -> str:
|
||||||
|
"""Return useful provider-failure metadata without echoing exception text."""
|
||||||
|
detail = f"type={type(exc).__name__}"
|
||||||
|
status = getattr(exc, "status_code", None)
|
||||||
|
if status is None:
|
||||||
|
status = getattr(getattr(exc, "response", None), "status_code", None)
|
||||||
|
if isinstance(status, int):
|
||||||
|
detail += f" status={status}"
|
||||||
|
return detail
|
||||||
|
|
||||||
|
|
||||||
def _apply_email_style_mechanics(text: str) -> str:
|
def _apply_email_style_mechanics(text: str) -> str:
|
||||||
"""Enforce deterministic writing-style mechanics that models often miss."""
|
"""Enforce deterministic writing-style mechanics that models often miss."""
|
||||||
if not text:
|
if not text:
|
||||||
|
|||||||
+23
-9
@@ -40,6 +40,7 @@ from routes.email_helpers import (
|
|||||||
_pre_retrieve_context,
|
_pre_retrieve_context,
|
||||||
_attach_compose_uploads, _cleanup_compose_uploads, _q,
|
_attach_compose_uploads, _cleanup_compose_uploads, _q,
|
||||||
SCHEDULED_DB, _EMAIL_REPLY_SYS_PROMPT_BASE, _email_cache_owner_clause,
|
SCHEDULED_DB, _EMAIL_REPLY_SYS_PROMPT_BASE, _email_cache_owner_clause,
|
||||||
|
_generate_scheduled_email_summary, _email_summary_failure_log_detail,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -653,6 +654,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
no_msgid = 0
|
no_msgid = 0
|
||||||
examined = 0
|
examined = 0
|
||||||
_summaries_created = 0
|
_summaries_created = 0
|
||||||
|
_summary_failed = 0
|
||||||
_events_created = 0
|
_events_created = 0
|
||||||
_replies_drafted = 0
|
_replies_drafted = 0
|
||||||
_reply_failed = 0
|
_reply_failed = 0
|
||||||
@@ -785,16 +787,17 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
|
|
||||||
if need_sum:
|
if need_sum:
|
||||||
try:
|
try:
|
||||||
summary = await task_llm_call_async(
|
summary = await _generate_scheduled_email_summary(
|
||||||
messages=[
|
url=url,
|
||||||
{"role": "system", "content": "You are an email summarizer. Format: 1-3 short bullet points (use '- '). Cover: main point, action items, deadlines. If the email has attachments (marked '--- ATTACHMENTS ---'), USE THEIR CONTENTS — pull out invoice totals, deadlines, key clauses, any concrete numbers/dates in PDFs/docs, and reflect them in the bullets. Be terse.\n\nOUTPUT FORMAT: Put ONLY the bullet points between these exact markers, each on its own line:\n<<<SUMMARY>>>\n- ...\n<<<END>>>\nAny reasoning or planning must come BEFORE <<<SUMMARY>>> (ideally inside <think>...</think>). Only the text between the markers is kept."},
|
model=model,
|
||||||
{"role": "user", "content": f"From: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}\n\n---\n\nSummarize the email. Output the bullets between <<<SUMMARY>>> and <<<END>>>."},
|
sender=sender,
|
||||||
],
|
subject=subject,
|
||||||
fallback_url=url, fallback_model=model, fallback_headers=headers,
|
body_for_llm=body_for_llm,
|
||||||
|
headers=req_headers,
|
||||||
owner=account_owner or None,
|
owner=account_owner or None,
|
||||||
temperature=0.3, max_tokens=16384, timeout=240,
|
max_tokens=16384,
|
||||||
|
timeout=240,
|
||||||
)
|
)
|
||||||
summary = _extract_reply((summary or "").strip())
|
|
||||||
if summary:
|
if summary:
|
||||||
_c = _sql3.connect(SCHEDULED_DB)
|
_c = _sql3.connect(SCHEDULED_DB)
|
||||||
_c.execute("""
|
_c.execute("""
|
||||||
@@ -808,10 +811,19 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
_summaries_created += 1
|
_summaries_created += 1
|
||||||
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
||||||
_detail_lines.append(f"summary · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}")
|
_detail_lines.append(f"summary · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}")
|
||||||
|
else:
|
||||||
|
_summary_failed += 1
|
||||||
|
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
||||||
|
_detail_lines.append(f"summary empty · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
_summary_failed += 1
|
||||||
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
||||||
_detail_lines.append(f"summary failed · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}")
|
_detail_lines.append(f"summary failed · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}")
|
||||||
logger.warning(f"Auto-summary {uid} failed: {e}")
|
logger.warning(
|
||||||
|
"Auto-summary uid=%s failed %s",
|
||||||
|
_uid_text,
|
||||||
|
_email_summary_failure_log_detail(e),
|
||||||
|
)
|
||||||
|
|
||||||
if need_reply:
|
if need_reply:
|
||||||
await _emit_progress(progress_cb, f"Drafting reply {processed + 1}/{_max_process} · checked {examined}/{len(uid_list)}")
|
await _emit_progress(progress_cb, f"Drafting reply {processed + 1}/{_max_process} · checked {examined}/{len(uid_list)}")
|
||||||
@@ -1320,6 +1332,8 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
parts.append(f"processed {processed} new")
|
parts.append(f"processed {processed} new")
|
||||||
if auto_sum:
|
if auto_sum:
|
||||||
parts.append(f"summarized {_summaries_created}")
|
parts.append(f"summarized {_summaries_created}")
|
||||||
|
if _summary_failed:
|
||||||
|
parts.append(f"{_summary_failed} summary failed")
|
||||||
if auto_reply_draft:
|
if auto_reply_draft:
|
||||||
parts.append(f"drafted {_replies_drafted} repl" + ("y" if _replies_drafted == 1 else "ies"))
|
parts.append(f"drafted {_replies_drafted} repl" + ("y" if _replies_drafted == 1 else "ies"))
|
||||||
if _reply_failed:
|
if _reply_failed:
|
||||||
|
|||||||
+46
-46
@@ -57,7 +57,8 @@ from routes.email_helpers import (
|
|||||||
_extract_attachment_to_disk, _extract_html, _extract_text,
|
_extract_attachment_to_disk, _extract_html, _extract_text,
|
||||||
_fetch_sender_thread_context, _pre_retrieve_context,
|
_fetch_sender_thread_context, _pre_retrieve_context,
|
||||||
_EMAIL_REPLY_SYS_PROMPT_BASE, _POOL_HOOKS,
|
_EMAIL_REPLY_SYS_PROMPT_BASE, _POOL_HOOKS,
|
||||||
_friendly_email_auth_error,
|
_friendly_email_auth_error, _email_summary_failure_log_detail,
|
||||||
|
_generate_email_summary, EMAIL_SUMMARY_ERROR_CODE, EMAIL_SUMMARY_ERROR_MESSAGE,
|
||||||
SendEmailRequest, ExtractStyleRequest,
|
SendEmailRequest, ExtractStyleRequest,
|
||||||
ATTACHMENTS_DIR, COMPOSE_UPLOADS_DIR, SCHEDULED_DB,
|
ATTACHMENTS_DIR, COMPOSE_UPLOADS_DIR, SCHEDULED_DB,
|
||||||
attachment_extract_dir, _email_cache_owner_clause, email_translation_body_hash,
|
attachment_extract_dir, _email_cache_owner_clause, email_translation_body_hash,
|
||||||
@@ -4766,8 +4767,6 @@ def setup_email_routes():
|
|||||||
"""Generate a quick AI summary of an email body."""
|
"""Generate a quick AI summary of an email body."""
|
||||||
try:
|
try:
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
from src.llm_core import _uses_max_completion_tokens, _restricts_temperature
|
|
||||||
import requests as _req
|
|
||||||
|
|
||||||
body = data.get("body", "")
|
body = data.get("body", "")
|
||||||
subject = data.get("subject", "")
|
subject = data.get("subject", "")
|
||||||
@@ -4778,7 +4777,11 @@ def setup_email_routes():
|
|||||||
if account_id:
|
if account_id:
|
||||||
_assert_owns_account(account_id, owner)
|
_assert_owns_account(account_id, owner)
|
||||||
if not body:
|
if not body:
|
||||||
return {"success": False, "error": "No body provided"}
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "No body provided",
|
||||||
|
"error_code": "email_summary_missing_body",
|
||||||
|
}
|
||||||
|
|
||||||
# If we know which UID this is, fetch the raw message and pull
|
# If we know which UID this is, fetch the raw message and pull
|
||||||
# attachment text so the summary can reference invoice totals,
|
# attachment text so the summary can reference invoice totals,
|
||||||
@@ -4807,53 +4810,43 @@ def setup_email_routes():
|
|||||||
if not url:
|
if not url:
|
||||||
url, model, headers = resolve_endpoint("default", owner=owner)
|
url, model, headers = resolve_endpoint("default", owner=owner)
|
||||||
if not url or not model:
|
if not url or not model:
|
||||||
return {"success": False, "error": "No LLM endpoint configured"}
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "No model configured for email summaries",
|
||||||
|
"error_code": "email_summary_not_configured",
|
||||||
|
}
|
||||||
|
|
||||||
req_headers = {"Content-Type": "application/json"}
|
req_headers = {"Content-Type": "application/json"}
|
||||||
if headers:
|
if headers:
|
||||||
req_headers.update(headers)
|
req_headers.update(headers)
|
||||||
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
try:
|
||||||
payload = {
|
content = await _generate_email_summary(
|
||||||
"model": model,
|
url=url,
|
||||||
"messages": [
|
model=model,
|
||||||
{"role": "system", "content": "You are an email summarizer. Format: 1-3 short bullet points (use '- '). Cover: main point, action items, deadlines. If the email has attachments (marked '--- ATTACHMENTS ---'), USE THEIR CONTENTS — pull invoice totals, deadlines, key clauses, concrete numbers/dates from PDFs/docs into the bullets. Be terse.\n\nOUTPUT FORMAT: Put ONLY the bullet points between these exact markers, each on its own line:\n<<<SUMMARY>>>\n- ...\n<<<END>>>\nAny reasoning must come BEFORE <<<SUMMARY>>> (ideally inside <think>...</think>). Only the text between the markers is kept."},
|
sender=sender,
|
||||||
{"role": "user", "content": f"From: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}\n\n---\n\nSummarize the email. Output the bullets between <<<SUMMARY>>> and <<<END>>>."},
|
subject=subject,
|
||||||
],
|
body_for_llm=body_for_llm,
|
||||||
tok_key: 8192,
|
headers=req_headers,
|
||||||
"temperature": 0.3,
|
max_tokens=8192,
|
||||||
"stream": False,
|
timeout=180,
|
||||||
}
|
|
||||||
# Reasoning models (o1/o3/o4/gpt-5) reject an explicit temperature.
|
|
||||||
if _restricts_temperature(model):
|
|
||||||
payload.pop("temperature", None)
|
|
||||||
resp = await asyncio.to_thread(
|
|
||||||
_req.post, url, json=payload, headers=req_headers, timeout=180
|
|
||||||
)
|
)
|
||||||
if not resp.ok:
|
except Exception as e:
|
||||||
return {"success": False, "error": f"LLM HTTP {resp.status_code}"}
|
logger.warning(
|
||||||
rdata = resp.json()
|
"Email summary LLM call failed %s",
|
||||||
msg = (rdata.get("choices") or [{}])[0].get("message", {})
|
_email_summary_failure_log_detail(e),
|
||||||
content = (msg.get("content") or "").strip()
|
)
|
||||||
content = _extract_reply(content)
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": EMAIL_SUMMARY_ERROR_MESSAGE,
|
||||||
|
"error_code": EMAIL_SUMMARY_ERROR_CODE,
|
||||||
|
}
|
||||||
|
|
||||||
if not content:
|
if not content:
|
||||||
# Model put everything in reasoning_content — extract bullet points
|
return {
|
||||||
rc = (msg.get("reasoning_content") or "").strip()
|
"success": False,
|
||||||
# Find bullet-point style output (lines starting with -, •, *, or numbered)
|
"error": "The model returned an empty summary",
|
||||||
bullet_lines = []
|
"error_code": "email_summary_empty",
|
||||||
for line in rc.split("\n"):
|
}
|
||||||
stripped = line.strip()
|
|
||||||
if re.match(r"^[-•*]\s+|^\d+[.)]\s+", stripped):
|
|
||||||
bullet_lines.append(stripped)
|
|
||||||
if bullet_lines:
|
|
||||||
content = "\n".join(bullet_lines)
|
|
||||||
else:
|
|
||||||
# Last resort: take the last paragraph
|
|
||||||
paragraphs = [p.strip() for p in rc.split("\n\n") if p.strip()]
|
|
||||||
content = paragraphs[-1] if paragraphs else rc[:500]
|
|
||||||
|
|
||||||
if not content:
|
|
||||||
return {"success": False, "error": "Empty response from model"}
|
|
||||||
|
|
||||||
# Cache the summary if we have a message_id
|
# Cache the summary if we have a message_id
|
||||||
mid = data.get("message_id", "")
|
mid = data.get("message_id", "")
|
||||||
@@ -4876,8 +4869,15 @@ def setup_email_routes():
|
|||||||
|
|
||||||
return {"success": True, "summary": content, "model_used": model}
|
return {"success": True, "summary": content, "model_used": model}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to summarize: {e}")
|
logger.error(
|
||||||
return {"success": False, "error": "Mail operation failed"}
|
"Email summary route failed %s",
|
||||||
|
_email_summary_failure_log_detail(e),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": EMAIL_SUMMARY_ERROR_MESSAGE,
|
||||||
|
"error_code": EMAIL_SUMMARY_ERROR_CODE,
|
||||||
|
}
|
||||||
|
|
||||||
@router.post("/translate")
|
@router.post("/translate")
|
||||||
async def translate_email(data: dict, owner: str = Depends(require_owner)):
|
async def translate_email(data: dict, owner: str = Depends(require_owner)):
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { makeWindowDraggable } from './windowDrag.js';
|
|||||||
import {
|
import {
|
||||||
_esc, _escLinkify, _extractName, _parseTurnMeta,
|
_esc, _escLinkify, _extractName, _parseTurnMeta,
|
||||||
_formatBubbleDate, _formatRecipients, _senderColor, _initials,
|
_formatBubbleDate, _formatRecipients, _senderColor, _initials,
|
||||||
_sanitizeHtml,
|
_sanitizeHtml, _renderEmailSummaryError,
|
||||||
_TALON_WROTE, _TALON_FROM, _TALON_SENT, _TALON_SUBJ, _TALON_TO,
|
_TALON_WROTE, _TALON_FROM, _TALON_SENT, _TALON_SUBJ, _TALON_TO,
|
||||||
_TALON_ORIG_RE, _SIG_BLOAT_MIN_CHARS,
|
_TALON_ORIG_RE, _SIG_BLOAT_MIN_CHARS,
|
||||||
} from './emailLibrary/utils.js';
|
} from './emailLibrary/utils.js';
|
||||||
@@ -7259,12 +7259,11 @@ async function _generateSummary(reader, data, btn) {
|
|||||||
if (label) label.textContent = 'Summary';
|
if (label) label.textContent = 'Summary';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
content.innerHTML = `<span style="color:var(--red)">${_esc(result.error || 'Failed to summarize')}</span>`;
|
_renderEmailSummaryError(content, result);
|
||||||
panel.remove();
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
sp.destroy();
|
sp.destroy();
|
||||||
panel.remove();
|
_renderEmailSummaryError(content, null);
|
||||||
if (uiModule) uiModule.showError?.('Failed to summarize');
|
if (uiModule) uiModule.showError?.('Failed to summarize');
|
||||||
} finally {
|
} finally {
|
||||||
if (btn) btn.disabled = false;
|
if (btn) btn.disabled = false;
|
||||||
|
|||||||
@@ -30,6 +30,25 @@ export function _esc(text) {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _EMAIL_SUMMARY_ERROR_MESSAGES = Object.freeze({
|
||||||
|
email_summary_missing_body: 'No email body to summarize',
|
||||||
|
email_summary_not_configured: 'No model configured for email summaries',
|
||||||
|
email_summary_empty: 'The model returned an empty summary',
|
||||||
|
email_summary_unavailable: 'Failed to summarize',
|
||||||
|
});
|
||||||
|
|
||||||
|
export function _emailSummaryErrorMessage(result) {
|
||||||
|
const code = String(result?.error_code || '');
|
||||||
|
return _EMAIL_SUMMARY_ERROR_MESSAGES[code] || 'Failed to summarize';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _renderEmailSummaryError(container, result) {
|
||||||
|
const message = container.ownerDocument.createElement('span');
|
||||||
|
message.style.color = 'var(--red)';
|
||||||
|
message.textContent = _emailSummaryErrorMessage(result);
|
||||||
|
container.replaceChildren(message);
|
||||||
|
}
|
||||||
|
|
||||||
function _attrEsc(text) {
|
function _attrEsc(text) {
|
||||||
return String(text ?? '')
|
return String(text ?? '')
|
||||||
.replace(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
|
_UTILS = (_REPO / "static" / "js" / "emailLibrary" / "utils.js").as_posix()
|
||||||
|
_HAS_NODE = shutil.which("node") is not None
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
|
||||||
|
|
||||||
|
def test_email_summary_renderer_ignores_untrusted_provider_error_text():
|
||||||
|
secret = (
|
||||||
|
"endpoint=https://private.example.internal/v1 provider=ollama "
|
||||||
|
"model=private-model response_body=private-response "
|
||||||
|
"Authorization: Bearer token-secret-value"
|
||||||
|
)
|
||||||
|
script = f"""
|
||||||
|
import {{ _renderEmailSummaryError }} from '{_UTILS}';
|
||||||
|
const host = {{
|
||||||
|
ownerDocument: {{
|
||||||
|
createElement() {{ return {{ style: {{}}, textContent: '' }}; }},
|
||||||
|
}},
|
||||||
|
replaceChildren(node) {{ this.child = node; }},
|
||||||
|
}};
|
||||||
|
_renderEmailSummaryError(host, {{
|
||||||
|
error_code: 'email_summary_unavailable',
|
||||||
|
error: {json.dumps(secret)},
|
||||||
|
}});
|
||||||
|
console.log(JSON.stringify({{
|
||||||
|
text: host.child.textContent,
|
||||||
|
color: host.child.style.color,
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
|
||||||
|
proc = subprocess.run(
|
||||||
|
["node", "--input-type=module"],
|
||||||
|
input=script,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=str(_REPO),
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
rendered = json.loads(proc.stdout)
|
||||||
|
assert rendered == {"text": "Failed to summarize", "color": "var(--red)"}
|
||||||
|
assert secret not in proc.stdout
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
_TMP_DATA = Path(tempfile.mkdtemp(prefix="odysseus-email-summary-"))
|
||||||
|
os.environ.setdefault("DATA_DIR", str(_TMP_DATA))
|
||||||
|
os.environ.setdefault("DATABASE_URL", f"sqlite:///{_TMP_DATA / 'app.db'}")
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
if str(PROJECT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
def _route_endpoint(router, path: str, method: str):
|
||||||
|
method = method.upper()
|
||||||
|
for route in router.routes:
|
||||||
|
if route.path == path and method in getattr(route, "methods", set()):
|
||||||
|
return route.endpoint
|
||||||
|
raise AssertionError(f"route not found: {method} {path}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_email_summary_uses_shared_llm_adapter(monkeypatch):
|
||||||
|
import routes.email_helpers as email_helpers
|
||||||
|
import src.llm_core as llm_core
|
||||||
|
|
||||||
|
calls = {}
|
||||||
|
|
||||||
|
async def fake_llm_call_async(url, model, messages, **kwargs):
|
||||||
|
calls["url"] = url
|
||||||
|
calls["model"] = model
|
||||||
|
calls["messages"] = messages
|
||||||
|
calls["kwargs"] = kwargs
|
||||||
|
return "thinking before marker\n<<<SUMMARY>>>\n- Pay the invoice by Friday.\n<<<END>>>"
|
||||||
|
|
||||||
|
monkeypatch.setattr(llm_core, "llm_call_async", fake_llm_call_async)
|
||||||
|
|
||||||
|
summary = await email_helpers._generate_email_summary(
|
||||||
|
url="https://chatgpt.com/backend-api/codex/responses",
|
||||||
|
model="gpt-5.5",
|
||||||
|
sender="Billing <billing@example.com>",
|
||||||
|
subject="Invoice due",
|
||||||
|
body_for_llm="Please pay invoice 123 by Friday.",
|
||||||
|
headers={"Authorization": "Bearer test"},
|
||||||
|
max_tokens=1234,
|
||||||
|
timeout=45,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert summary == "- Pay the invoice by Friday."
|
||||||
|
assert calls["url"] == "https://chatgpt.com/backend-api/codex/responses"
|
||||||
|
assert calls["model"] == "gpt-5.5"
|
||||||
|
assert calls["kwargs"]["headers"] == {"Authorization": "Bearer test"}
|
||||||
|
assert calls["kwargs"]["temperature"] == 0.3
|
||||||
|
assert calls["kwargs"]["max_tokens"] == 1234
|
||||||
|
assert calls["kwargs"]["timeout"] == 45
|
||||||
|
assert calls["kwargs"]["workload"] == "foreground"
|
||||||
|
assert calls["messages"][0]["role"] == "system"
|
||||||
|
assert calls["messages"][1]["role"] == "user"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scheduled_email_summary_uses_background_fallback_chain(monkeypatch):
|
||||||
|
import routes.email_helpers as email_helpers
|
||||||
|
import src.llm_core as llm_core
|
||||||
|
import src.task_endpoint as task_endpoint
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
("http://primary.invalid/v1", "primary-model", {"X-Candidate": "primary"}),
|
||||||
|
("http://fallback.invalid/v1", "fallback-model", {"X-Candidate": "fallback"}),
|
||||||
|
]
|
||||||
|
resolve_calls = []
|
||||||
|
wait_calls = []
|
||||||
|
llm_calls = []
|
||||||
|
|
||||||
|
def fake_resolve_task_candidates(**kwargs):
|
||||||
|
resolve_calls.append(kwargs)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
async def fake_wait_for_interactive_quiet(label):
|
||||||
|
wait_calls.append(label)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def fake_llm_call_async(url, model, messages, **kwargs):
|
||||||
|
llm_calls.append((url, model, messages, kwargs))
|
||||||
|
if model == "primary-model":
|
||||||
|
raise RuntimeError("primary unavailable")
|
||||||
|
return "<<<SUMMARY>>>\n- Used the fallback model.\n<<<END>>>"
|
||||||
|
|
||||||
|
monkeypatch.setattr(task_endpoint, "resolve_task_candidates", fake_resolve_task_candidates)
|
||||||
|
monkeypatch.setattr(task_endpoint, "wait_for_interactive_quiet", fake_wait_for_interactive_quiet)
|
||||||
|
monkeypatch.setattr(llm_core, "llm_call_async", fake_llm_call_async)
|
||||||
|
|
||||||
|
summary = await email_helpers._generate_scheduled_email_summary(
|
||||||
|
url="http://caller-fallback.invalid/v1",
|
||||||
|
model="caller-fallback-model",
|
||||||
|
sender="Sender <sender@example.com>",
|
||||||
|
subject="Scheduled subject",
|
||||||
|
body_for_llm="Please summarize this scheduled email.",
|
||||||
|
headers={"Authorization": "Bearer test"},
|
||||||
|
owner="alice",
|
||||||
|
max_tokens=321,
|
||||||
|
timeout=54,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert summary == "- Used the fallback model."
|
||||||
|
assert resolve_calls == [{
|
||||||
|
"fallback_url": "http://caller-fallback.invalid/v1",
|
||||||
|
"fallback_model": "caller-fallback-model",
|
||||||
|
"fallback_headers": {"Authorization": "Bearer test"},
|
||||||
|
"owner": "alice",
|
||||||
|
}]
|
||||||
|
assert wait_calls == ["background task LLM"]
|
||||||
|
assert [call[1] for call in llm_calls] == ["primary-model", "fallback-model"]
|
||||||
|
assert all(call[3]["workload"] == "background" for call in llm_calls)
|
||||||
|
assert all(call[3]["max_tokens"] == 321 for call in llm_calls)
|
||||||
|
assert all(call[3]["timeout"] == 54 for call in llm_calls)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scheduled_local_summary_is_preempted_by_foreground_call(monkeypatch):
|
||||||
|
import routes.email_helpers as email_helpers
|
||||||
|
import src.llm_core as llm_core
|
||||||
|
import src.task_endpoint as task_endpoint
|
||||||
|
|
||||||
|
local_url = "http://127.0.0.1:11434/v1/chat/completions"
|
||||||
|
background_started = asyncio.Event()
|
||||||
|
never_release = asyncio.Event()
|
||||||
|
observed_workloads = []
|
||||||
|
|
||||||
|
monkeypatch.setenv("ODYSSEUS_LOCAL_MODEL_GATE", "true")
|
||||||
|
monkeypatch.setenv("BACKGROUND_TASK_FOREGROUND_GATE", "false")
|
||||||
|
monkeypatch.setattr(llm_core, "_LOCAL_MODEL_LOCK", asyncio.Lock())
|
||||||
|
monkeypatch.setattr(llm_core, "_LOCAL_MODEL_CURRENT", {})
|
||||||
|
monkeypatch.setattr(llm_core, "_LOCAL_MODEL_WAITING_FOREGROUND", 0)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
task_endpoint,
|
||||||
|
"resolve_task_candidates",
|
||||||
|
lambda **_kwargs: [(local_url, "scheduled-model", {})],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_wait_for_interactive_quiet(_label):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def gated_llm_call(url, model, messages, **kwargs):
|
||||||
|
assert messages
|
||||||
|
workload = kwargs.get("workload")
|
||||||
|
observed_workloads.append(workload)
|
||||||
|
async with llm_core._local_model_slot(url, model, workload=workload):
|
||||||
|
background_started.set()
|
||||||
|
await never_release.wait()
|
||||||
|
return "unreachable"
|
||||||
|
|
||||||
|
monkeypatch.setattr(task_endpoint, "wait_for_interactive_quiet", fake_wait_for_interactive_quiet)
|
||||||
|
monkeypatch.setattr(llm_core, "llm_call_async", gated_llm_call)
|
||||||
|
|
||||||
|
background_task = asyncio.create_task(email_helpers._generate_scheduled_email_summary(
|
||||||
|
url=local_url,
|
||||||
|
model="scheduled-model",
|
||||||
|
sender="Sender",
|
||||||
|
subject="Scheduled",
|
||||||
|
body_for_llm="Scheduled body",
|
||||||
|
owner="alice",
|
||||||
|
))
|
||||||
|
foreground_task = None
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(background_started.wait(), timeout=1)
|
||||||
|
|
||||||
|
async def run_foreground():
|
||||||
|
async with llm_core._local_model_slot(
|
||||||
|
local_url,
|
||||||
|
"interactive-model",
|
||||||
|
workload="foreground",
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
foreground_task = asyncio.create_task(run_foreground())
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await asyncio.wait_for(background_task, timeout=1)
|
||||||
|
assert await asyncio.wait_for(foreground_task, timeout=1) is True
|
||||||
|
assert observed_workloads == ["background"]
|
||||||
|
finally:
|
||||||
|
for task in (background_task, foreground_task):
|
||||||
|
if task is not None and not task.done():
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_manual_email_summary_uses_shared_helper_and_caches(tmp_path, monkeypatch):
|
||||||
|
import routes.email_helpers as email_helpers
|
||||||
|
import routes.email_routes as email_routes
|
||||||
|
import src.endpoint_resolver as endpoint_resolver
|
||||||
|
|
||||||
|
db_path = tmp_path / "scheduled_emails.db"
|
||||||
|
monkeypatch.setattr(email_helpers, "SCHEDULED_DB", db_path)
|
||||||
|
monkeypatch.setattr(email_routes, "SCHEDULED_DB", db_path)
|
||||||
|
email_helpers._init_scheduled_db()
|
||||||
|
|
||||||
|
resolve_calls = []
|
||||||
|
|
||||||
|
def fake_resolve_endpoint(kind, owner=None):
|
||||||
|
resolve_calls.append((kind, owner))
|
||||||
|
assert kind == "utility"
|
||||||
|
assert owner == "alice"
|
||||||
|
return (
|
||||||
|
"https://chatgpt.com/backend-api/codex/responses",
|
||||||
|
"gpt-5.5",
|
||||||
|
{"Authorization": "Bearer test"},
|
||||||
|
)
|
||||||
|
|
||||||
|
helper_calls = {}
|
||||||
|
|
||||||
|
async def fake_generate_email_summary(**kwargs):
|
||||||
|
helper_calls.update(kwargs)
|
||||||
|
return "- Manual summary"
|
||||||
|
|
||||||
|
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint", fake_resolve_endpoint)
|
||||||
|
monkeypatch.setattr(email_routes, "_generate_email_summary", fake_generate_email_summary)
|
||||||
|
|
||||||
|
router = email_routes.setup_email_routes()
|
||||||
|
summarize = _route_endpoint(router, "/api/email/summarize", "POST")
|
||||||
|
|
||||||
|
result = await summarize(
|
||||||
|
{
|
||||||
|
"body": "This is a long enough email body for manual summary.",
|
||||||
|
"subject": "Manual subject",
|
||||||
|
"from": "Sender <sender@example.com>",
|
||||||
|
"message_id": "<manual@example.com>",
|
||||||
|
"folder": "INBOX",
|
||||||
|
},
|
||||||
|
owner="alice",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"success": True,
|
||||||
|
"summary": "- Manual summary",
|
||||||
|
"model_used": "gpt-5.5",
|
||||||
|
}
|
||||||
|
assert resolve_calls == [("utility", "alice")]
|
||||||
|
assert helper_calls["url"] == "https://chatgpt.com/backend-api/codex/responses"
|
||||||
|
assert helper_calls["model"] == "gpt-5.5"
|
||||||
|
assert helper_calls["headers"]["Authorization"] == "Bearer test"
|
||||||
|
assert helper_calls["headers"]["Content-Type"] == "application/json"
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT owner, summary, model_used FROM email_summaries WHERE message_id=?",
|
||||||
|
("<manual@example.com>",),
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
assert row == ("alice", "- Manual summary", "gpt-5.5")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("exception_kind", ["http", "runtime"])
|
||||||
|
async def test_manual_email_summary_never_exposes_provider_exception(
|
||||||
|
monkeypatch,
|
||||||
|
caplog,
|
||||||
|
exception_kind,
|
||||||
|
):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
import routes.email_routes as email_routes
|
||||||
|
import src.endpoint_resolver as endpoint_resolver
|
||||||
|
|
||||||
|
secret_detail = (
|
||||||
|
"endpoint=https://private.example.internal/v1 provider=ollama "
|
||||||
|
"model=private-model response_body=private-response "
|
||||||
|
"Authorization: Bearer token-secret-value"
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_resolve_endpoint(kind, owner=None):
|
||||||
|
assert kind == "utility"
|
||||||
|
assert owner == "alice"
|
||||||
|
return (
|
||||||
|
"https://private.example.internal/v1",
|
||||||
|
"private-model",
|
||||||
|
{"Authorization": "Bearer token-secret-value"},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fail_summary(**_kwargs):
|
||||||
|
if exception_kind == "http":
|
||||||
|
raise HTTPException(status_code=502, detail=secret_detail)
|
||||||
|
raise RuntimeError(secret_detail)
|
||||||
|
|
||||||
|
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint", fake_resolve_endpoint)
|
||||||
|
monkeypatch.setattr(email_routes, "_generate_email_summary", fail_summary)
|
||||||
|
caplog.set_level(logging.WARNING, logger=email_routes.__name__)
|
||||||
|
|
||||||
|
router = email_routes.setup_email_routes()
|
||||||
|
summarize = _route_endpoint(router, "/api/email/summarize", "POST")
|
||||||
|
result = await summarize(
|
||||||
|
{
|
||||||
|
"body": "This email body is long enough to summarize.",
|
||||||
|
"subject": "Sensitive provider failure",
|
||||||
|
"from": "Sender <sender@example.com>",
|
||||||
|
},
|
||||||
|
owner="alice",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"success": False,
|
||||||
|
"error": "Failed to summarize",
|
||||||
|
"error_code": "email_summary_unavailable",
|
||||||
|
}
|
||||||
|
exposed = json.dumps(result) + caplog.text
|
||||||
|
for marker in (
|
||||||
|
"private.example.internal",
|
||||||
|
"ollama",
|
||||||
|
"private-model",
|
||||||
|
"private-response",
|
||||||
|
"token-secret-value",
|
||||||
|
):
|
||||||
|
assert marker not in exposed
|
||||||
|
assert f"type={'HTTPException' if exception_kind == 'http' else 'RuntimeError'}" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scheduled_email_summary_uses_shared_helper_and_caches(tmp_path, monkeypatch):
|
||||||
|
import routes.email_helpers as email_helpers
|
||||||
|
import routes.email_pollers as email_pollers
|
||||||
|
|
||||||
|
db_path = tmp_path / "scheduled_emails.db"
|
||||||
|
monkeypatch.setattr(email_helpers, "SCHEDULED_DB", db_path)
|
||||||
|
monkeypatch.setattr(email_pollers, "SCHEDULED_DB", db_path)
|
||||||
|
email_helpers._init_scheduled_db()
|
||||||
|
|
||||||
|
raw_email = (
|
||||||
|
b"From: Sender <sender@example.com>\r\n"
|
||||||
|
b"To: Alice <alice@example.com>\r\n"
|
||||||
|
b"Subject: Scheduled subject\r\n"
|
||||||
|
b"Message-ID: <scheduled@example.com>\r\n"
|
||||||
|
b"Date: Tue, 01 Jan 2026 12:00:00 +0000\r\n"
|
||||||
|
b"Content-Type: text/plain; charset=utf-8\r\n"
|
||||||
|
b"\r\n"
|
||||||
|
+ (b"Please review this scheduled summary email. " * 8)
|
||||||
|
)
|
||||||
|
|
||||||
|
class FakeImap:
|
||||||
|
def __init__(self):
|
||||||
|
self.logout_calls = 0
|
||||||
|
|
||||||
|
def select(self, _folder, readonly=True):
|
||||||
|
return "OK", []
|
||||||
|
|
||||||
|
def uid(self, command, *args):
|
||||||
|
if command == "SEARCH":
|
||||||
|
return "OK", [b"1"]
|
||||||
|
if command == "FETCH":
|
||||||
|
return "OK", [(b"1 (RFC822)", raw_email)]
|
||||||
|
raise AssertionError(f"unexpected uid command: {command!r} {args!r}")
|
||||||
|
|
||||||
|
def logout(self):
|
||||||
|
self.logout_calls += 1
|
||||||
|
|
||||||
|
fake_conn = FakeImap()
|
||||||
|
|
||||||
|
def fake_resolve_task_candidates(owner=None):
|
||||||
|
assert owner == "alice"
|
||||||
|
return [(
|
||||||
|
"https://chatgpt.com/backend-api/codex/responses",
|
||||||
|
"gpt-5.5",
|
||||||
|
{"Authorization": "Bearer test"},
|
||||||
|
)]
|
||||||
|
|
||||||
|
helper_calls = {}
|
||||||
|
|
||||||
|
async def fake_generate_email_summary(**kwargs):
|
||||||
|
helper_calls.update(kwargs)
|
||||||
|
return "- Scheduled summary"
|
||||||
|
|
||||||
|
monkeypatch.setattr(email_pollers, "_load_settings", lambda: {"email_auto_summarize": True})
|
||||||
|
monkeypatch.setattr(email_pollers, "_owner_for_email_account", lambda _account_id: "alice")
|
||||||
|
monkeypatch.setattr(email_pollers, "_imap_connect", lambda account_id=None, owner="": fake_conn)
|
||||||
|
monkeypatch.setattr(email_pollers, "_get_email_config", lambda account_id=None, owner="": {"from_address": "alice@example.com"})
|
||||||
|
monkeypatch.setattr(email_pollers, "resolve_task_candidates", fake_resolve_task_candidates)
|
||||||
|
monkeypatch.setattr(email_pollers, "_generate_scheduled_email_summary", fake_generate_email_summary)
|
||||||
|
|
||||||
|
result = await email_pollers._auto_summarize_pass_single(account_id="acct-alice")
|
||||||
|
|
||||||
|
assert "summarized 1" in result
|
||||||
|
assert "summary failed" not in result
|
||||||
|
assert helper_calls["url"] == "https://chatgpt.com/backend-api/codex/responses"
|
||||||
|
assert helper_calls["model"] == "gpt-5.5"
|
||||||
|
assert helper_calls["headers"]["Authorization"] == "Bearer test"
|
||||||
|
assert helper_calls["headers"]["Content-Type"] == "application/json"
|
||||||
|
assert helper_calls["owner"] == "alice"
|
||||||
|
assert fake_conn.logout_calls == 1
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT owner, summary, model_used FROM email_summaries WHERE message_id=?",
|
||||||
|
("<scheduled@example.com>",),
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
assert row == ("alice", "- Scheduled summary", "gpt-5.5")
|
||||||
Reference in New Issue
Block a user