Etape 1 OK, sans optionnel

This commit is contained in:
Chevallier
2026-06-16 11:27:41 +02:00
parent a7d8914e25
commit bacfe49578
15 changed files with 765 additions and 51 deletions

View File

@@ -1,15 +1,179 @@
#pm_agent.py
import json
import re
import logging
from langchain_openai import ChatOpenAI
from app.schemas.spec import ProjectSpec from app.schemas.spec import ProjectSpec
from app.llm.prompts import PM_AGENT_PROMPT
from app.core.config import settings
logger = logging.getLogger(__name__)
async def run_pm_agent(user_input: str, history: list | None = None) -> ProjectSpec:
"""Agent PM qui remplit ProjectSpec en analysant le besoin utilisateur."""
llm = ChatOpenAI(
base_url=settings.llm_base_url,
api_key=settings.llm_api_key,
model="local-model",
temperature=0.3,
)
messages = [
{"role": "system", "content": PM_AGENT_PROMPT},
]
if history:
messages.extend(history)
messages.append({
"role": "user",
"content": f"""En te basant sur TOUT l'historique de notre conversation et sur cette dernière précision, génère le JSON ProjectSpec valide et complètement rempli.
DERNIÈRE PRÉCISION / INPUT UTILISATEUR :
{user_input}
IMPORTANT : Tu dois impérativement combiner les contraintes énoncées précédemment (comme le langage de programmation choisi, l'OS, etc.) avec cette nouvelle information pour enrichir la spécification existante.
Réponds UNIQUEMENT avec du JSON valide. Aucun texte avant le JSON, aucun texte après le JSON."""
})
try:
response = await llm.ainvoke(messages)
raw_response = response.content if hasattr(response, 'content') else str(response)
logger.debug(f"[PM Agent] Réponse brute: {raw_response}")
parsed_spec = _extract_json_robust(raw_response)
project_spec = ProjectSpec(**parsed_spec)
logger.info(f"[PM Agent] ✅ Tentative réussie (parsing JSON manuel)")
project_spec = _validate_and_sanitize(project_spec, user_input)
return project_spec
except Exception as e2:
logger.error(f"[PM Agent] ❌ Tentative échouée: {type(e2).__name__}: {str(e2)}")
logger.info("[PM Agent] Fallback: Génération automatique du spec")
return _generate_fallback_spec(user_input)
async def run_pm_agent(user_input: str) -> ProjectSpec: def _extract_json_robust(response_text: str) -> dict:
""" """
Agent PM minimal : Extrait et parse le JSON depuis la réponse du LLM.
- transforme l'entrée utilisateur en cahier des charges structuré Robuste pour llama.cpp qui ajoute souvent du texte avant/après le JSON.
""" """
logger.debug(f"[JSON Extract] Réponse brute: {response_text}")
# Stratégie 1: Chercher un bloc JSON entre ```json et ```
json_match = re.search(r'```json\s*(.*?)\s*```', response_text, re.DOTALL)
if json_match:
json_str = json_match.group(1).strip()
logger.debug(f"[JSON Extract] ✓ Trouvé via ```json block")
return json.loads(json_str)
# Stratégie 2: Chercher un bloc JSON entre ``` et ``` (sans spécifier json)
json_match = re.search(r'```\s*(.*?)\s*```', response_text, re.DOTALL)
if json_match:
json_str = json_match.group(1).strip()
logger.debug(f"[JSON Extract] ✓ Trouvé via ``` block")
try:
return json.loads(json_str)
except json.JSONDecodeError:
logger.debug(f"[JSON Extract] ✗ Code block trouvé mais pas JSON valide")
# Stratégie 3: Chercher directement un objet JSON { ... } en étant très prudent
first_brace = response_text.find('{')
last_brace = response_text.rfind('}')
if first_brace != -1 and last_brace != -1 and last_brace > first_brace:
json_str = response_text[first_brace:last_brace+1].strip()
logger.debug(f"[JSON Extract] Tentative extraction (longueur: {len(json_str)})")
try:
parsed = json.loads(json_str)
logger.debug(f"[JSON Extract] ✓ Trouvé via extraction")
return parsed
except json.JSONDecodeError as e:
logger.debug(f"[JSON Extract] ✗ Extraction invalide: {e}")
# Stratégie 4: Parser tout le texte comme JSON (dernier recours)
try:
parsed = json.loads(response_text.strip())
logger.debug(f"[JSON Extract] ✓ Trouvé via parsing direct")
return parsed
except json.JSONDecodeError:
logger.debug(f"[JSON Extract] ✗ Parsing direct échoué")
# Aucune stratégie n'a fonctionné
logger.error(f"[JSON Extract] ❌ Impossible d'extraire JSON valide")
logger.error(f"[JSON Extract] Réponse brute complète:\n{response_text}")
raise ValueError(f"Impossible d'extraire un JSON valide de la réponse du LLM.\nRéponse: {response_text}")
def _validate_and_sanitize(spec: ProjectSpec, user_input: str) -> ProjectSpec:
"""
Fait confiance au LLM pour la logique métier.
Se contente de s'assurer de la cohérence technique minimale.
"""
logger.debug(f"[Sanitize] Validation du spec généré pour : {spec.title}")
# 1. Sécurité minimale sur les chaînes de texte vides
if not spec.title or spec.title.lower() in ["null", "none", ""]:
spec.title = "Automation Script"
if not spec.description:
spec.description = f"Script d'automatisation basé sur : {user_input[:50]}..."
# 2. Gestion de la complétude :
if not spec.is_complete or (spec.clarifying_question and spec.clarifying_question.strip()):
spec.is_complete = False
if not spec.clarifying_question:
spec.clarifying_question = "Pourriez-vous apporter plus de précisions sur les actions que doit effectuer ce script ?"
logger.info(f"[Sanitize] Le besoin est qualifié d'INCOMPLET par l'agent PM.")
else:
spec.is_complete = True
spec.clarifying_question = None
logger.info(f"[Sanitize] Le besoin est qualifié de COMPLET par l'agent PM.")
return spec
def _generate_fallback_spec(user_input: str) -> ProjectSpec:
"""Génère un ProjectSpec par défaut en cas d'erreur complète."""
logger.warning(f"[Fallback] Génération d'un spec par défaut pour: {user_input}")
return ProjectSpec( return ProjectSpec(
title="Projet généré depuis demande utilisateur", title="Automation Script",
description=user_input, description=f"Automatisation du besoin utilisateur: {user_input}",
requirements=["MVP minimal", "Architecture modulaire"], requirements=[
constraints=["Python", "LangGraph", "Pydantic"], f"Exécuter l'action demandée: {user_input}",
target_stack="Python", "Gérer les erreurs",
"Générer des logs"
],
constraints=["PEP 8 strict"],
language="Python",
target_stack="",
io_config={
"has_inputs": False,
"input_type": "none",
"input_paths_or_sources": [],
"has_outputs": False,
"output_type": "log_only",
"output_formats": []
},
auth_config={
"requires_auth": False,
"auth_method": "none",
"target_tools_and_apis": []
},
env_config={
"target_os": "cross_platform",
"language_version": "3.11",
"critical_dependencies": ["logging"]
},
error_handling_strategy="log_and_continue",
is_complete=False,
clarifying_question="Pourriez-vous donner plus de détails sur votre demande ?"
) )

