Restrict comms_by_user/email_counts_by_user to matched-investor email
Both NL-query intents counted/listed a user's ENTIRE captured sent corpus (internal, vendor, personal mail) rather than only email to a matched investor — they were missing the `EXISTS email_investor_links` gate that recent_emails and the Communications panel's query_email_activity use. Their own docstrings said "investor emails", so the behavior was wrong, not just loose. Add the matched-only gate to both, mirroring query_email_activity. The runner test now seeds an unmatched sent email and asserts it is excluded (without the fix comms_by_user returns 3 not 2, this_week 2 not 1) — the prior fixture linked every email, so the leak went uncaught. Also documents the matched-only rule in the nl-query guide, and refreshes the AGENTS.md Current state (v93 deployed; this fix pending a v94 s9pk since the intents run on the box, not the bot).
This commit is contained in:
@@ -306,8 +306,11 @@ def run_investor_last_contact(conn, slots):
|
||||
|
||||
|
||||
def run_comms_by_user(conn, slots):
|
||||
"""The most recent `limit` outbound investor emails sent by a given user (matched by
|
||||
username or full name). Soft-delete-correct (live sighting, is_sent)."""
|
||||
"""The most recent `limit` outbound **investor** emails sent by a given user (matched by
|
||||
username or full name). MATCHED-ONLY: restricted to investor-linked email (an
|
||||
email_investor_links row exists), mirroring query_email_activity / recent_emails — NOT the
|
||||
user's entire sent corpus (internal/vendor/personal mail is captured but never surfaced
|
||||
here). Soft-delete-correct (live sighting, is_sent)."""
|
||||
n, pat = slots["limit"], like_contains(slots["user"])
|
||||
rows = _rows(conn.execute(
|
||||
"SELECT e.subject, e.sent_at, u.full_name AS sender, "
|
||||
@@ -318,6 +321,7 @@ def run_comms_by_user(conn, slots):
|
||||
"AND eam.deleted_at IS NULL AND eam.is_sent = 1 "
|
||||
"JOIN email_accounts ea ON ea.id = eam.account_id JOIN users u ON u.id = ea.user_id "
|
||||
"WHERE (u.username LIKE ? ESCAPE '\\' OR u.full_name LIKE ? ESCAPE '\\') "
|
||||
"AND EXISTS (SELECT 1 FROM email_investor_links l2 WHERE l2.email_id = e.id) "
|
||||
"ORDER BY e.sent_at DESC LIMIT ?", (pat, pat, n)))
|
||||
return {"columns": ["sent_at", "subject", "sender", "investor"], "rows": rows,
|
||||
"truncated": False,
|
||||
@@ -325,13 +329,16 @@ def run_comms_by_user(conn, slots):
|
||||
|
||||
|
||||
def run_email_counts_by_user(conn, slots):
|
||||
"""Per-user counts of outbound investor emails over this week / month / year-to-date.
|
||||
"""Per-user counts of outbound **investor** emails over this week / month / year-to-date.
|
||||
MATCHED-ONLY: counts only investor-linked email (an email_investor_links row exists),
|
||||
mirroring query_email_activity / recent_emails — not the user's entire sent corpus.
|
||||
Windows are calendar-based: week = since Monday, month = since the 1st, ytd = since Jan 1."""
|
||||
today = _today()
|
||||
wk = (today - timedelta(days=today.weekday())).isoformat()
|
||||
mo = today.replace(day=1).isoformat()
|
||||
yr = today.replace(month=1, day=1).isoformat()
|
||||
where = "WHERE eam.deleted_at IS NULL AND eam.is_sent = 1"
|
||||
where = ("WHERE eam.deleted_at IS NULL AND eam.is_sent = 1 "
|
||||
"AND EXISTS (SELECT 1 FROM email_investor_links l WHERE l.email_id = e.id)")
|
||||
params = [wk, mo, yr]
|
||||
if slots.get("user"):
|
||||
pat = like_contains(slots["user"])
|
||||
|
||||
@@ -101,6 +101,15 @@ def seed(conn):
|
||||
email("edel", "grant@ten31.xyz", "Grant Smith", 0, "i_beta", "a_grant", 1, deleted=True) # tombstoned
|
||||
email("ej", "jon@ten31.xyz", "Jonathan Lee", 0, "i_acme", "a_jon", 1) # jonathan today
|
||||
email("ein", "alice@acme.com", "Alice Acme", 3, "i_acme", "a_grant", 0) # inbound 3d
|
||||
# an UNMATCHED sent email by Grant (NO email_investor_links row) — captured, but not to a
|
||||
# known investor. The investor-email intents are matched-only, so it must be EXCLUDED from
|
||||
# comms_by_user / email_counts_by_user; without the matched-only filter it would inflate both.
|
||||
c("INSERT INTO emails (id, rfc_message_id, from_email, from_name, sent_at, subject, "
|
||||
"is_matched, match_status) VALUES ('eunm','rfc_eunm','grant@ten31.xyz','Grant Smith',?,"
|
||||
"'Internal: team lunch',0,'unmatched')", (_ago(0),))
|
||||
c("INSERT INTO email_account_messages (id, email_id, account_id, gmail_message_id, "
|
||||
"gmail_thread_id, is_sent, deleted_at) VALUES "
|
||||
"('eam_eunm','eunm','a_grant','g_eunm','t_eunm',1,NULL)")
|
||||
|
||||
# communications (the other recency leg) — Delta has ONLY comms: one live (5d), one tombstoned
|
||||
# (today). If the soft-delete filter broke, Delta would read as contacted today.
|
||||
@@ -187,9 +196,10 @@ def main():
|
||||
r = run("investor_last_contact", {"name": "beta"})
|
||||
check(r["rows"][0]["days_since"] >= 39, "investor_last_contact days_since")
|
||||
check(run("comms_by_user", {"user": "Grant"})["row_count"] == 2,
|
||||
"comms_by_user: grant's 2 live outbound (tombstoned excluded)")
|
||||
"comms_by_user: grant's 2 live MATCHED outbound (tombstoned + unmatched excluded)")
|
||||
r = run("email_counts_by_user", {"user": "grant"})
|
||||
check(r["rows"][0]["this_week"] == 1, "email_counts this_week = 1 live (tombstoned excluded)")
|
||||
check(r["rows"][0]["this_week"] == 1,
|
||||
"email_counts this_week = 1 live matched (tombstoned + unmatched excluded)")
|
||||
check(r["rows"][0]["ytd"] >= 1, "email_counts ytd")
|
||||
|
||||
print("trust boundary")
|
||||
|
||||
Reference in New Issue
Block a user