mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-02 03:02:28 +01: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:
co-authored by
Matyas Fenyves
parent
e4fa4ae5dd
commit
42da399b4d
@@ -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_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)
|
||||
_SUMMARY_BULLET_RE = _re_reply.compile(r"^(?:[-*\u2022]\s+|\d+[.)]\s+)")
|
||||
|
||||
|
||||
def _extract_reply(text: str) -> str:
|
||||
@@ -277,6 +278,125 @@ def _extract_reply(text: str) -> str:
|
||||
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:
|
||||
"""Enforce deterministic writing-style mechanics that models often miss."""
|
||||
if not text:
|
||||
|
||||
+23
-9
@@ -40,6 +40,7 @@ from routes.email_helpers import (
|
||||
_pre_retrieve_context,
|
||||
_attach_compose_uploads, _cleanup_compose_uploads, _q,
|
||||
SCHEDULED_DB, _EMAIL_REPLY_SYS_PROMPT_BASE, _email_cache_owner_clause,
|
||||
_generate_scheduled_email_summary, _email_summary_failure_log_detail,
|
||||
)
|
||||
|
||||
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
|
||||
examined = 0
|
||||
_summaries_created = 0
|
||||
_summary_failed = 0
|
||||
_events_created = 0
|
||||
_replies_drafted = 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:
|
||||
try:
|
||||
summary = await task_llm_call_async(
|
||||
messages=[
|
||||
{"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."},
|
||||
{"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>>>."},
|
||||
],
|
||||
fallback_url=url, fallback_model=model, fallback_headers=headers,
|
||||
summary = await _generate_scheduled_email_summary(
|
||||
url=url,
|
||||
model=model,
|
||||
sender=sender,
|
||||
subject=subject,
|
||||
body_for_llm=body_for_llm,
|
||||
headers=req_headers,
|
||||
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:
|
||||
_c = _sql3.connect(SCHEDULED_DB)
|
||||
_c.execute("""
|
||||
@@ -808,10 +811,19 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
_summaries_created += 1
|
||||
_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)'}")
|
||||
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:
|
||||
_summary_failed += 1
|
||||
_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)'}")
|
||||
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:
|
||||
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")
|
||||
if auto_sum:
|
||||
parts.append(f"summarized {_summaries_created}")
|
||||
if _summary_failed:
|
||||
parts.append(f"{_summary_failed} summary failed")
|
||||
if auto_reply_draft:
|
||||
parts.append(f"drafted {_replies_drafted} repl" + ("y" if _replies_drafted == 1 else "ies"))
|
||||
if _reply_failed:
|
||||
|
||||
+47
-47
@@ -57,7 +57,8 @@ from routes.email_helpers import (
|
||||
_extract_attachment_to_disk, _extract_html, _extract_text,
|
||||
_fetch_sender_thread_context, _pre_retrieve_context,
|
||||
_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,
|
||||
ATTACHMENTS_DIR, COMPOSE_UPLOADS_DIR, SCHEDULED_DB,
|
||||
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."""
|
||||
try:
|
||||
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", "")
|
||||
subject = data.get("subject", "")
|
||||
@@ -4778,7 +4777,11 @@ def setup_email_routes():
|
||||
if account_id:
|
||||
_assert_owns_account(account_id, owner)
|
||||
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
|
||||
# attachment text so the summary can reference invoice totals,
|
||||
@@ -4807,53 +4810,43 @@ def setup_email_routes():
|
||||
if not url:
|
||||
url, model, headers = resolve_endpoint("default", owner=owner)
|
||||
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"}
|
||||
if headers:
|
||||
req_headers.update(headers)
|
||||
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"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."},
|
||||
{"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>>>."},
|
||||
],
|
||||
tok_key: 8192,
|
||||
"temperature": 0.3,
|
||||
"stream": False,
|
||||
}
|
||||
# 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:
|
||||
return {"success": False, "error": f"LLM HTTP {resp.status_code}"}
|
||||
rdata = resp.json()
|
||||
msg = (rdata.get("choices") or [{}])[0].get("message", {})
|
||||
content = (msg.get("content") or "").strip()
|
||||
content = _extract_reply(content)
|
||||
try:
|
||||
content = await _generate_email_summary(
|
||||
url=url,
|
||||
model=model,
|
||||
sender=sender,
|
||||
subject=subject,
|
||||
body_for_llm=body_for_llm,
|
||||
headers=req_headers,
|
||||
max_tokens=8192,
|
||||
timeout=180,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Email summary LLM call failed %s",
|
||||
_email_summary_failure_log_detail(e),
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"error": EMAIL_SUMMARY_ERROR_MESSAGE,
|
||||
"error_code": EMAIL_SUMMARY_ERROR_CODE,
|
||||
}
|
||||
|
||||
if not content:
|
||||
# Model put everything in reasoning_content — extract bullet points
|
||||
rc = (msg.get("reasoning_content") or "").strip()
|
||||
# Find bullet-point style output (lines starting with -, •, *, or numbered)
|
||||
bullet_lines = []
|
||||
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"}
|
||||
return {
|
||||
"success": False,
|
||||
"error": "The model returned an empty summary",
|
||||
"error_code": "email_summary_empty",
|
||||
}
|
||||
|
||||
# Cache the summary if we have a message_id
|
||||
mid = data.get("message_id", "")
|
||||
@@ -4876,8 +4869,15 @@ def setup_email_routes():
|
||||
|
||||
return {"success": True, "summary": content, "model_used": model}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to summarize: {e}")
|
||||
return {"success": False, "error": "Mail operation failed"}
|
||||
logger.error(
|
||||
"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")
|
||||
async def translate_email(data: dict, owner: str = Depends(require_owner)):
|
||||
|
||||
Reference in New Issue
Block a user