View File

@@ -7,5 +7,6 @@ router = APIRouter(tags=["workflow"])
@router.post("/workflow/run", response_model=WorkflowResponse) @router.post("/workflow/run", response_model=WorkflowResponse)
async def run_workflow(payload: WorkflowRequest): async def run_workflow(payload: WorkflowRequest):
result = await run_arc_workflow(payload.user_input) result = await run_arc_workflow(payload.model_dump())
return WorkflowResponse(**result) return WorkflowResponse(**result)

View File

@@ -1,18 +1,34 @@
#nodes.py
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
from app.services.retrieval_service import find_existing_project from app.services.retrieval_service import find_existing_project
from app.graph.state import WorkflowState from app.graph.state import WorkflowState
async def pm_node(state: WorkflowState): async def pm_node(state: WorkflowState):
prompt = state["user_input"] history = state.get("chat_history", []) or []
if state.get("user_feedback"):
prompt += f"\nRetour utilisateur pour correction : {state['user_feedback']}" if state.get("status") == "spec_incomplete" and state.get("user_feedback"):
current_input = state["user_feedback"]
full_user_input = f"{state['user_input']}\n{current_input}"
else:
current_input = state["user_input"]
full_user_input = current_input
spec = await run_pm_agent(user_input=current_input, history=history)
updated_history = list(history)
updated_history.append({"role": "user", "content": current_input})
if not spec.is_complete and spec.clarifying_question:
updated_history.append({"role": "assistant", "content": spec.clarifying_question})
spec = await run_pm_agent(prompt)
return { return {
"spec": spec.model_dump(), "spec": spec.model_dump(),
"status": "spec_ready", "status": "spec_ready" if spec.is_complete else "spec_incomplete",
"chat_history": updated_history,
"user_input": full_user_input,
"user_feedback": None,
"loop_count": 0, "loop_count": 0,
} }
@@ -53,4 +69,45 @@ async def human_review_node(state: WorkflowState):
"existing_project_approved": True, "existing_project_approved": True,
"is_completed": True, "is_completed": True,
"status": "approved_by_human" "status": "approved_by_human"
} }
# def inspect_specifications_gaps(spec_dict: dict) -> list[str]:
# missing_fields = []
# # 1. Vérifications globales (clés alignées sur ProjectSpec)
# if not spec_dict.get("title"): missing_fields.append("title")
# if not spec_dict.get("description"): missing_fields.append("description")
# if not spec_dict.get("requirements"): missing_fields.append("requirements")
# # 2. Entrées/Sorties
# io = spec_dict.get("io_config", {})
# if io.get("has_inputs") is True and not io.get("input_type"):
# missing_fields.append("io_config.input_type")
# if io.get("has_outputs") is True and not io.get("output_type"):
# missing_fields.append("io_config.output_type")
# # 3. Authentification
# auth = spec_dict.get("auth_config", {})
# if auth.get("requires_auth") is True and not auth.get("auth_method"):
# missing_fields.append("auth_config.auth_method")
# return missing_fields
# def generate_clarifying_questions_prompt(missing_fields: list[str]) -> str:
# # Le mapping utilise désormais les EXACTES mêmes clés
# mapping_instructions = {
# "title": "- Préciser un titre pour le projet.",
# "description": "- Expliquer le but global du script.",
# "requirements": "- Lister les fonctionnalités attendues étape par étape.",
# "io_config.input_type": "- Préciser le type et le format des fichiers/données d'entrée (CSV, dossier, etc.).",
# "io_config.output_type": "- Préciser le type et le format attendus en sortie (Excel, PDF, log, etc.).",
# "auth_config.auth_method": "- Clarifier la méthode d'accès ou d'authentification exigée pour l'outil tiers."
# }
# bullet_points = "\n".join([mapping_instructions[field] for field in missing_fields if field in mapping_instructions])
# return f"""
# ATTENTION : Les données suivantes sont obligatoires mais absentes.
# Vous devez formuler une question naturelle et polie pour demander à l'utilisateur de préciser :
# {bullet_points}
# """

