Claude commited on
Commit
4a021be
·
unverified ·
1 Parent(s): 7e288e3

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

Files changed (3) hide show
  1. app/llm.py +26 -9
  2. app/main.py +41 -13
  3. 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 (verbatim from spec)
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: [Numéro]
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: [Numéro]
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 = template.replace("{topic}", topic).replace("{rag_context}", rag_text)
 
 
 
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", "mistralai/mistral-7b-instruct")
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
- return response.choices[0].message.content
 
 
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 \u00e0 analyser :\n\n{conversation_text}"}
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 # how many turns spent in current phase
43
  history: list[dict] = []
44
 
45
 
@@ -72,27 +74,54 @@ async def index():
72
  return FileResponse(str(STATIC_DIR / "index.html"))
73
 
74
 
75
- def _detect_phase(reply: str, current_phase: int) -> int:
76
- """Extract phase number from the companion's reply."""
77
- match = re.search(r"Phase:\s*(\d)", reply)
78
- if match:
79
- return int(match.group(1))
80
- return current_phase
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, req.phase, rag_chunks)
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
- detected_phase = _detect_phase(reply, req.phase)
94
 
95
- return ChatResponse(reply=reply, phase=detected_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 support\u00e9: {ext}"})
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 trouv\u00e9"}
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) { return res.json(); })
 
 
 
 
 
 
 
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
- addMessage("assistant", "Erreur de connexion. Veuillez reessayer.");
 
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) { return res.json(); })
 
 
 
 
 
 
 
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
- addMessage("assistant", "Erreur de connexion. Veuillez reessayer.");
 
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
  }