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.victories as victories_routes
|
||||||
import api.routes.adaptive_meds as adaptive_meds_routes
|
import api.routes.adaptive_meds as adaptive_meds_routes
|
||||||
import api.routes.snitch as snitch_routes
|
import api.routes.snitch as snitch_routes
|
||||||
|
import api.routes.ai as ai_routes
|
||||||
|
|
||||||
app = flask.Flask(__name__)
|
app = flask.Flask(__name__)
|
||||||
CORS(app)
|
CORS(app)
|
||||||
@@ -41,6 +42,7 @@ ROUTE_MODULES = [
|
|||||||
victories_routes,
|
victories_routes,
|
||||||
adaptive_meds_routes,
|
adaptive_meds_routes,
|
||||||
snitch_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)
|
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":
|
elif action == "add_steps":
|
||||||
routine_name = parsed.get("routine_name")
|
routine_name = parsed.get("routine_name")
|
||||||
steps = parsed.get("steps", [])
|
steps = parsed.get("steps", [])
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import api from '@/lib/api';
|
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 {
|
interface Step {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -41,6 +41,10 @@ export default function NewRoutinePage() {
|
|||||||
const [steps, setSteps] = useState<Step[]>([]);
|
const [steps, setSteps] = useState<Step[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [aiGoal, setAiGoal] = useState('');
|
||||||
|
const [showAiInput, setShowAiInput] = useState(false);
|
||||||
|
const [aiError, setAiError] = useState('');
|
||||||
|
|
||||||
// Schedule
|
// Schedule
|
||||||
const [scheduleDays, setScheduleDays] = useState<string[]>(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
|
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 })));
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
@@ -282,27 +311,101 @@ export default function NewRoutinePage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Steps</h2>
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Steps</h2>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={handleAddStep}
|
type="button"
|
||||||
className="text-indigo-600 dark:text-indigo-400 text-sm font-medium flex items-center gap-1"
|
onClick={() => {
|
||||||
>
|
setShowAiInput(!showAiInput);
|
||||||
<PlusIcon size={16} />
|
if (!showAiInput && !aiGoal) setAiGoal(name);
|
||||||
Add Step
|
setAiError('');
|
||||||
</button>
|
}}
|
||||||
</div>
|
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"
|
||||||
|
>
|
||||||
{steps.length === 0 ? (
|
<SparklesIcon size={16} />
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-sm text-center">
|
Generate with AI
|
||||||
<p className="text-gray-500 dark:text-gray-400 mb-4">Add steps to your routine</p>
|
</button>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleAddStep}
|
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>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="space-y-3">
|
||||||
{steps.map((step, index) => (
|
{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;
|
export default api;
|
||||||
|
|||||||
Reference in New Issue
Block a user