Correction QA: Validation arborescence + qualité/sécurité, ajout logique de fin complète, suppression repo non conforme
This commit is contained in:
@@ -6,7 +6,166 @@ import json
|
||||
import os
|
||||
import io
|
||||
|
||||
# --- FONCTION DE RENDU DE L'UI SELON LE STATUT ---
|
||||
# --- CONFIGURATION GITEA ---
|
||||
GITEA_TOKEN = os.getenv("GITEA_TOKEN", "ton_token_gitea_ici")
|
||||
|
||||
async def push_readme_update_to_gitea(repo_url: str, readme_content: str):
|
||||
"""
|
||||
Met à jour ou crée le fichier README.md directement sur Gitea via son API REST.
|
||||
"""
|
||||
if not repo_url:
|
||||
return
|
||||
|
||||
import base64
|
||||
clean_url = repo_url.replace(".git", "")
|
||||
parts = clean_url.rstrip("/").split("/")
|
||||
|
||||
if len(parts) >= 2:
|
||||
repo_name = parts[-1]
|
||||
owner = parts[-2]
|
||||
|
||||
# API Gitea pour récupérer/modifier le contenu d'un fichier
|
||||
gitea_file_url = f"http://git-arc:3000/api/v1/repos/{owner}/{repo_name}/contents/README.md"
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
if GITEA_TOKEN:
|
||||
headers["Authorization"] = f"token {GITEA_TOKEN}"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
sha = None
|
||||
# 1. On tente de récupérer le SHA du README actuel s'il existe (requis par Gitea pour les updates)
|
||||
try:
|
||||
get_resp = await client.get(gitea_file_url, headers=headers, timeout=5.0)
|
||||
if get_resp.status_code == 200:
|
||||
sha = get_resp.json().get("sha")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Impossible de récupérer le README actuel (il n'existe peut-être pas) : {e}")
|
||||
|
||||
# 2. Encodage du contenu en base64 (requis par l'API Gitea)
|
||||
encoded_content = base64.b64encode(readme_content.encode("utf-8")).decode("utf-8")
|
||||
|
||||
payload = {
|
||||
"content": encoded_content,
|
||||
"message": "docs: ajouter le statut de conformité QA au README",
|
||||
"branch": "main" # ou "master" selon ta configuration par défaut
|
||||
}
|
||||
if sha:
|
||||
payload["sha"] = sha # Requis si le fichier existe déjà
|
||||
|
||||
# 3. Écriture (PUT crée ou met à jour le fichier)
|
||||
try:
|
||||
put_resp = await client.put(gitea_file_url, headers=headers, json=payload, timeout=10.0)
|
||||
if put_resp.status_code in [200, 201]:
|
||||
print("📝 README.md mis à jour avec succès sur Gitea !")
|
||||
else:
|
||||
print(f"⚠️ Échec de la mise à jour du README sur Gitea : {put_resp.status_code} - {put_resp.text}")
|
||||
except Exception as e:
|
||||
print(f"❌ Erreur lors de l'écriture du README sur Gitea : {e}")
|
||||
|
||||
async def delete_gitea_repository(repo_url: str):
|
||||
"""
|
||||
Supprime le dépôt sur Gitea via son API REST en cas d'échec ou d'abandon.
|
||||
"""
|
||||
if not repo_url:
|
||||
return
|
||||
|
||||
# Nettoyage de l'URL pour extraire le owner et le repo_name
|
||||
clean_url = repo_url.replace(".git", "")
|
||||
parts = clean_url.rstrip("/").split("/")
|
||||
|
||||
if len(parts) >= 2:
|
||||
repo_name = parts[-1]
|
||||
owner = parts[-2]
|
||||
|
||||
# URL interne au réseau Docker pour appeler l'API Gitea
|
||||
gitea_api_url = f"http://git-arc:3000/api/v1/repos/{owner}/{repo_name}"
|
||||
|
||||
headers = {}
|
||||
if GITEA_TOKEN:
|
||||
headers["Authorization"] = f"token {GITEA_TOKEN}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.delete(gitea_api_url, headers=headers, timeout=10.0)
|
||||
if response.status_code == 204:
|
||||
print(f"🗑️ Dépôt Gitea {owner}/{repo_name} supprimé avec succès.")
|
||||
else:
|
||||
print(f"⚠️ Échec de la suppression Gitea : {response.status_code} - {response.text}")
|
||||
except Exception as e:
|
||||
print(f"❌ Erreur lors de la suppression du dépôt Gitea : {e}")
|
||||
|
||||
|
||||
def inject_conforme_badge(state: dict) -> dict:
|
||||
"""
|
||||
Injecte la mention 'Conforme' dans le README.md du projet
|
||||
dans l'état de session (gère le format liste de dict ou dictionnaire plat).
|
||||
"""
|
||||
dev_data = state.get("generated_code", {}) or {}
|
||||
raw_files = dev_data.get("files", {})
|
||||
badge_text = "\n\n---\n🛡️ **Statut QA :** ✅ Conforme\n"
|
||||
|
||||
if isinstance(raw_files, list):
|
||||
readme_found = False
|
||||
for f in raw_files:
|
||||
if isinstance(f, dict):
|
||||
path = f.get("path") or f.get("filename") or f.get("name") or ""
|
||||
if path.lower() in ["readme.md", "readme"]:
|
||||
content = f.get("content") or f.get("code") or ""
|
||||
if "✅ Conforme" not in content:
|
||||
new_content = content + badge_text
|
||||
if "content" in f:
|
||||
f["content"] = new_content
|
||||
elif "code" in f:
|
||||
f["code"] = new_content
|
||||
readme_found = True
|
||||
break
|
||||
if not readme_found:
|
||||
raw_files.append({
|
||||
"path": "README.md",
|
||||
"content": "# Projet\n" + badge_text
|
||||
})
|
||||
|
||||
elif isinstance(raw_files, dict):
|
||||
readme_key = None
|
||||
for k in raw_files.keys():
|
||||
if k.lower() in ["readme.md", "readme"]:
|
||||
readme_key = k
|
||||
break
|
||||
if readme_key:
|
||||
content = raw_files[readme_key] or ""
|
||||
if "✅ Conforme" not in content:
|
||||
raw_files[readme_key] = content + badge_text
|
||||
else:
|
||||
raw_files["README.md"] = "# Projet" + badge_text
|
||||
|
||||
return state
|
||||
|
||||
|
||||
async def reset_to_start(message_text: str = "Bonjour 👋 Je suis ARC. Décris-moi ton besoin logiciel."):
|
||||
"""
|
||||
Réinitialise complètement l'état de la session utilisateur.
|
||||
"""
|
||||
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)
|
||||
cl.user_session.set("current_files", {})
|
||||
cl.user_session.set("side_code_viewer", None)
|
||||
cl.user_session.set("files_message", None)
|
||||
|
||||
await cl.Message(content=message_text).send()
|
||||
|
||||
async def render_workflow_state(new_state: dict):
|
||||
"""
|
||||
Fonction centrale pour aiguiller l'affichage Chainlit
|
||||
@@ -62,7 +221,6 @@ async def render_workflow_state(new_state: dict):
|
||||
final_state = response.json()
|
||||
cl.user_session.set("graph_state", final_state)
|
||||
|
||||
# Rappel de la fonction pour traiter le nouvel état de review
|
||||
await render_workflow_state(final_state)
|
||||
|
||||
else:
|
||||
@@ -74,114 +232,175 @@ async def render_workflow_state(new_state: dict):
|
||||
).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"
|
||||
dev_data = new_state.get("generated_code", {}) or {}
|
||||
qa_res = new_state.get("qa_result", {}) or {}
|
||||
|
||||
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()
|
||||
is_safe = qa_res.get("is_complete_and_safe", False)
|
||||
|
||||
# 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}"
|
||||
# ==================== PARCOURS 1 : LE PROJET EST CONFORME ====================
|
||||
if is_safe:
|
||||
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
|
||||
|
||||
access_md = "**Résultat global QA :** ✅ CONFORME\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"
|
||||
|
||||
project_title = dev_data.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")
|
||||
)
|
||||
)
|
||||
|
||||
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)
|
||||
else:
|
||||
access_md += "⚠️ Aucun fichier source trouvé pour générer le ZIP.\n"
|
||||
|
||||
# 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"
|
||||
await cl.Message(content=access_md, elements=message_elements).send()
|
||||
|
||||
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)
|
||||
cl.user_session.set("current_files", files)
|
||||
|
||||
final_state = resp.json()
|
||||
cl.user_session.set("graph_state", final_state)
|
||||
file_actions = []
|
||||
for filename in files.keys():
|
||||
file_actions.append(
|
||||
cl.Action(
|
||||
name="open_file",
|
||||
payload={"filename": filename},
|
||||
label=f"📄 {filename}"
|
||||
)
|
||||
)
|
||||
|
||||
elif res and res.get("name") == "refuser_projet":
|
||||
feedback_user = await cl.AskUserMessage(
|
||||
content="Veuillez décrire les corrections ou les modifications à apporter au projet.",
|
||||
timeout=600
|
||||
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)
|
||||
|
||||
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="Ajoute le statut conforme au README et livre l'archive"),
|
||||
cl.Action(name="refuser_projet", payload={"value": "refuse"}, label="❌ Refuser et corriger", description="Renvoie le projet au PM avec tes commentaires")
|
||||
],
|
||||
timeout=3600
|
||||
).send()
|
||||
|
||||
if feedback_user:
|
||||
new_state["status"] = "human_refused"
|
||||
new_state["user_feedback"] = feedback_user["output"]
|
||||
cl.user_session.set("graph_state", new_state)
|
||||
if res and res.get("name") == "valider_projet":
|
||||
await cl.Message(content="🎉 **Projet validé et marqué comme conforme !**").send()
|
||||
|
||||
await cl.Message(content="🔄 **Feedback transmis.** Prise en compte des modifications...").send()
|
||||
new_state = inject_conforme_badge(new_state)
|
||||
|
||||
readme_content = "# Projet\n\n---\n🛡️ **Statut QA :** ✅ Conforme\n"
|
||||
dev_data = new_state.get("generated_code", {}) or {}
|
||||
raw_files = dev_data.get("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") or ""
|
||||
if path.lower() in ["readme.md", "readme"]:
|
||||
readme_content = f.get("content") or f.get("code") or ""
|
||||
break
|
||||
elif isinstance(raw_files, dict):
|
||||
for k, v in raw_files.items():
|
||||
if k.lower() in ["readme.md", "readme"]:
|
||||
readme_content = v
|
||||
break
|
||||
|
||||
await push_readme_update_to_gitea(gitea_url, readme_content)
|
||||
|
||||
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)
|
||||
|
||||
loop_state = resp.json()
|
||||
cl.user_session.set("graph_state", loop_state)
|
||||
final_state = resp.json()
|
||||
cl.user_session.set("graph_state", final_state)
|
||||
|
||||
elif res and res.get("name") == "refuser_projet":
|
||||
feedback_user = await cl.AskUserMessage(
|
||||
content="Veuillez décrire les corrections ou les modifications à apporter au projet.",
|
||||
timeout=600
|
||||
).send()
|
||||
|
||||
await render_workflow_state(loop_state)
|
||||
if feedback_user:
|
||||
new_state["status"] = "human_refused"
|
||||
new_state["user_feedback"] = feedback_user["output"]
|
||||
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)
|
||||
|
||||
await render_workflow_state(loop_state)
|
||||
|
||||
# ==================== PARCOURS 2 : LE PROJET EST NON CONFORME ====================
|
||||
else:
|
||||
qa_feedback = qa_res.get("feedback", "L'agent QA a détecté des anomalies majeures sans description.")
|
||||
|
||||
non_compliant_md = (
|
||||
"🚨 **Projet Non Conforme**\n\n"
|
||||
"Malheureusement, le code produit par ARC n'a pas passé les critères de sécurité ou de qualité requis.\n\n"
|
||||
f"### 📋 Rapport d'audit QA :\n"
|
||||
f"> {qa_feedback}\n\n"
|
||||
"Pour des raisons de sécurité et de qualité, le projet ne peut pas être livré en l'état.\n\n"
|
||||
)
|
||||
|
||||
await cl.Message(content=non_compliant_md).send()
|
||||
|
||||
res = await cl.AskActionMessage(
|
||||
content="**Que souhaitez-vous faire ?**",
|
||||
actions=[
|
||||
cl.Action(name="recommencer_projet", payload={"value": "restart"}, label="🔄 Recommencer de zéro", description="Supprime le dépôt et réinitialise l'analyse"),
|
||||
cl.Action(name="abandonner_projet", payload={"value": "abort"}, label="🗑️ Abandonner & Quitter", description="Supprime le dépôt et réinitialise l'application")
|
||||
],
|
||||
timeout=3600
|
||||
).send()
|
||||
|
||||
if res and res.get("name") in ["recommencer_projet", "abandonner_projet"]:
|
||||
await cl.Message(content="🗑️ **Nettoyage en cours... Suppression du dépôt Gitea...**").send()
|
||||
|
||||
# Suppression du dépôt distant
|
||||
await delete_gitea_repository(gitea_url)
|
||||
|
||||
if res.get("name") == "recommencer_projet":
|
||||
reset_msg = (
|
||||
"🔄 **Ancien projet supprimé avec succès.**\n\n"
|
||||
"Faisons table rase ! Décris-moi ton besoin logiciel pour repartir sur de nouvelles bases : 👇"
|
||||
)
|
||||
await reset_to_start(reset_msg)
|
||||
else:
|
||||
reset_msg = (
|
||||
"❌ **Session fermée et dépôt nettoyé.**\n\n"
|
||||
"Si tu as un nouveau besoin à soumettre plus tard, envoie simplement un message pour démarrer."
|
||||
)
|
||||
await reset_to_start(reset_msg)
|
||||
|
||||
elif current_status == "delivered":
|
||||
await cl.Message(content="✅ Ce projet a déjà été traité et livré.").send()
|
||||
@@ -222,22 +441,10 @@ async def on_open_file(action: cl.Action):
|
||||
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()
|
||||
await reset_to_start()
|
||||
|
||||
|
||||
@cl.on_message
|
||||
@@ -265,5 +472,4 @@ async def on_message(message: cl.Message):
|
||||
new_state = response.json()
|
||||
cl.user_session.set("graph_state", new_state)
|
||||
|
||||
# Appel du rendu
|
||||
await render_workflow_state(new_state)
|
||||
Reference in New Issue
Block a user