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:
File diff suppressed because one or more lines are too long
@@ -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
76
api/routes/ai.py
Normal 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
|
||||
@@ -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", [])
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user