Add AI task composition for routines (bot + web client)

Users can now describe a goal and have AI auto-generate 4-7 ADHD-friendly
steps, which they can review and modify before saving.

- ai/ai_config.json: Add step_generator prompt and ai_compose examples
  to command_parser so bot recognises vague task descriptions
- api/routes/ai.py: New POST /api/ai/generate-steps endpoint — calls
  LLM via ai_parser, validates and sanitises returned steps
- api/main.py: Register new ai_routes module
- bot/commands/routines.py: Add ai_compose action — generates steps,
  shows numbered list with durations, uses existing yes/no confirm flow
- synculous-client/src/lib/api.ts: Add api.ai.generateSteps(goal)
- synculous-client/src/app/dashboard/routines/new/page.tsx: Add
  Generate with AI panel with collapsible textarea, loading spinner,
  and inline error; generated steps slot into existing editable list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 13:56:54 -06:00
parent 9fb56edf74
commit 95ebae6766
6 changed files with 260 additions and 17 deletions

File diff suppressed because one or more lines are too long

View File

@@ -23,6 +23,7 @@ import api.routes.rewards as rewards_routes
import api.routes.victories as victories_routes
import api.routes.adaptive_meds as adaptive_meds_routes
import api.routes.snitch as snitch_routes
import api.routes.ai as ai_routes
app = flask.Flask(__name__)
CORS(app)
@@ -41,6 +42,7 @@ ROUTE_MODULES = [
victories_routes,
adaptive_meds_routes,
snitch_routes,
ai_routes,
]

76
api/routes/ai.py Normal file
View File

@@ -0,0 +1,76 @@
"""
api/routes/ai.py - AI-powered generation endpoints
"""
import asyncio
import flask
import jwt
import os
import ai.parser as ai_parser
JWT_SECRET = os.getenv("JWT_SECRET")
def _get_user_uuid(request):
"""Extract and validate user UUID from JWT token."""
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return None
token = auth_header[7:]
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
return payload.get("sub")
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
return None
def register(app):
@app.route("/api/ai/generate-steps", methods=["POST"])
def api_generate_steps():
"""
Generate ADHD-friendly routine steps from a goal description.
Body: {"goal": string}
Returns: {"steps": [{"name": string, "duration_minutes": int}]}
"""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401
data = flask.request.get_json()
if not data:
return flask.jsonify({"error": "missing body"}), 400
goal = data.get("goal", "").strip()
if not goal:
return flask.jsonify({"error": "missing required field: goal"}), 400
if len(goal) > 500:
return flask.jsonify({"error": "goal too long (max 500 characters)"}), 400
try:
result = asyncio.run(ai_parser.parse(goal, "step_generator"))
except Exception as e:
return flask.jsonify({"error": f"AI service error: {str(e)}"}), 500
if "error" in result:
return flask.jsonify({"error": result["error"]}), 500
steps = result.get("steps", [])
validated = []
for s in steps:
if not isinstance(s, dict):
continue
name = str(s.get("name", "")).strip()
if not name:
continue
try:
dur = max(1, min(60, int(s.get("duration_minutes", 5))))
except (ValueError, TypeError):
dur = 5
validated.append({"name": name, "duration_minutes": dur})
if len(validated) < 2:
return flask.jsonify({"error": "AI failed to generate valid steps"}), 500
return flask.jsonify({"steps": validated}), 200

View File

@@ -93,6 +93,56 @@ async def handle_routine(message, session, parsed):
await _create_routine_with_steps(message, token, name, description, steps)
elif action == "ai_compose":
goal = parsed.get("goal")
name = parsed.get("name", "my routine")
if not goal:
await message.channel.send(
"What's the goal for this routine? Tell me what you want to accomplish."
)
return
async with message.channel.typing():
resp, status = api_request(
"post", "/api/ai/generate-steps", token, {"goal": goal}
)
if status != 200:
await message.channel.send(
f"Couldn't generate steps: {resp.get('error', 'unknown error')}\n"
f"Try: \"create {name} routine with step1, step2, step3\""
)
return
steps = resp.get("steps", [])
if not steps:
await message.channel.send("The AI didn't return any steps. Try describing your goal differently.")
return
if "pending_confirmations" not in session:
session["pending_confirmations"] = {}
confirmation_id = f"routine_create_{name}"
session["pending_confirmations"][confirmation_id] = {
"action": "create_with_steps",
"interaction_type": "routine",
"name": name,
"description": f"AI-generated routine for: {goal}",
"steps": [s["name"] for s in steps],
"needs_confirmation": False,
}
total_min = sum(s.get("duration_minutes", 5) for s in steps)
steps_list = "\n".join(
[f"{i+1}. {s['name']} ({s.get('duration_minutes', 5)} min)" for i, s in enumerate(steps)]
)
await message.channel.send(
f"Here's what I suggest for **{name}** (~{total_min} min total):\n\n"
f"{steps_list}\n\n"
f"Reply **yes** to create this routine, or **no** to cancel."
)
elif action == "add_steps":
routine_name = parsed.get("routine_name")
steps = parsed.get("steps", [])

View File

