Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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 numpy

- name: integrated-backend suite (learnmate package imports LangGraph, no GGUF load)
working-directory: integrated-backend
run: python -m pytest tests -q
6 changes: 6 additions & 0 deletions integrated-backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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".
Expand Down
8 changes: 8 additions & 0 deletions integrated-backend/learnmate/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion integrated-backend/learnmate/llm/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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,
)
10 changes: 6 additions & 4 deletions integrated-backend/learnmate/llm/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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]
20 changes: 20 additions & 0 deletions integrated-backend/learnmate/resource_agent/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,24 @@
from .state import ResourceState
from .tasks import get_task

# Rough tokens per item, by task -- the same calibration whole_document.MAX_PER_CALL uses
# to cap a single call's item count, but expressed as a per-item cost so a passage-scope
# request (which asks for its whole count in one call, unbatched) can size max_tokens to
# fit instead of being silently capped. Can't import whole_document's constants here: that
# module imports .agent, which imports .graph, which imports this module -- a cycle.
_TOKENS_PER_ITEM = {"mcq": 130, "practice_qsn": 100, "keypoints": 65}
_DEFAULT_TOKENS_PER_ITEM = 100
_BASE_OVERHEAD_TOKENS = 150
# Ceiling, not a target: stays well inside GENERATOR_N_CTX's headroom after the prompt
# (source passage up to MAX_SOURCE_CHARS plus rules), so a bigger ask degrades to "still
# capped, still parses" rather than overflowing the context window.
_MAX_TOKENS_CEILING = 3072


def _max_tokens_for(task_name: str, count: int) -> int:
per_item = _TOKENS_PER_ITEM.get(task_name, _DEFAULT_TOKENS_PER_ITEM)
return min(_MAX_TOKENS_CEILING, max(1024, per_item * max(count, 1) + _BASE_OVERHEAD_TOKENS))


def _prompt_for(task, state: ResourceState) -> str:
fn = task.build_prompt
Expand Down Expand Up @@ -67,7 +85,9 @@ def generate_node(state: ResourceState) -> Dict:
started = time.time()
clock = time.perf_counter()
try:
max_tokens = _max_tokens_for(task.name, state.get("count", 5))
reply = get_generator_llm(
max_tokens=max_tokens,
model_id=state.get("model_id"),
on_progress=lambda message: _log(state, message),
).invoke(
Expand Down
2 changes: 1 addition & 1 deletion integrated-backend/learnmate/storage/qdrant_vectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
2 changes: 2 additions & 0 deletions integrated-frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ node_modules
dist
dist-ssr
*.local
cypress/screenshots/
cypress/videos/

# Editor directories and files
.vscode/*
Expand Down
10 changes: 10 additions & 0 deletions integrated-frontend/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,14 @@ export default defineConfig([
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
{
files: ['cypress.config.js', 'tests/**/*.mjs'],
languageOptions: { globals: globals.node },
},
{
files: ['cypress/e2e/**/*.js'],
languageOptions: {
globals: { ...globals.browser, ...globals.mocha, Cypress: 'readonly', cy: 'readonly', expect: 'readonly' },
},
},
])
Loading
Loading