Correction QA: Validation arborescence + qualité/sécurité, ajout logique de fin complète, suppression repo non conforme

This commit is contained in:
Chevallier
2026-07-15 14:00:59 +02:00
parent 2ded228202
commit 9d464fb261
13 changed files with 156656 additions and 230 deletions

View File

@@ -3,8 +3,8 @@ from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage from langchain_core.messages import SystemMessage, HumanMessage
import httpx import httpx
from app.core.config import settings from app.core.config import settings
from app.schemas.qa_report import QAEvaluation from app.schemas.qa_report import QAEvaluation, StructureEvaluation
from app.llm.prompts import QA_AGENT_PROMPT from app.llm.prompts import QA_AGENT_PROMPT, QA_STRUCTURE_AGENT_PROMPT
from app.sandbox.qa_client import run_project_qa from app.sandbox.qa_client import run_project_qa
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -15,8 +15,9 @@ async_client = httpx.AsyncClient(verify=False)
async def run_qa_agent(project_title: str, dev_output: dict) -> QAEvaluation: async def run_qa_agent(project_title: str, dev_output: dict) -> QAEvaluation:
""" """
Agent QA : Agent QA :
Analyse le code généré à l'aide de l'API externe de la Sandbox (Semgrep uniquement) Analyse le code généré à l'aide de l'API externe de la Sandbox (Semgrep uniquement),
et produit le rapport d'évaluation structuré final. valide la structure minimale du projet à l'aide d'un second appel LLM,
puis produit le rapport d'évaluation fusionné.
""" """
logger.info(f"[QA Agent] Début du processus d'audit statique pour le projet : {project_title}") logger.info(f"[QA Agent] Début du processus d'audit statique pour le projet : {project_title}")
@@ -39,12 +40,12 @@ async def run_qa_agent(project_title: str, dev_output: dict) -> QAEvaluation:
http_async_client=async_client, http_async_client=async_client,
) )
# 2. Exécution de l'analyse statique via le service Sandbox indépendant # 1. Exécution de l'analyse statique via le service Sandbox indépendant
try: try:
repo_name = repo_url.rstrip("/").split("/")[-1] repo_name = repo_url.rstrip("/").split("/")[-1]
logger.info(f"[QA Agent] Envoi du dépôt '{repo_name}' au conteneur d'analyse statique...") logger.info(f"[QA Agent] Envoi du dépôt '{repo_name}' au conteneur d'analyse statique...")
raw_results = await run_project_qa(repo_name=repo_name) raw_results = await run_project_qa(repo_url=repo_url)
logger.info(f"[QA Agent] 📊 Retour Sandbox Statis - Status : {'Succès' if raw_results.is_executable else 'Échec'}") logger.info(f"[QA Agent] 📊 Retour Sandbox Statis - Status : {'Succès' if raw_results.is_executable else 'Échec'}")
logger.info(f"[QA Agent] 📊 Retour Sandbox - Nombre d'alertes de sécurité/qualité (Semgrep): {len(raw_results.issues)}") logger.info(f"[QA Agent] 📊 Retour Sandbox - Nombre d'alertes de sécurité/qualité (Semgrep): {len(raw_results.issues)}")
@@ -57,46 +58,84 @@ async def run_qa_agent(project_title: str, dev_output: dict) -> QAEvaluation:
technical_feedback=[f"L'API de la sandbox a échoué : {type(e).__name__}: {str(e)}"] technical_feedback=[f"L'API de la sandbox a échoué : {type(e).__name__}: {str(e)}"]
) )
# 3. Génération de l'évaluation finale via LLM # 2. Premier appel LLM : Audit de sécurité (Semgrep)
structured_llm = llm.with_structured_output(QAEvaluation, strict=True) qa_evaluation = None
try:
logger.info("[QA Agent] Lancement de l'audit de sécurité et de qualité (Semgrep)...")
structured_security_llm = llm.with_structured_output(QAEvaluation, strict=True)
messages = [ security_messages = [
SystemMessage(content=QA_AGENT_PROMPT), SystemMessage(content=QA_AGENT_PROMPT),
HumanMessage(content=(
f"PROJET À AUDITER : {project_title}\n"
f"LIEN DU DÉPÔT : {repo_url}\n"
f"RÉSULTATS DE L'ANALYSE STATIQUE DU CODE (Semgrep) :\n"
f"{raw_results.model_dump_json(indent=2)}\n\n"
f"Analyse ces failles, vulnérabilités et défauts de qualité de code, puis génère ton évaluation de sécurité au format structuré demandé."
))
] ]
# Le prompt est nettoyé des mentions relatives à l'exécution de tests dynamiques qa_evaluation = await structured_security_llm.ainvoke(security_messages)
user_content = f"PROJET À AUDITER : {project_title}\n"
user_content += f"LIEN DU DÉPÔT : {repo_url}\n"
user_content += f"RÉSULTATS DE L'ANALYSE STATIQUE DU CODE (Semgrep) :\n"
user_content += f"{raw_results.model_dump_json(indent=2)}\n\n"
user_content += "Analyse ces failles, vulnérabilités et défauts de qualité de code, puis génère ton évaluation de sécurité au format structuré demandé."
messages.append(HumanMessage(content=user_content))
try:
qa_evaluation = await structured_llm.ainvoke(messages)
logger.info(f"[QA Agent] 📝 Justification du LLM : {qa_evaluation.global_summary}")
if not qa_evaluation:
logger.error("[QA Agent] Le LLM a renvoyé une réponse vide (None).")
return QAEvaluation(
is_complete_and_safe=False,
global_summary="Erreur de formatage de l'évaluation par l'IA.",
technical_feedback=["L'évaluation automatique n'a pas pu être structurée correctement."]
)
if qa_evaluation.is_complete_and_safe:
logger.info(f"[QA Agent] ✅ Projet '{project_title}' VALIDÉ (Analyse statique propre).")
else:
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
except Exception as e: except Exception as e:
logger.error(f"[QA Agent] ❌ Échec critique lors de l'analyse LLM : {type(e).__name__}: {str(e)}") logger.error(f"[QA Agent] ❌ Échec lors de l'audit de sécurité LLM : {type(e).__name__}: {str(e)}")
return QAEvaluation( qa_evaluation = QAEvaluation(
is_complete_and_safe=False, is_complete_and_safe=False,
global_summary="Erreur interne de l'Agent QA lors de la génération du rapport LLM.", global_summary="Erreur lors de la génération du rapport de sécurité par le LLM.",
technical_feedback=[f"Le LLM a crashé avec l'erreur : {type(e).__name__}."] technical_feedback=[f"Le module de sécurité a échoué : {type(e).__name__}"]
)
# 3. Second appel LLM : Validation de la structure minimale (Arborescence)
structure_evaluation = None
try:
logger.info("[QA Agent] Lancement de l'audit de conformité de l'arborescence...")
structured_structure_llm = llm.with_structured_output(StructureEvaluation, strict=True)
repo_structure = raw_results.runtime.stdout or "Arborescence non détectée ou vide."
structure_messages = [
SystemMessage(content=QA_STRUCTURE_AGENT_PROMPT),
HumanMessage(content=(
f"PROJET À ANALYSER : {project_title}\n"
f"ARBORESCENCE DU RÉPERTOIRE CLONÉ :\n"
f"{repo_structure}\n\n"
f"Vérifie si la structure respecte les exigences minimales et fournis ton évaluation structurée."
))
]
structure_evaluation = await structured_structure_llm.ainvoke(structure_messages)
logger.info(f"[QA Agent] 📁 Structure détectée : {structure_evaluation.detected_language} | Valide : {structure_evaluation.is_valid}")
except Exception as e:
logger.error(f"[QA Agent] ❌ Échec lors de la validation de structure LLM : {type(e).__name__}: {str(e)}")
structure_evaluation = StructureEvaluation(
is_valid=False,
detected_language="Inconnu",
missing_elements=["Erreur d'analyse structurelle"],
feedback="Le module d'analyse d'arborescence a rencontré une erreur de traitement."
)
# 4. Fusion des résultats des deux audits
final_is_complete_and_safe = qa_evaluation.is_complete_and_safe and structure_evaluation.is_valid
final_feedback = list(qa_evaluation.technical_feedback)
if not structure_evaluation.is_valid:
for missing in structure_evaluation.missing_elements:
final_feedback.append(f"[Structure] Élément obligatoire manquant : {missing}")
final_summary = (
f"{qa_evaluation.global_summary}\n\n"
f"--- 📁 Validation de la Structure ({structure_evaluation.detected_language}) ---\n"
f"Statut : {'Conforme' if structure_evaluation.is_valid else 'Non conforme'}\n"
f"Feedback : {structure_evaluation.feedback}"
)
if final_is_complete_and_safe:
logger.info(f"[QA Agent] ✅ Projet '{project_title}' VALIDÉ (Sécurité & Structure OK).")
else:
logger.warning(f"[QA Agent] ❌ Projet '{project_title}' REJETÉ (Non-conformité détectée).")
return QAEvaluation(
is_complete_and_safe=final_is_complete_and_safe,
global_summary=final_summary,
technical_feedback=final_feedback
) )

