Email search/query + windowed digest preview (v0.1.0:83)
Communications tab (search/query roadmap items 1 & 2): - Fix the investor dropdown: the facet only listed grid investors, so it came back empty whenever email matched a classic contact or org domain (no grid id — the common case). It now mirrors the email list, resolving each link to a typed identity (fund:/org:/contact:/addr:) with precedence grid -> org -> contact -> address; investor_id accepts the typed key (bare id = fund: for back-compat) and an unknown prefix matches nothing. - Add a date-range filter and a click-to-expand full-body view (GET /api/email/detail, admin, soft-delete-gated; body_text only, never raw remote HTML). - Add a "Search content" mode: GET /api/email/search wraps the ingest hybrid_search over the Qdrant email index (doc_type=email), hydrated and soft-delete-filtered against SQLite (canonical), 503 if Spark/Qdrant down. Daily digest: - Settings -> Admin builds a digest over a chosen window (last 24h or since a date) as an in-app preview before sending (POST /api/admin/digest/preview), so the local-Spark summarizer can be verified on demand even on a quiet day. Manual send uses the same window; neither advances the daily cursor, so a preview never suppresses the scheduled digest. Code-only, migrations no-op. 22/22 backend tests, render-smoke pass.
This commit is contained in:
@@ -21,7 +21,7 @@ importable (and testable with an injected chat fn) without Spark configured.
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
# One row per (account-sighting x investor-link) in the window. Grouped into
|
||||
@@ -225,6 +225,55 @@ def load_digest_policy(conn):
|
||||
return pol
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ window
|
||||
|
||||
# Cap a manual/preview window so an admin can't accidentally fire a build over
|
||||
# years of history — each active user in the window costs one Spark call. ~3
|
||||
# months covers any realistic "since last quarter" preview.
|
||||
MAX_WINDOW_DAYS = 92
|
||||
|
||||
|
||||
def resolve_digest_window(*, hours=None, since=None, now_local=None, now_utc=None):
|
||||
"""Resolve a digest content window to (since_iso, until_iso) as UTC ISO-8601.
|
||||
|
||||
`until` is always now. The start is driven by exactly one of:
|
||||
- since: a local calendar date 'YYYY-MM-DD' -> that day's local midnight
|
||||
- hours: a positive integer lookback (the default path; 24 when nothing given)
|
||||
`since` wins if both are supplied. The span is clamped to MAX_WINDOW_DAYS and
|
||||
the start must be strictly before now. Raises ValueError on malformed input so
|
||||
the caller can return a clean 400. Pure (now_* injectable) for testing.
|
||||
|
||||
Used by the admin-panel preview and manual-send — neither advances the daily
|
||||
cursor, so a wide window here never suppresses the scheduled digest."""
|
||||
nu = (now_utc or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
nl = now_local or datetime.now().astimezone()
|
||||
floor = nu - timedelta(days=MAX_WINDOW_DAYS)
|
||||
|
||||
if since not in (None, ""):
|
||||
try:
|
||||
d = datetime.strptime(str(since).strip()[:10], "%Y-%m-%d")
|
||||
except ValueError:
|
||||
raise ValueError("since must be a date in YYYY-MM-DD form")
|
||||
start = d.replace(tzinfo=nl.tzinfo or timezone.utc).astimezone(timezone.utc)
|
||||
else:
|
||||
h = 24 if hours in (None, "") else hours
|
||||
try:
|
||||
h = int(h)
|
||||
except (ValueError, TypeError):
|
||||
raise ValueError("hours must be an integer")
|
||||
if h < 1:
|
||||
raise ValueError("hours must be a positive integer")
|
||||
start = nu - timedelta(hours=h)
|
||||
|
||||
if start >= nu:
|
||||
raise ValueError("window start must be before now")
|
||||
if start < floor:
|
||||
start = floor # clamp to the max span (the response echoes the real window)
|
||||
|
||||
fmt = "%Y-%m-%dT%H:%M:%SZ"
|
||||
return start.strftime(fmt), nu.strftime(fmt)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ summarization
|
||||
|
||||
def _default_chat(prompt, system=None, max_tokens=220):
|
||||
|
||||
Reference in New Issue
Block a user