Browse Source

Initialize repository and commit S1_skeleton changes

Auto Commit 2 weeks ago
commit
041bc7e8b2

BIN
S1_carnet_TP.pdf View File


+ 30
- 0
s1_skeleton/.env.example View File

@@ -0,0 +1,30 @@
1
+# ──────────────────────────────────────────────────────────────────
2
+# Configuration de l'agent — copier en .env et adapter les valeurs
3
+# NE JAMAIS committer le fichier .env (il est dans .gitignore)
4
+# ──────────────────────────────────────────────────────────────────
5
+
6
+# ── LLM (Séance 1 : Ollama local) ─────────────────────────────────
7
+LLM_BASE_URL=http://localhost:11434/v1
8
+MODEL_NAME=llama3.1:8b
9
+
10
+# Séance 3 : routing multi-modèle (laisser vide pour S1)
11
+# MODEL_LIGHT=gemma3:4b
12
+# MODEL_HEAVY=qwen2.5:72b
13
+# LLM_PROVIDER=ollama   # ollama | vllm | litellm
14
+
15
+# ── Mémoire (Séance 2 : Redis) ─────────────────────────────────────
16
+# REDIS_URL=redis://localhost:6379
17
+# Laisser vide en S1 → MemorySaver (en mémoire, perdu au restart)
18
+
19
+# ── Base vectorielle (Séance 2 : Qdrant) ──────────────────────────
20
+# QDRANT_URL=http://localhost:6333
21
+# QDRANT_COLLECTION=formation-docs
22
+
23
+# ── API ────────────────────────────────────────────────────────────
24
+API_HOST=0.0.0.0
25
+API_PORT=8000
26
+LOG_LEVEL=INFO
27
+
28
+# ── Agent ──────────────────────────────────────────────────────────
29
+AGENT_MAX_ITERATIONS=20
30
+AGENT_MAX_TOKENS=4096

+ 36
- 0
s1_skeleton/.gitignore View File

@@ -0,0 +1,36 @@
1
+# Environnement Python
2
+.venv/
3
+__pycache__/
4
+*.pyc
5
+*.pyo
6
+*.pyd
7
+.Python
8
+*.egg-info/
9
+dist/
10
+build/
11
+
12
+# Variables d'environnement — NE JAMAIS committer
13
+.env
14
+
15
+# Mypy
16
+.mypy_cache/
17
+*.pyi
18
+
19
+# Pytest
20
+.pytest_cache/
21
+.coverage
22
+htmlcov/
23
+reports/
24
+
25
+# IDE
26
+.vscode/settings.json
27
+.idea/
28
+*.swp
29
+*.swo
30
+
31
+# OS
32
+.DS_Store
33
+Thumbs.db
34
+
35
+# Docker
36
+*.log

+ 28
- 0
s1_skeleton/.pre-commit-config.yaml View File

@@ -0,0 +1,28 @@
1
+repos:
2
+  - repo: https://github.com/astral-sh/ruff-pre-commit
3
+    rev: v0.6.0
4
+    hooks:
5
+      - id: ruff
6
+        args: [--fix]
7
+      - id: ruff-format
8
+
9
+  - repo: https://github.com/pre-commit/mirrors-mypy
10
+    rev: v1.11.0
11
+    hooks:
12
+      - id: mypy
13
+        additional_dependencies:
14
+          - langgraph
15
+          - langchain-core
16
+          - pydantic
17
+
18
+  - repo: https://github.com/pre-commit/pre-commit-hooks
19
+    rev: v4.6.0
20
+    hooks:
21
+      - id: trailing-whitespace
22
+      - id: end-of-file-fixer
23
+      - id: check-yaml
24
+      - id: check-toml
25
+      - id: check-added-large-files
26
+        args: [--maxkb=500]
27
+      - id: no-commit-to-branch
28
+        args: [--branch, main]

+ 145
- 0
s1_skeleton/README.md View File