View File

@@ -437,3 +437,16 @@ Tu disposes des données brutes suivantes :
### Ton Style de Feedback : ### Ton Style de Feedback :
Sois ultra-précis et technique. Ne dis pas "Il y a une erreur dans le code", dis plutôt : "La fonction X à la ligne Y lève une exception de type ValueError car la variable Z est passée à None". Traduis chaque erreur technique brute en une instruction claire et actionnable pour l'Agent Dev. Sois ultra-précis et technique. Ne dis pas "Il y a une erreur dans le code", dis plutôt : "La fonction X à la ligne Y lève une exception de type ValueError car la variable Z est passée à None". Traduis chaque erreur technique brute en une instruction claire et actionnable pour l'Agent Dev.
""" """
QA_STRUCTURE_AGENT_PROMPT = """
Tu es un agent expert en architecture et standardisation logicielle.
Ton rôle est d'analyser l'arborescence de fichiers d'un dépôt Git et de valider si la structure respecte rigoureusement les standards minimaux de livraison.
Critères stricts d'un projet conforme :
1. DOCUMENTATION : Présence obligatoire d'un fichier de documentation (ex: README.md, README.txt).
2. DÉPENDANCES : Présence obligatoire d'un fichier de gestion des dépendances adapté au langage (ex: requirements.txt ou pyproject.toml pour Python, package.json pour Node, go.mod pour Go, cargo.toml pour Rust, etc.). Ce fichier doit exister à la racine, même s'il est vide.
3. POINT D'ENTRÉE : Un script principal ou fichier de démarrage cohérent (ex: main.py/app.py pour Python, index.js/server.js pour Node, main.go pour Go, etc.).
4. SUITE DE TESTS : Présence obligatoire d'un dossier dédié aux tests (généralement nommé 'tests' ou 'test') contenant au moins un script de test (ex: test_script.py, app.test.js, etc.).
IMPORTANT : Sois intransigeant sur ces quatre piliers. Si le fichier de dépendances est absent (même si le script n'a pas de dépendances externes) ou si le dossier 'tests' est manquant ou vide, le projet doit être déclaré NON conforme (is_valid = False).
"""

