mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
194 lines
8.7 KiB
Python
194 lines
8.7 KiB
Python
"""Forwarding of inbox discussion questions into the scout steering channel.
|
|
|
|
When someone hits "Discuss" on an inbox report and types a question, the kickoff spins up a fresh
|
|
agentic task whose prompt is the report link plus that question — but nothing about it ever reaches
|
|
the scout that authored the report. Yet those questions often carry exactly the context a scout could
|
|
use next run: a correction ("this is Apple's approval flow, not a bug"), a preference ("we never JOIN
|
|
in batch exports"), or a domain fact the scout couldn't know. This module closes that gap the same way
|
|
`dismissal_notes.py` does for dismissal feedback: it also leaves the question as a `SignalScoutNote`,
|
|
which every run reads by name at cold start (`scout-notes-list`).
|
|
|
|
Unlike a dismissal — one reviewer's verdict that a report wasn't worth surfacing — a discussion
|
|
question is not a judgement on the report at all, just something a user wanted to know. So the note is
|
|
framed as context to weigh, explicitly inviting the scout to fold anything durable into its scratchpad
|
|
or ignore it as noise; the scout's own judgement decides which. It is a derived convenience, never a
|
|
record of truth: the question lives on the discussion task regardless, so forwarding is best-effort and
|
|
never allowed to fail task creation.
|
|
|
|
Authorization is the notes API's, in full. Creating a discussion task needs only `task:write`, while
|
|
the notes table is gated to skill-authoring authorization (scouts read note content verbatim while
|
|
holding privileged sandbox tools), so forwarding re-checks that the caller could have left the note by
|
|
hand: `principal_may_steer_scouts` (the token's `scoped_teams` leg plus canonical-project access and
|
|
the `llm_skill` editor bar) *and* the `signal_scout:write` / `llm_skill:write` key scopes. The
|
|
dismissal path skips that scope leg because dismissal text reaches run context anyway through the
|
|
reports API; a question has no such second path, so this note is its only carrier and a `task:write`
|
|
credential must not open the channel on its own. Forwarding runs against the canonical project whose
|
|
scouts read the row, and only when the task was created on the canonical team itself, so a child
|
|
environment's question never reaches the parent project's readers.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections.abc import Sequence
|
|
|
|
from django.utils import timezone
|
|
|
|
from insights.models import Team, User
|
|
|
|
from products.signals.backend.dismissal_notes import (
|
|
DERIVED_NOTE_TTL,
|
|
principal_may_steer_scouts,
|
|
resolve_report_scout_skill,
|
|
)
|
|
from products.signals.backend.models import SignalReport, SignalScoutNote
|
|
from products.signals.backend.scout_harness.tools.notes import leave_note
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Report titles are unbounded TextFields; a note references them for recognition only.
|
|
_MAX_TITLE_CHARS = 200
|
|
# The question is quoted into a note capped at MAX_NOTE_CONTENT_LENGTH. The Discuss box caps input
|
|
# well below this, so the truncation only bites on a hand-crafted create request — where losing the
|
|
# tail of a question beats losing the whole note to an oversize rejection.
|
|
_MAX_QUESTION_CHARS = 4_000
|
|
|
|
# The scopes the notes API demands of a token writing a note by hand, which this path forwards under.
|
|
_REQUIRED_NOTE_SCOPES = ("signal_scout:write", "llm_skill:write")
|
|
|
|
# The kickoff prompt (frontend `buildDiscussReportPrompt`) prefixes the report URL on its own line,
|
|
# then the user's question after a blank line. We forward just the question; matching is lenient so a
|
|
# format change degrades to forwarding the whole prompt rather than dropping the note.
|
|
_PROMPT_PREFIX = "let's discuss this insights inbox report:"
|
|
|
|
|
|
def forward_discussion_note(
|
|
*,
|
|
team: Team,
|
|
report_id: str,
|
|
text: str,
|
|
user_id: int | None,
|
|
scoped_team_ids: Sequence[int] | None,
|
|
api_scopes: Sequence[str] | None,
|
|
) -> str | None:
|
|
"""Leave a discussion question as a scout note. Returns the created note id, or None if nothing
|
|
was forwarded.
|
|
|
|
`scoped_team_ids` and `api_scopes` describe the calling credential (both None for session auth);
|
|
the caller reads them off the request so this stays independent of HTTP.
|
|
|
|
Best-effort by contract: nothing here may turn a successful task creation into a 5xx — the task
|
|
(and the question on it) already exists, so a failure must not surface. Every step past this point
|
|
runs inside one failure boundary, because authorization and target resolution both read the
|
|
database.
|
|
"""
|
|
if not text or not text.strip():
|
|
return None
|
|
try:
|
|
return _forward(
|
|
team=team,
|
|
report_id=str(report_id),
|
|
text=text,
|
|
user_id=user_id,
|
|
scoped_team_ids=scoped_team_ids,
|
|
api_scopes=api_scopes,
|
|
)
|
|
except Exception:
|
|
logger.exception(
|
|
"Failed to forward a discussion question to a scout note",
|
|
extra={"team_id": team.id, "report_id": report_id},
|
|
)
|
|
return None
|
|
|
|
|
|
def _forward(
|
|
*,
|
|
team: Team,
|
|
report_id: str,
|
|
text: str,
|
|
user_id: int | None,
|
|
scoped_team_ids: Sequence[int] | None,
|
|
api_scopes: Sequence[str] | None,
|
|
) -> str | None:
|
|
question = _extract_question(text)
|
|
if not question:
|
|
return None
|
|
|
|
# Scout rows and their notes live under the canonical parent team; forwarding a child
|
|
# environment's question would hand its report id, title, and text to an audience that may
|
|
# have no access to that environment. Those discussions stay on the task only.
|
|
canonical_team = team.parent_team or team
|
|
if team.id != canonical_team.id:
|
|
return None
|
|
|
|
if not _may_write_note(
|
|
user_id=user_id, scoped_team_ids=scoped_team_ids, api_scopes=api_scopes, team=canonical_team
|
|
):
|
|
return None
|
|
|
|
report = SignalReport.objects.filter(team_id=canonical_team.id, id=report_id).first()
|
|
if report is None:
|
|
return None
|
|
|
|
skill_name = resolve_report_scout_skill(canonical_team.id, report_id)
|
|
content = _build_note_content(report=report, question=question)
|
|
created = leave_note(
|
|
team_id=canonical_team.id,
|
|
content=content,
|
|
skill_name=skill_name,
|
|
created_by_id=user_id,
|
|
expires_at=timezone.now() + DERIVED_NOTE_TTL,
|
|
origin=SignalScoutNote.Origin.REPORT_DISCUSSION,
|
|
)
|
|
return created.id
|
|
|
|
|
|
def _may_write_note(
|
|
*, user_id: int | None, scoped_team_ids: Sequence[int] | None, api_scopes: Sequence[str] | None, team: Team
|
|
) -> bool:
|
|
"""Whether this caller could have left the same note by hand through the notes API."""
|
|
if api_scopes is not None and not _scopes_allow_note_write(api_scopes):
|
|
# A token that can create the discussion task but not write a note. Its question stays on
|
|
# the task, which is where the user asked it; it just doesn't enter the steering channel.
|
|
return False
|
|
|
|
user = User.objects.filter(pk=user_id).first() if user_id else None
|
|
if user is None:
|
|
return False
|
|
return principal_may_steer_scouts(user=user, scoped_team_ids=scoped_team_ids, canonical_team=team)
|
|
|
|
|
|
def _scopes_allow_note_write(api_scopes: Sequence[str]) -> bool:
|
|
scopes = set(api_scopes)
|
|
return "*" in scopes or scopes.issuperset(_REQUIRED_NOTE_SCOPES)
|
|
|
|
|
|
def _extract_question(text: str) -> str:
|
|
"""Pull the user's question out of the kickoff prompt, tolerating format drift.
|
|
|
|
The prompt's first line is the report link; what the user typed follows it. Text that doesn't
|
|
carry the prefix is forwarded whole, so a frontend format change costs a noisier note rather than
|
|
a dropped one — but a prompt that is only the prefix has no question in it, and forwarding the
|
|
link line as if it were one would put pure noise in the steering channel.
|
|
"""
|
|
stripped = text.strip()
|
|
if not stripped.lower().startswith(_PROMPT_PREFIX):
|
|
return stripped
|
|
_, _, question = stripped.partition("\n")
|
|
return question.strip()
|
|
|
|
|
|
def _build_note_content(*, report: SignalReport, question: str) -> str:
|
|
quoted = "\n".join(f"> {line}" for line in question[:_MAX_QUESTION_CHARS].splitlines())
|
|
title = (report.title or "").strip()
|
|
title_clause = f' ("{title[:_MAX_TITLE_CHARS]}")' if title else ""
|
|
return f"""Inbox activity: someone opened a discussion on report `{report.id}`{title_clause} and asked:
|
|
|
|
{quoted}
|
|
|
|
This is a question a user asked about a report you may have authored — context, not a directive, and
|
|
not a verdict on the report. If it points at a preference, a correction, or context that would help you
|
|
investigate better next time, weigh it and fold anything durable into your scratchpad (this note
|
|
expires). If it's a one-off question or unrelated chatter, it's fine to ignore. `inbox-reports-retrieve`
|
|
on the report id has the full report."""
|