Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Categories: For Humanity (primary), Best Local Agent (secondary), Most Useful Fi
- **Multi-Language Translation**: Translate to French, Spanish, Chinese, Punjabi, Arabic, and more
- **Split-Screen View**: Compare original and simplified/translated content side-by-side
- **Acronym Detection**: Hover tooltips for government acronyms (CRA, EI, GST, etc.)
- **Local AI Processing**: Privacy-first approach using local inference
- **Local or Cloud AI**: Run fully on-device (llama.cpp, LM Studio, Hugging Face, vLLM) or use the Groq API for speed. The popup always says which one is active, because with Groq the page text is sent to Groq's servers
- **Government Site Integration**: Optimized for Canada.ca and gc.ca domains

## Architecture
Expand Down
18 changes: 17 additions & 1 deletion extension/src/popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,23 @@ class MapleClearPopup {
if (response.ok) {
const data = await response.json();
this.serverStatus = 'connected';
this.updateStatus('connected', `${data.backend} backend ready`);
// Tell people where their page text goes before they send it. The
// server reports this, since only it knows e.g. Groq fell back to demo.
// demo_mode is a field this version of the server added; an older,
// still-running server omits it and returns local_mode:true even for
// live Groq requests, so without that field we must not claim
// on-device processing — prompt to update the server instead.
let where;
if (typeof data.demo_mode !== 'boolean') {
where = 'Ready · processing location unknown — update the MapleClear server';
} else if (data.demo_mode) {
where = 'Ready · demo mode (sample output, nothing is sent)';
} else if (data.local_mode === false) {
where = `Ready · ${data.backend} cloud (text leaves this device)`;
} else {
where = `Ready · on this device (${data.backend})`;
}
this.updateStatus('connected', where);
} else {
throw new Error('Server not responding');
}
Expand Down
11 changes: 9 additions & 2 deletions server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,16 @@ class AcronymRequest(BaseModel):
description="Context to help with disambiguation")


# Backends that send page text to a third-party API (see backends/).
CLOUD_BACKENDS = {"groq"}


class HealthResponse(BaseModel):
"""Response model for health check."""
status: str
model_info: ModelInfo
local_mode: bool
local_mode: bool # False when page text is sent to a third-party API
demo_mode: bool = False # canned responses; nothing is sent anywhere
backend: str
adapters: List[str]

Expand Down Expand Up @@ -226,10 +231,12 @@ async def get_terms_db():
async def health_check(backend: InferenceBackend = Depends(get_backend)):
"""Health check endpoint with model information."""
model_info = await backend.get_model_info()
demo_mode = bool(getattr(backend, "demo_mode", False))
Comment thread
djleamen marked this conversation as resolved.
Resolved
return HealthResponse(
status="healthy",
model_info=model_info,
local_mode=True,
local_mode=demo_mode or Config.BACKEND not in CLOUD_BACKENDS,
demo_mode=demo_mode,
backend=Config.BACKEND,
adapters=Config.ADAPTERS
)
Expand Down
5 changes: 5 additions & 0 deletions server/backends/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ def __init__(self, model_path: str, adapters: Optional[List[str]] = None):
self.adapters = adapters or []
self.model = None
self.tokenizer = None
# True once initialize() determines the backend will serve canned
# "demo" responses instead of real model output. /health reports this
# so the extension can warn that output is sample text, not a real
# simplification. Subclasses set it during initialize().
self.demo_mode = False

@abstractmethod
async def initialize(self) -> None:
Expand Down
2 changes: 2 additions & 0 deletions server/backends/huggingface_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ async def initialize(self) -> None:
print(
"⚠️ Transformers not available. Install with: pip install transformers torch")
print("Running in demo mode...")
self.demo_mode = True
return

try:
Expand All @@ -61,6 +62,7 @@ async def initialize(self) -> None:
self.model = None
self.tokenizer = None
self.generator = None
self.demo_mode = True

def _get_model_name(self) -> str:
"""Get the model name to use."""
Expand Down
3 changes: 3 additions & 0 deletions server/backends/llama_cpp.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ async def initialize(self) -> None:
print("Please download a model or set MAPLECLEAR_MODEL_PATH")
# For demo purposes: to be removed
print("Running in demo mode without real model...")
# _run_inference serves canned output whenever the model file is
# absent, so report that as demo mode.
self.demo_mode = True