View File

@@ -3,7 +3,7 @@ FROM semgrep/semgrep:latest
USER root USER root
RUN sed -i 's/https/http/g' /etc/apk/repositories && \ RUN sed -i 's/https/http/g' /etc/apk/repositories && \
apk update && apk add --no-cache python3 py3-pip apk update && apk add --no-cache python3 py3-pip git
RUN pip install --no-cache-dir \ RUN pip install --no-cache-dir \
--trusted-host pypi.org \ --trusted-host pypi.org \
@@ -13,6 +13,10 @@ RUN pip install --no-cache-dir \
WORKDIR /app WORKDIR /app
# COPY r2c-security-audit.yaml /app/r2c-security-audit.yaml
COPY rules/ /app/rules/
RUN sed -i 's/adjust_for_docker()/pass/g' /usr/lib/python3.12/site-packages/semgrep/commands/scan.py
COPY main.py . COPY main.py .
EXPOSE 8004 EXPOSE 8004

View File

@@ -1,25 +1,100 @@
import subprocess import subprocess
import json import json
import time import time
from fastapi import FastAPI import tempfile
import os
import logging import logging
import traceback
from fastapi import FastAPI
app = FastAPI() app = FastAPI()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SEMGREP_TIMEOUT = 300.0
@app.post("/scan/{repo_name}") SEMGREP_TIMEOUT = 300.0
async def scan_repository(repo_name: str): SEMGREP_RULES_DIR = "/app/rules/"
repo_path = f"/src/{repo_name}"
issues_list = [] def generate_tree_string(path: str, max_depth: int = 3) -> str:
"""Génère une représentation visuelle de l'arborescence (exclut le bruit)."""
lines = []
exclude_dirs = {".git", "__pycache__", "node_modules", "venv", ".venv", "env"}
def _walk(current_path, depth):
if depth > max_depth:
return
try:
entries = sorted(os.listdir(current_path))
except Exception:
return
for entry in entries:
if entry in exclude_dirs or entry.startswith('.'):
continue
full_path = os.path.join(current_path, entry)
indent = " " * depth
if os.path.isdir(full_path):
lines.append(f"{indent}📁 {entry}/")
_walk(full_path, depth + 1)
else:
lines.append(f"{indent}📄 {entry}")
_walk(path, 0)
return "\n".join(lines)
@app.post("/scan")
async def scan_repository(repo_url: str):
start_time = time.time() start_time = time.time()
issues_list = []
try: try:
secure_command = f"ulimit -f 10240 && semgrep scan --config=p/r2c-security-audit --json --quiet {repo_path}" # 1. CRÉATION DU DOSSIER TEMPORAIRE ET CLONAGE DU PROJET À SCANNER
with tempfile.TemporaryDirectory() as tmp_dir:
repo_path = os.path.join(tmp_dir, "repo")
logger.info(f"[Sandbox] Clonage dynamique du projet {repo_url} dans {repo_path}...")
clone_cmd = f"git clone --depth 1 {repo_url} {repo_path}"
clone_result = subprocess.run(clone_cmd, shell=True, capture_output=True, text=True)
if clone_result.returncode != 0:
logger.error(f"[Sandbox] Échec du git clone du projet : {clone_result.stderr}")
return {
"results": {
"is_executable": False,
"runtime": {
"exit_code": clone_result.returncode,
"stdout": "",
"stderr": f"Impossible de cloner le projet : {clone_result.stderr}",
"duration_seconds": round(time.time() - start_time, 2),
"timeout_triggered": False
},
"issues": []
}
}
repo_tree = generate_tree_string(repo_path)
try:
cloned_files = os.listdir(repo_path)
logger.info(f"[Sandbox] 📁 Fichiers récupérés après clone : {cloned_files}")
if not cloned_files or cloned_files == ['.git']:
logger.warning("[Sandbox] ⚠️ Le dossier cloné est vide (ou ne contient que .git) !")
except Exception as dir_err:
logger.error(f"[Sandbox] Impossible de lister le contenu du dossier cloné : {dir_err}")
# 2. EXÉCUTION DE SEMGREP AVEC LA RÈGLE UNIQUE
try:
if os.path.exists(SEMGREP_RULES_DIR):
logger.info(f"[Sandbox] Répertoire de règles détecté : {SEMGREP_RULES_DIR}")
else:
logger.error(f"[Sandbox] ❌ Répertoire de règles introuvable à l'emplacement : {SEMGREP_RULES_DIR}")
logger.info(f"[Sandbox] Lancement du scan Semgrep avec l'audit unique ({SEMGREP_RULES_DIR})...")
secure_command = f"semgrep scan --config={SEMGREP_RULES_DIR} --json --quiet ."
result = subprocess.run( result = subprocess.run(
secure_command, secure_command,
shell=True, shell=True,
cwd=repo_path,
capture_output=True, capture_output=True,
text=True, text=True,
timeout=SEMGREP_TIMEOUT timeout=SEMGREP_TIMEOUT
@@ -29,30 +104,28 @@ async def scan_repository(repo_name: str):
exit_code = result.returncode exit_code = result.returncode
stderr_output = result.stderr stderr_output = result.stderr
timeout_triggered = False timeout_triggered = False
is_executable = True
except subprocess.TimeoutExpired as te: except subprocess.TimeoutExpired:
logger.error(f"[Sandbox] Semgrep a dépassé le timeout sur {repo_name}") logger.error("[Sandbox] Timeout Semgrep dépassé.")
duration = round(time.time() - start_time, 2)
exit_code = -1
stderr_output = f"L'analyse statique a été coupée : Timeout de {SEMGREP_TIMEOUT}s dépassé."
timeout_triggered = True
is_executable = False
return { return {
"results": { "results": {
"is_executable": is_executable, "is_executable": False,
"runtime": { "runtime": {
"exit_code": exit_code, "exit_code": -1,
"stdout": "", "stdout": "",
"stderr": stderr_output, "stderr": "L'analyse statique a dépassé le timeout global.",
"duration_seconds": duration, "duration_seconds": round(time.time() - start_time, 2),
"timeout_triggered": timeout_triggered "timeout_triggered": True
}, },
"issues": [] "issues": []
} }
} }
# 3. PARSING DES RÉSULTATS
try: try:
if result.returncode == 0:
is_executable = True
stdout_msg = f"Scan statique réussi.\n\n[STRUCTURE DETECTEE] :\n{repo_tree}"
if result.stdout.strip(): if result.stdout.strip():
scan_data = json.loads(result.stdout) scan_data = json.loads(result.stdout)
for item in scan_data.get("results", []): for item in scan_data.get("results", []):
@@ -64,10 +137,14 @@ async def scan_repository(repo_name: str):
"severity": item.get("extra", {}).get("severity", "MEDIUM").upper(), "severity": item.get("extra", {}).get("severity", "MEDIUM").upper(),
"message": item.get("extra", {}).get("message", "") "message": item.get("extra", {}).get("message", "")
}) })
is_executable = True else:
stderr_output = result.stderr is_executable = False
stdout_msg = f"Échec du moteur de scan Semgrep (Exit code {result.returncode})."
logger.error(f"[Sandbox] Semgrep a échoué ! Code: {result.returncode}\nSTDOUT: {result.stdout}\nSTDERR: {result.stderr}")
except Exception as e: except Exception as e:
is_executable = False is_executable = False
stdout_msg = "Erreur lors du traitement des résultats du scan."
stderr_output = f"Erreur parsing JSON Semgrep: {str(e)}\n{result.stderr}" stderr_output = f"Erreur parsing JSON Semgrep: {str(e)}\n{result.stderr}"
return { return {
@@ -75,7 +152,7 @@ async def scan_repository(repo_name: str):
"is_executable": is_executable, "is_executable": is_executable,
"runtime": { "runtime": {
"exit_code": exit_code, "exit_code": exit_code,
"stdout": "Scan statique universel effectué avec succès.", "stdout": stdout_msg,
"stderr": stderr_output, "stderr": stderr_output,
"duration_seconds": duration, "duration_seconds": duration,
"timeout_triggered": timeout_triggered "timeout_triggered": timeout_triggered
@@ -83,3 +160,20 @@ async def scan_repository(repo_name: str):
"issues": issues_list "issues": issues_list
} }
} }
except Exception as global_e:
error_trace = traceback.format_exc()
logger.error(f"[Sandbox] CRASH INTERNE CRITIQUE :\n{error_trace}")
return {
"results": {
"is_executable": False,
"runtime": {
"exit_code": -99,
"stdout": "Crash critique du conteneur de la Sandbox.",
"stderr": f"Exception Python : {str(global_e)}\n\nTraceback complet :\n{error_trace}",
"duration_seconds": round(time.time() - start_time, 2),
"timeout_triggered": False
},
"issues": []
}
}