View File

@@ -1,4 +1,4 @@
from typing import TypedDict, Optional, Any, Dict from typing import TypedDict, Optional, Any, Dict, List
class WorkflowState(TypedDict, total=False): class WorkflowState(TypedDict, total=False):
user_input: str user_input: str
@@ -10,4 +10,5 @@ class WorkflowState(TypedDict, total=False):
loop_count: int # Compteur pour la Loop 1 (Dev <-> QA) loop_count: int # Compteur pour la Loop 1 (Dev <-> QA)
user_feedback: Optional[str] # Retours si l'utilisateur refuse le code final user_feedback: Optional[str] # Retours si l'utilisateur refuse le code final
is_completed: bool # Statut de livraison finale is_completed: bool # Statut de livraison finale
status: str status: str
chat_history: List[Dict[str, str]]

View File

@@ -12,7 +12,17 @@ from app.graph.nodes import (
human_review_node, human_review_node,
) )
def route_entry_point(state: WorkflowState):
if state.get("status") == "spec_approved":
return "retrieval"
return "pm"
# --- Fonctions de Routage (Conditional Edges) --- # --- Fonctions de Routage (Conditional Edges) ---
def route_after_pm(state: WorkflowState):
current_status = state.get("status")
if current_status in ["spec_incomplete", "spec_ready"]:
return END
return "retrieval"
def route_after_retrieval(state: WorkflowState): def route_after_retrieval(state: WorkflowState):
# Si un projet existe, on demande d'abord à l'humain (via le nœud de review) # Si un projet existe, on demande d'abord à l'humain (via le nœud de review)
@@ -55,8 +65,22 @@ 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.set_entry_point("pm") graph.set_conditional_entry_point(
graph.add_edge("pm", "retrieval") route_entry_point,
{
"pm": "pm",
"retrieval": "retrieval",
},
)
graph.add_conditional_edges(
"pm",
route_after_pm,
{
END: END,
"retrieval": "retrieval",
},
)
# Étape 1 : Choix après recherche vectorielle # Étape 1 : Choix après recherche vectorielle
graph.add_conditional_edges( graph.add_conditional_edges(

View File

@@ -0,0 +1,305 @@
PM_AGENT_PROMPT = """Tu es un Product Manager senior spécialisé en structuration de cahiers des charges.
=== TÂCHE UNIQUE ET EXCLUSIVE ===
Tu reçois une demande utilisateur brute.
Tu DOIS la transformer en JSON valide suivant EXACTEMENT cette structure (ProjectSpec).
⚠️ RÈGLE ABSOLUE : Tu génères UNIQUEMENT du JSON valide. Pas de texte avant, pas de texte après.
Si tu ne comprends pas un champ, tu le remplis avec une valeur par défaut INTELLIGENTE.
---
=== STRUCTURE JSON À RETOURNER ===
{
"title": "...",
"description": "...",
"requirements": ["...", "..."],
"constraints": ["..."],
"language": "...",
"target_stack": "...",
"io_config": {
"has_inputs": true/false,
"input_type": "file|directory|api|database|none",
"input_paths_or_sources": ["..."],
"has_outputs": true/false,
"output_type": "file|directory|database|api_response|log_only",
"output_formats": ["..."]
},
"auth_config": {
"requires_auth": true/false,
"auth_method": "env_variables|service_account_json|oauth2|api_key|none",
"target_tools_and_apis": ["..."]
},
"env_config": {
"target_os": "linux|windows|macos|cross_platform",
"python_version": "^3.11",
"critical_dependencies": ["..."]
},
"error_handling_strategy": "abort_on_error|log_and_continue|retry_policy",
"is_complete": true,
"clarifying_question": null
}
---
=== RÈGLES DE REMPLISSAGE STRICTES (NON-NÉGOCIABLES) ===
**1. title** :
- JAMAIS vide. Jamais null.
- Si vague: génère un titre court et clair dérivé du besoin
- Format: "Verb + Object" (ex: "CSV Data Cleaner", "API Report Generator")
- Si l'utilisateur dit "teste mon code""Code Test Runner"
- Si l'utilisateur dit juste "script""Automation Script"
**2. description** :
- JAMAIS vide. Jamais null.
- 1-2 phrases qui expliquent QUOI et POURQUOI.
- Réutilise les mots clés de la demande pour que ce soit cohérent.
- Ex: "Traite les fichiers CSV mensuels. Filtre les anomalies et génère un rapport Excel."
**3. requirements** :
- JAMAIS vide. JAMAIS une liste vide [].
- MINIMUM 3 items (même générés intelligemment).
- Format: "Action: Détail" ou "Étape N: Décrire l'action"
- Ex:
[
"Lire les données CSV depuis le dossier /input",
"Valider l'intégrité des colonnes requises",
"Générer un rapport Excel avec statistiques"
]
**4. constraints** :
- Peux être vide si pas mentionné.
- Sinon: normes de code, limitations, formats (ex: ["PEP 8 strict", "Logs rotatifs"])
**5. language** :
- JAMAIS vide.
- Par défaut: "Python"
- Sauf si l'utilisateur dit "Node.js", "Go", etc.
**6. target_stack** :
- Peux être vide
- Sauf si l'utilisateur dit "FastAPI", "Django", etc.
**7. io_config.has_inputs** :
- TRUE si l'utilisateur mentionne : "fichier", "dossier", "données", "lire", "importer", "télécharger", "API", "base de données", "table"
- FALSE sinon.
- ⚠️ Si TRUE → input_type DOIT être rempli (pas null)
**8. io_config.input_type** :
- DÉDUIS du contexte :
- "fichier CSV/Excel/JSON/PDF/TXT""file"
- "dossier""directory"
- "API REST/GraphQL/Service Web""api"
- "base de données/table SQL/MongoDB""database"
- Aucun input → "none"
- ❌ JAMAIS null si has_inputs=true
**9. io_config.has_outputs** :
- TRUE si l'utilisateur mentionne : "exporter", "générer", "créer", "sauvegarder", "envoyer", "résultat", "fichier", "rapport"
- FALSE sinon.
**10. io_config.output_type** :
- DÉDUIS du contexte :
- "fichier Excel/CSV/PDF/JSON""file"
- "dossier de résultats""directory"
- "base de données""database"
- "appel API/réponse HTTP""api_response"
- "juste des logs/console""log_only"
- ❌ JAMAIS null si has_outputs=true
**11. io_config.output_formats** :
- Formats de sortie mentionnés (ex: ["CSV", "JSON", "XLSX", "PDF"])
- Peux être vide si has_outputs=false
**12. auth_config.requires_auth** :
- TRUE si l'utilisateur mentionne : "API", "token", "key", "clé", "authentification", "compte", "service account", "OAuth", "login", "credential", "Jira", "SharePoint", "GCP", "AWS", "Azure", "base de données"
- FALSE sinon.
**13. auth_config.auth_method** :
- DÉDUIS du contexte :
- "variables d'environnement/.env""env_variables"
- "fichier .json avec credentials""service_account_json"
- "OAuth/OpenID""oauth2"
- "API key/token""api_key"
- Pas d'auth → "none"
- ⚠️ Si requires_auth=true → DÉDUIS intelligemment, jamais null
**14. auth_config.target_tools_and_apis** :
- Liste les services externes (ex: ["Jira API", "Google Drive", "AWS S3"])
- Peux être vide si requires_auth=false
**15. env_config.target_os** :
- "cross_platform" par défaut
- Sauf si mentionné : "linux", "windows", "macos"
**16. env_config.language_version** :
- "^3.11" par défaut si Python
- Sauf si la version exacte est mentionnée par l'utilisateur (ex: "Python 3.12", "Node.js 18", "Go 1.20")
- Sinon vide
**17. env_config.critical_dependencies** :
- DÉDUIS du contexte :
- Données tabulaires → "pandas"
- API REST → "requests"
- Configuration → "pydantic"
- Base de données → "sqlalchemy", "pymongo"
- Fichiers → "openpyxl", "python-docx", "PyPDF2"
- Parallelisation → "asyncio", "concurrent.futures"
- Toujours inclure: ["logging"] (natif mais important)
**18. error_handling_strategy** :
- JAMAIS vide. JAMAIS null.
- "log_and_continue" par défaut (robuste)
- "abort_on_error" si critique
- "retry_policy" si mentionné "retry", "robustesse", "résilience"
**19. is_complete** :
- TRUE si tu as pu remplir tous les champs principaux (title, description, requirements) sans ambiguïté
- FALSE UNIQUEMENT si quelque chose est vraiment manquant et ambigu
**20. clarifying_question** :
- null si is_complete=true
- Sinon: une question naturelle et polie pour l'utilisateur
- Ex: "Quel format de fichier en entrée (CSV, Excel, JSON) et où souhaitez-vous la sortie ?"
---
=== EXEMPLES CONCRETS ===
### Input 1: "Je veux un script en Ruby qui lit un CSV et l'exporte en Excel"
```json
{
"title": "CSV to Excel Converter",
"description": "Lit un fichier CSV, valide les données et exporte le résultat en format Excel (.xlsx).",
"requirements": [
"Lire le fichier CSV depuis le dossier source",
"Valider les colonnes requises",
"Exporter en fichier Excel avec formatage"
],
"constraints": ["PEP 8 strict"],
"language": "Ruby",
"target_stack": "",
"io_config": {
"has_inputs": true,
"input_type": "file",
"input_paths_or_sources": ["input.csv"],
"has_outputs": true,
"output_type": "file",
"output_formats": ["XLSX"]
},
"auth_config": {
"requires_auth": false,
"auth_method": "none",
"target_tools_and_apis": []
},
"env_config": {
"target_os": "cross_platform",
"language_version": "3.2.1O",
"critical_dependencies": ["pandas", "openpyxl"]
},
"error_handling_strategy": "log_and_continue",
"is_complete": true,
"clarifying_question": null
}
```
### Input 2: "Je dois récupérer des données sur une API Zabbix et les stocker en Python 3.12"
```json
{
"title": "API Data Fetcher",
"description": "Récupère les données depuis une API, les valide et les stocke en base de données.",
"requirements": [
"Appeler l'API avec authentification",
"Parser les données JSON",
"Insérer ou mettre à jour les enregistrements en base de données"
],
"constraints": [],
"language": "Python",
"target_stack": "Zabbix API",
"io_config": {
"has_inputs": true,
"input_type": "api",
"input_paths_or_sources": ["https://api.example.com/data"],
"has_outputs": true,
"output_type": "database",
"output_formats": ["SQL", "JSON"]
},
"auth_config": {
"requires_auth": true,
"auth_method": "api_key",
"target_tools_and_apis": ["REST API"]
},
"env_config": {
"target_os": "cross_platform",
"language_version": "3.12",
"critical_dependencies": ["requests", "sqlalchemy", "pydantic"]
},
"error_handling_strategy": "retry_policy",
"is_complete": true,
"clarifying_question": null
}
```
---
=== DERNIER CHECKPOINT ===
Avant de retourner le JSON :
1. ✅ title rempli ? (pas vide, pas null)
2. ✅ description rempli ? (pas vide, pas null)
3. ✅ requirements rempli ? (pas vide, 3+ items)
4. ✅ target_stack rempli ? (pas vide, pas null)
5. ✅ error_handling_strategy rempli ? (pas vide, pas null)
6. ✅ Si has_inputs=true → input_type rempli ? (pas null)
7. ✅ Si has_outputs=true → output_type rempli ? (pas null)
8. ✅ Si requires_auth=true → auth_method rempli ? (pas null, pas "none")
Si tout est ✅, tu retournes le JSON.
Si un champ fail, tu génères une VALEUR PAR DÉFAUT INTELLIGENTE et marque is_complete=false.
CRITICAL: Si la demande de l'utilisateur est trop courte, trop vague (ex: 'Je veux un script Ruby'), ou manque d'objectifs clairs, tu DOIS impérativement définir is_complete à false et formuler une question précise dans clarifying_question pour lui demander ce que le script doit faire concrètement.
---
=== MAINTENANT, ANALYSE ET GÉNÈRE LE JSON ===
Ci-dessous la demande utilisateur brute. Tu dois transformer cela en JSON ProjectSpec valide, rempli et cohérent.
"""
# DEV_AGENT_PROMPT = """Vous êtes un Product Manager & Architecte Logiciel Senior spécialisé dans la conception d'outils d'automatisation et de scripts robustes en Python.
# Votre objectif est double :
# 1. Agir comme garde-fou technique en imposant des standards de développement d'entreprise drastiques.
# 2. Fournir un code Python prêt à l'emploi, structuré et commenté, accompagné d'un fichier 'requirements.txt' et d'un 'README.md' de qualité professionnelle.
# ---
# ### DIRECTIVES DE CONCEPTION ET STANDARDS IMPOSÉS :
# Vous devez concevoir la solution technique en respectant les règles suivantes :
# 1. ARCHITECTURE ET FORMAT DES LIVRABLES :
# Le projet devra obligatoirement être constitué de 3 briques :
# - Le code source applicatif structuré et modulaire respectant les besoins indiqué par le PM Agent.
# - Un fichier 'requirements.txt' listant l'intégralité des dépendances avec leurs versions fixées.
# - Un fichier 'README.md' d'une qualité professionnelle exemplaire.
# 2. STRUCTURE ET NORMES DU CODE PYTHON :
# - Convention de nommage : Respect strict de la PEP 8. Fonctions, variables et scripts en snake_case (ex: 'file_processor.py'). Classes en PascalCase.
# - Modularité : Pas de script monolithique géant. Séparation claire entre la configuration (chargée via variables d'environnement), la logique métier (fonctions principales) et les connecteurs d'I/O ou d'API.
# - Sécurité : Interdiction formelle d'écrire des clés d'API, des tokens ou des identifiants en dur. Utilisation obligatoire du module 'os' ou de 'pydantic-settings' pour lire l'environnement.
# - Robustesse : Utilisation systématique de blocs 'try/except' ciblés avec un module de 'logging' Python natif (pas de simples 'print').
# 3. STRUCTURE ATTENDUE DU README.md :
# Le fichier d'accompagnement devra obligatoirement comporter les sections suivantes :
# - # [Titre du Projet] (Rappel de l'objectif macro).
# - ## Prérequis (Version de Python, outils tiers requis).
# - ## Installation (Création de l'environnement virtuel, installation des requirements).
# - ## Configuration (Exemple de fichier .env avec les variables nécessaires à créer).
# - ## Utilisation (Explication textuelle et exemples de commandes CLI pour lancer le script).
# ---
# ### PROCESSUS DE TRAVAIL ET SÉCURITÉ DES DONNÉES :
# - Développer à partir des informations fournies par l'Agent PM.
# """

View File

@@ -1,14 +1,27 @@
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, Any from typing import Optional, Any, List, Dict
class WorkflowRequest(BaseModel): class WorkflowRequest(BaseModel):
user_input: str user_input: str
user_feedback: Optional[str] = None
chat_history: Optional[List[Dict[str, str]]] = []
spec: Optional[dict] = {}
status: Optional[str] = "start"
loop_count: Optional[int] = 0
existing_project: Optional[Any] = None
generated_code: Optional[Any] = None
qa_result: Optional[Any] = None
is_completed: Optional[bool] = False
class WorkflowResponse(BaseModel): class WorkflowResponse(BaseModel):
user_input: str
user_feedback: Optional[str] = None
chat_history: Optional[List[Dict[str, str]]] = []
spec: dict
status: str status: str
spec: Optional[dict] = None loop_count: int
existing_project: Optional[dict] = None existing_project: Optional[Any] = None
generated_code: Optional[Any] = None generated_code: Optional[Any] = None
qa_result: Optional[Any] = None qa_result: Optional[Any] = None
is_completed: bool

View File

@@ -1,10 +1,55 @@
from pydantic import BaseModel, Field #spec.py
from typing import List, Optional from pydantic import BaseModel, Field, model_validator
from typing import List, Optional, Literal
class InputOutputConfig(BaseModel):
has_inputs: bool = Field(default=False, description="Le script prend-il des éléments en entrée ?")
input_type: Optional[Literal["file", "directory", "api", "database", "none"]] = Field(None, description="Type d'entrée principale")
input_paths_or_sources: List[str] = Field(default_factory=list, description="Chemins, tables ou endpoints sources")
has_outputs: bool = Field(default=False, description="Le script génère-t-style des éléments en sortie ?")
output_type: Optional[Literal["file", "directory", "database", "api_response", "log_only"]] = Field(None, description="Type de sortie principale")
output_formats: List[str] = Field(default_factory=list, description="Formats attendus (ex: CSV, JSON, XLSX)")
class AuthenticationConfig(BaseModel):
requires_auth: bool = Field(default=False, description="Le script nécessite-t-il des accès sécurisés ?")
auth_method: Optional[Literal["env_variables", "service_account_json", "oauth2", "api_key", "none"]] = Field(None, description="Méthode d'authentification")
target_tools_and_apis: List[str] = Field(default_factory=list, description="Logiciels ou API avec lesquels interagir (ex: Sharepoint, JIRA)")
class EnvironmentConfig(BaseModel):
target_os: Literal["linux", "windows", "macos", "cross_platform"] = Field(default="cross_platform", description="Système d'exploitation cible")
language_version: str = Field(default="^3.11", description="Contrainte de version du langage")
critical_dependencies: List[str] = Field(default_factory=list, description="Librairies tierces indispensables")
class ProjectSpec(BaseModel): class ProjectSpec(BaseModel):
title: str = Field(default="Projet ARC") title: Optional[str] = Field(None, description="Nom clair et concis du script")
description: str description: Optional[str] = Field(None, description="Description macro de l'objectif du script")
requirements: List[str] = Field(default_factory=list) requirements: List[str] = Field(default_factory=list, description="Liste des fonctionnalités pas-à-pas attendues")
constraints: List[str] = Field(default_factory=list) constraints: List[str] = Field(default_factory=list, description="Normes, formats de code et conventions de nommage imposés")
target_stack: Optional[str] = "Python" language: str = Field(default="Python", description="Langage de programmation principal")
target_stack: str = Field(None, description="Stack technique ou framework attendu (ex: Pandas, FastAPI, Flask, Django, etc.)")
# Sous-configurations détaillées (permettent la détection des champs manquants)
io_config: InputOutputConfig = Field(default_factory=InputOutputConfig)
auth_config: AuthenticationConfig = Field(default_factory=AuthenticationConfig)
env_config: EnvironmentConfig = Field(default_factory=EnvironmentConfig)
error_handling_strategy: Literal["abort_on_error", "log_and_continue", "retry_policy"] = Field(
default="log_and_continue",
description="Comportement du script face à une anomalie"
)
is_complete: bool = Field(default=False, description="Passez à True UNIQUEMENT si vous avez TOUTES les infos pour coder (titre, desc, reqs, io_config, auth_config si besoin).")
clarifying_question: Optional[str] = Field(None, description="Si is_complete est False, écrivez ici une question claire et polie pour Chainlit pour demander les détails manquants.")
@model_validator(mode="after")
def validate_conditional_fields(self) -> 'ProjectSpec':
"""Validation des dépendances logiques pour marquer le cahier des charges comme valide."""
# Si les champs majeurs sont remplis, on valide la cohérence interne
if self.title and self.description and len(self.requirements) > 0:
if self.io_config.has_inputs and not self.io_config.input_type:
raise ValueError("input_type manquant alors que has_inputs est True")
if self.io_config.has_outputs and not self.io_config.output_type:
raise ValueError("output_type manquant alors que has_outputs est True")
if self.auth_config.requires_auth and (not self.auth_config.auth_method or self.auth_config.auth_method == "none"):
raise ValueError("auth_method manquante alors que requires_auth est True")
return self

View File

@@ -1,6 +1,11 @@
from app.graph.workflow import compiled_graph from app.graph.workflow import compiled_graph
async def run_arc_workflow(state_data: dict) -> dict:
async def run_arc_workflow(user_input: str) -> dict: """
result = await compiled_graph.ainvoke({"user_input": user_input}) Prend le state actuel (provenant de l'API/Chainlit),
return result exécute le graphe jusqu'au prochain point d'arrêt (END),
et retourne le state mis à jour.
"""
final_state = await compiled_graph.ainvoke(state_data)
return dict(final_state)

View File

@@ -5,6 +5,20 @@ import json
@cl.on_chat_start @cl.on_chat_start
async def 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( await cl.Message(
content="Bonjour 👋 Je suis ARC. Décris-moi ton besoin logiciel." content="Bonjour 👋 Je suis ARC. Décris-moi ton besoin logiciel."
).send() ).send()
@@ -12,14 +26,98 @@ async def on_chat_start():
@cl.on_message @cl.on_message
async def on_message(message: cl.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: async with httpx.AsyncClient() as client:
response = await client.post( response = await client.post(
"http://127.0.0.1:8000/api/workflow/run", "http://127.0.0.1:8000/api/workflow/run",
json={"user_input": message.content}, json=state,
timeout=600.0
) )
result = response.json() # 4. Enregistrer le nouvel état retourné par le serveur
new_state = response.json()
cl.user_session.set("graph_state", new_state)
await cl.Message( # 5. Rendu UI intelligent dans Chainlit
content=f"Résultat workflow :\n```json\n{json.dumps(result, indent=2, ensure_ascii=False)}\n```" if new_state.get("status") == "spec_incomplete":
).send() 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":
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"- **Actions** : {', '.join(spec.get('requirements', []))}\n"
summary+= f"- **Contraintes** : {', '.join(spec.get('constraints', []))}\n"
summary+= f"- **Langage** : {spec.get('language')}\n"
summary += "\n**Est-ce que cela vous convient ?**"
# Ajout des boutons de décision
res = await cl.AskActionMessage(
content=summary,
actions=[
cl.Action(name="oui", payload={"value": "oui"}, label="Oui, c'est parfait 👍"),
cl.Action(name="non", payload={"value": "non"}, label="Non, modifier ❌")
],
timeout=3600
).send()
if res is None:
await cl.Message(
content="⏰ **Session expirée.** Si tu es toujours là, envoie un message pour relancer l'analyse."
).send()
return
if res and res.get("name") == "oui":
await cl.Message(content="🚀 **Spécifications validées !** Lancement de la génération du code...").send()
# On passe le statut attendu par ton graphe
new_state["status"] = "spec_approved"
cl.user_session.set("graph_state", new_state)
# On relance immédiatement le workflow pour exécuter la suite (dev, qa...)
async with httpx.AsyncClient() as client:
response = await client.post(
"http://127.0.0.1:8000/api/workflow/run",
json=new_state,
timeout=600.0
)
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()
else:
# Si "non" (ou si le choix a expiré)
new_state["status"] = "spec_incomplete"
cl.user_session.set("graph_state", new_state)
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()
else:
await cl.Message(
content=f"Résultat workflow :\n```json\n{json.dumps(new_state, indent=2, ensure_ascii=False)}\n```"
).send()

View File

@@ -66,7 +66,7 @@ services:
- model_storage:/models - model_storage:/models
ports: ports:
- "8003:8080" - "8003:8080"
command: "-m /models/gemma-4-E4B-it-UD-Q4_K_XL.gguf --host 0.0.0.0 --port 8080 -c 4096" command: "-m /models/gemma-4-E4B-it-UD-Q4_K_XL.gguf --host 0.0.0.0 --port 8080 -c 8192"
restart: unless-stopped restart: unless-stopped
networks: networks:
- arc-network - arc-network

View File

@@ -13,4 +13,5 @@ python-dotenv==1.0.1
pytest==8.3.3 pytest==8.3.3
ruff==0.6.8 ruff==0.6.8
bandit==1.7.10 bandit==1.7.10
requests requests
langchain-openai>=0.1.0

View File

@@ -29,21 +29,21 @@ gantt
Affichage CDC :e1, after d3, 0.5d Affichage CDC :e1, after d3, 0.5d
Boutons validation/refus :e2, after d3, 0.5d Boutons validation/refus :e2, after d3, 0.5d
section Qdrant (BDD vectorielle) section Qdrant (BDD vectorielle) (optionnel)
Installer Qdrant :f1, after e2, 0.5d Installer Qdrant :f1, after e2, 0.5d
Créer collection :f2, after e2, 0.5d Créer collection :f2, after e2, 0.5d
Structure payload :f3, after f2, 0.5d Structure payload :f3, after f2, 0.5d
section Embedding section Embedding (optionnel)
Intégrer Snowflake Arctic :g1, after f2, 0.5d Intégrer Snowflake Arctic :g1, after f2, 0.5d
Fonction embedding :g2, after f2, 0.5d Fonction embedding :g2, after f2, 0.5d
section Recherche d'existant section Recherche d'existant (optionnel)
Recherche projets similaires :h1, after g2, 0.5d Recherche projets similaires :h1, after g2, 0.5d
Filtres payload :h2, after g2, 0.5d Filtres payload :h2, after g2, 0.5d
Formatage résultats :h3, after h2, 0.5d Formatage résultats :h3, after h2, 0.5d
section Proposition utilisateur section Proposition utilisateur (optionnel)
Affichage résultats Chainlit :i1, after h2, 0.5d Affichage résultats Chainlit :i1, after h2, 0.5d
Bouton "utiliser projet" :i2, after h2, 0.5d Bouton "utiliser projet" :i2, after h2, 0.5d
Bouton "continuer génération" :i3, after h2, 0.5d Bouton "continuer génération" :i3, after h2, 0.5d

View File

@@ -89,21 +89,21 @@ gantt
Affichage CDC :e1, after d3, 0.5d Affichage CDC :e1, after d3, 0.5d
Boutons validation/refus :e2, after d3, 0.5d Boutons validation/refus :e2, after d3, 0.5d
section Qdrant (BDD vectorielle) section Qdrant (BDD vectorielle) (optionnel)
Installer Qdrant :f1, after e2, 0.5d Installer Qdrant :f1, after e2, 0.5d
Créer collection :f2, after e2, 0.5d Créer collection :f2, after e2, 0.5d
Structure payload :f3, after f2, 0.5d Structure payload :f3, after f2, 0.5d
section Embedding section Embedding (optionnel)
Intégrer Snowflake Arctic :g1, after f2, 0.5d Intégrer Snowflake Arctic :g1, after f2, 0.5d
Fonction embedding :g2, after f2, 0.5d Fonction embedding :g2, after f2, 0.5d
section Recherche d'existant section Recherche d'existant (optionnel)
Recherche projets similaires :h1, after g2, 0.5d Recherche projets similaires :h1, after g2, 0.5d
Filtres payload :h2, after g2, 0.5d Filtres payload :h2, after g2, 0.5d
Formatage résultats :h3, after h2, 0.5d Formatage résultats :h3, after h2, 0.5d
section Proposition utilisateur section Proposition utilisateur (optionnel)
Affichage résultats Chainlit :i1, after h2, 0.5d Affichage résultats Chainlit :i1, after h2, 0.5d
Bouton "utiliser projet" :i2, after h2, 0.5d Bouton "utiliser projet" :i2, after h2, 0.5d
Bouton "continuer génération" :i3, after h2, 0.5d Bouton "continuer génération" :i3, after h2, 0.5d