From dedeedf02470c8cd27cbdd0ac6e8ca6ffcb6720a Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Sun, 26 Apr 2026 18:51:09 +0000 Subject: [PATCH] =?UTF-8?q?fix(ralph):=20"Planific=C4=83"=20deschide=20mod?= =?UTF-8?q?al/ForceReply=20c=C3=A2nd=20descrierea=20lipse=C8=99te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Înainte: click pe 🧠 Planifică (Discord/Telegram) sau /plan fără descriere pe un proiect din workspace fără entry în approved-tasks.json → mesaj eroare "Adaugă mai întâi cu /p " și user-ul trebuia să facă două operații. Acum: - Discord button "Planifică" cu descriere goală → deschide RalphPlanModal cu TextInput pentru descriere; on_submit pornește direct start_planning_session - Discord /plan fără description param și fără entry → același modal (response.send_modal ÎNAINTE de defer — Discord constraint) - Telegram callback "Planifică" cu descriere goală → set state STEP_INPUT_DESCRIPTION_THEN_PLAN + ForceReply; handle_message detectează step și pornește planning cu textul user-ului - ralph_flow.py: nou STEP_INPUT_DESCRIPTION_THEN_PLAN (alături de cel existent pentru propose-only) start_planning_session deja auto-creează entry în approved-tasks.json dacă proiectul lipsește, deci flow-ul e end-to-end: workspace → click → descriere → planning agent activ. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/adapters/discord_bot.py | 11 ++++---- src/adapters/discord_views.py | 50 ++++++++++++++++++++++++++++++----- src/adapters/telegram_bot.py | 44 ++++++++++++++++++++++++------ src/ralph_flow.py | 3 ++- 4 files changed, 87 insertions(+), 21 deletions(-) diff --git a/src/adapters/discord_bot.py b/src/adapters/discord_bot.py index daa3b42..48bfcbe 100644 --- a/src/adapters/discord_bot.py +++ b/src/adapters/discord_bot.py @@ -1007,8 +1007,8 @@ def create_bot(config: Config) -> discord.Client: slug: str, description: str | None = None, ) -> None: - await interaction.response.defer(ephemeral=True) # Resolve description: explicit param wins, else look up in approved-tasks. + # Done BEFORE defer so we can fall back to a modal if missing. desc = (description or "").strip() if not desc: try: @@ -1020,13 +1020,12 @@ def create_bot(config: Config) -> discord.Client: except Exception: logger.exception("approved-tasks lookup failed") if not desc: - await interaction.followup.send( - f"Nu am descriere pentru `{slug}`. Adaugă cu `/p {slug} ` " - "sau pasează `description` la `/plan`.", - ephemeral=True, - ) + # No description anywhere — ask via modal and start planning on submit. + from src.adapters.discord_views import RalphPlanModal + await interaction.response.send_modal(RalphPlanModal(slug)) return + await interaction.response.defer(ephemeral=True) channel_id = str(interaction.channel_id) await interaction.followup.send( f"🧠 Pornesc planning pentru `{slug}`… (durează ~60s)", ephemeral=True diff --git a/src/adapters/discord_views.py b/src/adapters/discord_views.py index 7e1bd16..a7f4bae 100644 --- a/src/adapters/discord_views.py +++ b/src/adapters/discord_views.py @@ -116,6 +116,46 @@ class RalphProposeModal(discord.ui.Modal, title="Propune feature Ralph"): await interaction.response.send_message(result, ephemeral=True) +class RalphPlanModal(discord.ui.Modal, title="Planifică Ralph"): + """Modal asking for a description and starting a planning session immediately.""" + + description: discord.ui.TextInput = discord.ui.TextInput( + label="Descriere", + placeholder="Ce vrei să planificăm? (1-3 propoziții)", + style=discord.TextStyle.paragraph, + required=True, + max_length=1000, + ) + + def __init__(self, slug: str) -> None: + super().__init__(timeout=VIEW_TIMEOUT) + self.slug = slug + self.title = f"Planifică: {slug}"[:45] + + async def on_submit(self, interaction: discord.Interaction) -> None: + from src.router import start_planning_session + await interaction.response.defer(ephemeral=True) + description = str(self.description.value).strip() + await interaction.followup.send( + f"🧠 Pornesc planning pentru `{self.slug}`… (durează ~60s)", + ephemeral=True, + ) + channel_id = str(interaction.channel_id) + try: + first = start_planning_session(self.slug, description, channel_id, "discord") + except Exception as e: + log.exception("start_planning_session via modal failed for %s", self.slug) + await interaction.followup.send(f"Planning blocat: {e}", ephemeral=True) + return + for chunk in [first[i:i + 1900] for i in range(0, len(first), 1900)] or [first]: + await interaction.followup.send(chunk, ephemeral=True) + await interaction.followup.send( + "Răspunde aici. Apasă **Continuă faza** când ești gata să trec la următoarea.", + view=PlanningActiveView(), + ephemeral=True, + ) + + # --------------------------------------------------------------------------- # RalphProjectView — per-project action buttons # --------------------------------------------------------------------------- @@ -175,7 +215,6 @@ class RalphProjectView(discord.ui.View): @discord.ui.button(label="🧠 Planifică", style=discord.ButtonStyle.primary, row=2) async def plan(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: - await interaction.response.defer(ephemeral=True) # Look up description from approved-tasks.json description = "" try: @@ -187,12 +226,11 @@ class RalphProjectView(discord.ui.View): except Exception: log.exception("approved-tasks lookup failed") if not description: - await interaction.followup.send( - f"Nu am descriere pentru `{self.slug}`. " - f"Adaugă mai întâi cu `/p {self.slug} `.", - ephemeral=True, - ) + # No description yet — ask for one and start planning on submit. + # Modal must be sent BEFORE response.defer (Discord constraint). + await interaction.response.send_modal(RalphPlanModal(self.slug)) return + await interaction.response.defer(ephemeral=True) channel_id = str(interaction.channel_id) await interaction.followup.send( f"🧠 Pornesc planning pentru `{self.slug}`… (durează ~60s)", diff --git a/src/adapters/telegram_bot.py b/src/adapters/telegram_bot.py index 106fff4..d49edf1 100644 --- a/src/adapters/telegram_bot.py +++ b/src/adapters/telegram_bot.py @@ -684,12 +684,17 @@ async def callback_ralph(update: Update, context: ContextTypes.DEFAULT_TYPE) -> description = p.get("description") or "" break if not description: + # No description yet — set state and prompt with ForceReply. + # Next message in this chat will start the planning session. + ralph_flow.set_state( + ADAPTER_NAME, chat_id, user_id, + step=ralph_flow.STEP_INPUT_DESCRIPTION_THEN_PLAN, + project=slug, + ) await context.bot.send_message( chat_id=int(chat_id), - text=( - f"Nu am descriere pentru `{slug}`. " - f"Adaugă mai întâi cu `/p {slug} `." - ), + text=f"📝 Descriere pentru *{slug}* — pornesc planning după ce trimiți:", + reply_markup=ForceReply(selective=True), parse_mode="Markdown", ) return @@ -915,16 +920,39 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> chat_id, text[:100], ) - # Ralph multi-step state: if user is replying to a "Descriere pentru X" prompt, - # route this text to _ralph_propose instead of Claude. + # Ralph multi-step state: route the next message based on the recorded step. state = ralph_flow.get_state(ADAPTER_NAME, str(chat_id), str(user_id)) - if state and state.get("step") == ralph_flow.STEP_INPUT_DESCRIPTION: + if state: + step = state.get("step") slug = state.get("project") - if slug: + if slug and step == ralph_flow.STEP_INPUT_DESCRIPTION: ralph_flow.clear_state(ADAPTER_NAME, str(chat_id), str(user_id)) result = await asyncio.to_thread(_ralph_propose, slug, text) await message.reply_text(result) return + if slug and step == ralph_flow.STEP_INPUT_DESCRIPTION_THEN_PLAN: + ralph_flow.clear_state(ADAPTER_NAME, str(chat_id), str(user_id)) + await message.reply_text( + f"🧠 Pornesc planning pentru *{slug}*… (durează ~60s)", + parse_mode="Markdown", + ) + try: + first = await asyncio.to_thread( + start_planning_session, slug, text, str(chat_id), ADAPTER_NAME, + ) + except Exception as e: + logger.exception("start_planning_session failed for %s", slug) + await message.reply_text(f"Planning blocat: {e}") + return + for chunk in split_planning_chunks(first): + await context.bot.send_message(chat_id=chat_id, text=chunk) + await context.bot.send_message( + chat_id=chat_id, + text="Răspunde aici. Apasă _Continuă faza_ când ești gata să trec la următoarea.", + reply_markup=_build_planning_active_keyboard(), + parse_mode="Markdown", + ) + return # Show typing indicator await context.bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING) diff --git a/src/ralph_flow.py b/src/ralph_flow.py index ae0ae61..eb406f8 100644 --- a/src/ralph_flow.py +++ b/src/ralph_flow.py @@ -22,7 +22,8 @@ DEFAULT_TTL_SECONDS = 600 # 10 minutes # Step values used across adapters STEP_INPUT_DESCRIPTION = "input_description" -STEP_IN_PLANNING = "in_planning" # reserved for W2 (planning agent) +STEP_INPUT_DESCRIPTION_THEN_PLAN = "input_description_then_plan" +STEP_IN_PLANNING = "in_planning" # planning agent active in this channel def _key(adapter: str, chat_id: str, user_id: str) -> str: