first push
This commit is contained in:
14
backend/app/agents/dev_agent.py
Normal file
14
backend/app/agents/dev_agent.py
Normal file
@@ -0,0 +1,14 @@
|
||||
async def run_dev_agent(spec: dict, qa_feedback: list = None) -> dict:
|
||||
"""
|
||||
Agent Dev minimal :
|
||||
- retourne une pseudo arborescence + un code exemple
|
||||
"""
|
||||
return {
|
||||
"tree": [
|
||||
"main.py",
|
||||
"README.md",
|
||||
"app/__init__.py",
|
||||
],
|
||||
"code": 'print("Hello from ARC generated project")',
|
||||
"spec_title": spec.get("title"),
|
||||
}
|
||||
15
backend/app/agents/pm_agent.py
Normal file
15
backend/app/agents/pm_agent.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from app.schemas.spec import ProjectSpec
|
||||
|
||||
|
||||
async def run_pm_agent(user_input: str) -> ProjectSpec:
|
||||
"""
|
||||
Agent PM minimal :
|
||||
- transforme l'entrée utilisateur en cahier des charges structuré
|
||||
"""
|
||||
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",
|
||||
)
|
||||
10
backend/app/agents/qa_agent.py
Normal file
10
backend/app/agents/qa_agent.py
Normal file
@@ -0,0 +1,10 @@
|
||||
async def run_qa_agent(generated_code: dict) -> dict:
|
||||
"""
|
||||
Agent QA minimal :
|
||||
- renvoie un statut de validation simulé
|
||||
"""
|
||||
return {
|
||||
"status": "passed",
|
||||
"logs": [],
|
||||
"checked_files": generated_code.get("tree", []),
|
||||
}
|
||||
0
backend/app/api/deps.py
Normal file
0
backend/app/api/deps.py
Normal file
8
backend/app/api/routes/health.py
Normal file
8
backend/app/api/routes/health.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
11
backend/app/api/routes/workflow.py
Normal file
11
backend/app/api/routes/workflow.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from fastapi import APIRouter
|
||||
from app.schemas.api import WorkflowRequest, WorkflowResponse
|
||||
from app.services.workflow_service import run_arc_workflow
|
||||
|
||||
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)
|
||||
return WorkflowResponse(**result)
|
||||
25
backend/app/core/config.py
Normal file
25
backend/app/core/config.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = "ARC Backend"
|
||||
app_env: str = "dev"
|
||||
app_host: str = "0.0.0.0"
|
||||
app_port: int = 8000
|
||||
|
||||
qdrant_url: str = "http://localhost:6333"
|
||||
qdrant_collection: str = "arc_projects"
|
||||
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
llm_base_url: str = "http://gemma-server:8080/v1"
|
||||
llm_api_key: str = "llama-cpp-local"
|
||||
# llm_model: str = "gemma-4-E4B-it-UD-Q4_K_XL.gguf"
|
||||
|
||||
embedding_base_url: str = "http://localhost:8002/v1"
|
||||
embedding_model: str = "snowflake-arctic-embed-m-v1.5"
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
8
backend/app/core/logging.py
Normal file
8
backend/app/core/logging.py
Normal file
@@ -0,0 +1,8 @@
|
||||
import logging
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
||||
)
|
||||
0
backend/app/core/security.py
Normal file
0
backend/app/core/security.py
Normal file
56
backend/app/graph/nodes.py
Normal file
56
backend/app/graph/nodes.py
Normal file
@@ -0,0 +1,56 @@
|
||||
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']}"
|
||||
|
||||
spec = await run_pm_agent(prompt)
|
||||
return {
|
||||
"spec": spec.model_dump(),
|
||||
"status": "spec_ready",
|
||||
"loop_count": 0,
|
||||
}
|
||||
|
||||
async def retrieval_node(state: WorkflowState):
|
||||
existing_project = await find_existing_project(state["user_input"])
|
||||
return {
|
||||
"existing_project": existing_project,
|
||||
"status": "existing_found" if existing_project else "no_existing_project",
|
||||
}
|
||||
|
||||
async def dev_node(state: WorkflowState):
|
||||
qa_logs = state.get("qa_result", {}).get("logs", "") if state.get("qa_result") else None
|
||||
|
||||
generated_code = await run_dev_agent(state["spec"], qa_feedback=qa_logs)
|
||||
return {
|
||||
"generated_code": generated_code,
|
||||
"status": "code_generated",
|
||||
}
|
||||
|
||||
async def qa_node(state: WorkflowState):
|
||||
qa_result = await run_qa_agent(state["generated_code"])
|
||||
current_loops = state.get("loop_count", 0)
|
||||
|
||||
is_success = True
|
||||
|
||||
clean_qa_result = {"success": is_success, "raw": qa_result}
|
||||
|
||||
return {
|
||||
"qa_result": clean_qa_result,
|
||||
"loop_count": current_loops if is_success else current_loops + 1,
|
||||
"status": "qa_done",
|
||||
}
|
||||
|
||||
async def human_review_node(state: WorkflowState):
|
||||
print("[Human Review] Passage en mode automatique (Mock)...")
|
||||
|
||||
return {
|
||||
"existing_project_approved": True,
|
||||
"is_completed": True,
|
||||
"status": "approved_by_human"
|
||||
}
|
||||
13
backend/app/graph/state.py
Normal file
13
backend/app/graph/state.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from typing import TypedDict, Optional, Any, Dict
|
||||
|
||||
class WorkflowState(TypedDict, total=False):
|
||||
user_input: str
|
||||
spec: dict
|
||||
existing_project: Optional[dict]
|
||||
existing_project_approved: Optional[bool] # Choix utilisateur si projet similaire trouvé
|
||||
generated_code: Optional[Dict[str, str]] # Arborescence et code
|
||||
qa_result: Optional[dict] # Contient les clés 'success' et 'logs'
|
||||
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
|
||||
92
backend/app/graph/workflow.py
Normal file
92
backend/app/graph/workflow.py
Normal file
@@ -0,0 +1,92 @@
|
||||
import warnings
|
||||
from langchain_core._api.deprecation import LangChainPendingDeprecationWarning
|
||||
warnings.filterwarnings("ignore", category=LangChainPendingDeprecationWarning)
|
||||
|
||||
from langgraph.graph import StateGraph, END
|
||||
from app.graph.state import WorkflowState
|
||||
from app.graph.nodes import (
|
||||
pm_node,
|
||||
retrieval_node,
|
||||
dev_node,
|
||||
qa_node,
|
||||
human_review_node,
|
||||
)
|
||||
|
||||
# --- Fonctions de Routage (Conditional Edges) ---
|
||||
|
||||
def route_after_retrieval(state: WorkflowState):
|
||||
# Si un projet existe, on demande d'abord à l'humain (via le nœud de review)
|
||||
if state.get("existing_project"):
|
||||
return "human_review"
|
||||
return "dev"
|
||||
|
||||
def route_after_qa(state: WorkflowState):
|
||||
qa_res = state.get("qa_result", {})
|
||||
|
||||
# Loop 1 : Si échec des tests ET qu'on a pas dépassé 3 essais -> On renvoie chez le Dev
|
||||
if not qa_res.get("success") and state.get("loop_count", 0) < 3:
|
||||
return "dev"
|
||||
|
||||
# Si c'est vert (ou trop d'échecs), on présente le résultat à l'utilisateur
|
||||
# EXTENSION FUTURE : si trop d'échecs, on pourrait envoyer à une IA plus puissante
|
||||
return "human_review"
|
||||
|
||||
def route_after_human(state: WorkflowState):
|
||||
# Cas d'un projet existant proposé
|
||||
if state.get("existing_project") and not state.get("generated_code"):
|
||||
if state.get("existing_project_approved") == True:
|
||||
return END # L'utilisateur est satisfait du projet existant
|
||||
return "dev" # L'utilisateur refuse l'existant, on génère du neuf
|
||||
|
||||
# Cas du code généré
|
||||
if state.get("is_completed") == True:
|
||||
return END
|
||||
|
||||
# Si l'utilisateur a refusé le code -> Retour à la case PM avec ses commentaires
|
||||
return "pm"
|
||||
|
||||
# --- Assemblage du Graphe ---
|
||||
|
||||
graph = StateGraph(WorkflowState)
|
||||
|
||||
graph.add_node("pm", pm_node)
|
||||
graph.add_node("retrieval", retrieval_node)
|
||||
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")
|
||||
|
||||
# Étape 1 : Choix après recherche vectorielle
|
||||
graph.add_conditional_edges(
|
||||
"retrieval",
|
||||
route_after_retrieval,
|
||||
{
|
||||
"dev": "dev",
|
||||
"human_review": "human_review",
|
||||
},
|
||||
)
|
||||
|
||||
# Étape 2 & 3 : Boucle Dev <-> QA (Loop 1)
|
||||
graph.add_edge("dev", "qa")
|
||||
graph.add_conditional_edges(
|
||||
"qa",
|
||||
route_after_qa,
|
||||
{
|
||||
"dev": "dev",
|
||||
"human_review": "human_review",
|
||||
},
|
||||
)
|
||||
|
||||
# Étape 4 : Boucle de Feedback Humain (Loop 2) ou Clôture
|
||||
graph.add_conditional_edges(
|
||||
"human_review",
|
||||
route_after_human,
|
||||
{
|
||||
"pm": "pm",
|
||||
"dev": "dev",
|
||||
END: END,
|
||||
},
|
||||
)
|
||||
compiled_graph = graph.compile()
|
||||
12
backend/app/llm/client.py
Normal file
12
backend/app/llm/client.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from openai import AsyncOpenAI
|
||||
from app.core.config import settings
|
||||
|
||||
def get_llm_client() -> AsyncOpenAI:
|
||||
"""
|
||||
Initialise le client de génération (LLM) compatible OpenAI.
|
||||
Configuré pour pointer vers notre instance locale llama.cpp (Gemma 4).
|
||||
"""
|
||||
return AsyncOpenAI(
|
||||
base_url=settings.llm_base_url,
|
||||
api_key=settings.llm_api_key,
|
||||
)
|
||||
0
backend/app/llm/prompts.py
Normal file
0
backend/app/llm/prompts.py
Normal file
0
backend/app/llm/providers.py
Normal file
0
backend/app/llm/providers.py
Normal file
34
backend/app/main.py
Normal file
34
backend/app/main.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from fastapi import FastAPI
|
||||
from contextlib import asynccontextmanager
|
||||
from app.api.routes.health import router as health_router
|
||||
from app.api.routes.workflow import router as workflow_router
|
||||
from app.core.config import settings
|
||||
from app.core.logging import setup_logging
|
||||
from app.repositories.qdrant_repository import QdrantRepository
|
||||
|
||||
setup_logging()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
print("[Startup] Initialisation automatique de Qdrant dans Docker...")
|
||||
qdrant_repo = QdrantRepository()
|
||||
try:
|
||||
await qdrant_repo.init_collection(vector_size=1024)
|
||||
except Exception as e:
|
||||
print(f"[Startup] Erreur lors de l'initialisation de Qdrant : {e}")
|
||||
yield
|
||||
|
||||
print("[Shutdown] Fermeture propre de la connexion Qdrant...")
|
||||
await qdrant_repo.close()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
openapi_url=None,
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
app.include_router(health_router, prefix="/api")
|
||||
app.include_router(workflow_router, prefix="/api")
|
||||
10
backend/app/models/project.py
Normal file
10
backend/app/models/project.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class ProjectRecord(BaseModel):
|
||||
id: Optional[str] = None
|
||||
title: str
|
||||
summary: str
|
||||
tags: List[str] = []
|
||||
repository_url: Optional[str] = None
|
||||
0
backend/app/repositories/project_repository.py
Normal file
0
backend/app/repositories/project_repository.py
Normal file
58
backend/app/repositories/qdrant_repository.py
Normal file
58
backend/app/repositories/qdrant_repository.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# backend/app/repositories/qdrant_repository.py
|
||||
from typing import Optional, List
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
from qdrant_client.http import models
|
||||
from app.core.config import settings
|
||||
|
||||
class QdrantRepository:
|
||||
def __init__(self):
|
||||
# Initialisation du client asynchrone
|
||||
self.client = AsyncQdrantClient(
|
||||
url=settings.qdrant_url,
|
||||
# api_key=getattr(settings, "qdrant_api_key", None) # Qdrant Cloud
|
||||
)
|
||||
self.collection_name = settings.qdrant_collection
|
||||
|
||||
async def init_collection(self, vector_size: int = 1024):
|
||||
"""
|
||||
Crée la collection si elle n'existe pas encore.
|
||||
1024 correspond à la taille des vecteurs de Snowflake Arctic Embed 2.0 (large).
|
||||
"""
|
||||
exists = await self.client.collection_exists(collection_name=self.collection_name)
|
||||
if not exists:
|
||||
print(f"[Qdrant] Création de la collection '{self.collection_name}'...")
|
||||
await self.client.create_collection(
|
||||
collection_name=self.collection_name,
|
||||
vectors_config=models.VectorParams(
|
||||
size=vector_size,
|
||||
distance=models.Distance.COSINE
|
||||
)
|
||||
)
|
||||
print("[Qdrant] Collection créée avec succès.")
|
||||
else:
|
||||
print(f"[Qdrant] La collection '{self.collection_name}' existe déjà.")
|
||||
|
||||
async def search_similar_project(self, query_vector: List[float], limit: int = 1) -> Optional[dict]:
|
||||
"""
|
||||
Effectue la vraie recherche vectorielle.
|
||||
Note : On passe un 'query_vector' (généré par ton embedding_service) et non du texte brut.
|
||||
"""
|
||||
try:
|
||||
results = await self.client.search(
|
||||
collection_name=self.collection_name,
|
||||
query_vector=query_vector,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
if results:
|
||||
# On retourne le payload (les métadonnées du projet) du meilleur match
|
||||
return results[0].payload
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Qdrant] Erreur lors de la recherche : {e}")
|
||||
return None
|
||||
|
||||
async def close(self):
|
||||
"""Ferme proprement la connexion au client"""
|
||||
await self.client.close()
|
||||
13
backend/app/repositories/redis_repository.py
Normal file
13
backend/app/repositories/redis_repository.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class RedisRepository:
|
||||
"""
|
||||
Stub minimal Redis (optionnel).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.url = settings.redis_url
|
||||
|
||||
async def ping(self) -> bool:
|
||||
return True
|
||||
8
backend/app/sandbox/docker_runner.py
Normal file
8
backend/app/sandbox/docker_runner.py
Normal file
@@ -0,0 +1,8 @@
|
||||
async def run_in_sandbox(code: str) -> dict:
|
||||
"""
|
||||
Stub minimal pour future exécution sécurisée dans Docker.
|
||||
"""
|
||||
return {
|
||||
"status": "not_implemented",
|
||||
"logs": ["Sandbox Docker non branchée à l'étape 0."],
|
||||
}
|
||||
14
backend/app/schemas/api.py
Normal file
14
backend/app/schemas/api.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Any
|
||||
|
||||
|
||||
class WorkflowRequest(BaseModel):
|
||||
user_input: str
|
||||
|
||||
|
||||
class WorkflowResponse(BaseModel):
|
||||
status: str
|
||||
spec: Optional[dict] = None
|
||||
existing_project: Optional[dict] = None
|
||||
generated_code: Optional[Any] = None
|
||||
qa_result: Optional[Any] = None
|
||||
10
backend/app/schemas/project.py
Normal file
10
backend/app/schemas/project.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class ProjectSummary(BaseModel):
|
||||
id: Optional[str] = None
|
||||
title: str
|
||||
summary: str
|
||||
tags: List[str] = []
|
||||
repository_url: Optional[str] = None
|
||||
10
backend/app/schemas/spec.py
Normal file
10
backend/app/schemas/spec.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
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"
|
||||
0
backend/app/services/delivery_service.py
Normal file
0
backend/app/services/delivery_service.py
Normal file
40
backend/app/services/embedding_service.py
Normal file
40
backend/app/services/embedding_service.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import httpx
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
async def build_embedding(text: str) -> dict:
|
||||
"""
|
||||
Génère un vecteur d'embedding en interrogeant le conteneur local llama.cpp
|
||||
"""
|
||||
url = f"{settings.embedding_base_url}/embeddings"
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"input": text,
|
||||
"model": settings.embedding_model
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
try:
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
vector = data["data"][0]["embedding"]
|
||||
|
||||
return {
|
||||
"model": settings.embedding_model,
|
||||
"text_length": len(text),
|
||||
"vector": vector,
|
||||
}
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
print(f"Erreur lors de la génération de l'embedding : {e}")
|
||||
return {
|
||||
"model": settings.embedding_model,
|
||||
"text_length": len(text),
|
||||
"vector": [],
|
||||
}
|
||||
11
backend/app/services/retrieval_service.py
Normal file
11
backend/app/services/retrieval_service.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from app.repositories.qdrant_repository import QdrantRepository
|
||||
from app.services.embedding_service import build_embedding
|
||||
|
||||
|
||||
qdrant_repository = QdrantRepository()
|
||||
|
||||
|
||||
async def find_existing_project(user_input: str):
|
||||
# query_vector = await build_embedding.get_mesh_embedding(user_input)
|
||||
dummy_vector = [0.0] * 1024 # A modifier avec un vrai embedding plus tard TODO
|
||||
return await qdrant_repository.search_similar_project(query_vector=dummy_vector)
|
||||
6
backend/app/services/workflow_service.py
Normal file
6
backend/app/services/workflow_service.py
Normal file
@@ -0,0 +1,6 @@
|
||||
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
|
||||
Reference in New Issue
Block a user