From a9b2b58232fb5e03e9d13f8d61b070634be80a94 Mon Sep 17 00:00:00 2001 From: Tharumini Gamage Date: Sat, 19 Sep 2026 23:12:51 +0530 Subject: [PATCH 01/11] ci: add tests workflow for backend unit/integration/learnmate suites Runs testing/unit, testing/integration, and integrated-backend/tests against GitHub-hosted runners with no live services and no GGUF model load, so pushes and PRs get real backend test signal without needing the local stack. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/tests.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..3456d9a --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,27 @@ +name: tests + +on: + push: + pull_request: + +jobs: + backend-suites: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install light test stack + run: pip install -r testing/requirements.txt + + - name: Unit + integration suites (no live services, no GGUF) + run: python -m pytest testing/unit testing/integration -q + + - name: Install extras needed only by integrated-backend/tests + run: pip install langgraph python-docx python-pptx + + - name: integrated-backend suite (learnmate package imports LangGraph, no GGUF load) + working-directory: integrated-backend + run: python -m pytest tests -q From 41aca72c3d62d32f3da9991174c9e2fd8a3bd24d Mon Sep 17 00:00:00 2001 From: Tharumini Gamage Date: Sat, 19 Sep 2026 23:13:08 +0530 Subject: [PATCH 02/11] fix: clear a dead session on check-sso, not on a network blip check-sso resolving authenticated:false (e.g. an expired or server-invalidated token) now clears localStorage, so ProtectedRoute stops granting access to a dead session. A network error reaching Keycloak is left untouched instead -- that's genuinely unknown, not a confirmed "no session," so an existing session survives a transient outage rather than being logged out by it. Co-Authored-By: Claude Sonnet 5 --- integrated-frontend/src/context/AuthProvider.jsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/integrated-frontend/src/context/AuthProvider.jsx b/integrated-frontend/src/context/AuthProvider.jsx index 3678e7d..970c554 100644 --- a/integrated-frontend/src/context/AuthProvider.jsx +++ b/integrated-frontend/src/context/AuthProvider.jsx @@ -84,10 +84,18 @@ export function AuthProvider({ children }) { localStorage.setItem("user", JSON.stringify(nextUser)); setToken(keycloak.token); setUser(nextUser); + } else if (!cancelled) { + // check-sso resolved but found no valid session -- e.g. a stored token that has + // since expired or was invalidated server-side. Clear it so ProtectedRoute, which + // only checks "is there a token," doesn't keep granting access to a dead session. + localStorage.removeItem("token"); + localStorage.removeItem("user"); + setToken(null); + setUser(null); } } catch { - // Keycloak unreachable, or genuinely no session -- either way this just means - // "not logged in," not an error worth surfacing. + // Keycloak unreachable -- genuinely unknown, not a definite "no session," so + // an existing session is left alone rather than logged out on a network blip. } if (!cancelled) setChecking(false); From 40561112f0e5695b2da388f4aa94a7d47a52b50e Mon Sep 17 00:00:00 2001 From: Tharumini Gamage Date: Sat, 19 Sep 2026 23:13:31 +0530 Subject: [PATCH 03/11] test: fix stale assertions in test_frontend_contracts and test_pdf_validate These were checking for text that no longer exists in the app: auth.js (removed now that auth is Keycloak-only) and the old PDF-only upload/validation error messages (upload now accepts PDF/DOCX/PPTX/TEX). test_auth_api_paths now skips when auth.js is absent instead of failing on a file that was deliberately deleted; the other two assertions match the current, real error text. Co-Authored-By: Claude Sonnet 5 --- testing/unit/test_frontend_contracts.py | 9 +++++++-- testing/unit/test_pdf_validate.py | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/testing/unit/test_frontend_contracts.py b/testing/unit/test_frontend_contracts.py index c497c4f..ef67971 100644 --- a/testing/unit/test_frontend_contracts.py +++ b/testing/unit/test_frontend_contracts.py @@ -2,6 +2,8 @@ from pathlib import Path +import pytest + def test_error_message_uses_backend_detail_and_network_hint(frontend_root: Path): src = (frontend_root / "src" / "api" / "client.js").read_text(encoding="utf-8") @@ -13,7 +15,10 @@ def test_error_message_uses_backend_detail_and_network_hint(frontend_root: Path) def test_auth_api_paths(frontend_root: Path): - src = (frontend_root / "src" / "api" / "auth.js").read_text(encoding="utf-8") + auth_file = frontend_root / "src" / "api" / "auth.js" + if not auth_file.exists(): + pytest.skip("deployment is Keycloak-only; the local /api/auth/* client was removed") + src = auth_file.read_text(encoding="utf-8") assert "/api/auth/register" in src assert "/api/auth/login" in src assert "/api/auth/me" in src @@ -39,7 +44,7 @@ def test_app_routes_explore_vs_protected(frontend_root: Path): def test_upload_client_enforces_ten_megabytes(frontend_root: Path): src = (frontend_root / "src" / "components" / "DocumentsCard.jsx").read_text(encoding="utf-8") assert "const MAX_MB = 10" in src - assert "Only PDF files are accepted" in src + assert "Upload a PDF, Word (.docx), PowerPoint (.pptx), or LaTeX (.tex) file." in src def test_protected_route_waits_while_checking(frontend_root: Path): diff --git a/testing/unit/test_pdf_validate.py b/testing/unit/test_pdf_validate.py index 392b891..6402ec9 100644 --- a/testing/unit/test_pdf_validate.py +++ b/testing/unit/test_pdf_validate.py @@ -16,7 +16,7 @@ def _pdf_with_text(text: str = "Section 1. Directors owe duties to the company." def test_non_pdf_extension_rejected(): - with pytest.raises(ValueError, match="Only PDF"): + with pytest.raises(ValueError, match="Upload a PDF, Word"): validate_pdf(b"%PDF", "notes.txt", "application/pdf") @@ -40,5 +40,5 @@ def test_valid_pdf_returns_page_count(): def test_garbage_bytes_are_corrupt(): - with pytest.raises(ValueError, match="could not be read"): + with pytest.raises(ValueError, match="not a readable PDF"): validate_pdf(b"not-a-pdf-at-all", "notes.pdf", "application/pdf") From e385f7b7a217f759088093aaf1c2497c9a2f7fab Mon Sep 17 00:00:00 2001 From: Tharumini Gamage Date: Sat, 19 Sep 2026 23:14:05 +0530 Subject: [PATCH 04/11] fix: address bandit B615 (unpinned HF revision) and B324 (weak hash use) Generator and judge GGUF downloads now pin to a specific HF commit (GENERATOR_REVISION/JUDGE_REVISION) instead of tracking each repo's default branch, so a future push to either repo can't silently swap the model a fresh checkout downloads. Threaded through ensure_gguf() and both call sites in registry.py. qdrant_vectors.py's md5 use for sparse-vector indexing is non-cryptographic (bucketing words into an index space, not hashing anything sensitive); marked with usedforsecurity=False so bandit stops flagging it as B324. Co-Authored-By: Claude Sonnet 5 --- integrated-backend/.env.example | 6 ++++++ integrated-backend/learnmate/config.py | 8 ++++++++ integrated-backend/learnmate/llm/download.py | 7 ++++++- integrated-backend/learnmate/llm/registry.py | 10 ++++++---- integrated-backend/learnmate/storage/qdrant_vectors.py | 2 +- 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/integrated-backend/.env.example b/integrated-backend/.env.example index 203c523..a939c0d 100644 --- a/integrated-backend/.env.example +++ b/integrated-backend/.env.example @@ -128,6 +128,10 @@ LEARNMATE_GENERATOR_CHAT_FORMAT= # so a hand-placed finetune is never overwritten. # LEARNMATE_GENERATOR_REPO=Qwen/Qwen2.5-3B-Instruct-GGUF # LEARNMATE_GENERATOR_FILE=qwen2.5-3b-instruct-q4_k_m.gguf +# Pinned to a specific commit so a future push to the repo can't silently swap the model a +# fresh checkout downloads. Defaults to the repo's current commit as of 2026-09-19; only +# override this if deliberately moving to a newer (or older) commit. +# LEARNMATE_GENERATOR_REVISION=7dabda4d13d513e3e842b20f0d435c732f172cbe # --- Judge: grades what the generator wrote ---------------------------------------------- LEARNMATE_JUDGE_BACKEND=llamacpp @@ -139,6 +143,8 @@ LEARNMATE_JUDGE_N_CTX=8192 # LEARNMATE_JUDGE_MODEL=gemini-2.0-flash-lite # LEARNMATE_JUDGE_REPO=bartowski/Llama-3.2-3B-Instruct-GGUF # LEARNMATE_JUDGE_FILE=Llama-3.2-3B-Instruct-Q4_K_M.gguf +# Same reasoning as LEARNMATE_GENERATOR_REVISION above. +# LEARNMATE_JUDGE_REVISION=5ab33fa94d1d04e903623ae72c95d1696f09f9e8 # LEARNMATE_JUDGE_CHAT_FORMAT= # Read only when a role's backend is "gemini". diff --git a/integrated-backend/learnmate/config.py b/integrated-backend/learnmate/config.py index 957ea40..3f22176 100644 --- a/integrated-backend/learnmate/config.py +++ b/integrated-backend/learnmate/config.py @@ -128,6 +128,11 @@ def _env_bool(name: str, default: bool) -> bool: # rather than downloading a *different* model and running that instead. GENERATOR_REPO = _env_optional("LEARNMATE_GENERATOR_REPO", "Qwen/Qwen2.5-3B-Instruct-GGUF") GENERATOR_FILE = _env_optional("LEARNMATE_GENERATOR_FILE", "qwen2.5-3b-instruct-q4_k_m.gguf") +# Pinned so a future push to this repo can't silently swap the model a fresh checkout +# downloads. Current as of 2026-09-19 -- bump deliberately (via .env, not by editing this +# default) if the repo is ever intentionally updated. +GENERATOR_REVISION = _env_optional( + "LEARNMATE_GENERATOR_REVISION", "7dabda4d13d513e3e842b20f0d435c732f172cbe") # A finetune with a non-standard prompt template needs its chat format named here # (e.g. "chatml", "llama-3"). Empty lets llama.cpp read it from the GGUF metadata, @@ -150,6 +155,9 @@ def _env_bool(name: str, default: bool) -> bool: str(MODELS_DIR / "Llama-3.2-3B-Instruct-Q4_K_M.gguf")) JUDGE_REPO = _env_optional("LEARNMATE_JUDGE_REPO", "bartowski/Llama-3.2-3B-Instruct-GGUF") JUDGE_FILE = _env_optional("LEARNMATE_JUDGE_FILE", "Llama-3.2-3B-Instruct-Q4_K_M.gguf") +# Same reasoning as GENERATOR_REVISION above -- current as of 2026-09-19. +JUDGE_REVISION = _env_optional( + "LEARNMATE_JUDGE_REVISION", "5ab33fa94d1d04e903623ae72c95d1696f09f9e8") JUDGE_CHAT_FORMAT = _env("LEARNMATE_JUDGE_CHAT_FORMAT", "") # Judging is short-output / long-input: a resource plus its source text must fit. diff --git a/integrated-backend/learnmate/llm/download.py b/integrated-backend/learnmate/llm/download.py index 7c8e24f..64c294c 100644 --- a/integrated-backend/learnmate/llm/download.py +++ b/integrated-backend/learnmate/llm/download.py @@ -7,11 +7,12 @@ """ from pathlib import Path +from typing import Optional from .. import config -def ensure_gguf(path: str, repo_id: str, filename: str) -> str: +def ensure_gguf(path: str, repo_id: str, filename: str, revision: Optional[str] = None) -> str: """ Return a local path to a GGUF file, downloading it on first use. @@ -53,9 +54,13 @@ def ensure_gguf(path: str, repo_id: str, filename: str) -> str: print(f"[*] {target.name} not found locally; downloading from {repo_id} (~2 GB, once)...") # HF_TOKEN only lifts anonymous rate limits here; both default models are public. + # `revision` pins to a specific commit so a future push to the repo can't silently + # swap the model a fresh checkout downloads; omitted (None) falls back to the repo's + # default branch, same as before this was threaded through. return hf_hub_download( repo_id=repo_id, filename=filename, local_dir=str(models_dir), token=config.HF_TOKEN, + revision=revision, ) diff --git a/integrated-backend/learnmate/llm/registry.py b/integrated-backend/learnmate/llm/registry.py index 69f32d5..4d93fba 100644 --- a/integrated-backend/learnmate/llm/registry.py +++ b/integrated-backend/learnmate/llm/registry.py @@ -53,7 +53,7 @@ def consume_generator_load_ms() -> int: def _build(role: str, backend: str, model: str, repo: str, filename: str, chat_format: str, n_ctx: int, api_url: str, api_key: str, - temperature: float, max_tokens: int): + temperature: float, max_tokens: int, revision: Optional[str] = None): """Construct the chat model one role's configuration describes.""" if backend == "http": return HttpChatModel( @@ -94,7 +94,7 @@ def _build(role: str, backend: str, model: str, repo: str, filename: str, # ensure_gguf downloads on first use, so this is where a fresh checkout blocks for a # few minutes -- not somewhere deep inside a generation. return LlamaCppChatModel( - gguf_path=ensure_gguf(model, repo, filename), + gguf_path=ensure_gguf(model, repo, filename, revision), n_ctx=n_ctx, n_threads=config.N_THREADS, n_gpu_layers=config.N_GPU_LAYERS, @@ -133,6 +133,7 @@ def resolve_generator_settings(model_id: Optional[str] = None): "model": config.GENERATOR_MODEL, "repo": config.GENERATOR_REPO, "filename": config.GENERATOR_FILE, + "revision": config.GENERATOR_REVISION, "chat_format": config.GENERATOR_CHAT_FORMAT, "n_ctx": config.GENERATOR_N_CTX, "api_url": config.GENERATOR_API_URL, @@ -156,6 +157,7 @@ def resolve_generator_settings(model_id: Optional[str] = None): "model": entry["resolved_path"], "repo": "", "filename": "", + "revision": None, "chat_format": entry.get("chat_format") or "", "n_ctx": int(entry.get("context_length") or config.GENERATOR_N_CTX), "api_url": config.GENERATOR_API_URL, @@ -218,7 +220,7 @@ def get_generator_llm(temperature: Optional[float] = None, max_tokens: int = 102 "generator", settings["backend"], model_path, settings["repo"], settings["filename"], settings["chat_format"], settings["n_ctx"], settings["api_url"], settings["api_key"], - temp, max_tokens, + temp, max_tokens, settings.get("revision"), ) if settings["backend"] == "llamacpp": _LOADED_GENERATOR_PATH = model_path @@ -244,6 +246,6 @@ def get_judge_llm(temperature: float = 0.0, max_tokens: int = 512): "judge", config.JUDGE_BACKEND, config.JUDGE_MODEL, config.JUDGE_REPO, config.JUDGE_FILE, config.JUDGE_CHAT_FORMAT, config.JUDGE_N_CTX, config.JUDGE_API_URL, config.JUDGE_API_KEY, - temperature, max_tokens, + temperature, max_tokens, config.JUDGE_REVISION, ) return _LLM_CACHE[key] diff --git a/integrated-backend/learnmate/storage/qdrant_vectors.py b/integrated-backend/learnmate/storage/qdrant_vectors.py index e4b57e5..cb529ad 100644 --- a/integrated-backend/learnmate/storage/qdrant_vectors.py +++ b/integrated-backend/learnmate/storage/qdrant_vectors.py @@ -50,7 +50,7 @@ def _to_sparse(text: str) -> dict: indices = [] values = [] for w, c in counts.items(): - idx = int(hashlib.md5(w.encode()).hexdigest(), 16) % 1000000 + idx = int(hashlib.md5(w.encode(), usedforsecurity=False).hexdigest(), 16) % 1000000 if idx not in indices: indices.append(idx) values.append(float(c)) From 1d04c1267b60a2146ef45c8ff1aec1ba5b8ad44d Mon Sep 17 00:00:00 2001 From: Tharumini Gamage Date: Sat, 19 Sep 2026 23:36:43 +0530 Subject: [PATCH 05/11] ci: install numpy for integrated-backend/tests learnmate.storage.mongo_vectors does real np.asarray/np.linalg.norm work and is imported transitively by the learnmate package, so integrated-backend/tests fails at collection on a clean runner without it (confirmed: ModuleNotFoundError: No module named 'numpy'). Scoped to this job's own extras step, not testing/requirements.txt, since testing/unit and testing/integration don't need it. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3456d9a..04fbe2b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,7 +20,7 @@ jobs: run: python -m pytest testing/unit testing/integration -q - name: Install extras needed only by integrated-backend/tests - run: pip install langgraph python-docx python-pptx + run: pip install langgraph python-docx python-pptx numpy - name: integrated-backend suite (learnmate package imports LangGraph, no GGUF load) working-directory: integrated-backend From 5c5fbdae3f28c44add08d4363ca102a58cde9a1d Mon Sep 17 00:00:00 2001 From: Tharumini Gamage Date: Sat, 19 Sep 2026 23:55:58 +0530 Subject: [PATCH 06/11] fix: guard WorkspaceChat's session-open effect against StrictMode double-invoke StrictMode's dev-only double-invoke ran this effect twice back to back; without a cancelled guard on the create-or-reuse decision itself (not just the state updates after it), both invocations saw "no existing session" and each called createSession(), leaving one real session plus an orphaned duplicate for the same document (confirmed via API: two session_ids, same document_id, 3ms apart). Converted the effect to a single async function with a cancelled flag checked before every state update and before the create-or-reuse branch, matching the pattern already used in AuthProvider.jsx and chat.jsx. Co-Authored-By: Claude Sonnet 5 --- .../src/components/WorkspaceChat.jsx | 79 +++++++++++-------- 1 file changed, 48 insertions(+), 31 deletions(-) diff --git a/integrated-frontend/src/components/WorkspaceChat.jsx b/integrated-frontend/src/components/WorkspaceChat.jsx index 847a9d3..18dc15e 100644 --- a/integrated-frontend/src/components/WorkspaceChat.jsx +++ b/integrated-frontend/src/components/WorkspaceChat.jsx @@ -6,7 +6,7 @@ * /documents for /chat. */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { createSession, getMessages, listSessions, sendMessage } from "../api/chat.js"; import { errorMessage } from "../api/client.js"; import { useJob } from "../hooks/useJob.js"; @@ -24,44 +24,61 @@ function WorkspaceChat({ documentId, ready }) { const job = useJob(); const bottomRef = useRef(null); - const openSession = useCallback(async () => { - if (!documentId || !ready) { - setSessionId(null); - setTurns([]); - setLoading(false); - return; - } - setLoading(true); - setError(""); - try { - const listed = await listSessions(); - const existing = (listed.data || []).find((session) => session.document_id === documentId); - if (existing) { - setSessionId(existing.session_id); - const messages = await getMessages(existing.session_id); - setTurns(messages.data || []); - } else { - const created = await createSession({ documentId }); - setSessionId(created.data.session_id); - setTurns([]); - } - } catch (err) { - setError(errorMessage(err, "Could not open a conversation for this document.")); - } finally { - setLoading(false); - } - }, [documentId, ready]); - useEffect(() => { // Fetch-on-mount (and again if the selected document changes). The rule guards // against cascading renders from derived state; this is a request to an external // system, which is what an effect is for. - // eslint-disable-next-line react-hooks/set-state-in-effect + // + // `cancelled` guards the create-or-reuse decision itself, not just the state updates + // after it: StrictMode's dev-only double-invoke runs this effect twice back to back, + // and without checking `cancelled` before acting on `listSessions()`'s result, both + // invocations see "no existing session" and each call createSession(), leaving one + // real session plus an orphaned duplicate for the same document. + let cancelled = false; + + async function openSession() { + if (!documentId || !ready) { + if (!cancelled) { + setSessionId(null); + setTurns([]); + setLoading(false); + } + return; + } + if (!cancelled) { + setLoading(true); + setError(""); + } + try { + const listed = await listSessions(); + if (cancelled) return; + const existing = (listed.data || []).find((session) => session.document_id === documentId); + if (existing) { + setSessionId(existing.session_id); + const messages = await getMessages(existing.session_id); + if (cancelled) return; + setTurns(messages.data || []); + } else { + const created = await createSession({ documentId }); + if (cancelled) return; + setSessionId(created.data.session_id); + setTurns([]); + } + } catch (err) { + if (!cancelled) setError(errorMessage(err, "Could not open a conversation for this document.")); + } finally { + if (!cancelled) setLoading(false); + } + } + openSession(); job.reset(); + return () => { + cancelled = true; + }; // job.reset is stable; including it would clear a running turn on every render. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [openSession]); + }, [documentId, ready]); useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); From ed60ad15c1f065a79ac1093ec389d7a6eddad657 Mon Sep 17 00:00:00 2001 From: Tharumini Gamage Date: Sat, 19 Sep 2026 23:56:05 +0530 Subject: [PATCH 07/11] fix: associate checkbox labels with their inputs for accessibility The "evaluate" toggles in ResourcesPanel and MyAccountSettings had a