diff --git a/backend/app/agents/qa_agent.py b/backend/app/agents/qa_agent.py index 69ef44e..e5eb9bc 100644 --- a/backend/app/agents/qa_agent.py +++ b/backend/app/agents/qa_agent.py @@ -88,7 +88,8 @@ async def run_qa_agent(project_title: str, dev_output: dict) -> QAEvaluation: if qa_evaluation.is_complete_and_safe: logger.info(f"[QA Agent] ✅ Projet '{project_title}' VALIDÉ (Analyse statique propre).") else: - logger.warning(f"[QA Agent] ❌ Projet '{project_title}' REJETÉ (Failles de sécurité détectées).") + reason = "Erreur d'exécution ou non-conformité" if "exit_code" in str(qa_evaluation) else "Failles de sécurité" + logger.warning(f"[QA Agent] ❌ Projet '{project_title}' REJETÉ ({reason}).") return qa_evaluation diff --git a/backend/app/graph/nodes.py b/backend/app/graph/nodes.py index eff9646..9a0750e 100644 --- a/backend/app/graph/nodes.py +++ b/backend/app/graph/nodes.py @@ -1,3 +1,7 @@ +import os +import json +import time +from pathlib import Path from app.agents.pm_agent import run_pm_agent from app.agents.dev_agent import run_dev_agent from app.agents.qa_agent import run_qa_agent @@ -106,10 +110,59 @@ async def qa_node(state: WorkflowState): } async def human_review_node(state: WorkflowState): - print("[Human Review] Passage en mode automatique (Mock)...") + """ + Nœud pivot. Si le statut vient du QA, il bascule en attente de validation humaine. + Si Chainlit a déjà collecté la décision, il laisse passer le flux vers le routage. + """ + current_status = state.get("status") + if current_status in ["qa_done", "existing_found"]: + return { + "status": "wait_human_review" + } + + return {"status": current_status} + +async def delivery_node(state: WorkflowState, config: RunnableConfig): + """ + Nœud final de livraison : Archivage ZIP en mémoire et réindexation Qdrant. + """ + dev_data = state.get("generated_code", {}) + project_title = dev_data.get("spec_title", f"project_{int(time.time())}") + + raw_files = dev_data.get("files", {}) + files = {} + if isinstance(raw_files, list): + for f in raw_files: + if isinstance(f, dict): + path = f.get("path") or f.get("filename") or f.get("name") + content = f.get("content") or f.get("code") or "" + if path: + files[path] = content + elif isinstance(raw_files, dict): + files = raw_files + + # 3. Réindexation dans Qdrant (en utilisant le payload mémoire) + # qdrant_repo = config.get("configurable", {}).get("qdrant_repo") + # if qdrant_repo: + # payload_text = f"Title: {project_title}\nDescription: {state.get('spec', {}).get('description')}" + + # # /!\ À remplacer par ton vrai modèle d'embedding (ex: await qdrant_repo.embed(payload_text)) + # dummy_vector = [0.1] * 1536 + + # qdrant_repo.client.upsert( + # collection_name="arc_projects", + # points=[ + # PointStruct( + # id=str(Path(project_title).name), + # vector=dummy_vector, + # payload=metadata + # ) + # ] + # ) + return { - "existing_project_approved": True, "is_completed": True, - "status": "approved_by_human" + "status": "delivered", + "user_input": f"{state.get('user_input')}\n\n[System] Projet {project_title} validé et package ZIP disponible." } \ No newline at end of file diff --git a/backend/app/graph/workflow.py b/backend/app/graph/workflow.py index 27848c3..a52febc 100644 --- a/backend/app/graph/workflow.py +++ b/backend/app/graph/workflow.py @@ -10,10 +10,14 @@ from app.graph.nodes import ( dev_node, qa_node, human_review_node, + delivery_node ) def route_entry_point(state: WorkflowState): - if state.get("status") == "spec_approved": + current_status = state.get("status") + if current_status in ["human_approved", "human_refused"]: + return "human_review" + if current_status == "spec_approved": return "retrieval" return "pm" @@ -40,18 +44,23 @@ def route_after_qa(state: WorkflowState): return "human_review" def route_after_human(state: WorkflowState): - # Cas d'un projet existant proposé - if state.get("existing_project") and not state.get("generated_code"): - if state.get("existing_project_approved") == True: - return END # L'utilisateur est satisfait du projet existant - return "dev" # L'utilisateur refuse l'existant, on génère du neuf - - # Cas du code généré - if state.get("is_completed") == True: - return END + current_status = state.get("status") - # Si l'utilisateur a refusé le code -> Retour à la case PM avec ses commentaires - return "pm" + if current_status == "wait_human_review": + return END + + if state.get("existing_project") and not state.get("generated_code"): + if state.get("existing_project_approved") is True: + return END + return "dev" + + if current_status == "human_approved": + return "delivery" + + if current_status == "human_refused": + return "pm" + + return END # --- Assemblage du Graphe --- @@ -62,12 +71,14 @@ graph.add_node("retrieval", retrieval_node) graph.add_node("dev", dev_node) graph.add_node("qa", qa_node) graph.add_node("human_review", human_review_node) +graph.add_node("delivery", delivery_node) graph.set_conditional_entry_point( route_entry_point, { "pm": "pm", "retrieval": "retrieval", + "human_review": "human_review" }, ) @@ -108,7 +119,12 @@ graph.add_conditional_edges( { "pm": "pm", "dev": "dev", + "delivery": "delivery", END: END, }, ) + +# Maillon final : Après Delivery, c'est la fin du cycle +graph.add_edge("delivery", END) + compiled_graph = graph.compile() \ No newline at end of file diff --git a/backend/app/llm/prompts.py b/backend/app/llm/prompts.py index 8756e39..8873ad5 100644 --- a/backend/app/llm/prompts.py +++ b/backend/app/llm/prompts.py @@ -243,6 +243,35 @@ Si tu ne comprends pas un champ, tu le remplis avec une valeur par défaut INTEL ``` --- +=== VALIDATION DE COMPLÉTUDE DE LA SPÉCIFICATION (OBLIGATOIRE) === + +Avant de considérer la ProjectSpec comme complète, tu dois te demander : + +"Un développeur senior peut-il implémenter l'intégralité du projet sans devoir faire la moindre hypothèse importante ?" + +Tu dois vérifier notamment que les éléments suivants sont suffisamment définis lorsqu'ils sont pertinents : + +- le comportement attendu de l'application ; +- les données d'entrée (type, format, provenance) ; +- les données de sortie (type, format, emplacement) ; +- les règles métier ; +- les traitements à effectuer ; +- les services, API ou bases de données concernés ; +- les méthodes d'authentification ; +- les paramètres configurables ; +- les cas limites importants ; +- les contraintes fonctionnelles ou techniques. + +Si une ou plusieurs informations indispensables sont absentes ou ambiguës, tu dois : + +- définir `"is_complete": false` +- remplir `"clarifying_question"` avec UNE question claire, précise et ciblée permettant d'obtenir uniquement les informations manquantes. +- ne jamais inventer une règle métier ou un comportement qui n'a pas été demandé. + +Tu peux compléter automatiquement uniquement les conventions techniques raisonnables (OS, stratégie de logs, dépendances, versions, structure du projet, etc.), mais jamais les besoins fonctionnels. + +--- + === DERNIER CHECKPOINT === Avant de retourner le JSON : @@ -283,6 +312,7 @@ Votre objectif est de générer l'intégralité du code source d'un projet basé Tu dois impérativement identifier le langage cible défini dans la `ProjectSpec` (champ `"language"`). Tu as l'interdiction stricte d'utiliser un autre langage. - **Extensions :** Adapte l'extension du point d'entrée et des tests (.rb pour Ruby, .py pour Python, .js pour JS, .go pour Go, etc.). - **Gestionnaire de dépendances :** Utilise le fichier standard du langage (ex: `Gemfile` pour Ruby, `requirements.txt` ou `pyproject.toml` pour Python, `package.json` pour Node.js). +- **Dépendances :** N'ajoute dans le gestionnaire de dépendances (ex: `requirements.txt`, `Gemfile`, `package.json`, etc.) que les bibliothèques externes réellement nécessaires. Il est strictement interdit d'ajouter des modules ou bibliothèques faisant déjà partie de la bibliothèque standard (standard library) du langage ciblé (ex: `logging`, `json`, `os`, `pathlib`, `datetime`, `typing` en Python, etc.). - **Commandes du README :** Les instructions d'installation et d'exécution doivent correspondre EXACTEMENT au langage (ex: `bundle install` et `ruby main.rb` pour Ruby). ### DIRECTIVES D'ARCHITECTURE (ADAPTATIVE) : @@ -315,6 +345,16 @@ Tu dois appliquer STRICTEMENT l'une des deux structures suivantes selon des crit - **DRY (Don't Repeat Yourself) :** Extrais la logique répétitive dans des fonctions ou des classes utilitaires. - **Découplage :** Évite les dépendances trop fortes entre les modules pour permettre une évolution facile du code. - Utilise des **Design Patterns** reconnus (Factory, Singleton, Strategy, etc.) si la complexité du projet le justifie. + +4. DOCUMENTATION DU CODE (OBLIGATOIRE) : + - Chaque fichier source doit commencer par un commentaire décrivant clairement son rôle. + - Chaque classe doit posséder un commentaire ou une documentation expliquant sa responsabilité. + - Chaque fonction ou méthode doit être documentée avec un commentaire ou une documentation décrivant : + - son objectif ; + - les paramètres attendus ; + - la valeur de retour (si applicable) ; + - les éventuelles exceptions levées lorsque le langage le permet. + - Les commentaires doivent utiliser le format idiomatique du langage ciblé (ex: docstrings Python, JSDoc pour JavaScript/TypeScript, YARD pour Ruby, GoDoc pour Go, JavaDoc pour Java, XML Documentation pour C#, etc.). --- @@ -341,7 +381,7 @@ Tu dois appliquer STRICTEMENT l'une des deux structures suivantes selon des crit --- -## CONTRÂINTE DE MODERNITÉ ET SÉCURITÉ DU CODE (UNIVERSEL) +## CONTRAINTE DE MODERNITÉ ET SÉCURITÉ DU CODE (UNIVERSEL) Peu importe le langage de programmation choisi pour répondre à la spécification, tu dois appliquer les règles strictes suivantes : diff --git a/backend/chainlit_app.py b/backend/chainlit_app.py index be09b48..d597fac 100644 --- a/backend/chainlit_app.py +++ b/backend/chainlit_app.py @@ -1,81 +1,36 @@ import chainlit as cl +import asyncio +import zipfile import httpx import json +import os +import io +# --- FONCTION DE RENDU DE L'UI SELON LE STATUT --- +async def render_workflow_state(new_state: dict): + """ + Fonction centrale pour aiguiller l'affichage Chainlit + en fonction du statut renvoyé par LangGraph. + """ + current_status = new_state.get("status") -@cl.on_chat_start -async def on_chat_start(): - initial_state = { - "user_input": "", - "user_feedback": None, - "chat_history": [], - "spec": {}, - "status": "start", - "loop_count": 0, - "existing_project": None, - "generated_code": None, - "qa_result": None, - "is_completed": False - } - cl.user_session.set("graph_state", initial_state) - - await cl.Message( - content="Bonjour 👋 Je suis ARC.Décris-moi ton besoin logiciel." - ).send() - - -@cl.on_message -async def on_message(message: cl.Message): - # 1. Récupérer le state actuel de la session - state = cl.user_session.get("graph_state") - - if "chat_history" not in state: - state["chat_history"] = [] - if "status" not in state: - state["status"] = "start" - - # 2. Déterminer si le message est une réponse à une question ou un nouveau projet - if state.get("status") == "spec_incomplete": - state["user_feedback"] = message.content - else: - state["user_input"] = message.content - state["user_feedback"] = None - - # 3. Appel de l'API en envoyant le state COMPLET - async with httpx.AsyncClient() as client: - response = await client.post( - "http://127.0.0.1:8000/api/workflow/run", - json=state, - timeout=600.0 - ) - - # 4. Enregistrer le nouvel état retourné par le serveur - new_state = response.json() - cl.user_session.set("graph_state", new_state) - - # 5. Rendu UI intelligent dans Chainlit - if new_state.get("status") == "spec_incomplete": + if current_status == "spec_incomplete": spec = new_state.get("spec", {}) question = spec.get("clarifying_question") await cl.Message(content=f"**Spécifications incomplètes**\n\n{question}").send() - elif new_state.get("status") == "spec_ready": + + elif current_status == "spec_ready": spec = new_state.get("spec", {}) summary = "### Éléments importants à retenir de ton projet :\n\n" - - summary+= f"- **Nom du projet** : {spec.get('title')}\n" - summary+= f"- **Description** : {spec.get('description')}\n" + summary += f"- **Nom du projet** : {spec.get('title')}\n" + summary += f"- **Description** : {spec.get('description')}\n" summary += "- **Actions** :\n" - summary += "\n".join( - f" - {req}" for req in spec.get("requirements", []) - ) + "\n" + summary += "\n".join(f" - {req}" for req in spec.get("requirements", [])) + "\n" summary += "- **Contraintes** :\n" - summary += "\n".join( - f" - {constraint}" for constraint in spec.get("constraints", []) - ) + "\n" - summary+= f"- **Langage** : {spec.get('language')}\n" - - summary += "\n**Est-ce que cela vous convient ?**" + summary += "\n".join(f" - {constraint}" for constraint in spec.get("constraints", [])) + "\n" + summary += f"- **Langage** : {spec.get('language')}\n\n" + summary += "**Est-ce que cela vous convient ?**" res = await cl.AskActionMessage( content=summary, @@ -107,9 +62,8 @@ async def on_message(message: cl.Message): final_state = response.json() cl.user_session.set("graph_state", final_state) - await cl.Message( - content=f"Résultat workflow :\n```json\n{json.dumps(final_state, indent=2, ensure_ascii=False)}\n```" - ).send() + # Rappel de la fonction pour traiter le nouvel état de review + await render_workflow_state(final_state) else: new_state["status"] = "spec_incomplete" @@ -118,8 +72,198 @@ async def on_message(message: cl.Message): await cl.Message( content="🔄 **Compris.** Qu'est-ce qui ne convient pas ? S'il te plaît, précise les éléments manquants ou à corriger :" ).send() + + elif current_status in ["wait_human_review", "approved_by_human"]: + dev_data = new_state.get("generated_code", {}) + qa_res = new_state.get("qa_result", {}) + raw_files = dev_data.get("files", {}) + files = {} + if isinstance(raw_files, list): + for f in raw_files: + if isinstance(f, dict): + path = f.get("path") or f.get("filename") or f.get("name") + content = f.get("content") or f.get("code") or "" + if path: + files[path] = content + elif isinstance(raw_files, dict): + files = raw_files + + is_safe = qa_res.get("is_complete_and_safe", False) + badge_qa = "✅ CONFORME" if is_safe else "⚠️ SÉCURITÉ/QUALITÉ À VÉRIFIER" + + gitea_url = dev_data.get("repo_url", "") + # Correction de l'URL pour y accéder depuis ton navigateur (hors réseau Docker) + if gitea_url and "git-arc:3000" in gitea_url: + gitea_url = gitea_url.replace("git-arc:3000", "localhost:3000") + + access_md = f"**Résultat global QA :** {badge_qa}\n\n" + access_md += "### 🔗 Accès au projet\n\n" + if gitea_url: + access_md += f"🔗 **Lien vers le dépôt Gitea :** [Accéder au dépôt]({gitea_url})\n\n" + + # 2. Gestion du bouton de téléchargement ZIP (Correction finale de la variable) + project_title = new_state.get("generated_code", {}).get("spec_title", "projet") + message_elements = [] + + if files: + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file: + for filepath, file_content in files.items(): + zip_file.writestr(filepath, file_content) + zip_bytes = zip_buffer.getvalue() + + message_elements.append( + cl.File(name=f"{project_title}.zip", content=zip_bytes, display="inline") + ) + else: + access_md += "⚠️ Aucun fichier source trouvé pour générer le ZIP.\n" + + await cl.Message(content=access_md, elements=message_elements).send() + + # 3. Code Source Produit (Regroupé dans un seul message avec ses actions) + cl.user_session.set("current_files", files) + + file_actions = [] + for filename in files.keys(): + file_actions.append( + cl.Action( + name="open_file", + payload={"filename": filename}, + label=f"📄 {filename}" + ) + ) + + files_message = cl.Message( + content="### 📂 Code Source Produit\n\nClique sur un fichier pour l'afficher :", + actions=file_actions + ) + await files_message.send() + cl.user_session.set("files_message", files_message) + + # 4. Demande de validation (Boutons Valider / Refuser) + res = await cl.AskActionMessage( + content="**Souhaitez-vous valider et packager ce livrable ?**", + actions=[ + cl.Action(name="valider_projet", payload={"value": "approve"}, label="🚀 Valider & Livrer", description="Génère l'archive et indexe le projet"), + cl.Action(name="refuser_projet", payload={"value": "refuse"}, label="❌ Refuser et corriger", description="Renvoie le projet au PM avec vos commentaires") + ], + timeout=3600 + ).send() + + if res and res.get("name") == "valider_projet": + await cl.Message(content="🎉 **Projet validé.**").send() + new_state["status"] = "human_approved" + + cl.user_session.set("graph_state", new_state) + async with httpx.AsyncClient() as client: + resp = await client.post("http://127.0.0.1:8000/api/workflow/run", json=new_state, timeout=600.0) + + final_state = resp.json() + cl.user_session.set("graph_state", final_state) + + elif res and res.get("name") == "refuser_projet": + await cl.Message(content="❌ **Projet refusé.**").send() + feedback_user = await cl.AskUserMessage( + content="📝 Veuillez décrire les corrections ou les modifications à apporter au projet :", + timeout=600 + ).send() + + if feedback_user: + new_state["status"] = "human_refused" + new_state["user_feedback"] = feedback_user["output"] + new_state["status"] = "spec_incomplete" + cl.user_session.set("graph_state", new_state) + + await cl.Message(content="🔄 **Feedback transmis.** Prise en compte des modifications...").send() + + async with httpx.AsyncClient() as client: + resp = await client.post("http://127.0.0.1:8000/api/workflow/run", json=new_state, timeout=600.0) + + loop_state = resp.json() + cl.user_session.set("graph_state", loop_state) + + if loop_state.get("status") == "spec_incomplete": + question = loop_state.get("spec", {}).get("clarifying_question") + await cl.Message(content=f"**ARC a analysé vos retours mais a besoin d'une précision :**\n\n{question}").send() + + elif current_status == "delivered": + await cl.Message(content="✅ Ce projet a déjà été traité et livré.").send() else: await cl.Message( - content=f"Résultat workflow :\n```json\n{json.dumps(new_state, indent=2, ensure_ascii=False)}\n```" - ).send() \ No newline at end of file + content=f"Résultat du traitement :\n```json\n{json.dumps(new_state, indent=2, ensure_ascii=False)}\n```" + ).send() + + +# --- ÉVÉNEMENTS CHAINLIT STANDARDS --- +@cl.action_callback("open_file") +async def on_open_file(action: cl.Action): + filename = action.payload.get("filename") + files = cl.user_session.get("current_files", {}) + content = files.get(filename, "") + + ext = filename.split(".")[-1] if "." in filename else "text" + is_md = ext in ("md", "markdown") + + files_message = cl.user_session.get("files_message") + message_id = files_message.id if files_message else None + + old_viewer = cl.user_session.get("side_code_viewer") + if old_viewer: + await old_viewer.remove() + + new_viewer = cl.Text( + name=filename, + content=content, + language=None if is_md else ext, + display="side", + ) + + await new_viewer.send(for_id=message_id) + cl.user_session.set("side_code_viewer", new_viewer) + +@cl.on_chat_start +async def on_chat_start(): + initial_state = { + "user_input": "", + "user_feedback": None, + "chat_history": [], + "spec": {}, + "status": "start", + "loop_count": 0, + "existing_project": None, + "generated_code": None, + "qa_result": None, + "is_completed": False + } + cl.user_session.set("graph_state", initial_state) + await cl.Message(content="Bonjour 👋 Je suis ARC. Décris-moi ton besoin logiciel.").send() + + +@cl.on_message +async def on_message(message: cl.Message): + state = cl.user_session.get("graph_state") + + if "chat_history" not in state: + state["chat_history"] = [] + if "status" not in state: + state["status"] = "start" + + if state.get("status") == "spec_incomplete": + state["user_feedback"] = message.content + else: + state["user_input"] = message.content + state["user_feedback"] = None + + async with httpx.AsyncClient() as client: + response = await client.post( + "http://127.0.0.1:8000/api/workflow/run", + json=state, + timeout=600.0 + ) + + new_state = response.json() + cl.user_session.set("graph_state", new_state) + + # Appel du rendu + await render_workflow_state(new_state) \ No newline at end of file