diff --git a/backend/app/agents/pm_agent.py b/backend/app/agents/pm_agent.py index cfcfa00..975a5cd 100644 --- a/backend/app/agents/pm_agent.py +++ b/backend/app/agents/pm_agent.py @@ -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.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 : - - transforme l'entrée utilisateur en cahier des charges structuré + Extrait et parse le JSON depuis la réponse du LLM. + 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( - title="Projet généré depuis demande utilisateur", - description=user_input, - requirements=["MVP minimal", "Architecture modulaire"], - constraints=["Python", "LangGraph", "Pydantic"], - target_stack="Python", + title="Automation Script", + description=f"Automatisation du besoin utilisateur: {user_input}", + requirements=[ + f"Exécuter l'action demandée: {user_input}", + "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 ?" ) \ No newline at end of file diff --git a/backend/app/api/routes/workflow.py b/backend/app/api/routes/workflow.py index 8ac92f4..9a6e8a3 100644 --- a/backend/app/api/routes/workflow.py +++ b/backend/app/api/routes/workflow.py @@ -7,5 +7,6 @@ router = APIRouter(tags=["workflow"]) @router.post("/workflow/run", response_model=WorkflowResponse) 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) \ No newline at end of file diff --git a/backend/app/graph/nodes.py b/backend/app/graph/nodes.py index 5e78a5e..d9013cc 100644 --- a/backend/app/graph/nodes.py +++ b/backend/app/graph/nodes.py @@ -1,18 +1,34 @@ +#nodes.py from app.agents.pm_agent import run_pm_agent from app.agents.dev_agent import run_dev_agent from app.agents.qa_agent import run_qa_agent from app.services.retrieval_service import find_existing_project from app.graph.state import WorkflowState -async def pm_node(state: WorkflowState): - prompt = state["user_input"] - if state.get("user_feedback"): - prompt += f"\nRetour utilisateur pour correction : {state['user_feedback']}" +async def pm_node(state: WorkflowState): + history = state.get("chat_history", []) or [] + + 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 { "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, } @@ -53,4 +69,45 @@ async def human_review_node(state: WorkflowState): "existing_project_approved": True, "is_completed": True, "status": "approved_by_human" - } \ No newline at end of file + } + +# 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} +# """ \ No newline at end of file diff --git a/backend/app/graph/state.py b/backend/app/graph/state.py index bdbe1c1..967d352 100644 --- a/backend/app/graph/state.py +++ b/backend/app/graph/state.py @@ -1,4 +1,4 @@ -from typing import TypedDict, Optional, Any, Dict +from typing import TypedDict, Optional, Any, Dict, List class WorkflowState(TypedDict, total=False): user_input: str @@ -10,4 +10,5 @@ class WorkflowState(TypedDict, total=False): loop_count: int # Compteur pour la Loop 1 (Dev <-> QA) user_feedback: Optional[str] # Retours si l'utilisateur refuse le code final is_completed: bool # Statut de livraison finale - status: str \ No newline at end of file + status: str + chat_history: List[Dict[str, str]] \ No newline at end of file diff --git a/backend/app/graph/workflow.py b/backend/app/graph/workflow.py index 385594d..ae78867 100644 --- a/backend/app/graph/workflow.py +++ b/backend/app/graph/workflow.py @@ -12,7 +12,17 @@ from app.graph.nodes import ( human_review_node, ) +def route_entry_point(state: WorkflowState): + if state.get("status") == "spec_approved": + return "retrieval" + return "pm" + # --- 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): # 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("human_review", human_review_node) -graph.set_entry_point("pm") -graph.add_edge("pm", "retrieval") +graph.set_conditional_entry_point( + 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 graph.add_conditional_edges( diff --git a/backend/app/llm/prompts.py b/backend/app/llm/prompts.py index e69de29..18e87cb 100644 --- a/backend/app/llm/prompts.py +++ b/backend/app/llm/prompts.py @@ -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. +# """ \ No newline at end of file diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index db28bb9..db3597a 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -1,14 +1,27 @@ from pydantic import BaseModel -from typing import Optional, Any +from typing import Optional, Any, List, Dict class WorkflowRequest(BaseModel): 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): + user_input: str + user_feedback: Optional[str] = None + chat_history: Optional[List[Dict[str, str]]] = [] + spec: dict status: str - spec: Optional[dict] = None - existing_project: Optional[dict] = None + loop_count: int + existing_project: Optional[Any] = None generated_code: Optional[Any] = None - qa_result: Optional[Any] = None \ No newline at end of file + qa_result: Optional[Any] = None + is_completed: bool \ No newline at end of file diff --git a/backend/app/schemas/spec.py b/backend/app/schemas/spec.py index f76e00a..80003e5 100644 --- a/backend/app/schemas/spec.py +++ b/backend/app/schemas/spec.py @@ -1,10 +1,55 @@ -from pydantic import BaseModel, Field -from typing import List, Optional +#spec.py +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): - title: str = Field(default="Projet ARC") - description: str - requirements: List[str] = Field(default_factory=list) - constraints: List[str] = Field(default_factory=list) - target_stack: Optional[str] = "Python" \ No newline at end of file + title: Optional[str] = Field(None, description="Nom clair et concis du script") + description: Optional[str] = Field(None, description="Description macro de l'objectif du script") + requirements: List[str] = Field(default_factory=list, description="Liste des fonctionnalités pas-à-pas attendues") + constraints: List[str] = Field(default_factory=list, description="Normes, formats de code et conventions de nommage imposés") + 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 \ No newline at end of file diff --git a/backend/app/services/workflow_service.py b/backend/app/services/workflow_service.py index ac21dd0..b32115c 100644 --- a/backend/app/services/workflow_service.py +++ b/backend/app/services/workflow_service.py @@ -1,6 +1,11 @@ from app.graph.workflow import compiled_graph - -async def run_arc_workflow(user_input: str) -> dict: - result = await compiled_graph.ainvoke({"user_input": user_input}) - return result \ No newline at end of file +async def run_arc_workflow(state_data: dict) -> dict: + """ + Prend le state actuel (provenant de l'API/Chainlit), + 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) \ No newline at end of file diff --git a/backend/chainlit_app.py b/backend/chainlit_app.py index 8083e34..7eeb008 100644 --- a/backend/chainlit_app.py +++ b/backend/chainlit_app.py @@ -5,6 +5,20 @@ import json @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() @@ -12,14 +26,98 @@ async def on_chat_start(): @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={"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( - content=f"Résultat workflow :\n```json\n{json.dumps(result, indent=2, ensure_ascii=False)}\n```" - ).send() \ No newline at end of file + # 5. Rendu UI intelligent dans Chainlit + if new_state.get("status") == "spec_incomplete": + spec = new_state.get("spec", {}) + question = spec.get("clarifying_question") + await cl.Message(content=f"**Spécifications incomplètes**\n\n{question}").send() + elif new_state.get("status") == "spec_ready": + 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() \ No newline at end of file diff --git a/backend/chainlit.md b/backend/chainlit_fr-FR.md similarity index 100% rename from backend/chainlit.md rename to backend/chainlit_fr-FR.md diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index a138ca4..bb7ec4a 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -66,7 +66,7 @@ services: - model_storage:/models ports: - "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 networks: - arc-network diff --git a/backend/requirements.txt b/backend/requirements.txt index 1874dbe..ee8a075 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,4 +13,5 @@ python-dotenv==1.0.1 pytest==8.3.3 ruff==0.6.8 bandit==1.7.10 -requests \ No newline at end of file +requests +langchain-openai>=0.1.0 \ No newline at end of file diff --git a/ressources/Etape1.md b/ressources/Etape1.md index a69a9db..1c5a044 100644 --- a/ressources/Etape1.md +++ b/ressources/Etape1.md @@ -29,21 +29,21 @@ gantt Affichage CDC :e1, 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 Créer collection :f2, after e2, 0.5d Structure payload :f3, after f2, 0.5d - section Embedding + section Embedding (optionnel) Intégrer Snowflake Arctic :g1, 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 Filtres payload :h2, after g2, 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 Bouton "utiliser projet" :i2, after h2, 0.5d Bouton "continuer génération" :i3, after h2, 0.5d diff --git a/ressources/test.md b/ressources/test.md index ed36a94..be51256 100644 --- a/ressources/test.md +++ b/ressources/test.md @@ -89,21 +89,21 @@ gantt Affichage CDC :e1, 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 Créer collection :f2, after e2, 0.5d Structure payload :f3, after f2, 0.5d - section Embedding + section Embedding (optionnel) Intégrer Snowflake Arctic :g1, 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 Filtres payload :h2, after g2, 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 Bouton "utiliser projet" :i2, after h2, 0.5d Bouton "continuer génération" :i3, after h2, 0.5d