mirror of
https://github.com/hanzoai/insights.git
synced 2026-11-03 00:52:32 +00:00
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.
68 lines
2.6 KiB
Python
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
|