@@ -0,0 +1,145 @@
1
+# Agent Catalogue — Séance 1 · Squelette TP
2
+
3
+> Formation Agents IA · LangGraph · Projet fil rouge
4
+
5
+---
6
+
7
+## Objectif
8
+
9
+Implémenter un agent ReAct capable de répondre à des questions sur
10
+un catalogue produit, en utilisant des outils LangGraph.
11
+
12
+Les tests vous disent si votre implémentation est correcte.
13
+Le formateur a la correction en cas de blocage.
14
+
15
+---
16
+
17
+## Installation (5 min)
18
+
19
+```bash
20
+# 1. Installer les dépendances
21
+uv sync --extra dev
22
+
23
+# 2. Copier la configuration
24
+cp .env.example .env
25
+# Vérifier que MODEL_NAME correspond au modèle téléchargé
26
+
27
+# 3. Démarrer Ollama
28
+ollama serve &
29
+
30
+# 4. Vérifier qu'Ollama répond
31
+curl http://localhost:11434/v1/models
32
+```
33
+
34
+---
35
+
36
+## Ce que vous devez implémenter
37
+
38
+### Étape 1 — `src/tools/catalogue.py`
39
+
40
+4 fonctions marquées `TODO` (dans l'ordre recommandé) :
41
+
42
+| Fonction | Difficulté | Tests |
43
+|---|---|---|
44
+| `search_product()` | ⭐ | `TestSearchProduct` (5 tests) |
45
+| `calculate_price()` | ⭐⭐ | `TestCalculatePrice` (7 tests) |
46
+| `list_products()` | ⭐⭐ | `TestListProducts` (6 tests) |
47
+| `check_stock()` | ⭐ bonus | `TestCheckStock` (5 tests) |
48
+
49
+### Étape 2 — `src/models/provider.py`
50
+
51
+1 fonction `get_llm()` : lire les variables d'env et retourner un `ChatOpenAI`.
52
+
53
+### Étape 3 — `src/agents/react_agent.py`
54
+
55
+3 fonctions dans l'ordre :
56
+
57
+| Fonction | Ce qu'elle fait |
58
+|---|---|
59
+| `should_continue()` | Décide si l'agent utilise un outil ou s'arrête |
60
+| `agent_node()` | Appelle le LLM et retourne sa décision |
61
+| `build_agent()` | Assemble le graphe (noeuds + edges + compilation) |
62
+
63
+---
64
+
65
+## Lancer les tests
66
+
67
+```bash
68
+# Tous les tests unitaires (Ollama non requis)
69
+uv run pytest tests/unit/ -v
70
+
71
+# Un module à la fois
72
+uv run pytest tests/unit/test_tools.py -v
73
+uv run pytest tests/unit/test_agent.py -v
74
+
75
+# Un test précis
76
+uv run pytest tests/unit/test_tools.py::TestSearchProduct::test_found_by_partial_name -v
77
+```
78
+
79
+---
80
+
81
+## Tester l'agent manuellement
82
+
83
+```bash
84
+# Mode interactif (une fois react_agent.py implémenté)
85
+uv run python main.py
86
+
87
+# Question directe
88
+uv run python main.py --ask "Quel est le prix d'une souris ?"
89
+
90
+# Avec traces des outils appelés
91
+uv run python main.py --verbose
92
+
93
+# Visualiser le graphe LangGraph
94
+uv run python main.py --visualize
95
+```
96
+
97
+**Questions de test suggérées :**
98
+```
99
+Quel est le prix d'un laptop pro ?
100
+Je veux 3 souris avec une remise de 15%, combien ça coûte ?
101
+Quels produits audio avez-vous ?
102
+Puis-je commander 10 écrans 4K ?
103
+Avez-vous des produits en rupture de stock ?
104
+```
105
+
106
+---
107
+
108
+## Vérification qualité
109
+
110
+```bash
111
+# Linting
112
+uv run ruff check src/
113
+
114
+# Typage strict
115
+uv run mypy src/
116
+```
117
+
118
+---
119
+
120
+## Progression attendue
121
+
122
+```
123
+⬜ Étape 1a : search_product() → TestSearchProduct passe (5 tests)
124
+⬜ Étape 1b : calculate_price() → TestCalculatePrice passe (7 tests)
125
+⬜ Étape 1c : list_products() → TestListProducts passe (6 tests)
126
+⬜ Étape 2  : get_llm() → provider.py implémenté
127
+⬜ Étape 3a : should_continue() → TestShouldContinue passe (4 tests)
128
+⬜ Étape 3b : agent_node() → TestAgentNode passe (4 tests)
129
+⬜ Étape 3c : build_agent() → TestBuildAgent passe (3 tests)
130
+⬜ Bonus     : check_stock() → TestCheckStock passe (5 tests)
131
+⬜ Livrable  : premier commit poussé sur Git
132
+```
133
+
134
+---
135
+
136
+## Fichiers fournis (ne pas modifier)
137
+
138
+| Fichier | Rôle |
139
+|---|---|
140
+| `src/tools/catalogue.py` | `CATALOGUE` dict + squelettes |
141
+| `tests/unit/test_tools.py` | Spécification des outils |
142
+| `tests/unit/test_agent.py` | Spécification du graphe |
143
+| `main.py` | CLI interactif complet |
144
+| `pyproject.toml` | Dépendances + config |
145
+| `.env.example` | Variables d'environnement |

+ 0
- 0
s1_skeleton/deploy/compose/.gitkeep View File


+ 0
- 0
s1_skeleton/deploy/helm/.gitkeep View File


+ 0
- 0
s1_skeleton/docs/graphs/.gitkeep View File


+ 26
- 0
s1_skeleton/docs/graphs/agent_react.md View File

@@ -0,0 +1,26 @@
1
+# Graphe Agent ReAct
2
+
3
+```mermaid
4
+---
5
+---
6
+config:
7
+  flowchart:
8
+    curve: linear
9
+---
10
+graph TD;
11
+        __start__([<p>__start__</p>]):::first
12
+        agent(agent)
13
+        tools(tools)
14
+        __end__([<p>__end__</p>]):::last
15
+        __start__ --> agent;
16
+        agent -. &nbsp;end&nbsp; .-> __end__;
17
+        agent -.-> tools;
18
+        tools --> agent;
19
+        classDef default fill:#f2f0ff,line-height:1.2
20
+        classDef first fill-opacity:0
21
+        classDef last fill:#bfb6fc
22
+
23
+
24
+✅ Sauvegardé dans docs\graphs\agent_react.md
25
+   → Visualiser sur https://mermaid.live
26
+```

+ 148
- 0
s1_skeleton/main.py View File

@@ -0,0 +1,148 @@
1
+"""Point d'entrée principal — CLI interactif et visualisation du graphe.
2
+
3
+Ce fichier est FOURNI COMPLET — ce n'est pas un exercice.
4
+Il fonctionnera dès que react_agent.py sera implémenté.
5
+
6
+Usage :
7
+    uv run python main.py                          # mode interactif
8
+    uv run python main.py --verbose                # avec traces des outils
9
+    uv run python main.py --ask "Prix d'un laptop ?"
10
+    uv run python main.py --visualize              # diagramme Mermaid
11
+"""
12
+
13
+import argparse
14
+import asyncio
15
+import os
16
+import sys
17
+import uuid
18
+from pathlib import Path
19
+
20
+from dotenv import load_dotenv
21
+from langchain_core.messages import HumanMessage
22
+
23
+load_dotenv()
24
+
25
+
26
+async def chat(question: str, thread_id: str, verbose: bool = False) -> str:
27
+    """Envoie une question à l'agent et retourne la réponse finale."""
28
+    # Import ici pour éviter l'import au démarrage si --visualize
29
+    from src.agents.react_agent import agent
30
+
31
+    config = {"configurable": {"thread_id": thread_id}}
32
+    initial_state = {"messages": [HumanMessage(content=question)], "iterations": 0}
33
+
34
+    if verbose:
35
+        step_num = 0
36
+        async for event in agent.astream_events(initial_state, config=config, version="v2"):
37
+            kind = event.get("event", "")
38
+            if kind == "on_tool_start":
39
+                step_num += 1
40
+                print(f"\n  🔧 [{step_num}] {event.get('name', '?')} "
41
+                      f"← {event.get('data', {}).get('input', {})}")
42
+            elif kind == "on_tool_end":
43
+                out = str(event.get("data", {}).get("output", ""))
44
+                print(f"       → {out[:100]}{'...' if len(out) > 100 else ''}")
45
+
46
+        final = await agent.aget_state(config)
47
+        msgs = final.values.get("messages", [])
48
+        return str(msgs[-1].content) if msgs else "(aucune réponse)"
49
+    else:
50
+        result = await agent.ainvoke(initial_state, config=config)
51
+        msgs = result.get("messages", [])
52
+        return str(msgs[-1].content) if msgs else "(aucune réponse)"
53
+
54
+
55
+async def interactive_loop(verbose: bool = False) -> None:
56
+    """Boucle interactive. Commandes : exit | reset | verbose"""
57
+    thread_id = f"session-{uuid.uuid4().hex[:8]}"
58
+    show_verbose = verbose
59
+
60
+    print(f"\n{'═'*55}")
61
+    print("  Agent Catalogue — Formation Agents IA S1")
62
+    print(f"{'═'*55}")
63
+    print(f"  Modèle  : {os.getenv('MODEL_NAME', 'llama3.1:8b')}")
64
+    print(f"  Session : {thread_id}")
65
+    print(f"  Aide    : exit | reset | verbose")
66
+    print(f"{'═'*55}\n")
67
+
68
+    while True:
69
+        try:
70
+            question = input("Vous : ").strip()
71
+        except (KeyboardInterrupt, EOFError):
72
+            print("\nAu revoir !")
73
+            break
74
+
75
+        if not question:
76
+            continue
77
+        if question.lower() in ("exit", "quit", "q"):
78
+            print("Au revoir !")
79
+            break
80
+        if question.lower() == "reset":
81
+            thread_id = f"session-{uuid.uuid4().hex[:8]}"
82
+            print(f"Nouvelle session : {thread_id}\n")
83
+            continue
84
+        if question.lower() == "verbose":
85
+            show_verbose = not show_verbose
86
+            print(f"Verbose {'activé' if show_verbose else 'désactivé'}\n")
87
+            continue
88
+
89
+        print("\nAgent : ", end="", flush=True)
90
+        try:
91
+            response = await chat(question, thread_id, verbose=show_verbose)
92
+            print(response)
93
+        except NotImplementedError as e:
94
+            print(f"\n[TODO] {e}")
95
+            print("→ Implémentez les fonctions marquées TODO dans src/")
96
+        except Exception as e:
97
+            print(f"\n[Erreur] {e}")
98
+            print("→ Vérifiez qu'Ollama tourne : ollama serve")
99
+        print()
100
+
101
+
102
+async def ask_once(question: str, verbose: bool = False) -> None:
103
+    """Pose une question unique."""
104
+    thread_id = f"cli-{uuid.uuid4().hex[:8]}"
105
+    print(f"\nQuestion : {question}\n{'─'*50}")
106
+    try:
107
+        response = await chat(question, thread_id, verbose=verbose)
108
+        print(f"\n{response}\n")
109
+    except NotImplementedError as e:
110
+        print(f"\n[TODO] {e}")
111
+        sys.exit(1)
112
+    except Exception as e:
113
+        print(f"\n[Erreur] {e}")
114
+        sys.exit(1)
115
+
116
+
117
+def visualize_graph() -> None:
118
+    """Génère le diagramme Mermaid et le sauvegarde dans docs/graphs/."""
119
+    from src.agents.react_agent import agent
120
+
121
+    mermaid = agent.get_graph().draw_mermaid()
122
+    print(f"\n{'═'*50}\n  Graphe LangGraph — Mermaid\n{'═'*50}")
123
+    print(mermaid)
124
+
125
+    path = Path("docs/graphs/agent_react.md")
126
+    path.parent.mkdir(parents=True, exist_ok=True)
127
+    path.write_text(f"# Graphe Agent ReAct\n\n```mermaid\n{mermaid}```\n", encoding="utf-8")
128
+    print(f"\n✅ Sauvegardé dans {path}")
129
+    print("   → Visualiser sur https://mermaid.live\n")
130
+
131
+
132
+def main() -> None:
133
+    parser = argparse.ArgumentParser(description="Agent Catalogue — S1")
134
+    parser.add_argument("--visualize", action="store_true")
135
+    parser.add_argument("--ask", type=str, metavar="QUESTION")
136
+    parser.add_argument("--verbose", action="store_true")
137
+    args = parser.parse_args()
138
+
139
+    if args.visualize:
140
+        visualize_graph()
141
+    elif args.ask:
142
+        asyncio.run(ask_once(args.ask, verbose=args.verbose))
143
+    else:
144
+        asyncio.run(interactive_loop(verbose=args.verbose))
145
+
146
+
147
+if __name__ == "__main__":
148
+    main()

+ 51
- 0
s1_skeleton/pyproject.toml View File

@@ -0,0 +1,51 @@
1
+[project]
2
+name = "agent-project"
3
+version = "0.1.0"
4
+description = "Formation Agents IA — Projet fil rouge LangGraph + MCP"
5
+requires-python = ">=3.11"
6
+dependencies = [
7
+    "langgraph>=0.2.0",
8
+    "langchain-core>=0.3.0",
9
+    "langchain-openai>=0.2.0",
10
+    "fastapi>=0.115.0",
11
+    "uvicorn[standard]>=0.32.0",
12
+    "pydantic>=2.9.0",
13
+    "python-dotenv>=1.0.0",
14
+    "redis>=5.0.0",
15
+]
16
+
17
+[project.optional-dependencies]
18
+dev = [
19
+    "pytest>=8.0.0",
20
+    "pytest-asyncio>=0.24.0",
21
+    "mypy>=1.11.0",
22
+    "ruff>=0.6.0",
23
+    "pre-commit>=3.8.0",
24
+]
25
+
26
+[tool.ruff]
27
+line-length = 100
28
+target-version = "py311"
29
+select = ["E", "F", "I", "N", "W", "UP", "ANN"]
30
+ignore = ["ANN101", "ANN102"]
31
+
32
+[tool.ruff.format]
33
+quote-style = "double"
34
+
35
+[tool.mypy]
36
+python_version = "3.11"
37
+strict = true
38
+ignore_missing_imports = true
39
+plugins = ["pydantic.mypy"]
40
+
41
+[tool.pytest.ini_options]
42
+asyncio_mode = "auto"
43
+testpaths = ["tests"]
44
+markers = [
45
+    "unit: tests unitaires (rapides, sans dépendances externes)",
46
+    "integration: tests d'intégration (nécessitent Ollama + services)",
47
+]
48
+
49
+[tool.coverage.run]
50
+source = ["src"]
51
+omit = ["tests/*", "**/__init__.py"]

+ 0
- 0
s1_skeleton/src/__init__.py View File


+ 0
- 0
s1_skeleton/src/agents/__init__.py View File


+ 193
- 0
s1_skeleton/src/agents/react_agent.py View File

@@ -0,0 +1,193 @@
1
+"""Agent ReAct pour le catalogue produit — graphe LangGraph.
2
+
3
+SÉANCE 1 — TP : Implémenter les fonctions marquées TODO.
4
+
5
+Architecture cible :
6
+    ┌─────────┐   tool_calls    ┌───────┐
7
+    │  agent  │ ──────────────► │ tools │
8
+    │ (LLM)   │ ◄────────────── │       │
9
+    └─────────┘  tool results   └───────┘
10
+         │
11
+         │ no tool_calls  ou  iterations >= MAX
12
+         ▼
13
+       [END]
14
+
15
+Ordre recommandé :
16
+    1. Lire AgentState et SYSTEM_PROMPT — comprendre la structure
17
+    2. Implémenter should_continue()
18
+    3. Implémenter agent_node()
19
+    4. Compléter build_agent() en ajoutant noeuds et edges
20
+    5. Tester avec : uv run python main.py --ask "Prix d'un laptop ?"
21
+"""
22
+
23
+import operator
24
+import os
25
+from typing import Annotated, Sequence
26
+
27
+from langchain_core.messages import (
28
+    AIMessage,
29
+    BaseMessage,
30
+    HumanMessage,
31
+    SystemMessage,
32
+)
33
+from langgraph.checkpoint.memory import MemorySaver
34
+from langgraph.graph import END, StateGraph
35
+from langgraph.graph.state import CompiledStateGraph
36
+from langgraph.prebuilt import ToolNode
37
+from typing_extensions import TypedDict
38
+
39
+from src.models.provider import get_llm
40
+from src.tools.catalogue import CATALOGUE_TOOLS
41
+
42
+
43
+# ── État du graphe (FOURNI — ne pas modifier) ─────────────────────────────────
44
+
45
+class AgentState(TypedDict):
46
+    """État partagé entre tous les noeuds du graphe.
47
+
48
+    Attributes:
49
+        messages: Historique de la conversation.
50
+                  operator.add → les nouveaux messages s'AJOUTENT à la liste.
51
+        iterations: Compteur d'itérations (protection anti-boucle).
52
+                    Pas d'Annotated → la valeur est REMPLACÉE à chaque update.
53
+    """
54
+    messages: Annotated[Sequence[BaseMessage], operator.add]
55
+    iterations: int
56
+
57
+
58
+# ── Prompt système (FOURNI — vous pouvez l'améliorer) ────────────────────────
59
+
60
+SYSTEM_PROMPT = """Tu es un assistant spécialisé dans la consultation du catalogue produit.
61
+
62
+Tu as accès aux outils suivants :
63
+- search_product    : rechercher un produit par nom ou mot-clé
64
+- calculate_price   : calculer le prix total avec remise optionnelle
65
+- list_products     : lister les produits par catégorie
66
+- check_stock       : vérifier la disponibilité d'un produit
67
+
68
+Règles :
69
+1. Utilise TOUJOURS un outil pour obtenir des informations sur les produits.
70
+2. Ne jamais inventer des prix, stocks ou descriptions.
71
+3. Réponds en français, de manière concise et professionnelle."""
72
+
73
+
74
+# ── TODO 1 — Implémenter should_continue ─────────────────────────────────────
75
+
76
+def should_continue(state: AgentState) -> str:
77
+    """Décide si l'agent doit utiliser des outils ou terminer.
78
+
79
+    Args:
80
+        state: État courant du graphe.
81
+
82
+    Returns:
83
+        "tools" si le dernier message contient des tool_calls,
84
+        END sinon (réponse finale ou limite d'itérations atteinte).
85
+    """
86
+    # TODO étape 1 : lire la limite depuis os.getenv("AGENT_MAX_ITERATIONS", "20")
87
+    #               retourner END si state["iterations"] >= limite
88
+    #
89
+    # TODO étape 2 : récupérer le dernier message de state["messages"]
90
+    #               si c'est un AIMessage avec des tool_calls → retourner "tools"
91
+    #               sinon → retourner END
92
+    #
93
+    # Indice : hasattr(last_message, "tool_calls") and last_message.tool_calls
94
+    max_iter = int(os.getenv('AGENT_MAX_ITERATIONS', '20'))
95
+    if state.get('iterations', 0) >= max_iter:
96
+        return "end" # stop : trop d'itér
97
+    last_message = state['messages'][-1]
98
+    if isinstance(last_message, AIMessage):
99
+        if hasattr(last_message, 'tool_calls') and last_message.tool_calls:
100
+            return 'tools' # le LLM veut utiliser un outil
101
+    return "end" # pas de tool_calls = réponse finale
102
+
103
+# ── TODO 2 — Implémenter agent_node ──────────────────────────────────────────
104
+
105
+def agent_node(state: AgentState) -> dict[str, object]:
106
+    """Noeud agent : appelle le LLM pour décider de l'action suivante.
107
+
108
+    Args:
109
+        state: État courant du graphe.
110
+
111
+    Returns:
112
+        Dictionnaire avec :
113
+          - "messages"   : liste contenant le nouveau message AI
114
+          - "iterations" : compteur incrémenté de 1
115
+    """
116
+    # TODO étape 1 : obtenir le LLM via get_llm() et le lier aux outils
117
+    #               avec llm.bind_tools(CATALOGUE_TOOLS)
118
+    #
119
+    # TODO étape 2 : construire la liste de messages à envoyer au LLM
120
+    #               = [SystemMessage(SYSTEM_PROMPT)] + list(state["messages"])
121
+    #
122
+    # TODO étape 3 : appeler llm_with_tools.invoke(messages)
123
+    #
124
+    # TODO étape 4 : retourner le dict avec messages et iterations + 1
125
+    #               Attention : state.get("iterations", 0) + 1
126
+
127
+    # Étape 1
128
+    llm = get_llm(task = "default")
129
+    llm_with_tools = llm.bind_tools(CATALOGUE_TOOLS)
130
+
131
+    # Étape 2
132
+    messages: list[BaseMessage] = [
133
+        SystemMessage(content=SYSTEM_PROMPT), # contexte et règles
134
+        * list(state['messages']), # historique complet
135
+]
136
+
137
+    # Étape 3
138
+    ai_message = llm_with_tools.invoke(messages)
139
+
140
+    # Étape 4
141
+    return {
142
+        "messages": [ai_message],
143
+        "iterations": state.get("iterations", 0) + 1,
144
+    }
145
+
146
+# ── TODO 3 — Compléter build_agent ───────────────────────────────────────────
147
+
148
+def build_agent(use_memory: bool = True) -> CompiledStateGraph:
149
+    """Construit et compile le graphe LangGraph.
150
+
151
+    Args:
152
+        use_memory: Si True, utilise MemorySaver (persistance en RAM).
153
+
154
+    Returns:
155
+        Graphe compilé prêt à être invoqué.
156
+    """
157
+    graph: StateGraph = StateGraph(AgentState)
158
+
159
+    # TODO étape 1 : ajouter le noeud "agent" avec agent_node
160
+    #               graph.add_node("agent", agent_node)
161
+    graph.add_node("agent", agent_node)
162
+
163
+    # TODO étape 2 : ajouter le noeud "tools" avec ToolNode(CATALOGUE_TOOLS)
164
+    #               graph.add_node("tools", ToolNode(CATALOGUE_TOOLS))
165
+    graph.add_node("tools", ToolNode(CATALOGUE_TOOLS))
166
+
167
+    # TODO étape 3 : définir le point d'entrée
168
+    #               graph.set_entry_point("agent")
169
+    graph.set_entry_point("agent")
170
+    # TODO étape 4 : ajouter l'edge conditionnel depuis "agent"
171
+    #               graph.add_conditional_edges("agent", should_continue, {...})
172
+    graph.add_conditional_edges(
173
+        "agent",
174
+        should_continue,
175
+        {
176
+            "tools": "tools",
177
+            "end": END,
178
+        },
179
+    )
180
+    # TODO étape 5 : ajouter l'edge de retour "tools" → "agent"
181
+    #               graph.add_edge("tools", "agent")
182
+    graph.add_edge("tools", "agent")
183
+    # TODO étape 6 : compiler avec ou sans checkpointer selon use_memory
184
+    if use_memory:
185
+        checkpointer = MemorySaver()
186
+        return graph.compile(checkpointer=checkpointer)
187
+    else:
188
+        return graph.compile()
189
+
190
+
191
+# ── Instance globale (décommentez quand build_agent() est implémenté) ─────────
192
+
193
+agent: CompiledStateGraph = build_agent(use_memory=True)

+ 0
- 0
s1_skeleton/src/api/__init__.py View File


+ 0
- 0
s1_skeleton/src/memory/__init__.py View File


+ 0
- 0
s1_skeleton/src/models/__init__.py View File


+ 51
- 0
s1_skeleton/src/models/provider.py View File

@@ -0,0 +1,51 @@
1
+"""Abstraction du fournisseur LLM.
2
+
3
+SÉANCE 1 — TP : Implémenter get_llm().
4
+
5
+Objectif : cette fonction doit retourner un LLM configuré depuis les
6
+variables d'environnement, sans que le reste du code connaisse le
7
+fournisseur (Ollama, vLLM, LiteLLM...).
8
+
9
+En séance 3, le routing multi-modèle sera ajouté ici.
10
+"""
11
+
12
+import os
13
+
14
+from langchain_core.language_models import BaseChatModel
15
+from langchain_openai import ChatOpenAI
16
+from openai import base_url
17
+
18
+
19
+def get_llm(task: str = "default") -> BaseChatModel:
20
+    """Retourne un LLM configuré depuis les variables d'environnement.
21
+
22
+    Variables d'environnement :
23
+        LLM_BASE_URL : URL de base de l'API.  Ex : http://localhost:11434/v1
24
+        MODEL_NAME   : Nom du modèle.          Ex : llama3.1:8b
25
+
26
+    Args:
27
+        task: Type de tâche (ignoré en S1, utilisé en S3 pour le routing).
28
+              Valeurs possibles : "default", "reasoning", "classification".
29
+
30
+    Returns:
31
+        Instance BaseChatModel configurée et prête à l'emploi.
32
+
33
+    Example:
34
+        >>> llm = get_llm()
35
+        >>> response = llm.invoke([HumanMessage("Bonjour")])
36
+    """
37
+    # TODO : lire LLM_BASE_URL et MODEL_NAME depuis os.getenv()
38
+    #        avec des valeurs par défaut raisonnables
39
+    # TODO : instancier et retourner un ChatOpenAI avec ces paramètres
40
+    # Indice : api_key="local" (ignoré par Ollama mais requis par l'interface)
41
+    #          temperature=0 (déterministe, meilleur pour les agents)
42
+    base_url: str = os.getenv('LLM_BASE_URL', 'http://localhost:11434/v1')
43
+    model_name: str = os.getenv('MODEL_NAME', 'llama3.1:8b')
44
+
45
+    return ChatOpenAI(
46
+        base_url=base_url,
47
+        model=model_name,
48
+        api_key='local', # ignoré par Ollama mais requis
49
+        temperature=0, # 0 = déterministe, essentiel pour les agents
50
+        timeout=120,
51
+    )

+ 0
- 0
s1_skeleton/src/security/__init__.py View File


+ 0
- 0
s1_skeleton/src/tools/__init__.py View File


+ 247
- 0
s1_skeleton/src/tools/catalogue.py View File

@@ -0,0 +1,247 @@
1
+"""Outils de consultation du catalogue produit.
2
+
3
+SÉANCE 1 — TP : Implémenter les 4 outils ci-dessous.
4
+
5
+Chaque outil est décoré avec @tool.
6
+La docstring est lue par le LLM pour décider quand et comment l'utiliser :
7
+  - soyez précis dans la description
8
+  - documentez bien les arguments
9
+  - indiquez ce que retourne la fonction
10
+
11
+En séance 2, ces outils seront remplacés par des appels MCP.
12
+"""
13
+
14
+from langchain_core.tools import tool
15
+
16
+# ── Catalogue produit (FOURNI — ne pas modifier) ──────────────────────────────
17
+
18
+CATALOGUE: dict[str, dict[str, object]] = {
19
+    "laptop-pro-15": {
20
+        "name": "Laptop Pro 15",
21
+        "price": 1299.0,
22
+        "stock": 5,
23
+        "category": "informatique",
24
+        "description": "Laptop haute performance, écran 15 pouces, 32 GB RAM, SSD 1 TB.",
25
+    },
26
+    "souris-ergonomique": {
27
+        "name": "Souris Ergonomique",
28
+        "price": 49.90,
29
+        "stock": 23,
30
+        "category": "accessoires",
31
+        "description": "Souris verticale ergonomique, sans fil, autonomie 6 mois.",
32
+    },
33
+    "clavier-mecanique": {
34
+        "name": "Clavier Mécanique",
35
+        "price": 129.0,
36
+        "stock": 12,
37
+        "category": "accessoires",
38
+        "description": "Clavier mécanique Cherry MX Red, rétroéclairé, AZERTY.",
39
+    },
40
+    "ecran-4k-27": {
41
+        "name": 'Écran 4K 27"',
42
+        "price": 549.0,
43
+        "stock": 3,
44
+        "category": "informatique",
45
+        "description": "Écran 4K UHD 27 pouces, 144 Hz, temps de réponse 1 ms.",
46
+    },
47
+    "casque-audio-pro": {
48
+        "name": "Casque Audio Pro",
49
+        "price": 199.0,
50
+        "stock": 8,
51
+        "category": "audio",
52
+        "description": "Casque circum-aural, réduction de bruit active, autonomie 30 h.",
53
+    },
54
+    "webcam-4k": {
55
+        "name": "Webcam 4K",
56
+        "price": 89.0,
57
+        "stock": 15,
58
+        "category": "accessoires",
59
+        "description": "Webcam 4K 30fps, autofocus, micro stéréo intégré.",
60
+    },
61
+    "hub-usb-c": {
62
+        "name": "Hub USB-C 7 ports",
63
+        "price": 59.90,
64
+        "stock": 30,
65
+        "category": "accessoires",
66
+        "description": "Hub USB-C 7 en 1 : HDMI 4K, USB-A ×3, SD, microSD, USB-C PD.",
67
+    },
68
+    "enceinte-bluetooth": {
69
+        "name": "Enceinte Bluetooth",
70
+        "price": 79.0,
71
+        "stock": 0,
72
+        "category": "audio",
73
+        "description": "Enceinte portative 20W, waterproof IPX5, autonomie 12 h.",
74
+    },
75
+}
76
+
77
+
78
+# ── TODO 1 — Implémenter search_product ───────────────────────────────────────
79
+
80
+@tool
81
+def search_product(name: str) -> str:
82
+    """Recherche un ou plusieurs produits dans le catalogue par nom ou mot-clé.
83
+
84
+    Args:
85
+        name: Terme de recherche partiel ou complet pour le nom du produit.
86
+
87
+    Returns:
88
+        Une description lisible des produits correspondants si trouvés,
89
+        ou un message clair indiquant qu'aucun produit ne correspond.
90
+    """
91
+    name_lower = name.strip().lower()
92
+    results: list[dict[str, object]] = []
93
+
94
+    for product_id, product in CATALOGUE.items():
95
+        product_name = str(product["name"]).lower()
96
+        description = str(product["description"]).lower()
97
+
98
+        if name_lower in product_id or name_lower in product_name or name_lower in description:
99
+            results.append(product)
100
+
101
+    if not results:
102
+        return f"Aucun produit trouvé pour '{name}'."
103
+
104
+    lines: list[str] = []
105
+    for product in results:
106
+        stock = int(product.get("stock", 0))
107
+        stock_info = f"{stock} en stock" if stock > 0 else "RUPTURE"
108
+        lines.append(f"- {product['name']} : {float(product['price']):.2f}€ ({stock_info})")
109
+    return f"Produits trouvés ({len(results)}) :\n" + "\n".join(lines)
110
+
111
+# ── TODO 2 — Implémenter calculate_price ──────────────────────────────────────
112
+
113
+@tool
114
+def calculate_price(
115
+    product_name: str,
116
+    quantity: int,
117
+    discount_percent: float = 0.0,
118
+) -> str:
119
+    """Calcule le prix total pour une quantité donnée, avec remise optionnelle.
120
+
121
+    Args:
122
+        product_name: Nom complet ou partiel du produit.
123
+        quantity: Quantité souhaitée.
124
+        discount_percent: Remise en pourcentage (0 à 100).
125
+
126
+    Returns:
127
+        Un récapitulatif du calcul ou un message d'erreur.
128
+    """
129
+
130
+    if quantity <= 0:
131
+        return "La quantité doit être un entier positif."
132
+
133
+    if not (0.0 <= discount_percent <= 100.0):
134
+        return "La remise doit être comprise entre 0 et 100%."
135
+
136
+    search = product_name.strip().lower()
137
+    product = None
138
+
139
+    for product_id, p in CATALOGUE.items():
140
+        if search in str(p['name']).lower() or search in product_id:
141
+            product = p
142
+            break
143
+
144
+    if product is None:
145
+        return f"Produit '{product_name}' introuvable dans le catalogue."
146
+
147
+    stock = int(str(product['stock']))
148
+    if stock <= 0:
149
+        return f"RUPTURE DE STOCK : {product['name']}"
150
+
151
+    unit_price = float(str(product['price']))
152
+    subtotal = unit_price * quantity
153
+    total = subtotal * (1 - discount_percent / 100)
154
+
155
+    return (
156
+        f"Produit : {product['name']}\n"
157
+        f"Prix unitaire : {unit_price:.2f}€\n"
158
+        f"Quantité : {quantity}\n"
159
+        f"Sous-total : {subtotal:.2f}€\n"
160
+        f"Remise : {discount_percent:.1f}%\n"
161
+        f"Total : {total:.2f}€"
162
+    )
163
+
164
+
165
+# ── TODO 3 — Implémenter list_products ────────────────────────────────────────
166
+
167
+@tool
168
+def list_products(category: str | None = None) -> str:
169
+    """Liste les produits disponibles, avec filtre optionnel par catégorie.
170
+
171
+    Args:
172
+        category: Nom de la catégorie à filtrer (informatique, accessoires, audio).
173
+                  Si None, tous les produits sont retournés.
174
+
175
+    Returns:
176
+        Une liste lisible des produits correspondant au filtre, avec l'indication
177
+        du stock ou de la rupture de stock. Si la catégorie est inconnue,
178
+        retourne un message clair.
179
+    """
180
+    category_search = category.strip().lower() if category else None
181
+    results: list[str] = []
182
+
183
+    for product_id, product in CATALOGUE.items():
184
+        product_category = str(product.get("category", "")).lower()
185
+        if category_search is None or category_search == product_category:
186
+            stock = int(str(product.get("stock", 0)))
187
+            stock_info = "RUPTURE" if stock <= 0 else f"{stock} en stock"
188
+            results.append(
189
+                f"- {product['name']} : {product['price']:.2f}€ ({stock_info})"
190
+            )
191
+
192
+    if category_search is not None and not results:
193
+        return f"Catégorie inconnue ou aucun produit trouvé pour '{category}'."
194
+
195
+    header = "Produits disponibles"
196
+    if category_search is not None:
197
+        header += f" dans la catégorie '{category}'"
198
+    return header + ":\n" + "\n".join(results)
199
+
200
+
201
+# ── TODO 4 (exercice bonus) — Implémenter check_stock ─────────────────────────
202
+
203
+@tool
204
+def check_stock(product_name: str, required_quantity: int) -> str:
205
+    """Vérifie si le stock est suffisant pour une quantité demandée.
206
+
207
+    Args:
208
+        product_name: Nom complet ou partiel du produit.
209
+        required_quantity: Quantité demandée.
210
+
211
+    Returns:
212
+        Un message indiquant si la commande est possible, insuffisante,
213
+        rupture de stock, ou si le produit est introuvable.
214
+    """
215
+    if required_quantity <= 0:
216
+        return "La quantité requise doit être un entier positif."
217
+
218
+    search = product_name.strip().lower()
219
+    product = None
220
+
221
+    for product_id, p in CATALOGUE.items():
222
+        if search in str(p['name']).lower() or search in product_id:
223
+            product = p
224
+            break
225
+
226
+    if product is None:
227
+        return f"Produit '{product_name}' introuvable."
228
+
229
+    stock = int(str(product.get('stock', 0)))
230
+    if stock <= 0:
231
+        return f"RUPTURE DE STOCK : {product['name']}"
232
+
233
+    if stock >= required_quantity:
234
+        return f"Commande possible : {required_quantity} × {product['name']} (stock disponible : {stock}) ✅"
235
+
236
+    return f"Stock insuffisant pour {product['name']} : {stock} disponible(s), {required_quantity} demandé(s) ⚠"
237
+
238
+
239
+# ── Liste des outils (FOURNI — compléter au fur et à mesure) ──────────────────
240
+
241
+# Commencer avec search_product uniquement, ajouter les autres au fil du TP
242
+CATALOGUE_TOOLS = [
243
+    search_product,
244
+    calculate_price,
245
+    list_products,
246
+    check_stock,
247
+]

+ 0
- 0
s1_skeleton/tests/__init__.py View File


+ 0
- 0
s1_skeleton/tests/eval/__init__.py View File


+ 0
- 0
s1_skeleton/tests/integration/__init__.py View File


+ 0
- 0
s1_skeleton/tests/unit/__init__.py View File


+ 108
- 0
s1_skeleton/tests/unit/test_agent.py View File

@@ -0,0 +1,108 @@
1
+"""Tests unitaires du graphe LangGraph.
2
+
3
+Ces tests sont FOURNIS COMPLETS — ils servent de spécification.
4
+Ils doivent tous passer une fois react_agent.py implémenté.
5
+Le LLM est entièrement mocké : Ollama n'est pas nécessaire.
6
+
7
+Lancer : uv run pytest tests/unit/test_agent.py -v
8
+"""
9
+
10
+from unittest.mock import MagicMock, patch
11
+
12
+import pytest
13
+from langchain_core.messages import AIMessage, HumanMessage
14
+
15
+from src.agents.react_agent import AgentState, agent_node, should_continue
16
+
17
+
18
+# ── Helpers ───────────────────────────────────────────────────────────────────
19
+
20
+def make_state(messages: list, iterations: int = 0) -> AgentState:
21
+    return AgentState(messages=messages, iterations=iterations)
22
+
23
+def ai_with_tools() -> AIMessage:
24
+    return AIMessage(content="", tool_calls=[
25
+        {"name": "search_product", "args": {"name": "laptop"}, "id": "c1", "type": "tool_call"}
26
+    ])
27
+
28
+def ai_final(content: str = "Voici la réponse.") -> AIMessage:
29
+    return AIMessage(content=content)
30
+
31
+
32
+# ── Tests should_continue ─────────────────────────────────────────────────────
33
+
34
+class TestShouldContinue:
35
+
36
+    def test_returns_tools_when_tool_calls_present(self) -> None:
37
+        state = make_state([HumanMessage("test"), ai_with_tools()])
38
+        assert should_continue(state) == "tools"
39
+
40
+    def test_returns_end_when_no_tool_calls(self) -> None:
41
+        from langgraph.graph import END
42
+        state = make_state([HumanMessage("test"), ai_final()])
43
+        assert should_continue(state) == END
44
+
45
+    def test_returns_end_at_max_iterations(self) -> None:
46
+        from langgraph.graph import END
47
+        state = make_state([HumanMessage("test"), ai_with_tools()], iterations=20)
48
+        assert should_continue(state) == END
49
+
50
+    def test_continues_below_max_iterations(self) -> None:
51
+        state = make_state([HumanMessage("test"), ai_with_tools()], iterations=5)
52
+        assert should_continue(state) == "tools"
53
+
54
+
55
+# ── Tests agent_node ──────────────────────────────────────────────────────────
56
+
57
+class TestAgentNode:
58
+
59
+    def test_increments_iterations(self) -> None:
60
+        with patch("src.agents.react_agent.get_llm") as mock:
61
+            mock.return_value.bind_tools.return_value.invoke.return_value = ai_final()
62
+            result = agent_node(make_state([HumanMessage("test")], iterations=2))
63
+        assert result["iterations"] == 3
64
+
65
+    def test_appends_response_to_messages(self) -> None:
66
+        response = ai_final("Réponse test")
67
+        with patch("src.agents.react_agent.get_llm") as mock:
68
+            mock.return_value.bind_tools.return_value.invoke.return_value = response
69
+            result = agent_node(make_state([HumanMessage("test")]))
70
+        assert response in result["messages"]
71
+
72
+    def test_returns_messages_and_iterations_keys(self) -> None:
73
+        with patch("src.agents.react_agent.get_llm") as mock:
74
+            mock.return_value.bind_tools.return_value.invoke.return_value = ai_final()
75
+            result = agent_node(make_state([HumanMessage("test")]))
76
+        assert "messages" in result
77
+        assert "iterations" in result
78
+
79
+    def test_binds_tools_to_llm(self) -> None:
80
+        with patch("src.agents.react_agent.get_llm") as mock:
81
+            mock_llm = MagicMock()
82
+            mock_llm.bind_tools.return_value.invoke.return_value = ai_final()
83
+            mock.return_value = mock_llm
84
+            agent_node(make_state([HumanMessage("test")]))
85
+        mock_llm.bind_tools.assert_called_once()
86
+        assert len(mock_llm.bind_tools.call_args[0][0]) > 0
87
+
88
+
89
+# ── Tests build_agent ─────────────────────────────────────────────────────────
90
+
91
+class TestBuildAgent:
92
+
93
+    def test_graph_compiles(self) -> None:
94
+        from src.agents.react_agent import build_agent
95
+        assert build_agent(use_memory=False) is not None
96
+
97
+    def test_graph_has_agent_and_tools_nodes(self) -> None:
98
+        from src.agents.react_agent import build_agent
99
+        app = build_agent(use_memory=False)
100
+        ids = list(app.get_graph().nodes.keys())
101
+        assert "agent" in ids
102
+        assert "tools" in ids
103
+
104
+    def test_mermaid_is_generated(self) -> None:
105
+        from src.agents.react_agent import build_agent
106
+        mermaid = build_agent(use_memory=False).get_graph().draw_mermaid()
107
+        assert "agent" in mermaid
108
+        assert "tools" in mermaid

+ 144
- 0
s1_skeleton/tests/unit/test_tools.py View File

@@ -0,0 +1,144 @@
1
+"""Tests unitaires des outils du catalogue.
2
+
3
+Ces tests sont FOURNIS COMPLETS — ils servent de spécification.
4
+Ils doivent tous passer une fois vos outils implémentés.
5
+
6
+Lancer : uv run pytest tests/unit/test_tools.py -v
7
+
8
+Progression recommandée :
9
+    1. Implémenter search_product → faire passer TestSearchProduct
10
+    2. Implémenter calculate_price → faire passer TestCalculatePrice
11
+    3. Implémenter list_products → faire passer TestListProducts
12
+    4. Implémenter check_stock (bonus) → faire passer TestCheckStock
13
+"""
14
+
15
+import pytest
16
+
17
+from src.tools.catalogue import (
18
+    calculate_price,
19
+    check_stock,
20
+    list_products,
21
+    search_product,
22
+)
23
+
24
+
25
+# ── search_product ────────────────────────────────────────────────────────────
26
+
27
+class TestSearchProduct:
28
+
29
+    def test_found_by_partial_name(self) -> None:
30
+        result = search_product.invoke({"name": "laptop"})
31
+        assert "Laptop Pro 15" in result
32
+        assert "1299" in result
33
+
34
+    def test_found_case_insensitive(self) -> None:
35
+        assert "Souris Ergonomique" in search_product.invoke({"name": "SOURIS"})
36
+        assert "Souris Ergonomique" in search_product.invoke({"name": "souris"})
37
+
38
+    def test_not_found_returns_message(self) -> None:
39
+        result = search_product.invoke({"name": "produit-xyz-inexistant"})
40
+        assert "Aucun produit" in result
41
+
42
+    def test_search_by_description_word(self) -> None:
43
+        result = search_product.invoke({"name": "ergonomique"})
44
+        assert "Souris Ergonomique" in result
45
+
46
+    def test_multiple_results(self) -> None:
47
+        # "casque" doit trouver au moins le casque audio
48
+        result = search_product.invoke({"name": "casque"})
49
+        assert "Casque Audio Pro" in result
50
+
51
+
52
+# ── calculate_price ───────────────────────────────────────────────────────────
53
+
54
+class TestCalculatePrice:
55
+
56
+    def test_price_no_discount(self) -> None:
57
+        result = calculate_price.invoke({"product_name": "souris", "quantity": 2})
58
+        assert "99.80" in result   # 2 × 49.90
59
+
60
+    def test_price_with_discount(self) -> None:
61
+        result = calculate_price.invoke({
62
+            "product_name": "souris", "quantity": 2, "discount_percent": 10.0
63
+        })
64
+        assert "89.82" in result   # 99.80 - 10%
65
+
66
+    def test_quantity_one(self) -> None:
67
+        result = calculate_price.invoke({"product_name": "clavier", "quantity": 1})
68
+        assert "129" in result
69
+
70
+    def test_invalid_quantity_zero(self) -> None:
71
+        result = calculate_price.invoke({"product_name": "souris", "quantity": 0})
72
+        assert "positif" in result.lower()
73
+
74
+    def test_invalid_discount_over_100(self) -> None:
75
+        result = calculate_price.invoke({
76
+            "product_name": "souris", "quantity": 1, "discount_percent": 150.0
77
+        })
78
+        assert "100" in result
79
+
80
+    def test_product_not_found(self) -> None:
81
+        result = calculate_price.invoke({"product_name": "inexistant", "quantity": 1})
82
+        assert "introuvable" in result.lower()
83
+
84
+    def test_out_of_stock_warning(self) -> None:
85
+        # enceinte-bluetooth est en stock=0
86
+        result = calculate_price.invoke({"product_name": "enceinte", "quantity": 1})
87
+        assert "rupture" in result.lower() or "RUPTURE" in result
88
+
89
+
90
+# ── list_products ─────────────────────────────────────────────────────────────
91
+
92
+class TestListProducts:
93
+
94
+    def test_list_all(self) -> None:
95
+        result = list_products.invoke({})
96
+        assert "Laptop Pro 15" in result
97
+        assert "Casque Audio Pro" in result
98
+
99
+    def test_filter_informatique(self) -> None:
100
+        result = list_products.invoke({"category": "informatique"})
101
+        assert "Laptop Pro 15" in result
102
+        assert "Souris" not in result
103
+
104
+    def test_filter_audio(self) -> None:
105
+        result = list_products.invoke({"category": "audio"})
106
+        assert "Casque Audio Pro" in result
107
+        assert "Laptop" not in result
108
+
109
+    def test_filter_case_insensitive(self) -> None:
110
+        assert "Casque" in list_products.invoke({"category": "AUDIO"})
111
+
112
+    def test_unknown_category(self) -> None:
113
+        result = list_products.invoke({"category": "meuble"})
114
+        assert "meuble" in result.lower()
115
+
116
+    def test_out_of_stock_shown(self) -> None:
117
+        result = list_products.invoke({})
118
+        assert "RUPTURE" in result   # enceinte-bluetooth stock=0
119
+
120
+
121
+# ── check_stock (bonus) ────────────────────────────────────────────────────────
122
+
123
+class TestCheckStock:
124
+
125
+    def test_sufficient_stock(self) -> None:
126
+        result = check_stock.invoke({"product_name": "souris", "required_quantity": 5})
127
+        assert "possible" in result.lower() or "✅" in result
128
+
129
+    def test_insufficient_stock(self) -> None:
130
+        # écran-4k a stock=3, on en demande 10
131
+        result = check_stock.invoke({"product_name": "ecran", "required_quantity": 10})
132
+        assert "insuffisant" in result.lower() or "⚠" in result
133
+
134
+    def test_out_of_stock(self) -> None:
135
+        result = check_stock.invoke({"product_name": "enceinte", "required_quantity": 1})
136
+        assert "RUPTURE" in result or "impossible" in result.lower()
137
+
138
+    def test_invalid_quantity(self) -> None:
139
+        result = check_stock.invoke({"product_name": "souris", "required_quantity": -1})
140
+        assert "positif" in result.lower()
141
+
142
+    def test_product_not_found(self) -> None:
143
+        result = check_stock.invoke({"product_name": "xyz", "required_quantity": 1})
144
+        assert "introuvable" in result.lower()

+ 2022
- 0
s1_skeleton/uv.lock
File diff suppressed because it is too large
View File


Powered by TurnKey Linux.