saral 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- saral/__init__.py +7 -0
- saral/__main__.py +3 -0
- saral/app.py +129 -0
- saral/autostart.py +77 -0
- saral/binaries.py +124 -0
- saral/cli.py +161 -0
- saral/config.py +84 -0
- saral/discover.py +319 -0
- saral/engine.py +159 -0
- saral/engine_fetch.py +162 -0
- saral/evals.py +155 -0
- saral/evals_data/documents.jsonl +10 -0
- saral/evals_data/scam.jsonl +32 -0
- saral/extract.py +394 -0
- saral/guard.py +84 -0
- saral/i18n.py +292 -0
- saral/jobs/__init__.py +1 -0
- saral/jobs/explain.py +189 -0
- saral/jobs/scam.py +361 -0
- saral/llm.py +161 -0
- saral/models.py +458 -0
- saral/ocr.py +102 -0
- saral/pairing.py +62 -0
- saral/probe.py +92 -0
- saral/prompts.py +68 -0
- saral/safety.py +102 -0
- saral/server.py +454 -0
- saral/setup_api.py +190 -0
- saral/speech.py +59 -0
- saral/web/app.js +372 -0
- saral/web/i18n.js +164 -0
- saral/web/icon.svg +5 -0
- saral/web/index.html +114 -0
- saral/web/manifest.webmanifest +17 -0
- saral/web/setup-i18n.js +270 -0
- saral/web/setup.css +153 -0
- saral/web/setup.html +128 -0
- saral/web/setup.js +324 -0
- saral/web/style.css +253 -0
- saral/web/sw.js +29 -0
- saral-0.1.0.dist-info/METADATA +174 -0
- saral-0.1.0.dist-info/RECORD +46 -0
- saral-0.1.0.dist-info/WHEEL +4 -0
- saral-0.1.0.dist-info/entry_points.txt +2 -0
- saral-0.1.0.dist-info/licenses/LICENSE +202 -0
- saral-0.1.0.dist-info/licenses/NOTICE +10 -0
saral/__init__.py
ADDED
saral/__main__.py
ADDED
saral/app.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""The one-click app: what runs when someone double-clicks Saral.
|
|
2
|
+
|
|
3
|
+
It starts the local server (reachable by paired phones on home Wi-Fi), starts the
|
|
4
|
+
bundled AI engine when the model is present, and opens the browser on the setup
|
|
5
|
+
screen the first time or on Saral itself afterwards. If Saral is already running,
|
|
6
|
+
double-clicking just opens the window again.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import contextlib
|
|
13
|
+
import logging
|
|
14
|
+
import socket
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import webbrowser
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from .config import Settings, data_dir
|
|
23
|
+
|
|
24
|
+
DEFAULT_PORT = 8765
|
|
25
|
+
log = logging.getLogger("saral")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _setup_logging() -> None:
|
|
29
|
+
# Windowed builds have no console: keep a small log file for troubleshooting.
|
|
30
|
+
log_path = data_dir() / "saral.log"
|
|
31
|
+
handlers: list[logging.Handler] = [logging.FileHandler(log_path, encoding="utf-8")]
|
|
32
|
+
if sys.stderr is not None:
|
|
33
|
+
handlers.append(logging.StreamHandler())
|
|
34
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", handlers=handlers)
|
|
35
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
36
|
+
if sys.stdout is None:
|
|
37
|
+
sys.stdout = open(log_path, "a", encoding="utf-8") # noqa: SIM115
|
|
38
|
+
if sys.stderr is None:
|
|
39
|
+
sys.stderr = sys.stdout
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def saral_running(port: int) -> bool:
|
|
43
|
+
try:
|
|
44
|
+
r = httpx.get(f"http://127.0.0.1:{port}/api/health", timeout=1.0)
|
|
45
|
+
return r.status_code == 200 and "version" in r.json()
|
|
46
|
+
except (httpx.HTTPError, ValueError):
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def port_free(port: int) -> bool:
|
|
51
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
52
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
53
|
+
try:
|
|
54
|
+
s.bind(("0.0.0.0", port))
|
|
55
|
+
except OSError:
|
|
56
|
+
return False
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def choose_port() -> int:
|
|
61
|
+
"""Keep the same port across restarts so paired phones keep working."""
|
|
62
|
+
saved = data_dir() / "port"
|
|
63
|
+
with contextlib.suppress(ValueError, OSError):
|
|
64
|
+
port = int(saved.read_text().strip())
|
|
65
|
+
if port_free(port):
|
|
66
|
+
return port
|
|
67
|
+
for port in range(DEFAULT_PORT, DEFAULT_PORT + 20):
|
|
68
|
+
if port_free(port):
|
|
69
|
+
saved.write_text(str(port))
|
|
70
|
+
return port
|
|
71
|
+
raise OSError("No free port for Saral between 8765 and 8784.")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def open_window(port: int, path: str) -> None:
|
|
75
|
+
webbrowser.open(f"http://127.0.0.1:{port}{path}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def run_app(background: bool = False) -> int:
|
|
79
|
+
_setup_logging()
|
|
80
|
+
saved = data_dir() / "port"
|
|
81
|
+
with contextlib.suppress(ValueError, OSError):
|
|
82
|
+
existing = int(saved.read_text().strip())
|
|
83
|
+
if saral_running(existing):
|
|
84
|
+
if not background:
|
|
85
|
+
open_window(existing, "/")
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
import uvicorn
|
|
89
|
+
|
|
90
|
+
from .server import create_app
|
|
91
|
+
|
|
92
|
+
settings = Settings.from_env()
|
|
93
|
+
settings.port = choose_port()
|
|
94
|
+
settings.host = "0.0.0.0" # paired phones on home Wi-Fi; the token protects the API
|
|
95
|
+
app = create_app(settings)
|
|
96
|
+
rt = app.state.runtime
|
|
97
|
+
server = uvicorn.Server(
|
|
98
|
+
uvicorn.Config(app, host=settings.host, port=settings.port, log_level="warning")
|
|
99
|
+
)
|
|
100
|
+
rt.on_quit.append(lambda: setattr(server, "should_exit", True))
|
|
101
|
+
|
|
102
|
+
def open_when_ready() -> None:
|
|
103
|
+
for _ in range(100):
|
|
104
|
+
if server.started:
|
|
105
|
+
break
|
|
106
|
+
time.sleep(0.1)
|
|
107
|
+
if not background:
|
|
108
|
+
open_window(settings.port, "/" if rt.model_installed() else "/setup")
|
|
109
|
+
|
|
110
|
+
threading.Thread(target=open_when_ready, daemon=True).start()
|
|
111
|
+
log.info("Saral started on port %s (tier %s)", settings.port, rt.report.tier)
|
|
112
|
+
try:
|
|
113
|
+
server.run()
|
|
114
|
+
finally:
|
|
115
|
+
if rt.engine_manager:
|
|
116
|
+
rt.engine_manager.stop()
|
|
117
|
+
log.info("Saral stopped")
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def main(argv: list[str] | None = None) -> int:
|
|
122
|
+
parser = argparse.ArgumentParser(prog="saral-app")
|
|
123
|
+
parser.add_argument("--background", action="store_true", help="start without opening a window")
|
|
124
|
+
args, _ = parser.parse_known_args(argv)
|
|
125
|
+
return run_app(background=args.background)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
raise SystemExit(main())
|
saral/autostart.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Start Saral when the computer turns on, so nobody has to remember to open it.
|
|
2
|
+
|
|
3
|
+
Windows: a tiny script in the Startup folder. macOS: a LaunchAgent.
|
|
4
|
+
Linux: an XDG autostart entry. All are per-user and easy to remove.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import platform
|
|
11
|
+
import plistlib
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
LABEL = "org.saral.app"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def launch_command() -> list[str]:
|
|
19
|
+
"""How to start Saral in the background (no browser window)."""
|
|
20
|
+
if getattr(sys, "frozen", False):
|
|
21
|
+
return [sys.executable, "--background"]
|
|
22
|
+
return [sys.executable, "-m", "saral", "app", "--background"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _windows_path() -> Path:
|
|
26
|
+
appdata = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
|
27
|
+
return appdata / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup" / "Saral.vbs"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _mac_path() -> Path:
|
|
31
|
+
return Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _linux_path() -> Path:
|
|
35
|
+
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
36
|
+
return base / "autostart" / "saral.desktop"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def entry_path() -> Path:
|
|
40
|
+
system = platform.system()
|
|
41
|
+
if system == "Windows":
|
|
42
|
+
return _windows_path()
|
|
43
|
+
if system == "Darwin":
|
|
44
|
+
return _mac_path()
|
|
45
|
+
return _linux_path()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_enabled() -> bool:
|
|
49
|
+
return entry_path().exists()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def enable() -> Path:
|
|
53
|
+
path = entry_path()
|
|
54
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
cmd = launch_command()
|
|
56
|
+
system = platform.system()
|
|
57
|
+
if system == "Windows":
|
|
58
|
+
# VBScript runs the app with no console window (style 0 = hidden).
|
|
59
|
+
quoted = " ".join(f'""{part}""' for part in cmd)
|
|
60
|
+
path.write_text(f'CreateObject("WScript.Shell").Run "{quoted}", 0, False\r\n')
|
|
61
|
+
elif system == "Darwin":
|
|
62
|
+
with path.open("wb") as fh:
|
|
63
|
+
plistlib.dump(
|
|
64
|
+
{"Label": LABEL, "ProgramArguments": cmd, "RunAtLoad": True, "KeepAlive": False},
|
|
65
|
+
fh,
|
|
66
|
+
)
|
|
67
|
+
else:
|
|
68
|
+
exec_line = " ".join(f'"{part}"' if " " in part else part for part in cmd)
|
|
69
|
+
path.write_text(
|
|
70
|
+
"[Desktop Entry]\nType=Application\nName=Saral\n"
|
|
71
|
+
f"Exec={exec_line}\nX-GNOME-Autostart-enabled=true\nNoDisplay=false\n"
|
|
72
|
+
)
|
|
73
|
+
return path
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def disable() -> None:
|
|
77
|
+
entry_path().unlink(missing_ok=True)
|
saral/binaries.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Find tools that ship inside the Saral installer (llama-server, Tesseract).
|
|
2
|
+
|
|
3
|
+
Search order: an explicit environment variable, the folder bundled inside the
|
|
4
|
+
installed app, Saral's data folder, then the system PATH (for developers).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .config import data_dir
|
|
17
|
+
|
|
18
|
+
IS_WINDOWS = os.name == "nt"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def app_root() -> Path:
|
|
22
|
+
"""Folder of the installed app (PyInstaller) or the source package."""
|
|
23
|
+
frozen = getattr(sys, "_MEIPASS", None)
|
|
24
|
+
return Path(frozen) if frozen else Path(__file__).resolve().parent
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def bundled_dir() -> Path:
|
|
28
|
+
return app_root() / "bin"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_frozen() -> bool:
|
|
32
|
+
return bool(getattr(sys, "frozen", False))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _exe(name: str) -> str:
|
|
36
|
+
return f"{name}.exe" if IS_WINDOWS else name
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def find_tool(name: str, env_var: str | None = None, subdir: str | None = None) -> Path | None:
|
|
40
|
+
if env_var and os.environ.get(env_var):
|
|
41
|
+
path = Path(os.environ[env_var])
|
|
42
|
+
return path if path.exists() else None
|
|
43
|
+
folders = [bundled_dir(), data_dir() / "bin"]
|
|
44
|
+
if subdir:
|
|
45
|
+
folders = [f / subdir for f in folders] + folders
|
|
46
|
+
for folder in folders:
|
|
47
|
+
candidate = folder / _exe(name)
|
|
48
|
+
if candidate.exists():
|
|
49
|
+
return candidate
|
|
50
|
+
found = shutil.which(name)
|
|
51
|
+
return Path(found) if found else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Oldest llama.cpp build Saral trusts on the system PATH (Qwen3 + chat template options).
|
|
55
|
+
MIN_LLAMA_BUILD = int(os.environ.get("SARAL_MIN_LLAMA_BUILD", "5300"))
|
|
56
|
+
_version_cache: dict[tuple[str, float], int | None] = {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def llama_version(path: Path) -> int | None:
|
|
60
|
+
"""llama.cpp build number from `llama-server --version`, or None if unknown."""
|
|
61
|
+
try:
|
|
62
|
+
key = (str(path), path.stat().st_mtime)
|
|
63
|
+
except OSError:
|
|
64
|
+
return None
|
|
65
|
+
if key not in _version_cache:
|
|
66
|
+
version = None
|
|
67
|
+
try:
|
|
68
|
+
out = subprocess.run(
|
|
69
|
+
[str(path), "--version"],
|
|
70
|
+
capture_output=True,
|
|
71
|
+
text=True,
|
|
72
|
+
timeout=15,
|
|
73
|
+
creationflags=0x08000000 if IS_WINDOWS else 0,
|
|
74
|
+
)
|
|
75
|
+
match = re.search(r"version:?\s*b?(\d+)", out.stdout + out.stderr)
|
|
76
|
+
version = int(match.group(1)) if match else None
|
|
77
|
+
except (OSError, subprocess.SubprocessError):
|
|
78
|
+
version = None
|
|
79
|
+
_version_cache[key] = version
|
|
80
|
+
return _version_cache[key]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _skip_system_flag() -> Path:
|
|
84
|
+
return data_dir() / "skip_system_engine"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def mark_system_engine_bad(path: Path) -> None:
|
|
88
|
+
"""Remember that the PATH llama-server failed, so Saral fetches its own next time."""
|
|
89
|
+
_skip_system_flag().write_text(str(path))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def llama_server_info() -> tuple[Path | None, str]:
|
|
93
|
+
"""The engine to use and where it came from: env | bundled | saral | system | none."""
|
|
94
|
+
if os.environ.get("SARAL_LLAMA_SERVER"):
|
|
95
|
+
path = Path(os.environ["SARAL_LLAMA_SERVER"])
|
|
96
|
+
return (path, "env") if path.exists() else (None, "none")
|
|
97
|
+
for source, folder in (("bundled", bundled_dir()), ("saral", data_dir() / "bin")):
|
|
98
|
+
for candidate in (folder / "llama" / _exe("llama-server"), folder / _exe("llama-server")):
|
|
99
|
+
if candidate.exists():
|
|
100
|
+
return candidate, source
|
|
101
|
+
found = shutil.which("llama-server")
|
|
102
|
+
if found:
|
|
103
|
+
path = Path(found)
|
|
104
|
+
skipped = _skip_system_flag()
|
|
105
|
+
if skipped.exists() and skipped.read_text().strip() == str(path):
|
|
106
|
+
return None, "none"
|
|
107
|
+
version = llama_version(path)
|
|
108
|
+
if version is not None and version < MIN_LLAMA_BUILD:
|
|
109
|
+
return None, "none" # too old: Saral downloads its own instead
|
|
110
|
+
return path, "system"
|
|
111
|
+
return None, "none"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def llama_server() -> Path | None:
|
|
115
|
+
return llama_server_info()[0]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def tesseract() -> tuple[Path | None, Path | None]:
|
|
119
|
+
"""Tesseract binary and, when bundled, its tessdata folder."""
|
|
120
|
+
binary = find_tool("tesseract", "SARAL_TESSERACT", subdir="tesseract")
|
|
121
|
+
if binary is None:
|
|
122
|
+
return None, None
|
|
123
|
+
tessdata = binary.parent / "tessdata"
|
|
124
|
+
return binary, (tessdata if tessdata.is_dir() else None)
|
saral/cli.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Command line: `saral doctor`, `saral serve`, `saral pull`, `saral eval`, `saral pair --reset`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from .config import Settings
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def cmd_app(args: argparse.Namespace) -> int:
|
|
13
|
+
from .app import run_app
|
|
14
|
+
|
|
15
|
+
return run_app(background=args.background)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def cmd_doctor(_: argparse.Namespace) -> int:
|
|
19
|
+
from . import binaries
|
|
20
|
+
from .models import CATALOG, choose_tier, installed, model_path
|
|
21
|
+
from .ocr import tesseract_available
|
|
22
|
+
from .probe import probe
|
|
23
|
+
from .speech import speech_available
|
|
24
|
+
|
|
25
|
+
report = probe()
|
|
26
|
+
tier, reason = choose_tier(report.ram_gb, report.gpu_vram_gb)
|
|
27
|
+
model = CATALOG[tier]
|
|
28
|
+
engine = binaries.llama_server()
|
|
29
|
+
print("Saral doctor (for developers; families use the setup screen)\n")
|
|
30
|
+
print(f" Computer {report.os}, {report.cpu_cores} cores, {report.ram_gb} GB memory")
|
|
31
|
+
print(f" Disk free {report.free_disk_gb} GB")
|
|
32
|
+
print(f" GPU {report.gpu or 'none (CPU is fine)'}")
|
|
33
|
+
why = {"chosen": "your choice", "disk": "smaller: low disk space", "hardware": "fits"}[reason]
|
|
34
|
+
print(f" Model {model.filename} ({model.size_gb} GB, {why})")
|
|
35
|
+
print(f" AI engine {engine or 'llama-server not found'}")
|
|
36
|
+
print(f" AI model {model_path(model) if installed(model) else 'not downloaded yet'}")
|
|
37
|
+
print(f" Photo OCR {'ready' if tesseract_available() else 'not installed'}")
|
|
38
|
+
print(f" Voice input {'ready' if speech_available() else 'not installed'}")
|
|
39
|
+
for warning in report.warnings:
|
|
40
|
+
print(f"\n ! {warning}")
|
|
41
|
+
if engine is None:
|
|
42
|
+
print("\n Developers: put llama.cpp's llama-server on PATH or set SARAL_LLAMA_SERVER.")
|
|
43
|
+
if not installed(model):
|
|
44
|
+
print(" Get the model with: saral pull (or run `saral` and use the setup screen)")
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cmd_pull(_: argparse.Namespace) -> int:
|
|
49
|
+
import time
|
|
50
|
+
|
|
51
|
+
from .engine_fetch import EngineStep
|
|
52
|
+
from .models import CATALOG, ModelInstaller, choose_tier
|
|
53
|
+
from .probe import probe
|
|
54
|
+
|
|
55
|
+
report = probe()
|
|
56
|
+
model = CATALOG[choose_tier(report.ram_gb, report.gpu_vram_gb)[0]]
|
|
57
|
+
print(f"Downloading {model.filename} (about {model.size_gb} GB). It resumes if interrupted.")
|
|
58
|
+
installer = ModelInstaller(model, before=EngineStep()) # also fetches the engine if missing
|
|
59
|
+
installer.start_download()
|
|
60
|
+
while installer.busy:
|
|
61
|
+
st = installer.state.to_dict()
|
|
62
|
+
print(f"\r {st['status']:<11} {st['percent']:5.1f}%", end="", flush=True)
|
|
63
|
+
time.sleep(1)
|
|
64
|
+
print()
|
|
65
|
+
if installer.state.status != "done":
|
|
66
|
+
print(f"Failed: {installer.state.error}", file=sys.stderr)
|
|
67
|
+
return 1
|
|
68
|
+
print("Done.")
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cmd_serve(args: argparse.Namespace) -> int:
|
|
73
|
+
import uvicorn
|
|
74
|
+
|
|
75
|
+
from .pairing import get_or_create_token, lan_ip
|
|
76
|
+
from .server import create_app
|
|
77
|
+
|
|
78
|
+
settings = Settings.from_env()
|
|
79
|
+
if args.backend:
|
|
80
|
+
settings.backend = args.backend
|
|
81
|
+
if args.region:
|
|
82
|
+
settings.region = args.region.upper()
|
|
83
|
+
settings.port = args.port
|
|
84
|
+
settings.host = "0.0.0.0" if args.lan else "127.0.0.1"
|
|
85
|
+
|
|
86
|
+
app = create_app(settings)
|
|
87
|
+
print(f"\nSaral is running. On this computer open: http://127.0.0.1:{settings.port}")
|
|
88
|
+
if args.lan:
|
|
89
|
+
link = f"http://{lan_ip()}:{settings.port}/?pair={get_or_create_token()}"
|
|
90
|
+
print("\nOn your phone (same Wi-Fi), open this pairing link once:")
|
|
91
|
+
print(f" {link}")
|
|
92
|
+
try:
|
|
93
|
+
import qrcode
|
|
94
|
+
|
|
95
|
+
qr = qrcode.QRCode(border=1)
|
|
96
|
+
qr.add_data(link)
|
|
97
|
+
qr.print_ascii(invert=True)
|
|
98
|
+
except ImportError:
|
|
99
|
+
print(" (pip install 'saral[qr]' to show a QR code here)")
|
|
100
|
+
print("Keep this link private. Run `saral pair --reset` to unpair all phones.\n")
|
|
101
|
+
uvicorn.run(app, host=settings.host, port=settings.port, log_level="warning")
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cmd_pair(args: argparse.Namespace) -> int:
|
|
106
|
+
from .pairing import get_or_create_token, reset_token
|
|
107
|
+
|
|
108
|
+
token = reset_token() if args.reset else get_or_create_token()
|
|
109
|
+
print("All phones unpaired. New pairing token created." if args.reset else token)
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def cmd_eval(args: argparse.Namespace) -> int:
|
|
114
|
+
from .evals import run_all
|
|
115
|
+
|
|
116
|
+
report = run_all(backend_name=args.backend)
|
|
117
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
118
|
+
return 0 if report["gate"]["passed"] else 1
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def main(argv: list[str] | None = None) -> int:
|
|
122
|
+
parser = argparse.ArgumentParser(
|
|
123
|
+
prog="saral", description="Saral: your private helper at home."
|
|
124
|
+
)
|
|
125
|
+
sub = parser.add_subparsers(dest="command")
|
|
126
|
+
|
|
127
|
+
app = sub.add_parser("app", help="start Saral the easy way (default when no command)")
|
|
128
|
+
app.add_argument("--background", action="store_true", help="start without opening a window")
|
|
129
|
+
app.set_defaults(func=cmd_app)
|
|
130
|
+
|
|
131
|
+
sub.add_parser("doctor", help="check this computer and pick a model size").set_defaults(
|
|
132
|
+
func=cmd_doctor
|
|
133
|
+
)
|
|
134
|
+
sub.add_parser("pull", help="download the recommended model via Ollama").set_defaults(
|
|
135
|
+
func=cmd_pull
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
serve = sub.add_parser("serve", help="start Saral")
|
|
139
|
+
serve.add_argument("--lan", action="store_true", help="allow paired phones on home Wi-Fi")
|
|
140
|
+
serve.add_argument("--port", type=int, default=8765)
|
|
141
|
+
serve.add_argument("--backend", choices=["local", "ollama", "rules"])
|
|
142
|
+
serve.add_argument("--region", choices=["IN", "US", "in", "us"])
|
|
143
|
+
serve.set_defaults(func=cmd_serve)
|
|
144
|
+
|
|
145
|
+
pair = sub.add_parser("pair", help="show or reset the phone pairing token")
|
|
146
|
+
pair.add_argument("--reset", action="store_true", help="unpair every phone")
|
|
147
|
+
pair.set_defaults(func=cmd_pair)
|
|
148
|
+
|
|
149
|
+
ev = sub.add_parser("eval", help="run the eval sets and check the release gate")
|
|
150
|
+
ev.add_argument("--backend", choices=["rules", "ollama"], default="rules")
|
|
151
|
+
ev.set_defaults(func=cmd_eval)
|
|
152
|
+
|
|
153
|
+
args = parser.parse_args(argv)
|
|
154
|
+
if args.command is None:
|
|
155
|
+
# Double-click or plain `saral`: the one-click app.
|
|
156
|
+
return cmd_app(argparse.Namespace(background=False))
|
|
157
|
+
return args.func(args)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
raise SystemExit(main())
|
saral/config.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Settings, hardware tiers and supported languages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Tier:
|
|
12
|
+
"""A hardware tier and the model that runs well on it."""
|
|
13
|
+
|
|
14
|
+
name: str
|
|
15
|
+
min_ram_gb: float
|
|
16
|
+
model: str
|
|
17
|
+
approx_model_gb: float
|
|
18
|
+
note: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Ordered from smallest to largest. The probe picks the largest tier that fits.
|
|
22
|
+
# Qwen3 is Apache-2.0, trained on 119 languages, and ships in sizes for each tier.
|
|
23
|
+
TIERS: tuple[Tier, ...] = (
|
|
24
|
+
Tier("lite", 0, "qwen3:1.7b", 1.4, "Works on 8 GB laptops. Short answers, slower."),
|
|
25
|
+
Tier("standard", 12, "qwen3:4b", 2.6, "16 GB laptop or mini-PC. All jobs at usable speed."),
|
|
26
|
+
Tier("plus", 24, "qwen3:8b", 5.2, "32 GB or a GPU. Long documents, better reasoning."),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Language:
|
|
32
|
+
code: str
|
|
33
|
+
english_name: str
|
|
34
|
+
native_name: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
LANGUAGES: dict[str, Language] = {
|
|
38
|
+
"en": Language("en", "English", "English"),
|
|
39
|
+
"hi": Language("hi", "Hindi", "हिन्दी"),
|
|
40
|
+
"te": Language("te", "Telugu", "తెలుగు"),
|
|
41
|
+
"es": Language("es", "Spanish", "Español"),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
DEFAULT_LANGUAGE = "en"
|
|
45
|
+
|
|
46
|
+
# Region changes date order (03/04 = 3 April in India, March 4 in the US)
|
|
47
|
+
# and which emergency numbers we show.
|
|
48
|
+
REGIONS = ("IN", "US")
|
|
49
|
+
DEFAULT_REGION = "IN"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def data_dir() -> Path:
|
|
53
|
+
"""Where Saral keeps its small amount of local state (the pairing token)."""
|
|
54
|
+
root = os.environ.get("SARAL_HOME")
|
|
55
|
+
path = Path(root) if root else Path.home() / ".saral"
|
|
56
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
return path
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class Settings:
|
|
62
|
+
"""Runtime settings. Everything defaults to local-only."""
|
|
63
|
+
|
|
64
|
+
# "local" = bundled llama.cpp engine (default, nothing extra to install),
|
|
65
|
+
# "ollama" = an existing Ollama install (for developers),
|
|
66
|
+
# "rules" = no model, deterministic output only.
|
|
67
|
+
backend: str = "local"
|
|
68
|
+
model: str | None = None # None = pick from hardware tier
|
|
69
|
+
ollama_url: str = "http://127.0.0.1:11434"
|
|
70
|
+
allow_remote_model_host: bool = False # refuse to send data off this machine
|
|
71
|
+
region: str = DEFAULT_REGION
|
|
72
|
+
host: str = "127.0.0.1"
|
|
73
|
+
port: int = 8765
|
|
74
|
+
require_token_for_lan: bool = True
|
|
75
|
+
extra: dict = field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_env(cls) -> Settings:
|
|
79
|
+
s = cls()
|
|
80
|
+
s.backend = os.environ.get("SARAL_BACKEND", s.backend)
|
|
81
|
+
s.model = os.environ.get("SARAL_MODEL") or None
|
|
82
|
+
s.ollama_url = os.environ.get("SARAL_OLLAMA_URL", s.ollama_url)
|
|
83
|
+
s.region = os.environ.get("SARAL_REGION", s.region).upper()
|
|
84
|
+
return s
|