first push

This commit is contained in:
Chevallier
2026-06-12 18:16:58 +02:00
commit a7d8914e25
53 changed files with 1655 additions and 0 deletions

23
backend/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM python:3.13.13
WORKDIR /workspace
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir \
--trusted-host pypi.org \
--trusted-host pypi.python.org \
--trusted-host files.pythonhosted.org \
-r requirements.txt
COPY . .
RUN chmod +x start.sh
EXPOSE 8000
EXPOSE 8001
CMD ["./start.sh"]

39
backend/README.md Normal file
View File

@@ -0,0 +1,39 @@
# ARC Backend
Backend minimal pour le projet ARC :
- API FastAPI
- orchestration LangGraph
- agents PM / Dev / QA
- interface Chainlit
- intégration future Qdrant / Redis / vLLM
## Installation
```bash
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000
```
## Lancer Chainlit
```bash
chainlit run chainlit_app.py --port 8001
```
## Lancement auto
```bash
docker compose up --build
```
## Tests
```bash
python .\tests\test_snowflake.py
docker compose exec app python tests/test_qdrant.py
```
API dispo sur :
- http://127.0.0.1:8001

View 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"),
}

View 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",
)

View 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
View File

View File

@@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter(tags=["health"])
@router.get("/health")
def health():
return {"status": "ok"}

View 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)

View 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()

View 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",
)

View File

View 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"
}

View 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

View 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
View 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,
)

View File

View File

34
backend/app/main.py Normal file
View 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")

View 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

View 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()

View 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

View 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."],
}

View 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

View 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

View 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"

View File

View 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": [],
}

View 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)

View 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

0
backend/chainlit.md Normal file
View File

25
backend/chainlit_app.py Normal file
View File

@@ -0,0 +1,25 @@
import chainlit as cl
import httpx
import json
@cl.on_chat_start
async def on_chat_start():
await cl.Message(
content="Bonjour 👋 Je suis ARC. Décris-moi ton besoin logiciel."
).send()
@cl.on_message
async def on_message(message: cl.Message):
async with httpx.AsyncClient() as client:
response = await client.post(
"http://127.0.0.1:8000/api/workflow/run",
json={"user_input": message.content},
)
result = response.json()
await cl.Message(
content=f"Résultat workflow :\n```json\n{json.dumps(result, indent=2, ensure_ascii=False)}\n```"
).send()

102
backend/docker-compose.yml Normal file
View File

@@ -0,0 +1,102 @@
services:
qdrant:
image: qdrant/qdrant:latest
container_name: qdrant-arc
ports:
- "6333:6333"
- "6334:6334"
environment:
- QDRANT__TELEMETRY_DISABLED=true
volumes:
- qdrant_storage:/qdrant/storage
networks:
- arc-network
download-model:
image: alpine:latest
container_name: download-embedding-model
volumes:
- model_storage:/models
command: >
sh -c "
if [ ! -f /models/snowflake-arctic-embed-m-v1.5-f16.gguf ]; then
echo 'Téléchargement du modèle (Contournement SSL Proxy activé)...';
wget --no-check-certificate 'https://huggingface.co/Snowflake/snowflake-arctic-embed-m-v1.5/resolve/main/gguf/snowflake-arctic-embed-m-v1.5-f16.gguf' -O /models/snowflake-arctic-embed-m-v1.5-f16.gguf;
echo 'Téléchargement terminé avec succès !';
else
echo 'Le modèle est déjà présent.';
fi
"
embedding-server:
image: ghcr.io/ggml-org/llama.cpp:server
container_name: embedding-arc
volumes:
- model_storage:/models
ports:
- "8002:8080"
command: "-m /models/snowflake-arctic-embed-m-v1.5-f16.gguf --embedding --host 0.0.0.0 --port 8080"
restart: unless-stopped
networks:
- arc-network
depends_on:
download-model:
condition: service_completed_successfully
download-gemma:
image: alpine:latest
container_name: download-gemma-model
volumes:
- model_storage:/models
command: >
sh -c "
if [ ! -f /models/gemma-4-E4B-it-UD-Q4_K_XL.gguf ]; then
echo 'Téléchargement de Gemma 4 (Contournement SSL Proxy)...';
wget --no-check-certificate 'https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF/resolve/main/gemma-4-E4B-it-UD-Q4_K_XL.gguf' -O /models/gemma-4-E4B-it-UD-Q4_K_XL.gguf;
echo 'Téléchargement de Gemma 4 terminé !';
else
echo 'Le modèle Gemma 4 est déjà présent.';
fi
"
gemma-server:
image: ghcr.io/ggml-org/llama.cpp:server
container_name: gemma-arc
volumes:
- 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"
restart: unless-stopped
networks:
- arc-network
depends_on:
download-gemma:
condition: service_completed_successfully
app:
build: .
container_name: arc-app
ports:
- "8000:8000"
- "8001:8001"
volumes:
- .:/workspace
environment:
- PYTHONPATH=/workspace
- QDRANT_URL=http://qdrant:6333
- QDRANT_COLLECTION=arc_projects
- EMBEDDING_SERVER_URL=http://embedding-server:8080
depends_on:
- qdrant
- embedding-server
networks:
- arc-network
volumes:
qdrant_storage:
model_storage:
networks:
arc-network:
driver: bridge

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

