mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-29 11:19:44 +02:00
297 lines
16 KiB
Python
297 lines
16 KiB
Python
"""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)
|