Etape 4 finie, TODO : correction QA, gestion retour au PM après refus utilisateur

This commit is contained in:
Chevallier
2026-07-08 16:11:11 +02:00
parent 5f06de19e0
commit 37d493ac20
5 changed files with 341 additions and 87 deletions

View File

@@ -88,7 +88,8 @@ async def run_qa_agent(project_title: str, dev_output: dict) -> QAEvaluation:
if qa_evaluation.is_complete_and_safe: if qa_evaluation.is_complete_and_safe:
logger.info(f"[QA Agent] ✅ Projet '{project_title}' VALIDÉ (Analyse statique propre).") logger.info(f"[QA Agent] ✅ Projet '{project_title}' VALIDÉ (Analyse statique propre).")
else: 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 return qa_evaluation

View File

@@ -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.pm_agent import run_pm_agent
from app.agents.dev_agent import run_dev_agent from app.agents.dev_agent import run_dev_agent
from app.agents.qa_agent import run_qa_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): 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 { return {
"existing_project_approved": True,
"is_completed": 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."
} }

View File

@@ -10,10 +10,14 @@ from app.graph.nodes import (
dev_node, dev_node,
qa_node, qa_node,
human_review_node, human_review_node,
delivery_node
) )
def route_entry_point(state: WorkflowState): 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 "retrieval"
return "pm" return "pm"
@@ -40,19 +44,24 @@ def route_after_qa(state: WorkflowState):
return "human_review" return "human_review"
def route_after_human(state: WorkflowState): def route_after_human(state: WorkflowState):
# Cas d'un projet existant proposé current_status = state.get("status")
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 current_status == "wait_human_review":
if state.get("is_completed") == True:
return END return END
# Si l'utilisateur a refusé le code -> Retour à la case PM avec ses commentaires 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 "pm"
return END
# --- Assemblage du Graphe --- # --- Assemblage du Graphe ---
graph = StateGraph(WorkflowState) graph = StateGraph(WorkflowState)
@@ -62,12 +71,14 @@ graph.add_node("retrieval", retrieval_node)
graph.add_node("dev", dev_node) graph.add_node("dev", dev_node)
graph.add_node("qa", qa_node) graph.add_node("qa", qa_node)
graph.add_node("human_review", human_review_node) graph.add_node("human_review", human_review_node)
graph.add_node("delivery", delivery_node)
graph.set_conditional_entry_point( graph.set_conditional_entry_point(
route_entry_point, route_entry_point,
{ {
"pm": "pm", "pm": "pm",
"retrieval": "retrieval", "retrieval": "retrieval",
"human_review": "human_review"
}, },
) )
@@ -108,7 +119,12 @@ graph.add_conditional_edges(
{ {
"pm": "pm", "pm": "pm",
"dev": "dev", "dev": "dev",
"delivery": "delivery",
END: END, END: END,
}, },
) )
# Maillon final : Après Delivery, c'est la fin du cycle
graph.add_edge("delivery", END)
compiled_graph = graph.compile() compiled_graph = graph.compile()

View File

@@ -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 === === DERNIER CHECKPOINT ===
Avant de retourner le JSON : 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. 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.). - **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). - **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). - **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) : ### DIRECTIVES D'ARCHITECTURE (ADAPTATIVE) :
@@ -316,6 +346,16 @@ Tu dois appliquer STRICTEMENT l'une des deux structures suivantes selon des crit
- **Découplage :** Évite les dépendances trop fortes entre les modules pour permettre une évolution facile du code. - **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. - 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.).
--- ---
### STANDARDS DE CONCEPTION IMPOSÉS : ### STANDARDS DE CONCEPTION IMPOSÉS :
@@ -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 : Peu importe le langage de programmation choisi pour répondre à la spécification, tu dois appliquer les règles strictes suivantes :

View File

@@ -1,81 +1,36 @@
import chainlit as cl import chainlit as cl
import asyncio
import zipfile
import httpx import httpx
import json 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 if current_status == "spec_incomplete":
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":
spec = new_state.get("spec", {}) spec = new_state.get("spec", {})
question = spec.get("clarifying_question") question = spec.get("clarifying_question")
await cl.Message(content=f"**Spécifications incomplètes**\n\n{question}").send() 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", {}) spec = new_state.get("spec", {})
summary = "### Éléments importants à retenir de ton projet :\n\n" summary = "### Éléments importants à retenir de ton projet :\n\n"
summary += f"- **Nom du projet** : {spec.get('title')}\n" summary += f"- **Nom du projet** : {spec.get('title')}\n"
summary += f"- **Description** : {spec.get('description')}\n" summary += f"- **Description** : {spec.get('description')}\n"
summary += "- **Actions** :\n" summary += "- **Actions** :\n"
summary += "\n".join( summary += "\n".join(f" - {req}" for req in spec.get("requirements", [])) + "\n"
f" - {req}" for req in spec.get("requirements", [])
) + "\n"
summary += "- **Contraintes** :\n" summary += "- **Contraintes** :\n"
summary += "\n".join( summary += "\n".join(f" - {constraint}" for constraint in spec.get("constraints", [])) + "\n"
f" - {constraint}" for constraint in spec.get("constraints", []) summary += f"- **Langage** : {spec.get('language')}\n\n"
) + "\n" summary += "**Est-ce que cela vous convient ?**"
summary+= f"- **Langage** : {spec.get('language')}\n"
summary += "\n**Est-ce que cela vous convient ?**"
res = await cl.AskActionMessage( res = await cl.AskActionMessage(
content=summary, content=summary,
@@ -107,9 +62,8 @@ async def on_message(message: cl.Message):
final_state = response.json() final_state = response.json()
cl.user_session.set("graph_state", final_state) cl.user_session.set("graph_state", final_state)
await cl.Message( # Rappel de la fonction pour traiter le nouvel état de review
content=f"Résultat workflow :\n```json\n{json.dumps(final_state, indent=2, ensure_ascii=False)}\n```" await render_workflow_state(final_state)
).send()
else: else:
new_state["status"] = "spec_incomplete" new_state["status"] = "spec_incomplete"
@@ -119,7 +73,197 @@ async def on_message(message: cl.Message):
content="🔄 **Compris.** Qu'est-ce qui ne convient pas ? S'il te plaît, précise les éléments manquants ou à corriger :" content="🔄 **Compris.** Qu'est-ce qui ne convient pas ? S'il te plaît, précise les éléments manquants ou à corriger :"
).send() ).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: else:
await cl.Message( await cl.Message(
content=f"Résultat workflow :\n```json\n{json.dumps(new_state, indent=2, ensure_ascii=False)}\n```" content=f"Résultat du traitement :\n```json\n{json.dumps(new_state, indent=2, ensure_ascii=False)}\n```"
).send() ).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)