@@ -4,7 +4,7 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import api from '@/lib/api';
import { ArrowLeftIcon, PlusIcon, TrashIcon, GripVerticalIcon, CopyIcon } from '@/components/ui/Icons';
import { ArrowLeftIcon, PlusIcon, TrashIcon, GripVerticalIcon, CopyIcon, SparklesIcon } from '@/components/ui/Icons';
interface Step {
id: string;
@@ -41,6 +41,10 @@ export default function NewRoutinePage() {
const [steps, setSteps] = useState<Step[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [aiGoal, setAiGoal] = useState('');
const [showAiInput, setShowAiInput] = useState(false);
const [aiError, setAiError] = useState('');
// Schedule
const [scheduleDays, setScheduleDays] = useState<string[]>(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
@@ -74,6 +78,31 @@ export default function NewRoutinePage() {
setSteps(newSteps.map((s, i) => ({ ...s, position: i + 1 })));
};
const handleGenerateSteps = async () => {
const goal = aiGoal.trim() || name.trim();
if (!goal) {
setAiError('Enter a goal or fill in the routine name first.');
return;
}
setIsGenerating(true);
setAiError('');
try {
const result = await api.ai.generateSteps(goal);
const generated = result.steps.map((s, i) => ({
id: `temp-${Date.now()}-${i}`,
name: s.name,
duration_minutes: s.duration_minutes,
position: steps.length + i + 1,
}));
setSteps(prev => [...prev, ...generated]);
setShowAiInput(false);
} catch (err) {
setAiError((err as Error).message || 'Failed to generate steps. Try again.');
} finally {
setIsGenerating(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
@@ -282,27 +311,101 @@ export default function NewRoutinePage() {
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Steps</h2>
<button
type="button"
onClick={handleAddStep}
className="text-indigo-600 dark:text-indigo-400 text-sm font-medium flex items-center gap-1"
>
<PlusIcon size={16} />
Add Step
</button>
</div>
{steps.length === 0 ? (
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-sm text-center">
<p className="text-gray-500 dark:text-gray-400 mb-4">Add steps to your routine</p>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
setShowAiInput(!showAiInput);
if (!showAiInput && !aiGoal) setAiGoal(name);
setAiError('');
}}
className="flex items-center gap-1 text-sm font-medium text-purple-600 dark:text-purple-400 hover:text-purple-700 dark:hover:text-purple-300 transition-colors"
>
<SparklesIcon size={16} />
Generate with AI
</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button
type="button"
onClick={handleAddStep}
className="text-indigo-600 dark:text-indigo-400 font-medium"
className="text-indigo-600 dark:text-indigo-400 text-sm font-medium flex items-center gap-1"
>
+ Add your first step
<PlusIcon size={16} />
Add Step
</button>
</div>
</div>
{/* AI Generation Panel */}
{showAiInput && (
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-xl p-4 mb-4 space-y-3">
<p className="text-sm font-medium text-purple-800 dark:text-purple-300">
Describe your goal and AI will suggest steps
</p>
<textarea
value={aiGoal}
onChange={(e) => setAiGoal(e.target.value)}
placeholder="e.g. help me build a morning routine that starts slow"
rows={2}
disabled={isGenerating}
className="w-full px-3 py-2 border border-purple-300 dark:border-purple-700 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 outline-none text-sm resize-none disabled:opacity-50"
/>
{aiError && (
<p className="text-sm text-red-600 dark:text-red-400">{aiError}</p>
)}
<div className="flex gap-2">
<button
type="button"
onClick={handleGenerateSteps}
disabled={isGenerating}
className="flex items-center gap-2 bg-purple-600 text-white text-sm font-medium px-4 py-2 rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
>
{isGenerating ? (
<>
<div className="w-3 h-3 border-2 border-white border-t-transparent rounded-full animate-spin" />
Generating...
</>
) : (
<>
<SparklesIcon size={14} />
Generate Steps
</>
)}
</button>
<button
type="button"
onClick={() => { setShowAiInput(false); setAiError(''); }}
disabled={isGenerating}
className="text-sm text-gray-500 dark:text-gray-400 px-3 py-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 transition-colors"
>
Cancel
</button>
</div>
</div>
)}
{steps.length === 0 ? (
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-sm text-center space-y-4">
<p className="text-gray-500 dark:text-gray-400">Add steps to your routine</p>
<div className="flex flex-col sm:flex-row gap-2 justify-center">
<button
type="button"
onClick={() => { setShowAiInput(true); if (!aiGoal) setAiGoal(name); }}
className="flex items-center justify-center gap-2 bg-purple-600 text-white text-sm font-medium px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors"
>
<SparklesIcon size={16} />
Generate with AI
</button>
<button
type="button"
onClick={handleAddStep}
className="flex items-center justify-center gap-2 text-indigo-600 dark:text-indigo-400 font-medium text-sm px-4 py-2 rounded-lg border border-indigo-200 dark:border-indigo-800 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 transition-colors"
>
<PlusIcon size={16} />
Add manually
</button>
</div>
</div>
) : (
<div className="space-y-3">
{steps.map((step, index) => (

View File

@@ -1051,6 +1051,14 @@ export const api = {
});
},
},
ai: {
generateSteps: (goal: string) =>
request<{ steps: { name: string; duration_minutes: number }[] }>(
'/api/ai/generate-steps',
{ method: 'POST', body: JSON.stringify({ goal }) }
),
},
};
export default api;