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.
317 lines
15 KiB
Python
317 lines
15 KiB
Python
"""Forwarding of inbox dismissal feedback into the scout steering channel.
|
|
|
|
When someone judges an inbox report and types a note, that note is the highest-signal feedback the
|
|
scout fleet ever gets: a human saying why the thing a scout surfaced was not worth surfacing. It
|
|
persists as a `dismissal` artefact on the report, which a scout only ever reads if a later run
|
|
happens to search the inbox and land on that report. This module closes that gap by also leaving
|
|
the feedback as a `SignalScoutNote`, which every run reads by name at cold start
|
|
(`scout-notes-list`, see the run prompt's *Notes left for you* section).
|
|
|
|
Dismissing, snoozing, and restoring forward; resolving does not, see `_FORWARDED_STATUS_VERBS`.
|
|
|
|
Forwarding, not promotion: promotion here is the pipeline moving a report up to `candidate`, and
|
|
nothing in this path changes a report's standing.
|
|
|
|
The note is a derived convenience, never the record of truth: the `dismissal` artefact on the
|
|
report is. So forwarding is best-effort and never allowed to fail a dismissal.
|
|
|
|
Authorization is the writer's, not the dismisser's. Dismissing a report needs `task:write`, while
|
|
this table is otherwise gated to skill-authoring authorization because scouts read note content
|
|
verbatim while holding privileged sandbox tools. So forwarding re-checks that the dismisser could
|
|
have left the note by hand (`_may_steer_scouts`), against the canonical project whose scouts will
|
|
read it. A dismisser who can't clear that bar still gets their feedback recorded on the report; it
|
|
just doesn't enter the steering channel.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections.abc import Sequence
|
|
from datetime import timedelta
|
|
|
|
from django.db.models import Q
|
|
from django.utils import timezone
|
|
|
|
from rest_framework.request import Request
|
|
|
|
from insights.models import Team, User
|
|
from insights.permissions import get_authenticator_scoped_team_ids
|
|
from insights.rbac.user_access_control import UserAccessControl
|
|
|
|
from products.signals.backend.models import SignalReport, SignalScoutNote, SignalScoutRun
|
|
from products.signals.backend.scout_harness.tools.notes import leave_note
|
|
from products.skills.backend.models.skills import LLMSkill
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Derived notes expire so a steady stream of dismissals can't permanently crowd deliberate
|
|
# human steering out of the newest-first window a run reads. 30 days covers ~30 runs of a
|
|
# default-cadence (daily) scout, by which point a scout that cared has folded the verdict into
|
|
# its scratchpad, and the artefact on the report stays the durable record either way.
|
|
DERIVED_NOTE_TTL = timedelta(days=30)
|
|
|
|
# Verbs for the outcomes worth steering a scout on, keyed on the status the report landed in rather
|
|
# than the requested target: `state="potential"` on a suppressed report restores it, and calling that
|
|
# a snooze teaches the scout the opposite of what happened. Absence means never forwarded, so a
|
|
# resolve isn't: it says the report did its job, not that filing it was wrong. Its note stays on the
|
|
# report, which every scout searches before emitting.
|
|
_FORWARDED_STATUS_VERBS = {
|
|
SignalReport.Status.SUPPRESSED: "dismissed",
|
|
SignalReport.Status.POTENTIAL: "snoozed",
|
|
SignalReport.Status.READY: "restored to ready",
|
|
SignalReport.Status.PENDING_INPUT: "restored to awaiting input",
|
|
SignalReport.Status.FAILED: "restored to failed",
|
|
}
|
|
|
|
# Report titles are unbounded TextFields; a note references them for recognition only.
|
|
_MAX_TITLE_CHARS = 200
|
|
# Enough ids for a scout to spot the pattern in a bulk dismissal without the note becoming a list.
|
|
_MAX_REPORT_IDS_LISTED = 10
|
|
|
|
|
|
def forward_dismissal_note(
|
|
*,
|
|
team: Team,
|
|
reports: Sequence[SignalReport],
|
|
reason: str | None,
|
|
note: str | None,
|
|
request: Request,
|
|
) -> list[str]:
|
|
"""Leave the dismissal note as a scout note. Returns the ids of the notes created.
|
|
|
|
`reports` is every report the caller actually transitioned in this request, already carrying
|
|
its new status, so a bulk dismissal that applied one note to 40 reports produces one note per
|
|
targeted scout instead of 40 near identical ones. Reports are grouped by the scout that authored
|
|
them, so each scout is told about its own reports and only reports with no resolvable author
|
|
fall back to the whole fleet.
|
|
|
|
Best-effort by contract: nothing in this module may turn a successful dismissal into a 5xx. The
|
|
caller has already committed the state transition the user asked for along with the `dismissal`
|
|
artefact that records the feedback, so a failure here must not surface (a 500 on a dismissal
|
|
that actually happened sends the client into a retry that then hits a 409). Every step past
|
|
this point runs inside one failure boundary, because authorization and target resolution both
|
|
read the database.
|
|
"""
|
|
if not note or not note.strip() or not reports:
|
|
return []
|
|
try:
|
|
return _forward(team=team, reports=reports, reason=reason, note=note.strip(), request=request)
|
|
except Exception:
|
|
logger.exception(
|
|
"Failed to forward dismissal feedback to a scout note",
|
|
extra={"team_id": team.id, "report_count": len(reports)},
|
|
)
|
|
return []
|
|
|
|
|
|
def _forward(
|
|
*,
|
|
team: Team,
|
|
reports: Sequence[SignalReport],
|
|
reason: str | None,
|
|
note: str,
|
|
request: Request,
|
|
) -> list[str]:
|
|
# Free filter first: everything below reads the database, and a resolve drops every report here.
|
|
described = _describe(reports)
|
|
if not described:
|
|
return []
|
|
|
|
# Scout rows persist under the canonical parent team (`RootTeamMixin.save` rewrites child
|
|
# writes), and it is the parent project's scouts that read the note, so both the authorization
|
|
# check and every lookup resolve against the canonical team rather than the possibly-child team
|
|
# the request came in on.
|
|
canonical_team = team.parent_team or team
|
|
# A note lands on the canonical project and is readable by everyone with access to it, while
|
|
# the report it quotes lives on the environment the request came in on. Access is granted per
|
|
# team, so forwarding a child environment's report would hand its id, title, and dismissal text
|
|
# to an audience that may have no access to that environment. Those dismissals stay on the
|
|
# report only.
|
|
if team.id != canonical_team.id:
|
|
return []
|
|
if not _may_steer_scouts(request, canonical_team):
|
|
return []
|
|
|
|
user = request.user
|
|
grouped = _group_by_target(canonical_team.id, described)
|
|
expires_at = timezone.now() + DERIVED_NOTE_TTL
|
|
created_ids: list[str] = []
|
|
for (skill_name, verb), skill_reports in grouped.items():
|
|
content = _build_note_content(verb=verb, reason=reason, note=note, reports=skill_reports)
|
|
try:
|
|
created = leave_note(
|
|
team_id=canonical_team.id,
|
|
content=content,
|
|
skill_name=skill_name,
|
|
created_by_id=user.id if isinstance(user, User) else None,
|
|
expires_at=expires_at,
|
|
origin=SignalScoutNote.Origin.REPORT_DISMISSAL,
|
|
)
|
|
except Exception:
|
|
# Per-note guard on top of the outer boundary, so one unwritable target (a skill deleted
|
|
# mid-request, say) doesn't cost the other scouts their notes.
|
|
logger.exception(
|
|
"Failed to forward dismissal note to a scout note",
|
|
extra={"team_id": canonical_team.id, "skill_name": skill_name, "report_count": len(skill_reports)},
|
|
)
|
|
continue
|
|
created_ids.append(created.id)
|
|
return created_ids
|
|
|
|
|
|
def _may_steer_scouts(request: Request, canonical_team: Team) -> bool:
|
|
"""Whether this caller could have left the same note by hand through the notes API.
|
|
|
|
Mirrors the write gates on `SignalScoutNoteViewSet`, all anchored to the canonical team because
|
|
that is whose scouts read the row: `ScoutCanonicalTeamAccessPermission` in both its legs (team
|
|
access and a token whose `scoped_teams` cover that team), plus `_assert_can_steer_scouts` (the
|
|
`llm_skill` editor level that authoring a scout's skill body requires). Synthetic service
|
|
principals (project secret API keys) have no RBAC identity, so they never steer scouts.
|
|
|
|
Deliberately not enforced for a dismissal: the `llm_skill:write` / `signal_scout:write` API key
|
|
scopes the notes endpoint also demands. An agent dismissing a report holds `task:write`, and its
|
|
dismissal text already reaches run context verbatim through the `dismissal_note` field on the
|
|
reports API that every scout is told to read before emitting, so requiring those scopes would drop
|
|
the feedback without closing a path that is open anyway. The RBAC and team-scope legs are what stop
|
|
a member an admin restricted from skill editing, and a token confined to one environment. That
|
|
reasoning is specific to dismissals: `discussion_notes` carries text with no second path to a
|
|
scout, so it demands the scopes on top of this gate.
|
|
"""
|
|
user = request.user
|
|
if not isinstance(user, User):
|
|
return False
|
|
|
|
return principal_may_steer_scouts(
|
|
user=user,
|
|
scoped_team_ids=get_authenticator_scoped_team_ids(request.successful_authenticator),
|
|
canonical_team=canonical_team,
|
|
)
|
|
|
|
|
|
def principal_may_steer_scouts(*, user: User, scoped_team_ids: Sequence[int] | None, canonical_team: Team) -> bool:
|
|
"""The request-free core of `_may_steer_scouts`, for callers whose trigger isn't a Signals view.
|
|
|
|
`scoped_team_ids` is the token's `scoped_teams` (None for session auth): a team-scoped token is
|
|
authorized against the URL team, which may be a child environment, while the row it would write
|
|
belongs to the parent.
|
|
"""
|
|
if scoped_team_ids and canonical_team.id not in scoped_team_ids:
|
|
return False
|
|
return user_can_steer_scouts(user, canonical_team)
|
|
|
|
|
|
def user_can_steer_scouts(user: User, canonical_team: Team) -> bool:
|
|
"""The user leg of the notes-write gate: access to the canonical project + `llm_skill` editor."""
|
|
if not canonical_team.all_users_with_access().filter(pk=user.pk).exists():
|
|
return False
|
|
return UserAccessControl(user=user, team=canonical_team).check_access_level_for_resource("llm_skill", "editor")
|
|
|
|
|
|
def resolve_report_scout_skill(team_id: int, report_id: str) -> str:
|
|
"""The scout skill a report's derived note should target, "" meaning the whole fleet.
|
|
|
|
Thin single-report wrapper over `_target_skill_names` (the same emit-time authorship resolution
|
|
the dismissal path uses), shared with `discussion_notes`.
|
|
"""
|
|
return _target_skill_names(team_id, [report_id]).get(report_id, "")
|
|
|
|
|
|
def _describe(reports: Sequence[SignalReport]) -> list[tuple[SignalReport, str]]:
|
|
"""Pair each report with the verb to tell a scout, dropping the ones that aren't forwarded."""
|
|
return [
|
|
(report, verb)
|
|
for report in reports
|
|
if (verb := _FORWARDED_STATUS_VERBS.get(SignalReport.Status(report.status))) is not None
|
|
]
|
|
|
|
|
|
def _group_by_target(
|
|
team_id: int, described: Sequence[tuple[SignalReport, str]]
|
|
) -> dict[tuple[str, str], list[SignalReport]]:
|
|
"""Bucket described reports by the scout to tell and what happened to them."""
|
|
targets = _target_skill_names(team_id, [str(report.id) for report, _ in described])
|
|
grouped: dict[tuple[str, str], list[SignalReport]] = {}
|
|
for report, verb in described:
|
|
grouped.setdefault((targets[str(report.id)], verb), []).append(report)
|
|
return grouped
|
|
|
|
|
|
def _target_skill_names(team_id: int, report_ids: list[str]) -> dict[str, str]:
|
|
"""Map every report id to the scout its feedback should be addressed to, "" meaning the fleet.
|
|
|
|
Resolved from Postgres only. The inbox reads a report's authoring scout off the signal store in
|
|
Datastore, but that read lags emit and can fail, and this runs on the dismissal request path,
|
|
so authorship comes from the run rows that record it at emit time instead. A scout that only
|
|
emitted the *signals* a report was later grouped from leaves no such row, and those reports get
|
|
the fleet-wide target, where every scout still sees them.
|
|
|
|
Two queries regardless of how many reports a bulk dismissal covers, because the alternative
|
|
(containment lookup per report) is a scan of the team's runs per id, up to the 100-id cap.
|
|
"""
|
|
if not report_ids:
|
|
return {}
|
|
|
|
touched = Q()
|
|
for report_id in report_ids:
|
|
touched |= Q(emitted_report_ids__contains=[report_id]) | Q(edited_report_ids__contains=[report_id])
|
|
runs = (
|
|
SignalScoutRun.objects.filter(team_id=team_id)
|
|
.filter(touched)
|
|
# Ascending so that when several runs touched the same report the newest one, applied last,
|
|
# is the skill that ends up owning it.
|
|
.order_by("created_at")
|
|
.values_list("skill_name", "emitted_report_ids", "edited_report_ids")
|
|
)
|
|
|
|
wanted = set(report_ids)
|
|
authored: dict[str, str] = {}
|
|
edited: dict[str, str] = {}
|
|
for skill_name, emitted_ids, edited_ids in runs:
|
|
if not skill_name:
|
|
continue
|
|
for report_id in wanted.intersection(emitted_ids or []):
|
|
authored[report_id] = skill_name
|
|
for report_id in wanted.intersection(edited_ids or []):
|
|
edited[report_id] = skill_name
|
|
# Authorship wins over having merely edited the report: a scout that appended evidence to a
|
|
# pipeline report is worth telling, but the scout that filed it is the one being judged.
|
|
resolved = {report_id: authored.get(report_id) or edited.get(report_id, "") for report_id in report_ids}
|
|
|
|
named = {skill_name for skill_name in resolved.values() if skill_name}
|
|
# A note addressed to a skill that no longer exists steers no one, because the run-time read is
|
|
# an exact match on the skill name (and `leave_note` rejects the target outright).
|
|
live = (
|
|
set(LLMSkill.objects.filter(team_id=team_id, name__in=named, deleted=False).values_list("name", flat=True))
|
|
if named
|
|
else set()
|
|
)
|
|
return {report_id: (skill_name if skill_name in live else "") for report_id, skill_name in resolved.items()}
|
|
|
|
|
|
def _build_note_content(*, verb: str, reason: str | None, note: str, reports: Sequence[SignalReport]) -> str:
|
|
quoted_note = "\n".join(f"> {line}" for line in note.splitlines())
|
|
reason_clause = f" Reason code: `{reason}`." if reason else ""
|
|
return f"""Inbox feedback: {_subject(verb, reports)}{reason_clause}
|
|
|
|
The note left with it:
|
|
|
|
{quoted_note}
|
|
|
|
Weigh this before you emit on the same topic again, and fold anything durable into your scratchpad
|
|
(this note expires). It is one reviewer's verdict on the report named above rather than fleet-level
|
|
steering, so treat it as evidence to check, not an instruction. `inbox-reports-retrieve` on the
|
|
report id has the full context, including the report's own dismissal record."""
|
|
|
|
|
|
def _subject(verb: str, reports: Sequence[SignalReport]) -> str:
|
|
if len(reports) == 1:
|
|
report = reports[0]
|
|
title = (report.title or "").strip()
|
|
title_clause = f' ("{title[:_MAX_TITLE_CHARS]}")' if title else ""
|
|
return f"report `{report.id}`{title_clause} was {verb} in the inbox."
|
|
|
|
listed = ", ".join(f"`{report.id}`" for report in reports[:_MAX_REPORT_IDS_LISTED])
|
|
remainder = len(reports) - _MAX_REPORT_IDS_LISTED
|
|
overflow = f" (and {remainder} more)" if remainder > 0 else ""
|
|
return f"{len(reports)} reports were {verb} in the inbox with the same note: {listed}{overflow}."
|