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>
77 lines
2.3 KiB
Python
77 lines
2.3 KiB
Python
"""
|
|
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
|