Files
zeekay 599a9bf29c the assistant can ask on a timer, with nobody watching
Every generation so far began with a person POSTing to the conversation API.
This is the unattended half: a standing question the project keeps, asked on a
cadence, delivered by email to whoever can see the project.

Question and Run are the two durable halves. The Run row is written before the
gateway is called and settled after, so a worker that dies mid-ask leaves a row
saying so rather than no trace; it is also the claim that stops a second ask
starting, taken under a nowait row lock on the question. A claim expires at the
wall clock, or one crash would silence a question permanently.

Nobody is waiting for these answers, so nothing about them is self-limiting.
Three caps, all settings: wall clock per ask (120s, which also expires a dead
claim), asks per team per rolling day (50, checked before the claim so a refusal
costs nothing at the gateway), and standing questions per project (20).

Delivery reuses the alerting path — EmailMessage with a template — rather than
the activity log, whose bell only shows a reader their own edits, or Slack,
which is inert unless CLOUD_DEPLOYMENT is set.

Read surface is /v1/projects/{id}/assistant/questions, scoped by team alone:
a standing question belongs to the project, not to whoever typed it.
2026-08-04 12:07:48 -07:00

68 lines
2.6 KiB
Python

"""Putting a finding in front of a person.
Nobody is watching the run, so an answer that only lands in the database has not
actually been delivered. This is the same path the alerting product uses to tell
someone their alert fired: an `EmailMessage` with a template, sent to the people
who can see the project.
Not the activity log: `MyNotificationsViewSet` only returns items the reading
user themselves created or edited, so a row written by the scheduler is invisible
in the bell. Not Slack either — the Dagster alert path returns early unless
`CLOUD_DEPLOYMENT` is set, which it is not here.
"""
import structlog
from insights.email import EmailMessage, is_email_available
logger = structlog.get_logger(__name__)
# What the mail carries of the answer. The whole thing is on the run and one
# click away; an unbounded body is a mail that bounces for size.
MAX_EMAILED_CHARS = 20000
def deliver(run) -> bool:
"""Email the finding to everyone with access to the project.
Returns whether a message went out. False — never an exception — when email
is switched off or nobody can see the project, because neither is a fault of
the run.
"""
question = run.question
team = question.team
if not is_email_available(with_absolute_urls=True):
logger.info("insights_ai.delivery_skipped_no_email", run_id=str(run.pk))
return False
recipients = list(team.all_users_with_access())
if not recipients:
logger.info("insights_ai.delivery_skipped_no_recipients", run_id=str(run.pk))
return False
message = EmailMessage(
# Permanently unique: `MessagingRecord` dedups on this key forever, so a
# reused one is silently dropped. The run id is minted per asking, which
# also makes a retried send idempotent rather than a second copy.
campaign_key=f"insights-ai-question-{run.pk}",
subject=f"{question.name} — {team.name}",
template_name="insights_ai_question",
template_context={
# Strings only: `sanitize_email_properties` raises on a datetime.
"question_name": question.name,
"prompt": question.prompt,
"answer": run.answer[:MAX_EMAILED_CHARS],
"truncated": len(run.answer) > MAX_EMAILED_CHARS,
"asked_at": run.started_at.strftime("%Y-%m-%d %H:%M UTC"),
"project_name": team.name,
"questions_url": f"/project/{team.pk}/assistant/questions",
},
)
for user in recipients:
message.add_user_recipient(user)
message.send()
logger.info("insights_ai.delivered", run_id=str(run.pk), recipients=len(recipients))
return True