Files
openswarm/backend/apps/onboarding/prep.py
T

297 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Turn the local scan + app picks into a personalized greeting and starters.
One cheap aux call on whatever lane the user just connected; every failure path
returns the static fallback so the reveal can never be an error card.
"""
import json
import re
from typing import List, Optional
from typeguard import typechecked
from backend.apps.agents.core.aux_llm import aux_max_tokens_for, safe_resp_text
from backend.apps.onboarding.models import PrepRequest, PrepResponse
from backend.apps.settings.models import AppSettings, PersonalizedAutomation, PersonalizedStarter
VALID_CADENCE = {"daily", "weekday", "weekly"}
FALLBACK_STARTERS: List[PersonalizedStarter] = [
PersonalizedStarter(title="Clean up Downloads", prompt="Sort my Downloads folder into tidy subfolders. Show me the plan before moving anything."),
PersonalizedStarter(title="Research something", prompt="Research the best noise-cancelling headphones under $300 and give me a comparison table."),
PersonalizedStarter(title="Build a tiny app", prompt="Build me a simple habit tracker app I can use right now."),
PersonalizedStarter(title="Plan a trip", prompt="Plan a 3-day weekend trip itinerary and turn it into a printable page."),
]
P_SYSTEM = (
"You write first-run starter tasks for OpenSwarm, a desktop AI agent platform that can "
"organize local files, browse the web in a real browser, build small apps, and run agents in parallel. "
"Given facts about the user's machine and the apps they picked, respond with STRICT JSON only: "
'{"greeting": string, "starters": [{"title": string, "prompt": string, "reason": string}], "app_title": string, "app_prompt": string, "app_reason": string, "research_title": string, "research_prompt": string, "research_reason": string, "automations": [{"title": string, "prompt": string, "cadence": "daily"|"weekday"|"weekly"}]}. '
"First, silently infer a short, confident profile of this user: who they are and what they are working on. "
"If usage_summary is present it is the STRONGEST signal (it is what they actually ask their AI about and facts "
"their AI remembers about them); weight it above everything else, then signal_apps (the high-signal tools they "
"have installed, like an IDE, a design app, or a DAW: these reveal their craft), then folders, plan tier, email "
"domain. Tune every task and the personal app to that profile; do not output the profile. "
"Exactly 4 starters. Each title is 2-5 words that PLAINLY say what the task does (like 'Sort my "
"Downloads' or 'Compare headphones'), never clever, punny, or brand-style. Each prompt is a concrete, safe, immediately runnable task "
"referencing the user's real folders, file counts, or picked apps when possible; never invent facts, never "
"propose deleting anything without review. The FIRST starter must be an audit sized for parallel sub-work "
"(inspect folders, cross-reference, produce one report); it may create ONE new report file but must never "
"modify or delete existing files, because it may be run automatically on the user's behalf. Every starter "
"must produce a tangible result the user can see (a sorted "
"folder, a report, a working page); never propose setup, documentation of preferences, or planning-only tasks. "
"Each starter's 'reason' is ONE short standalone clause (max 12 words, no leading 'because') naming the SPECIFIC "
"real thing you observed (a folder, a file count, a picked app, a usage fact) that makes this task useful for THIS "
"user; it must be grounded in the input facts, never invented, and read like a person pointing at what they saw. "
"Design ONE personal app and make it the CENTERPIECE: an interactive single-page dashboard that mirrors THIS "
"user's world, drawn from usage_summary and their files. It surfaces the real projects they are juggling, the "
"threads or questions they keep returning to, and what they have been focused on lately, laid out as a clean "
"visual dashboard they will want to keep open (cards, lists, a simple chart), seeded with their REAL specifics so "
"it is unmistakably about them, not a generic template. app_title is 2-4 words that PLAINLY say what it is (like "
"'Your Work Dashboard' or 'Project Hub'), never clever or punny; app_prompt starts with 'Build me' and names the "
"actual sections to include using the user's real projects and topics; self-contained, no accounts or API keys. "
"app_reason follows the same one-clause grounded-observation rule as a starter reason. "
"Also pick the SINGLE topic this user most repeatedly asks their AI about (from usage_summary; if it is thin, use "
"their strongest work signal from signal_apps or folders) and turn it into a live web-research task. research_title "
"is 2-4 words plainly naming the topic (like 'App Store Fees' or 'Best Vector DBs'), never clever or punny. "
"research_prompt is one instruction telling the agent to search the web RIGHT NOW and produce a tight, useful, "
"current answer or comparison of THAT topic for this user (an actual answer, never a plan); it must demand "
"THIS-YEAR information with publication dates on sources, so the answer cannot quietly be stale training data. "
"research_reason follows the one-clause grounded-observation rule and names the specific recurring question you saw. "
"Also propose 2-3 automations: recurring routines worth running on a schedule for THIS user, drawn from their "
"profile and habits (for example a daily morning brief, a weekly folder cleanup, a weekday summary of their "
"connected apps). Each automation title is 2-4 words that plainly name the routine (like 'Downloads Cleanup' or "
"'Morning Brief'), cadence is exactly 'daily', 'weekday', or 'weekly'. The prompt is the COMPLETE instruction an "
"agent executes alone on each scheduled run with NO human present: it must produce its result in one pass, never "
"ask questions, never wait for input, and never set up schedules or reminders (the schedule already exists; "
"writing 'remind me' or 'set up a log' is wrong, 'summarize X into a file' is right). "
"Automations must be safe to run unattended (never delete without review). "
"The greeting is one or two warm sentences: first say out loud, specifically and confidently, what this person is "
"into or working on (grounded in usage_summary, signal_apps, and folders, for example 'Looks like you live in "
"Xcode and ship iOS apps'), then name 2-3 concrete things you actually saw. Be specific, never generic, and never "
"name boring system apps. Never use em-dashes or en-dashes anywhere. No markdown, no commentary, JSON only."
)
P_CURLY_QUOTES = {"“": '"', "”": '"', "": "'", "": "'"}
@typechecked
def p_normalize_json_text(text: str) -> str:
for bad, good in P_CURLY_QUOTES.items():
text = text.replace(bad, good)
return text
@typechecked
def p_strip_trailing_commas(s: str) -> str:
return re.sub(r",(\s*[}\]])", r"\1", s)
@typechecked
def p_strip_dashes(s: str) -> str:
"""The house style bans em/en dashes and the model slips them into the greeting anyway, so
guarantee it in code: turn a dash-clause into a comma-clause, then tidy any doubled punctuation."""
s = s.replace(" — ", ", ").replace("—", ", ").replace(" ", ", ").replace("", ", ")
s = re.sub(r"\s+([,.;:])", r"\1", s)
s = re.sub(r",\s*,", ", ", s)
s = re.sub(r"\s{2,}", " ", s)
return s.strip()
@typechecked
def p_load_object(text: str) -> dict:
"""Best-effort load of the outermost JSON object: strict first, then a
trailing-comma repair. Returns {} if neither parses (salvage handles the rest)."""
match = re.search(r"\{.*\}", text, re.DOTALL)
if not match:
return {}
for candidate in (match.group(0), p_strip_trailing_commas(match.group(0))):
try:
parsed = json.loads(candidate)
if isinstance(parsed, dict):
return parsed
except Exception:
continue
return {}
@typechecked
def p_salvage_flat_objects(text: str) -> List[dict]:
"""Pull every complete flat {..} object out of a truncated/malformed blob so a
cut-off response still yields the starters it did finish (partial > generic)."""
out: List[dict] = []
for m in re.finditer(r"\{[^{}]*\}", text):
try:
obj = json.loads(p_strip_trailing_commas(m.group(0)))
except Exception:
continue
if isinstance(obj, dict):
out.append(obj)
return out
@typechecked
def p_build_starters(rows: List[dict]) -> List[PersonalizedStarter]:
return [
PersonalizedStarter(title=p_strip_dashes(str(s.get("title", ""))), prompt=p_strip_dashes(str(s.get("prompt", ""))), reason=p_strip_dashes(str(s.get("reason", ""))))
for s in rows
if isinstance(s, dict) and str(s.get("title", "")).strip() and str(s.get("prompt", "")).strip() and "cadence" not in s
]
@typechecked
def p_extract_string_field(text: str, name: str) -> str:
"""Pull a top-level "name": "value" string straight out of the raw blob, for the fields that
aren't objects (greeting, app_*) so they survive when the strict JSON load failed and we salvage."""
m = re.search(rf'"{name}"\s*:\s*"((?:[^"\\]|\\.)*)"', text)
return m.group(1).strip() if m else ""
@typechecked
def p_build_automations(rows: List[dict]) -> List[PersonalizedAutomation]:
return [
PersonalizedAutomation(
title=p_strip_dashes(str(a.get("title", ""))),
prompt=p_strip_dashes(str(a.get("prompt", ""))),
cadence=(str(a.get("cadence", "weekly")).strip().lower() if str(a.get("cadence", "")).strip().lower() in VALID_CADENCE else "weekly"),
)
for a in rows
if isinstance(a, dict) and str(a.get("title", "")).strip() and str(a.get("prompt", "")).strip()
]
@typechecked
def parse_prep(text: str) -> Optional[PrepResponse]:
text = p_normalize_json_text(text)
data = p_load_object(text)
starters = p_build_starters(data.get("starters") if isinstance(data.get("starters"), list) else [])
automations = p_build_automations(data.get("automations") if isinstance(data.get("automations"), list) else [])
greeting = str(data.get("greeting", "")).strip()
app_title = str(data.get("app_title", "")).strip()
app_prompt = str(data.get("app_prompt", "")).strip()
app_reason = str(data.get("app_reason", "")).strip()
research_title = str(data.get("research_title", "")).strip()
research_prompt = str(data.get("research_prompt", "")).strip()
research_reason = str(data.get("research_reason", "")).strip()
# Truncation / trailing comma / smart quotes broke the strict load: salvage the complete pieces
# rather than throwing the whole personalized reveal away for one bad character.
if not starters or not automations:
objs = p_salvage_flat_objects(text)
if not starters:
starters = p_build_starters([o for o in objs if "cadence" not in o])
if not automations:
automations = p_build_automations([o for o in objs if "cadence" in o])
# Top-level string fields don't live in the flat objects above, so recover them by name when the
# strict load dropped them (a malformed response was still yielding starters but a blank app).
if not greeting:
greeting = p_extract_string_field(text, "greeting")
if not app_title:
app_title = p_extract_string_field(text, "app_title")
if not app_prompt:
app_prompt = p_extract_string_field(text, "app_prompt")
if not app_reason:
app_reason = p_extract_string_field(text, "app_reason")
if not research_title:
research_title = p_extract_string_field(text, "research_title")
if not research_prompt:
research_prompt = p_extract_string_field(text, "research_prompt")
if not research_reason:
research_reason = p_extract_string_field(text, "research_reason")
if not starters:
return None
return PrepResponse(
greeting=p_strip_dashes(greeting),
starters=starters[:4],
app_title=p_strip_dashes(app_title),
app_prompt=p_strip_dashes(app_prompt),
app_reason=p_strip_dashes(app_reason),
research_title=p_strip_dashes(research_title),
research_prompt=p_strip_dashes(research_prompt),
research_reason=p_strip_dashes(research_reason),
automations=automations[:3],
)
@typechecked
def p_scan_grounded_fallback(request: PrepRequest) -> PrepResponse:
"""When the aux call can't be made (a sole gemini/codex lane returns empty on 0.3.60, provider
down, no anthropic-reachable model), still ground the reveal in the REAL scan via a template so a
cross-provider user gets their-Mac-specific starters, not generic stubs. No LLM, so it never fails."""
scan = request.scan
if scan is None:
return PrepResponse(greeting="", starters=list(FALLBACK_STARTERS))
downloads = next((f for f in scan.folders if f.name == "Downloads" and f.entry_count > 0), None)
apps = scan.signal_apps[:3]
bits: List[str] = []
if downloads:
bits.append(f"{downloads.entry_count} files in Downloads")
if apps:
bits.append(", ".join(apps))
greeting = f"I took a look around your Mac: {'; '.join(bits)}. Here is where I would start." if bits else ""
starters: List[PersonalizedStarter] = []
if downloads:
starters.append(PersonalizedStarter(
title="Audit Downloads",
prompt=f"Scan my Downloads folder ({downloads.entry_count} files) and produce one report grouping files by type with cleanup suggestions. Do not move or delete anything; write only the report.",
reason=f"Downloads has {downloads.entry_count} files worth sorting.",
))
for s in FALLBACK_STARTERS:
if len(starters) >= 4:
break
if all(s.title != existing.title for existing in starters):
starters.append(s)
# Ground the research card on their top tool so the "looked into this" card still appears cross-provider.
research_title = ""
research_prompt = ""
research_reason = ""
if apps:
research_title = f"{apps[0]} Tips"
research_prompt = f"Search the web right now for the most useful current tips, shortcuts, and workflows for {apps[0]}, and give me a tight summary with sources."
research_reason = f"You have {apps[0]} installed and use it a lot."
return PrepResponse(
greeting=greeting,
starters=starters[:4],
research_title=research_title,
research_prompt=research_prompt,
research_reason=research_reason,
)
@typechecked
async def build_prep(settings: AppSettings, request: PrepRequest) -> PrepResponse:
from datetime import date
facts = request.model_dump()
# The aux otherwise assumes its training-cutoff year and writes stale ranges like "2024-2025"
# into research prompts; telling it today's date keeps "current" meaning current.
facts["today"] = date.today().isoformat()
try:
from backend.apps.agents.providers.registry import resolve_aux_model
from backend.apps.settings.credentials import get_anthropic_client_for_model
aux_model, _ = await resolve_aux_model(settings, preferred_tier="haiku")
client = get_anthropic_client_for_model(settings, aux_model)
resp = await client.messages.create(
model=aux_model,
# The full shape (greeting + 4 starters w/ prompts + app + 3 automations) runs ~1.5-2K
# tokens for a rich user; 1100 truncated the JSON mid-object so parse silently fell back.
max_tokens=aux_max_tokens_for(aux_model, base=2200),
system=P_SYSTEM,
messages=[{"role": "user", "content": json.dumps(facts)}],
# Bound the wait: the SDK default is ~10min, so a wedged router/provider would hang the
# auto-launch (which awaits this) instead of degrading to the static starters.
timeout=45.0,
)
parsed = parse_prep(safe_resp_text(resp))
if parsed is not None:
return parsed
except Exception:
pass
# Aux unusable (empty gemini/codex response on 0.3.60, provider down, no anthropic lane): still
# ground the reveal in the real scan rather than shipping generic stubs.
return p_scan_grounded_fallback(request)