View File

@@ -4,23 +4,42 @@ from app.core.config import settings
from app.schemas.qa_report import QARawResults, RuntimeOutput from app.schemas.qa_report import QARawResults, RuntimeOutput
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async_client = httpx.AsyncClient(verify=False) async_client = httpx.AsyncClient(verify=False)
async def run_project_qa(repo_name: str) -> QARawResults: async def run_project_qa(repo_url: str) -> QARawResults:
""" """
Sollicite l'API du conteneur persistant arc-sandbox pour effectuer Sollicite l'API du conteneur arc-sandbox pour effectuer
une analyse de qualité et sécurité statique (Semgrep). une analyse de qualité et sécurité statique (Semgrep) en clonant le dépôt.
""" """
sandbox_url = f"http://arc-sandbox:8004/scan/{repo_name}" sandbox_url = "http://arc-sandbox:8004/scan"
logger.info(f"[QA Client] Envoi de la requête de scan à la sandbox : {sandbox_url}") logger.info(f"[QA Client] Envoi de la requête de scan pour le dépôt : {repo_url}")
try: try:
response = await async_client.post(sandbox_url, timeout=120.0) response = await async_client.post(sandbox_url, params={"repo_url": repo_url}, timeout=150.0)
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
return QARawResults.model_validate(data.get("results"))
if data is None:
logger.error("[QA Client] ❌ La Sandbox a répondu 200 OK mais a renvoyé une réponse vide (null).")
return QARawResults(
is_executable=False,
runtime=RuntimeOutput(
exit_code=-1,
stdout="",
stderr="Erreur interne Sandbox : Réponse JSON vide.",
duration_seconds=0,
timeout_triggered=False
),
issues=[]
)
results = data.get("results", {})
if not results.get("is_executable"):
logger.error(f"[QA Client] ❌ Erreur interne Sandbox (Semgrep STDERR) : {results.get('runtime', {}).get('stderr')}")
return QARawResults.model_validate(results)
else: else:
logger.error(f"[QA Client] Erreur de la Sandbox (Status {response.status_code}): {response.text}") logger.error(f"[QA Client] Erreur de la Sandbox (Status {response.status_code}): {response.text}")
return QARawResults( return QARawResults(

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -30,3 +30,10 @@ class QAEvaluation(BaseModel):
default_factory=list, default_factory=list,
description="Instructions de correction ultra-précises destinées au Dev Agent" description="Instructions de correction ultra-précises destinées au Dev Agent"
) )
class StructureEvaluation(BaseModel):
is_valid: bool = Field(description="True si le projet possède un README, un fichier de dépendance cohérent et un point d'entrée d'exécution.")
detected_language: str = Field(description="Le langage principal identifié d'après la structure (ex: Python, Node, Go, Rust...).")
missing_elements: list[str] = Field(description="Liste des fichiers obligatoires manquants (ex: ['README.md', 'requirements.txt']). Vide si tout est OK.")
feedback: str = Field(description="Courte explication des manquements ou félicitations sur la structure.")