16
backend/requirements.txt Normal file
View File

@@ -0,0 +1,16 @@
fastapi==0.117.0
uvicorn[standard]==0.35.0
anyio>=4.6.0
pydantic==2.12
pydantic-settings==2.10.1
langgraph==0.2.39
chainlit==2.11.0
qdrant-client==1.11.3
redis==5.0.8
httpx==0.27.2
openai==1.51.2
python-dotenv==1.0.1
pytest==8.3.3
ruff==0.6.8
bandit==1.7.10
requests

7
backend/start.sh Normal file
View File

@@ -0,0 +1,7 @@
#!/bin/sh
echo "Démarrage du Backend FastAPI sur le port 8000..."
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload &
echo "Démarrage de Chainlit sur le port 8001..."
chainlit run chainlit_app.py --host 0.0.0.0 --port 8001

View File

View File

@@ -0,0 +1,28 @@
import requests
def tester_gemma():
url = "http://localhost:8003/v1/chat/completions"
payload = {
"messages": [
{"role": "user", "content": "Donne-moi une astuce de code Python originale."}
],
"temperature": 0.7
}
print("🧠 Envoi de la requête à Gemma 4...")
try:
response = requests.post(url, json=payload)
response.raise_for_status()
answer = response.json()["choices"][0]["message"]["content"]
print("\n🤖 Réponse de Gemma 4 :")
print("-" * 40)
print(answer)
print("-" * 40)
except Exception as e:
print(f"❌ Erreur : {e}")
if __name__ == "__main__":
tester_gemma()

View File

@@ -0,0 +1,10 @@
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health():
response = client.get("/api/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"

View File

@@ -0,0 +1,55 @@
import asyncio
import random
from app.repositories.qdrant_repository import QdrantRepository
from qdrant_client.http import models
async def test_pipeline():
print("--- Test de connexion Qdrant ---")
repo = QdrantRepository()
try:
# 1. Tester la connexion et initialiser la collection
await repo.init_collection(vector_size=1024)
# 2. Insérer un faux projet pour valider le fonctionnement (Upsert)
print("\n[Test] Insertion d'un faux projet indexé...")
mock_vector = [random.uniform(-1.0, 1.0) for _ in range(1024)]
await repo.client.upsert(
collection_name=repo.collection_name,
points=[
models.PointStruct(
id=1,
vector=mock_vector,
payload={
"title": "Application E-commerce de test",
"description": "Un projet test généré pour valider Qdrant",
"git_url": "https://github.com/test/test"
}
)
]
)
print("[Test] Faux projet inséré.")
# 3. Tester la recherche vectorielle
print("\n[Test] Lancement de la recherche vectorielle...")
project_found = await repo.search_similar_project(query_vector=mock_vector)
if project_found:
print(f"🎉 Succès ! Projet trouvé en BDD : {project_found['title']} ({project_found['git_url']})")
else:
print("❌ Erreur : Aucun projet trouvé alors qu'on vient d'en insérer un.")
except Exception as e:
print(f"❌ Échec critique du test : {e}")
print("Vérifie que ton conteneur Qdrant est bien lancé et que l'URL dans ton .env est correcte.")
finally:
await repo.close()
print("\n--- Fin du test ---")
if __name__ == "__main__":
from dotenv import load_dotenv
load_dotenv()
asyncio.run(test_pipeline())

View File

@@ -0,0 +1,42 @@
import requests
import json
def test_embedding_server():
url = "http://localhost:8002/v1/embeddings"
phrase = "Ceci est un test."
payload = {
"input": phrase
}
headers = {
"Content-Type": "application/json"
}
print("Envoi de la phrase au serveur Snowflake Arctic local...")
try:
response = requests.post(url, json=payload, headers=headers)
response.raise_for_status()
resultat = response.json()
vecteur = resultat["data"][0]["embedding"]
tokens_utilises = resultat["usage"]["total_tokens"]
print("\n[SUCCÈS] Le serveur d'embedding répond parfaitement !")
print(f"Texte analysé : '{phrase}'")
print(f"Nombre de tokens consommés : {tokens_utilises}")
print(f"Dimension du vecteur : {len(vecteur)} (Attendu : 768)")
print(f"Début du vecteur (5 premiers chiffres) : {vecteur[:5]}")
except requests.exceptions.ConnectionError:
print("\n[ERREUR] Impossible de joindre le serveur d'embedding.")
print("Vérifie que ton Docker Compose est bien démarré avec 'docker compose up'.")
except Exception as e:
print(f"\n[ERREUR] Une erreur inattendue est survenue : {e}")
if __name__ == "__main__":
test_embedding_server()

View File