[eric] onboarding: reveal shows what it saw and why it acted (per-job personalized reasons)

This commit is contained in:
ciregenz
2026-07-15 17:57:11 -07:00
parent 01ea10d888
commit b73ff9ab6f
9 changed files with 41 additions and 14 deletions
+1
View File
@@ -56,4 +56,5 @@ class PrepResponse(BaseModel):
starters: List[PersonalizedStarter] = Field(default_factory=list)
app_title: str = ""
app_prompt: str = ""
app_reason: str = ""
automations: List[PersonalizedAutomation] = Field(default_factory=list)
+8 -3
View File
@@ -27,7 +27,7 @@ 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}], "app_title": string, "app_prompt": string, "automations": [{"title": string, "prompt": string, "cadence": "daily"|"weekday"|"weekly"}]}. '
'{"greeting": string, "starters": [{"title": string, "prompt": string, "reason": string}], "app_title": string, "app_prompt": string, "app_reason": string, "automations": [{"title": string, "prompt": string, "cadence": "daily"|"weekday"|"weekly"}]}. '
"First, silently infer a short profile of this user. 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 apps, folders, plan tier, email domain. Tune every task and the personal app to that profile; do not output the profile. "
@@ -38,9 +38,13 @@ P_SYSTEM = (
"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. "
"Also design ONE small personal app for this user: app_title is 2-4 words, app_prompt starts with 'Build me' and "
"describes a small, immediately useful single-page app tailored to the profile (their files, habits, or picked "
"apps), self-contained with no accounts or API keys. "
"apps), self-contained with no accounts or API keys. app_reason follows the same one-clause grounded-observation "
"rule as a starter reason. "
"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, prompt is one runnable instruction, cadence is exactly "
@@ -58,7 +62,7 @@ def parse_prep(text: str) -> Optional[PrepResponse]:
try:
data = json.loads(match.group(0))
starters = [
PersonalizedStarter(title=str(s.get("title", "")).strip(), prompt=str(s.get("prompt", "")).strip())
PersonalizedStarter(title=str(s.get("title", "")).strip(), prompt=str(s.get("prompt", "")).strip(), reason=str(s.get("reason", "")).strip())
for s in data.get("starters", [])
if isinstance(s, dict) and str(s.get("title", "")).strip() and str(s.get("prompt", "")).strip()
]
@@ -78,6 +82,7 @@ def parse_prep(text: str) -> Optional[PrepResponse]:
starters=starters[:4],
app_title=str(data.get("app_title", "")).strip(),
app_prompt=str(data.get("app_prompt", "")).strip(),
app_reason=str(data.get("app_reason", "")).strip(),
automations=automations[:3],
)
except Exception:
+2
View File
@@ -111,6 +111,8 @@ class CustomProvider(BaseModel):
class PersonalizedStarter(BaseModel):
title: str
prompt: str
# One short clause tying this task to something real we saw about the user; the reveal shows it off.
reason: str = ""
class PersonalizedAutomation(BaseModel):
+14
View File
@@ -84,10 +84,24 @@ def test_parse_prep_strict_and_lenient():
assert parsed is not None
assert parsed.greeting == "Hey!"
assert [s.title for s in parsed.starters] == ["A", "B"]
# Reason is optional; missing reason defaults to empty, never drops the starter.
assert parsed.starters[0].reason == ""
assert parse_prep("no json here") is None
assert parse_prep('{"greeting": "hi", "starters": []}') is None
def test_parse_prep_carries_reasons():
rich = (
'{"greeting": "Hey!", "app_title": "Lift Log", "app_prompt": "Build me a lifting tracker", '
'"app_reason": "you plan lifts with ChatGPT daily", '
'"starters": [{"title": "Audit Downloads", "prompt": "audit it", "reason": "1,305 files piling up there"}]}'
)
parsed = parse_prep(rich)
assert parsed is not None
assert parsed.starters[0].reason == "1,305 files piling up there"
assert parsed.app_reason == "you plan lifts with ChatGPT daily"
@pytest.mark.asyncio
async def test_build_prep_fails_open_without_provider(monkeypatch):
async def boom(*args, **kwargs):
@@ -27,6 +27,7 @@ export interface PrepResponse {
starters: PersonalizedStarter[];
app_title: string;
app_prompt: string;
app_reason: string;
automations: PersonalizedAutomation[];
}
@@ -71,7 +71,7 @@ export function useOnboardingV3Pipeline() {
}, []);
// Fire one real background agent; the session exists in redux without a card until the reveal composes the canvas.
const launchJob = useCallback((title: string, prompt: string, kind: 'audit' | 'app') => {
const launchJob = useCallback((title: string, prompt: string, kind: 'audit' | 'app', reason: string) => {
const { model: liveModel } = launchCtxRef.current;
const dashboardId = getLastDashboardId() ?? undefined;
const config: AgentConfig = { name: title, model: liveModel, mode: 'agent', dashboard_id: dashboardId };
@@ -79,7 +79,7 @@ export function useOnboardingV3Pipeline() {
void dispatch(launchAndSendFirstMessage({ draftId, config, prompt, mode: 'agent', model: liveModel }))
.then((action) => {
if (launchAndSendFirstMessage.fulfilled.match(action)) {
dispatch(addPreppedJob({ sessionId: action.payload.session.id, title, kind }));
dispatch(addPreppedJob({ sessionId: action.payload.session.id, title, kind, reason }));
}
})
.catch(() => {});
@@ -96,8 +96,8 @@ export function useOnboardingV3Pipeline() {
void prepRef.current.then((prep) => {
if (launchedRef.current || !prep || !prep.greeting || !launchCtxRef.current.connected) return;
launchedRef.current = true;
if (prep.starters.length > 0) launchJob(prep.starters[0].title, prep.starters[0].prompt, 'audit');
if (prep.app_title && prep.app_prompt) launchJob(prep.app_title, prep.app_prompt, 'app');
if (prep.starters.length > 0) launchJob(prep.starters[0].title, prep.starters[0].prompt, 'audit', prep.starters[0].reason ?? '');
if (prep.app_title && prep.app_prompt) launchJob(prep.app_title, prep.app_prompt, 'app', prep.app_reason ?? '');
});
}, [launchJob]);
@@ -34,14 +34,15 @@ export function useOnboardingRevealSeed({ isActive, dashboardId, expandedSession
const cy = (vr.height / 2 - cs.panY) / cs.zoom;
const audit = prepped.find((j) => j.kind === 'audit');
const app = prepped.find((j) => j.kind === 'app');
const lines: string[] = ['Here is what I set up for you.'];
if (scanSummary) lines.push(`Looked around: ${scanSummary}.`);
const jobLine = (j: typeof prepped[number]) => (j.reason ? `- ${j.title}: ${j.reason}` : `- ${j.title}`);
const lines: string[] = ['While you were setting up, I got a head start.'];
if (scanSummary) lines.push(`I looked around your Mac and saw ${scanSummary}.`);
const cards: string[] = [];
if (audit) cards.push(`- ${audit.title} (running now, on the left)`);
if (app) cards.push(`- ${app.title} (building now)`);
if (cards.length > 0) lines.push(`Working on:\n${cards.join('\n')}`);
if (starters.length > 0) lines.push(`Ready when you are:\n${starters.slice(audit ? 1 : 0).map((s) => `- ${s.title}`).join('\n')}`);
lines.push('Nothing is saved or deleted without you. Keep or discard anytime.');
if (audit) cards.push(jobLine(audit));
if (app) cards.push(jobLine(app));
if (cards.length > 0) lines.push(`So here is what I already have going (cards on your left):\n${cards.join('\n')}`);
if (starters.length > 0) lines.push(`Ready whenever you want:\n${starters.slice(audit ? 1 : 0).map((s) => `- ${s.title}`).join('\n')}`);
lines.push('Nothing is saved or deleted without you. Keep it going or clear it anytime.');
dispatch(addNote({ x: cx + DEFAULT_CARD_W / 2 + 48, y: cy - 140, color: 'yellow', content: lines.join('\n\n') }));
prepped.forEach((job, i) => {
dispatch(placeCard({
@@ -7,6 +7,8 @@ export interface PreppedJob {
sessionId: string;
title: string;
kind: 'audit' | 'app';
/** The one-clause "why we started this for you", shown in the reveal note. */
reason?: string;
}
export interface OnboardingV3State {
@@ -90,6 +90,7 @@ export interface AppSettings {
export interface PersonalizedStarter {
title: string;
prompt: string;
reason?: string;
}
export interface PersonalizedAutomation {