self.model_loaded = True
print("✅ llama.cpp backend initialized")
Expand Down
5 changes: 5 additions & 0 deletions server/backends/lmstudio_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,13 @@ async def initialize(self) -> None:
if not AIOHTTP_AVAILABLE:
print("aiohttp not available. Install with: pip install aiohttp")
print(DEMO_MODE_MESSAGE)
self.demo_mode = True
return

if aiohttp is None:
print("aiohttp not available. Install with: pip install aiohttp")
print(DEMO_MODE_MESSAGE)
self.demo_mode = True
return

try:
Expand All @@ -80,6 +82,8 @@ async def initialize(self) -> None:
print("Please load openai/gpt-oss-20b in LM Studio and restart")
self.model_name = None

# No usable model means requests fall back to canned demo output.
self.demo_mode = self.model_name is None
print("✅ LM Studio backend initialized successfully")

except LMStudioError as e:
Expand All @@ -88,6 +92,7 @@ async def initialize(self) -> None:
print("Download LM Studio from: https://lmstudio.ai/")
print(DEMO_MODE_MESSAGE)
self.model_name = None
self.demo_mode = True

async def _check_server_status(self) -> None:
"""Check if LM Studio server is running."""
Expand Down
11 changes: 11 additions & 0 deletions server/backends/vllm_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,25 @@ def __init__(self, model_path: str, adapters: Optional[List[str]] = None):
super().__init__(model_path, adapters or [])
self.llm = None
self.sampling_params = None
# This sample backend's simplify/translate/expand_acronyms return canned
# responses unconditionally (they never call _run_inference), so it is
# always demo mode even when vLLM loads successfully. Report that so the
# popup never presents sample output as real on-device inference.
self.demo_mode = True

async def initialize(self) -> None:
"""Initialize vLLM backend."""
if not VLLM_AVAILABLE:
print("vLLM not available. Install with: pip install vllm")
print(DEMO_MODE_MESSAGE)
self.demo_mode = True
Comment thread
djleamen marked this conversation as resolved.
Resolved
return

try:
if SamplingParams is None:
print("vLLM components not available")
print(DEMO_MODE_MESSAGE)
self.demo_mode = True
return

self.sampling_params = SamplingParams(
Expand All @@ -52,11 +59,13 @@ async def initialize(self) -> None:
if not model_path.exists():
print(f"Model not found: {model_path}")
print(DEMO_MODE_MESSAGE)
self.demo_mode = True
return

if LLM is None:
print("vLLM LLM class not available")
print(DEMO_MODE_MESSAGE)
self.demo_mode = True
return

self.llm = LLM(
Expand All @@ -67,9 +76,11 @@ async def initialize(self) -> None:
except ImportError:
print("❌ vLLM not installed. Install with: pip install vllm")
print(DEMO_MODE_MESSAGE)
self.demo_mode = True
except (RuntimeError, OSError, ValueError) as e:
print(f"❌ Failed to initialize vLLM: {e}")
print("Running in demo mode...")
self.demo_mode = True

async def cleanup(self) -> None:
"""Clean up resources."""
Expand Down
29 changes: 29 additions & 0 deletions server/tests/test_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,32 @@ def test_translate_defaults():
req = TranslateRequest(text="Bonjour")
assert req.target_language == "French"
assert req.preserve_terms is True


def test_health_reports_where_text_is_processed(monkeypatch):
"""Groq without an API key falls back to demo mode: nothing leaves the device."""
from fastapi.testclient import TestClient

from server import app as app_module

monkeypatch.setattr(app_module.Config, "BACKEND", "groq")
monkeypatch.delenv("GROQ", raising=False)
with TestClient(app_module.app) as client:
data = client.get("/health").json()
assert data["demo_mode"] is True
assert data["local_mode"] is True


def test_health_reports_demo_mode_for_non_groq_backend(monkeypatch):
"""A local backend with no model loaded also serves canned demo output."""
from fastapi.testclient import TestClient

from server import app as app_module

# vLLM is not installed in the test environment, so the backend falls back
# to demo responses. /health must surface that, not report it as ready.
monkeypatch.setattr(app_module.Config, "BACKEND", "vllm")
with TestClient(app_module.app) as client:
data = client.get("/health").json()
assert data["demo_mode"] is True
assert data["local_mode"] is True
Loading