View File

@@ -6,7 +6,166 @@ import json
import os import os
import io 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): async def render_workflow_state(new_state: dict):
""" """
Fonction centrale pour aiguiller l'affichage Chainlit Fonction centrale pour aiguiller l'affichage Chainlit
@@ -62,7 +221,6 @@ async def render_workflow_state(new_state: dict):
final_state = response.json() final_state = response.json()
cl.user_session.set("graph_state", final_state) 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) await render_workflow_state(final_state)
else: else:
@@ -74,9 +232,17 @@ async def render_workflow_state(new_state: dict):
).send() ).send()
elif current_status in ["wait_human_review", "approved_by_human"]: elif current_status in ["wait_human_review", "approved_by_human"]:
dev_data = new_state.get("generated_code", {}) dev_data = new_state.get("generated_code", {}) or {}
qa_res = new_state.get("qa_result", {}) qa_res = new_state.get("qa_result", {}) or {}
gitea_url = dev_data.get("repo_url", "")
if gitea_url and "git-arc:3000" in gitea_url:
gitea_url = gitea_url.replace("git-arc:3000", "localhost:3000")
is_safe = qa_res.get("is_complete_and_safe", False)
# ==================== PARCOURS 1 : LE PROJET EST CONFORME ====================
if is_safe:
raw_files = dev_data.get("files", {}) raw_files = dev_data.get("files", {})
files = {} files = {}
if isinstance(raw_files, list): if isinstance(raw_files, list):
@@ -89,21 +255,12 @@ async def render_workflow_state(new_state: dict):
elif isinstance(raw_files, dict): elif isinstance(raw_files, dict):
files = raw_files files = raw_files
is_safe = qa_res.get("is_complete_and_safe", False) access_md = "**Résultat global QA :** ✅ CONFORME\n\n"
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" access_md += "### 🔗 Accès au projet\n\n"
if gitea_url: if gitea_url:
access_md += f"🔗 **Lien vers le dépôt Gitea :** [Accéder au dépôt]({gitea_url})\n\n" 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 = dev_data.get("spec_title", "projet")
project_title = new_state.get("generated_code", {}).get("spec_title", "projet")
message_elements = [] message_elements = []
if files: if files:
@@ -121,7 +278,6 @@ async def render_workflow_state(new_state: dict):
await cl.Message(content=access_md, elements=message_elements).send() 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) cl.user_session.set("current_files", files)
file_actions = [] file_actions = []
@@ -141,21 +297,42 @@ async def render_workflow_state(new_state: dict):
await files_message.send() await files_message.send()
cl.user_session.set("files_message", files_message) cl.user_session.set("files_message", files_message)
# 4. Demande de validation (Boutons Valider / Refuser)
res = await cl.AskActionMessage( res = await cl.AskActionMessage(
content="**Souhaitez-vous valider et packager ce livrable ?**", content="**Souhaitez-vous valider et packager ce livrable ?**",
actions=[ 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="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 vos commentaires") cl.Action(name="refuser_projet", payload={"value": "refuse"}, label="❌ Refuser et corriger", description="Renvoie le projet au PM avec tes commentaires")
], ],
timeout=3600 timeout=3600
).send() ).send()
if res and res.get("name") == "valider_projet": if res and res.get("name") == "valider_projet":
await cl.Message(content="🎉 **Projet validé.**").send() await cl.Message(content="🎉 **Projet validé et marqué comme conforme !**").send()
new_state["status"] = "human_approved"
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) cl.user_session.set("graph_state", new_state)
async with httpx.AsyncClient() as client: 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) resp = await client.post("http://127.0.0.1:8000/api/workflow/run", json=new_state, timeout=600.0)
@@ -183,6 +360,48 @@ async def render_workflow_state(new_state: dict):
await render_workflow_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": elif current_status == "delivered":
await cl.Message(content="✅ Ce projet a déjà été traité et livré.").send() await cl.Message(content="✅ Ce projet a déjà été traité et livré.").send()
else: else:
@@ -222,22 +441,10 @@ async def on_open_file(action: cl.Action):
await new_viewer.send(for_id=message_id) await new_viewer.send(for_id=message_id)
cl.user_session.set("side_code_viewer", new_viewer) cl.user_session.set("side_code_viewer", new_viewer)
@cl.on_chat_start @cl.on_chat_start
async def on_chat_start(): async def on_chat_start():
initial_state = { await reset_to_start()
"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 @cl.on_message
@@ -265,5 +472,4 @@ async def on_message(message: cl.Message):
new_state = response.json() new_state = response.json()
cl.user_session.set("graph_state", new_state) cl.user_session.set("graph_state", new_state)
# Appel du rendu
await render_workflow_state(new_state) await render_workflow_state(new_state)

View File

@@ -7,8 +7,6 @@ services:
container_name: arc-sandbox container_name: arc-sandbox
ports: ports:
- "8004:8004" - "8004:8004"
volumes:
- ./temp_repos:/src:rw
networks: networks:
- arc-network - arc-network