Claude commited on
Reapply all fixes on main after merge overwrite
Browse files- Strip env vars (.strip()) to handle trailing newlines
- Default model: openrouter/free
- Server-side phase progression (2 turns per phase max)
- System prompt: ban suggested examples, ban excessive validation
- Frontend: strip phase marker from chat, proper error handling
- Add /download endpoint for corpus files
- Add LLM call logging and timeout
https://claude.ai/code/session_015z3yZxNNfXF63JuQDuPbEG
- app/llm.py +26 -9
- app/main.py +41 -13
- static/app.js +49 -6
app/llm.py
CHANGED
|
@@ -1,23 +1,26 @@
|
|
| 1 |
"""LLM interaction via OpenAI-compatible API."""
|
| 2 |
|
| 3 |
import json
|
|
|
|
| 4 |
import os
|
| 5 |
|
| 6 |
from openai import AsyncOpenAI
|
| 7 |
|
|
|
|
|
|
|
| 8 |
_client: AsyncOpenAI | None = None
|
| 9 |
|
| 10 |
|
| 11 |
def _get_client() -> AsyncOpenAI:
|
| 12 |
global _client
|
| 13 |
if _client is None:
|
| 14 |
-
api_key = os.environ.get("OPENROUTER_API_KEY", "")
|
| 15 |
-
base_url = os.environ.get("LLM_BASE_URL", "") or None
|
| 16 |
_client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
| 17 |
return _client
|
| 18 |
|
| 19 |
# ---------------------------------------------------------------------------
|
| 20 |
-
# System prompts
|
| 21 |
# ---------------------------------------------------------------------------
|
| 22 |
|
| 23 |
SYSTEM_TUTOR = """Tu es un mentor socratique bienveillant, empathique et complice.
|
|
@@ -31,9 +34,12 @@ Règles :
|
|
| 31 |
4. Si une définition est demandée, explique en max 2 phrases puis pose immédiatement une question de vérification.
|
| 32 |
5. Dès qu'une base est posée en Phase 1, avance vers Phase 2.
|
| 33 |
6. Préfère l'invitation au reproche : "Ce point semble complexe, essayons un autre angle..."
|
|
|
|
|
|
|
|
|
|
| 34 |
À la fin de chaque message, ajoute obligatoirement :
|
| 35 |
---
|
| 36 |
-
Phase:
|
| 37 |
Mode : Tuteur
|
| 38 |
Sujet d'exploration : "{topic}"
|
| 39 |
Contexte du cours (extrait RAG) :
|
|
@@ -50,9 +56,12 @@ Règles :
|
|
| 50 |
4. Si une définition est demandée, explique en max 2 phrases puis pose immédiatement une question de vérification.
|
| 51 |
5. Dès qu'une base est posée en Phase 1, avance vers Phase 2.
|
| 52 |
6. Préfère l'invitation au reproche : "Ce point semble complexe, essayons un autre angle..."
|
|
|
|
|
|
|
|
|
|
| 53 |
À la fin de chaque message, ajoute obligatoirement :
|
| 54 |
---
|
| 55 |
-
Phase:
|
| 56 |
Mode : Critique
|
| 57 |
Ta mission : proposer des raisonnements fallacieux pour tester la vigilance.
|
| 58 |
Reste un partenaire de jeu élégant, jamais méprisant.
|
|
@@ -89,7 +98,10 @@ def build_system_prompt(mode: str, topic: str, phase: int, rag_chunks: list[str]
|
|
| 89 |
template = SYSTEM_TUTOR if mode == "TUTOR" else SYSTEM_CRITIC
|
| 90 |
|
| 91 |
rag_text = "\n---\n".join(rag_chunks) if rag_chunks else "(aucun document chargé)"
|
| 92 |
-
prompt =
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
prompt += f"\n\n{PHASE_GUIDANCE.get(phase, PHASE_GUIDANCE[0])}"
|
| 95 |
|
|
@@ -99,14 +111,19 @@ def build_system_prompt(mode: str, topic: str, phase: int, rag_chunks: list[str]
|
|
| 99 |
async def chat(system_prompt: str, messages: list[dict]) -> str:
|
| 100 |
"""Send chat completion request and return assistant message."""
|
| 101 |
client = _get_client()
|
| 102 |
-
model = os.environ.get("LLM_MODEL", "
|
| 103 |
api_messages = [{"role": "system", "content": system_prompt}] + messages
|
| 104 |
|
|
|
|
|
|
|
| 105 |
response = await client.chat.completions.create(
|
| 106 |
model=model,
|
| 107 |
messages=api_messages,
|
|
|
|
| 108 |
)
|
| 109 |
-
|
|
|
|
|
|
|
| 110 |
|
| 111 |
|
| 112 |
async def analyze_session(messages: list[dict]) -> dict:
|
|
@@ -117,7 +134,7 @@ async def analyze_session(messages: list[dict]) -> dict:
|
|
| 117 |
)
|
| 118 |
|
| 119 |
analysis_messages = [
|
| 120 |
-
{"role": "user", "content": f"Voici la conversation
|
| 121 |
]
|
| 122 |
|
| 123 |
raw = await chat(ANALYSIS_SYSTEM, analysis_messages)
|
|
|
|
| 1 |
"""LLM interaction via OpenAI-compatible API."""
|
| 2 |
|
| 3 |
import json
|
| 4 |
+
import logging
|
| 5 |
import os
|
| 6 |
|
| 7 |
from openai import AsyncOpenAI
|
| 8 |
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
_client: AsyncOpenAI | None = None
|
| 12 |
|
| 13 |
|
| 14 |
def _get_client() -> AsyncOpenAI:
|
| 15 |
global _client
|
| 16 |
if _client is None:
|
| 17 |
+
api_key = os.environ.get("OPENROUTER_API_KEY", "").strip()
|
| 18 |
+
base_url = os.environ.get("LLM_BASE_URL", "").strip() or None
|
| 19 |
_client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
| 20 |
return _client
|
| 21 |
|
| 22 |
# ---------------------------------------------------------------------------
|
| 23 |
+
# System prompts
|
| 24 |
# ---------------------------------------------------------------------------
|
| 25 |
|
| 26 |
SYSTEM_TUTOR = """Tu es un mentor socratique bienveillant, empathique et complice.
|
|
|
|
| 34 |
4. Si une définition est demandée, explique en max 2 phrases puis pose immédiatement une question de vérification.
|
| 35 |
5. Dès qu'une base est posée en Phase 1, avance vers Phase 2.
|
| 36 |
6. Préfère l'invitation au reproche : "Ce point semble complexe, essayons un autre angle..."
|
| 37 |
+
7. INTERDIT : ne propose JAMAIS d'exemples, de listes d'options ou de choix multiples dans tes questions. L'apprenant·e doit produire le contenu. Mauvais : "Par exemple, X, Y ou Z ?" — Bon : "Donne-moi un exemple concret issu de ta propre expérience."
|
| 38 |
+
8. Ta question doit être ouverte et exiger que l'apprenant·e formule sa propre réponse.
|
| 39 |
+
9. Interdit absolu : "Excellent", "Très bien", "Parfait", "Bravo", "Super", "C'est une excellente question", "Absolument", "Exactement" et tout équivalent enthousiaste. Validation autorisée : une phrase neutre et courte maximum ("C'est une piste.", "Je vois ce que tu veux dire.") avant de poser la question suivante.
|
| 40 |
À la fin de chaque message, ajoute obligatoirement :
|
| 41 |
---
|
| 42 |
+
Phase: {phase}
|
| 43 |
Mode : Tuteur
|
| 44 |
Sujet d'exploration : "{topic}"
|
| 45 |
Contexte du cours (extrait RAG) :
|
|
|
|
| 56 |
4. Si une définition est demandée, explique en max 2 phrases puis pose immédiatement une question de vérification.
|
| 57 |
5. Dès qu'une base est posée en Phase 1, avance vers Phase 2.
|
| 58 |
6. Préfère l'invitation au reproche : "Ce point semble complexe, essayons un autre angle..."
|
| 59 |
+
7. INTERDIT : ne propose JAMAIS d'exemples, de listes d'options ou de choix multiples dans tes questions. L'apprenant·e doit produire le contenu. Mauvais : "Par exemple, X, Y ou Z ?" — Bon : "Donne-moi un exemple concret issu de ta propre expérience."
|
| 60 |
+
8. Ta question doit être ouverte et exiger que l'apprenant·e formule sa propre réponse.
|
| 61 |
+
9. Interdit absolu : "Excellent", "Très bien", "Parfait", "Bravo", "Super", "C'est une excellente question", "Absolument", "Exactement" et tout équivalent enthousiaste. Validation autorisée : une phrase neutre et courte maximum ("C'est une piste.", "Je vois ce que tu veux dire.") avant de poser la question suivante.
|
| 62 |
À la fin de chaque message, ajoute obligatoirement :
|
| 63 |
---
|
| 64 |
+
Phase: {phase}
|
| 65 |
Mode : Critique
|
| 66 |
Ta mission : proposer des raisonnements fallacieux pour tester la vigilance.
|
| 67 |
Reste un partenaire de jeu élégant, jamais méprisant.
|
|
|
|
| 98 |
template = SYSTEM_TUTOR if mode == "TUTOR" else SYSTEM_CRITIC
|
| 99 |
|
| 100 |
rag_text = "\n---\n".join(rag_chunks) if rag_chunks else "(aucun document chargé)"
|
| 101 |
+
prompt = (template
|
| 102 |
+
.replace("{topic}", topic)
|
| 103 |
+
.replace("{rag_context}", rag_text)
|
| 104 |
+
.replace("{phase}", str(phase)))
|
| 105 |
|
| 106 |
prompt += f"\n\n{PHASE_GUIDANCE.get(phase, PHASE_GUIDANCE[0])}"
|
| 107 |
|
|
|
|
| 111 |
async def chat(system_prompt: str, messages: list[dict]) -> str:
|
| 112 |
"""Send chat completion request and return assistant message."""
|
| 113 |
client = _get_client()
|
| 114 |
+
model = os.environ.get("LLM_MODEL", "openrouter/free").strip()
|
| 115 |
api_messages = [{"role": "system", "content": system_prompt}] + messages
|
| 116 |
|
| 117 |
+
logger.info(f"LLM call: model={model!r}, messages={len(api_messages)}, system_prompt_len={len(system_prompt)}")
|
| 118 |
+
|
| 119 |
response = await client.chat.completions.create(
|
| 120 |
model=model,
|
| 121 |
messages=api_messages,
|
| 122 |
+
timeout=60,
|
| 123 |
)
|
| 124 |
+
reply = response.choices[0].message.content
|
| 125 |
+
logger.info(f"LLM response: {len(reply)} chars")
|
| 126 |
+
return reply
|
| 127 |
|
| 128 |
|
| 129 |
async def analyze_session(messages: list[dict]) -> dict:
|
|
|
|
| 134 |
)
|
| 135 |
|
| 136 |
analysis_messages = [
|
| 137 |
+
{"role": "user", "content": f"Voici la conversation à analyser :\n\n{conversation_text}"}
|
| 138 |
]
|
| 139 |
|
| 140 |
raw = await chat(ANALYSIS_SYSTEM, analysis_messages)
|
app/main.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"""FastAPI application for AIM Learning Companion."""
|
| 2 |
|
| 3 |
import logging
|
|
|
|
| 4 |
import re
|
| 5 |
import traceback
|
| 6 |
from contextlib import asynccontextmanager
|
|
@@ -29,6 +30,7 @@ async def lifespan(app: FastAPI):
|
|
| 29 |
app = FastAPI(title="AIM Learning Companion", lifespan=lifespan)
|
| 30 |
|
| 31 |
STATIC_DIR = Path(__file__).parent.parent / "static"
|
|
|
|
| 32 |
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 33 |
|
| 34 |
ALLOWED_EXTENSIONS = {".txt", ".pdf", ".pptx", ".ppt", ".zip"}
|
|
@@ -39,7 +41,7 @@ class ChatRequest(BaseModel):
|
|
| 39 |
mode: str = "TUTOR"
|
| 40 |
topic: str = ""
|
| 41 |
phase: int = 0
|
| 42 |
-
phase_turns: int = 0
|
| 43 |
history: list[dict] = []
|
| 44 |
|
| 45 |
|
|
@@ -72,27 +74,54 @@ async def index():
|
|
| 72 |
return FileResponse(str(STATIC_DIR / "index.html"))
|
| 73 |
|
| 74 |
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
|
| 83 |
@app.post("/api/chat", response_model=ChatResponse)
|
| 84 |
async def api_chat(req: ChatRequest):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
rag_chunks = retrieve(req.message)
|
| 87 |
-
system_prompt = build_system_prompt(req.mode, req.topic,
|
| 88 |
|
| 89 |
messages = [{"role": m["role"], "content": m["content"]} for m in req.history]
|
| 90 |
messages.append({"role": "user", "content": req.message})
|
| 91 |
|
| 92 |
reply = await chat(system_prompt, messages)
|
| 93 |
-
|
| 94 |
|
| 95 |
-
return ChatResponse(reply=reply, phase=
|
| 96 |
except Exception as e:
|
| 97 |
logger.error(f"Chat error: {e}\n{traceback.format_exc()}")
|
| 98 |
return JSONResponse(status_code=500, content={"error": str(e)})
|
|
@@ -107,7 +136,7 @@ async def api_upload(files: List[UploadFile] = File(...)):
|
|
| 107 |
for f in files:
|
| 108 |
ext = Path(f.filename).suffix.lower() if f.filename else ""
|
| 109 |
if ext not in ALLOWED_EXTENSIONS:
|
| 110 |
-
skipped.append({"filename": f.filename, "reason": f"Type non
|
| 111 |
continue
|
| 112 |
content = await f.read()
|
| 113 |
file_data.append((f.filename, content))
|
|
@@ -128,7 +157,7 @@ async def api_delete_document(filename: str):
|
|
| 128 |
ok = delete_document(filename)
|
| 129 |
if ok:
|
| 130 |
return {"status": "ok"}
|
| 131 |
-
return {"status": "error", "message": "Fichier non
|
| 132 |
|
| 133 |
|
| 134 |
@app.post("/api/analyze", response_model=AnalysisResponse)
|
|
@@ -159,7 +188,6 @@ async def api_analyze(req: AnalysisRequest):
|
|
| 159 |
|
| 160 |
@app.get("/api/health")
|
| 161 |
async def health():
|
| 162 |
-
import os
|
| 163 |
return {
|
| 164 |
"status": "ok",
|
| 165 |
"has_api_key": bool(os.environ.get("OPENROUTER_API_KEY", "")),
|
|
|
|
| 1 |
"""FastAPI application for AIM Learning Companion."""
|
| 2 |
|
| 3 |
import logging
|
| 4 |
+
import os
|
| 5 |
import re
|
| 6 |
import traceback
|
| 7 |
from contextlib import asynccontextmanager
|
|
|
|
| 30 |
app = FastAPI(title="AIM Learning Companion", lifespan=lifespan)
|
| 31 |
|
| 32 |
STATIC_DIR = Path(__file__).parent.parent / "static"
|
| 33 |
+
CORPUS_DIR = Path(__file__).parent.parent / "corpus"
|
| 34 |
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 35 |
|
| 36 |
ALLOWED_EXTENSIONS = {".txt", ".pdf", ".pptx", ".ppt", ".zip"}
|
|
|
|
| 41 |
mode: str = "TUTOR"
|
| 42 |
topic: str = ""
|
| 43 |
phase: int = 0
|
| 44 |
+
phase_turns: int = 0
|
| 45 |
history: list[dict] = []
|
| 46 |
|
| 47 |
|
|
|
|
| 74 |
return FileResponse(str(STATIC_DIR / "index.html"))
|
| 75 |
|
| 76 |
|
| 77 |
+
@app.get("/download/{filename}")
|
| 78 |
+
async def download_file(filename: str):
|
| 79 |
+
"""Serve a file from the corpus directory for download."""
|
| 80 |
+
file_path = CORPUS_DIR / filename
|
| 81 |
+
if not file_path.exists() or not file_path.is_file():
|
| 82 |
+
return JSONResponse(status_code=404, content={"error": "Fichier non trouvé"})
|
| 83 |
+
return FileResponse(str(file_path), filename=filename)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
MAX_TURNS_PER_PHASE = 2
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _compute_phase(current_phase: int, phase_turns: int) -> tuple[int, int]:
|
| 90 |
+
"""Advance phase based on conversation depth.
|
| 91 |
+
|
| 92 |
+
Returns (new_phase, new_phase_turns).
|
| 93 |
+
Phase advances after MAX_TURNS_PER_PHASE learner turns in the same phase.
|
| 94 |
+
"""
|
| 95 |
+
new_turns = phase_turns + 1
|
| 96 |
+
if new_turns >= MAX_TURNS_PER_PHASE and current_phase < 4:
|
| 97 |
+
return current_phase + 1, 0
|
| 98 |
+
return current_phase, new_turns
|
| 99 |
|
| 100 |
|
| 101 |
@app.post("/api/chat", response_model=ChatResponse)
|
| 102 |
async def api_chat(req: ChatRequest):
|
| 103 |
+
api_key = os.environ.get("OPENROUTER_API_KEY", "").strip()
|
| 104 |
+
base_url = os.environ.get("LLM_BASE_URL", "").strip()
|
| 105 |
+
model = os.environ.get("LLM_MODEL", "").strip()
|
| 106 |
+
if not api_key:
|
| 107 |
+
logger.error("OPENROUTER_API_KEY is not set!")
|
| 108 |
+
return JSONResponse(status_code=500, content={"error": "Cle API non configuree (OPENROUTER_API_KEY manquant)"})
|
| 109 |
+
|
| 110 |
try:
|
| 111 |
+
# Compute phase progression server-side
|
| 112 |
+
new_phase, new_phase_turns = _compute_phase(req.phase, req.phase_turns)
|
| 113 |
+
logger.info(f"Chat request: mode={req.mode}, topic={req.topic[:50]}, phase={req.phase}->{new_phase}, turns={req.phase_turns}->{new_phase_turns}, model={model}")
|
| 114 |
+
|
| 115 |
rag_chunks = retrieve(req.message)
|
| 116 |
+
system_prompt = build_system_prompt(req.mode, req.topic, new_phase, rag_chunks)
|
| 117 |
|
| 118 |
messages = [{"role": m["role"], "content": m["content"]} for m in req.history]
|
| 119 |
messages.append({"role": "user", "content": req.message})
|
| 120 |
|
| 121 |
reply = await chat(system_prompt, messages)
|
| 122 |
+
logger.info(f"LLM reply received ({len(reply)} chars)")
|
| 123 |
|
| 124 |
+
return ChatResponse(reply=reply, phase=new_phase, phase_turns=new_phase_turns)
|
| 125 |
except Exception as e:
|
| 126 |
logger.error(f"Chat error: {e}\n{traceback.format_exc()}")
|
| 127 |
return JSONResponse(status_code=500, content={"error": str(e)})
|
|
|
|
| 136 |
for f in files:
|
| 137 |
ext = Path(f.filename).suffix.lower() if f.filename else ""
|
| 138 |
if ext not in ALLOWED_EXTENSIONS:
|
| 139 |
+
skipped.append({"filename": f.filename, "reason": f"Type non supporté: {ext}"})
|
| 140 |
continue
|
| 141 |
content = await f.read()
|
| 142 |
file_data.append((f.filename, content))
|
|
|
|
| 157 |
ok = delete_document(filename)
|
| 158 |
if ok:
|
| 159 |
return {"status": "ok"}
|
| 160 |
+
return {"status": "error", "message": "Fichier non trouvé"}
|
| 161 |
|
| 162 |
|
| 163 |
@app.post("/api/analyze", response_model=AnalysisResponse)
|
|
|
|
| 188 |
|
| 189 |
@app.get("/api/health")
|
| 190 |
async def health():
|
|
|
|
| 191 |
return {
|
| 192 |
"status": "ok",
|
| 193 |
"has_api_key": bool(os.environ.get("OPENROUTER_API_KEY", "")),
|
static/app.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
| 11 |
mode: "TUTOR",
|
| 12 |
topic: "",
|
| 13 |
phase: 0,
|
|
|
|
| 14 |
history: [], // {role, content}
|
| 15 |
timestamps: [], // epoch ms for every message (user & assistant alternating)
|
| 16 |
analysisResult: null,
|
|
@@ -94,10 +95,17 @@
|
|
| 94 |
}
|
| 95 |
|
| 96 |
/* ===== Messages ===== */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
function addMessage(role, content) {
|
| 98 |
var div = document.createElement("div");
|
| 99 |
div.className = "message " + role;
|
| 100 |
-
div.textContent = content;
|
| 101 |
messagesEl.insertBefore(div, typingEl);
|
| 102 |
messagesEl.scrollTop = messagesEl.scrollHeight;
|
| 103 |
}
|
|
@@ -252,13 +260,27 @@
|
|
| 252 |
mode: state.mode,
|
| 253 |
topic: state.topic,
|
| 254 |
phase: state.phase,
|
|
|
|
| 255 |
history: state.history.slice(0, -1)
|
| 256 |
})
|
| 257 |
})
|
| 258 |
-
.then(function (res) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
.then(function (data) {
|
| 260 |
setTyping(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
state.phase = data.phase;
|
|
|
|
| 262 |
state.history.push({ role: "assistant", content: data.reply });
|
| 263 |
state.timestamps.push(Date.now());
|
| 264 |
addMessage("assistant", data.reply);
|
|
@@ -268,7 +290,8 @@
|
|
| 268 |
})
|
| 269 |
.catch(function (err) {
|
| 270 |
setTyping(false);
|
| 271 |
-
|
|
|
|
| 272 |
btnSend.disabled = false;
|
| 273 |
});
|
| 274 |
}
|
|
@@ -364,6 +387,7 @@
|
|
| 364 |
state.mode = "TUTOR";
|
| 365 |
state.topic = "";
|
| 366 |
state.phase = 0;
|
|
|
|
| 367 |
state.history = [];
|
| 368 |
state.timestamps = [];
|
| 369 |
state.analysisResult = null;
|
|
@@ -424,6 +448,10 @@
|
|
| 424 |
if (!topic) return;
|
| 425 |
|
| 426 |
state.topic = topic;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
modeBadge.textContent = state.mode === "TUTOR" ? "Tuteur" : "Critique";
|
| 428 |
topicBadge.textContent = topic;
|
| 429 |
|
|
@@ -455,13 +483,27 @@
|
|
| 455 |
mode: state.mode,
|
| 456 |
topic: state.topic,
|
| 457 |
phase: state.phase,
|
|
|
|
| 458 |
history: []
|
| 459 |
})
|
| 460 |
})
|
| 461 |
-
.then(function (res) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
.then(function (data) {
|
| 463 |
setTyping(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 464 |
state.phase = data.phase;
|
|
|
|
| 465 |
state.history.push({ role: "assistant", content: data.reply });
|
| 466 |
state.timestamps.push(Date.now());
|
| 467 |
addMessage("assistant", data.reply);
|
|
@@ -469,9 +511,10 @@
|
|
| 469 |
btnSend.disabled = false;
|
| 470 |
chatInput.focus();
|
| 471 |
})
|
| 472 |
-
.catch(function () {
|
| 473 |
setTyping(false);
|
| 474 |
-
|
|
|
|
| 475 |
btnSend.disabled = false;
|
| 476 |
});
|
| 477 |
}
|
|
|
|
| 11 |
mode: "TUTOR",
|
| 12 |
topic: "",
|
| 13 |
phase: 0,
|
| 14 |
+
phaseTurns: 0,
|
| 15 |
history: [], // {role, content}
|
| 16 |
timestamps: [], // epoch ms for every message (user & assistant alternating)
|
| 17 |
analysisResult: null,
|
|
|
|
| 95 |
}
|
| 96 |
|
| 97 |
/* ===== Messages ===== */
|
| 98 |
+
function stripPhaseMarker(text) {
|
| 99 |
+
// Remove "---\nPhase: ..." block from the end of assistant messages
|
| 100 |
+
var idx = text.indexOf("\n---");
|
| 101 |
+
if (idx === -1) idx = text.indexOf("---\nPhase");
|
| 102 |
+
return idx >= 0 ? text.substring(0, idx).trim() : text;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
function addMessage(role, content) {
|
| 106 |
var div = document.createElement("div");
|
| 107 |
div.className = "message " + role;
|
| 108 |
+
div.textContent = role === "assistant" ? stripPhaseMarker(content) : content;
|
| 109 |
messagesEl.insertBefore(div, typingEl);
|
| 110 |
messagesEl.scrollTop = messagesEl.scrollHeight;
|
| 111 |
}
|
|
|
|
| 260 |
mode: state.mode,
|
| 261 |
topic: state.topic,
|
| 262 |
phase: state.phase,
|
| 263 |
+
phase_turns: state.phaseTurns,
|
| 264 |
history: state.history.slice(0, -1)
|
| 265 |
})
|
| 266 |
})
|
| 267 |
+
.then(function (res) {
|
| 268 |
+
if (!res.ok) {
|
| 269 |
+
return res.json().then(function (err) {
|
| 270 |
+
throw new Error(err.error || "Erreur serveur " + res.status);
|
| 271 |
+
});
|
| 272 |
+
}
|
| 273 |
+
return res.json();
|
| 274 |
+
})
|
| 275 |
.then(function (data) {
|
| 276 |
setTyping(false);
|
| 277 |
+
if (!data.reply) {
|
| 278 |
+
addMessage("assistant", "Reponse vide du serveur. Verifiez la configuration API.");
|
| 279 |
+
btnSend.disabled = false;
|
| 280 |
+
return;
|
| 281 |
+
}
|
| 282 |
state.phase = data.phase;
|
| 283 |
+
state.phaseTurns = data.phase_turns || 0;
|
| 284 |
state.history.push({ role: "assistant", content: data.reply });
|
| 285 |
state.timestamps.push(Date.now());
|
| 286 |
addMessage("assistant", data.reply);
|
|
|
|
| 290 |
})
|
| 291 |
.catch(function (err) {
|
| 292 |
setTyping(false);
|
| 293 |
+
console.error("sendMessage error:", err);
|
| 294 |
+
addMessage("assistant", "Erreur: " + (err.message || "Connexion impossible. Veuillez reessayer."));
|
| 295 |
btnSend.disabled = false;
|
| 296 |
});
|
| 297 |
}
|
|
|
|
| 387 |
state.mode = "TUTOR";
|
| 388 |
state.topic = "";
|
| 389 |
state.phase = 0;
|
| 390 |
+
state.phaseTurns = 0;
|
| 391 |
state.history = [];
|
| 392 |
state.timestamps = [];
|
| 393 |
state.analysisResult = null;
|
|
|
|
| 448 |
if (!topic) return;
|
| 449 |
|
| 450 |
state.topic = topic;
|
| 451 |
+
state.phase = 0;
|
| 452 |
+
state.phaseTurns = 0;
|
| 453 |
+
state.history = [];
|
| 454 |
+
state.timestamps = [];
|
| 455 |
modeBadge.textContent = state.mode === "TUTOR" ? "Tuteur" : "Critique";
|
| 456 |
topicBadge.textContent = topic;
|
| 457 |
|
|
|
|
| 483 |
mode: state.mode,
|
| 484 |
topic: state.topic,
|
| 485 |
phase: state.phase,
|
| 486 |
+
phase_turns: state.phaseTurns,
|
| 487 |
history: []
|
| 488 |
})
|
| 489 |
})
|
| 490 |
+
.then(function (res) {
|
| 491 |
+
if (!res.ok) {
|
| 492 |
+
return res.json().then(function (err) {
|
| 493 |
+
throw new Error(err.error || "Erreur serveur " + res.status);
|
| 494 |
+
});
|
| 495 |
+
}
|
| 496 |
+
return res.json();
|
| 497 |
+
})
|
| 498 |
.then(function (data) {
|
| 499 |
setTyping(false);
|
| 500 |
+
if (!data.reply) {
|
| 501 |
+
addMessage("assistant", "Reponse vide du serveur. Verifiez la configuration API.");
|
| 502 |
+
btnSend.disabled = false;
|
| 503 |
+
return;
|
| 504 |
+
}
|
| 505 |
state.phase = data.phase;
|
| 506 |
+
state.phaseTurns = data.phase_turns || 0;
|
| 507 |
state.history.push({ role: "assistant", content: data.reply });
|
| 508 |
state.timestamps.push(Date.now());
|
| 509 |
addMessage("assistant", data.reply);
|
|
|
|
| 511 |
btnSend.disabled = false;
|
| 512 |
chatInput.focus();
|
| 513 |
})
|
| 514 |
+
.catch(function (err) {
|
| 515 |
setTyping(false);
|
| 516 |
+
console.error("startSession error:", err);
|
| 517 |
+
addMessage("assistant", "Erreur: " + (err.message || "Connexion impossible. Veuillez reessayer."));
|
| 518 |
btnSend.disabled = false;
|
| 519 |
});
|
| 520 |
}
|