sankalphs commited on
Commit
7e810ce
·
1 Parent(s): 1c45b4e

fix: restore llama.cpp with source build, use fine-tuned GGUF model

Browse files
Files changed (5) hide show
  1. Dockerfile +13 -4
  2. agents.py +91 -88
  3. app.py +22 -1
  4. requirements.txt +1 -0
  5. tests/test_agents.py +1 -0
Dockerfile CHANGED
@@ -3,16 +3,25 @@ FROM python:3.11-slim
3
  WORKDIR /app
4
 
5
  # System dependencies:
6
- # - git, curl : hf_hub_download + health checks
7
- # Cache bust: 2026-06-14-v3
 
 
 
 
 
8
  RUN apt-get update && apt-get install -y --no-install-recommends \
9
  git \
10
  curl \
 
 
 
 
11
  && rm -rf /var/lib/apt/lists/*
12
 
13
- # Copy requirements and install Python deps
14
  COPY requirements.txt .
15
- RUN pip install --no-cache-dir -r requirements.txt
16
 
17
  # Copy application code
18
  COPY . .
 
3
  WORKDIR /app
4
 
5
  # System dependencies:
6
+ # - git, curl : hf_hub_download + health checks
7
+ # - cmake, build-essential : compile llama-cpp-python from source
8
+ # (avoids the musl/glibc mismatch from
9
+ # prebuilt wheels on Debian base image)
10
+ # - libgomp1, libgfortran5 : OpenMP + Fortran runtimes required by
11
+ # the compiled libllama.so at runtime
12
+ # Cache bust: 2026-06-14-v4
13
  RUN apt-get update && apt-get install -y --no-install-recommends \
14
  git \
15
  curl \
16
+ cmake \
17
+ build-essential \
18
+ libgomp1 \
19
+ libgfortran5 \
20
  && rm -rf /var/lib/apt/lists/*
21
 
22
+ # Install Python deps, forcing llama-cpp-python to compile from source
23
  COPY requirements.txt .
24
+ RUN pip install --no-cache-dir --no-binary llama-cpp-python -r requirements.txt
25
 
26
  # Copy application code
27
  COPY . .
agents.py CHANGED
@@ -1,16 +1,23 @@
1
  """
2
- Agent inference using Hugging Face Inference API (fallback: deterministic).
3
-
4
- If HF_TOKEN is set, the hosted model (default google/gemma-2-2b-it) is used
5
- for chat, mentor, insight, and NPC decisions. If the API is unreachable or
6
- the token is missing, deterministic rules kick in so the game never breaks.
 
 
 
 
 
 
 
 
7
  """
8
 
9
  import json
10
  import os
11
  import re
12
  import threading
13
- import time
14
  from pathlib import Path
15
  from typing import Dict, List
16
 
@@ -21,27 +28,20 @@ load_dotenv()
21
  ASSETS = ["cash", "fd", "gov_bonds", "nifty_50", "nifty_it", "real_estate", "crypto", "gold"]
22
  PERSONAS = ["whale", "retail", "permabull"]
23
 
24
- _MODEL_DIR = Path(__file__).resolve().parent / "models"
25
- MODEL_PATH = os.getenv("MODEL_PATH") or str(_MODEL_DIR / "NVIDIA-Nemotron-3-Nano-4B.Q4_K_M.gguf")
26
-
27
- _HF_TOKEN = os.getenv("HF_TOKEN", "")
28
- _HF_MODEL = os.getenv("HF_MODEL", "microsoft/Phi-3-mini-4k-instruct")
29
 
30
- _client = None
31
- _llm_status = "mock"
32
  _llm_error = ""
 
 
33
 
34
- if _HF_TOKEN:
35
- try:
36
- from huggingface_hub import InferenceClient
37
- _client = InferenceClient(api_key=_HF_TOKEN)
38
- _llm_status = "loading"
39
- _llm_error = ""
40
- print(f"HF Inference client ready. Model: {_HF_MODEL}")
41
- except Exception as e:
42
- _llm_status = "mock"
43
- _llm_error = f"HF client init: {type(e).__name__}"
44
- print(f"Warning: HF Inference client failed: {_llm_error}")
45
 
46
 
47
  def llm_status() -> str:
@@ -52,59 +52,44 @@ def llm_error() -> str:
52
  return _llm_error
53
 
54
 
55
- def _hf_generate(messages: list, max_tokens: int = 256, temperature: float = 0.7) -> str:
56
- if _client is None:
57
- return ""
58
  try:
59
- response = _client.chat_completion(
60
- messages=messages,
61
- model=_HF_MODEL,
62
- max_tokens=max_tokens,
63
- temperature=temperature,
 
 
 
 
 
 
 
 
 
64
  )
65
- content = response.choices[0].message.content
66
- if isinstance(content, str) and content.strip():
67
- return clean_text(content)
68
  except Exception as e:
69
- global _llm_status, _llm_error
70
- msg = str(e)
71
- # Extract the actual API error message if present
72
- try:
73
- body = e.response.json() if hasattr(e, 'response') else None
74
- if body and isinstance(body, dict):
75
- msg = str(body.get("error", body.get("message", msg)))
76
- except Exception:
77
- pass
78
- _llm_error = msg[:200]
79
- print(f"HF API call failed: {msg[:200]}")
80
- if _llm_status == "loading":
81
- _llm_status = "error"
82
- return ""
83
-
84
-
85
- def _warmup() -> None:
86
- """Send a tiny request to wake up the HF Inference endpoint from cold start."""
87
- global _llm_status
88
- try:
89
- result = _hf_generate(
90
- [{"role": "user", "content": "Say 'ready'."}],
91
- max_tokens=4, temperature=0.0,
92
- )
93
- if result.strip():
94
- _llm_status = "loaded"
95
- _llm_error = ""
96
- print("HF Inference API warm-up OK — LLM online.")
97
- else:
98
- raise RuntimeError("empty warm-up response")
99
- except Exception:
100
- pass
101
 
102
 
103
  def start_background_load() -> None:
104
- """Kick off an async warm-up call to the HF Inference API."""
105
- if _client is None or _llm_status == "loaded":
106
  return
107
- t = threading.Thread(target=_warmup, name="hf-warmup", daemon=True)
 
 
 
 
 
108
  t.start()
109
 
110
 
@@ -118,22 +103,34 @@ def clean_text(text: str) -> str:
118
 
119
 
120
  def generate(prompt: str, system: str = "", max_tokens: int = 256, temperature: float = 0.7) -> str:
121
- """Generate via HF Inference API if available, else deterministic fallback.
122
-
123
- Returns "" on failure so per-feature deterministic fallbacks in callers
124
- (chat_reply, generate_insight, parse_mentor_response) take over.
125
- """
126
- # Try HF API first
127
- if _client is not None and _llm_status != "mock":
128
- messages = []
129
- if system:
130
- messages.append({"role": "system", "content": system})
131
- messages.append({"role": "user", "content": prompt})
132
- text = _hf_generate(messages, max_tokens=max_tokens, temperature=temperature)
133
- if text:
134
- return text
135
-
136
- # Deterministic fallback
 
 
 
 
 
 
 
 
 
 
 
 
137
  p = prompt.lower()
138
  s = system.lower()
139
  if "agent" in p and "whale" in p:
@@ -254,7 +251,10 @@ def generate_insight(event: Dict, state_snapshot: Dict) -> str:
254
  f"Player P&L ₹{pnl:,.0f}, cash {cash_pct:.0f}%, total ₹{total:,.0f}. "
255
  f"One actionable sentence."
256
  )
257
- text = generate(prompt, system=system, max_tokens=80, temperature=0.4).strip()
 
 
 
258
  if not text:
259
  if pnl < -50_000:
260
  text = f"Cut losers in {regime.replace('_', ' ')} regimes and rotate into defensives."
@@ -286,7 +286,10 @@ def chat_reply(user_message: str, state_snapshot: Dict) -> str:
286
  f"unrealized P&L ₹{pnl:,.0f}. Positions: {pos_lines}.\n"
287
  f"Player: {user_message}\nReply in 2-3 short sentences."
288
  )
289
- text = generate(prompt, system=system, max_tokens=140, temperature=0.5).strip()
 
 
 
290
  if not text:
291
  if "buy" in user_message.lower() or "should i" in user_message.lower():
292
  text = f"With cash at ₹{cash:,.0f} and P&L ₹{pnl:,.0f}, I'd wait for a confirmed trend before adding. Check the chart for support levels."
 
1
  """
2
+ Agent inference using a local llama.cpp GGUF model.
3
+
4
+ Loading is non-blocking: the model is loaded in a background thread
5
+ kicked off by the app's startup event. This lets uvicorn open the port
6
+ immediately (so HF Spaces' health check passes during a slow 2.84 GB
7
+ cold-start download) and surfaces a real "loading" status to the UI.
8
+
9
+ generate() is therefore non-blocking too:
10
+ - "loaded" -> call the real LLM
11
+ - "mock" -> return mock_generate(prompt)
12
+ - otherwise -> return "" so the per-feature deterministic fallbacks
13
+ in chat_reply / generate_insight / parse_mentor_response
14
+ take over
15
  """
16
 
17
  import json
18
  import os
19
  import re
20
  import threading
 
21
  from pathlib import Path
22
  from typing import Dict, List
23
 
 
28
  ASSETS = ["cash", "fd", "gov_bonds", "nifty_50", "nifty_it", "real_estate", "crypto", "gold"]
29
  PERSONAS = ["whale", "retail", "permabull"]
30
 
31
+ _DEFAULT_MODEL_DIR = Path(__file__).resolve().parent / "models"
32
+ _DEFAULT_MODEL_FILE = "NVIDIA-Nemotron-3-Nano-4B.Q4_K_M.gguf"
33
+ MODEL_PATH = os.getenv("MODEL_PATH") or str(_DEFAULT_MODEL_DIR / _DEFAULT_MODEL_FILE)
 
 
34
 
35
+ _llm = None
36
+ _llm_status = "uninitialized"
37
  _llm_error = ""
38
+ _load_started = False
39
+ _load_lock = threading.Lock()
40
 
41
+ if os.getenv("MOCK_LLM") == "1":
42
+ _llm = "mock"
43
+ _llm_status = "mock"
44
+ _llm_error = "MOCK_LLM=1 (test mode)"
 
 
 
 
 
 
 
45
 
46
 
47
  def llm_status() -> str:
 
52
  return _llm_error
53
 
54
 
55
+ def _do_load() -> None:
56
+ global _llm, _llm_status, _llm_error
 
57
  try:
58
+ from llama_cpp import Llama
59
+ mp = Path(MODEL_PATH)
60
+ if not mp.exists():
61
+ raise FileNotFoundError(
62
+ f"Model file not found at '{mp}'. "
63
+ f"Set MODEL_PATH or check the download."
64
+ )
65
+ size_gb = mp.stat().st_size / 1e9
66
+ print(f"Loading LLM from {mp} ({size_gb:.2f} GB)...")
67
+ _llm = Llama(
68
+ model_path=str(mp),
69
+ n_ctx=int(os.getenv("LLAMA_CTX", "2048")),
70
+ n_threads=int(os.getenv("LLAMA_THREADS", "4")),
71
+ verbose=False,
72
  )
73
+ _llm_status = "loaded"
74
+ _llm_error = ""
75
+ print("LLM loaded successfully.")
76
  except Exception as e:
77
+ _llm_error = f"{type(e).__name__}: {e}"
78
+ print(f"Warning: could not load LLM: {_llm_error}. Using mock mode.")
79
+ _llm = "mock"
80
+ _llm_status = "error"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
 
82
 
83
  def start_background_load() -> None:
84
+ global _load_started, _llm_status
85
+ if _llm_status == "mock":
86
  return
87
+ with _load_lock:
88
+ if _load_started:
89
+ return
90
+ _load_started = True
91
+ _llm_status = "loading"
92
+ t = threading.Thread(target=_do_load, name="llm-loader", daemon=True)
93
  t.start()
94
 
95
 
 
103
 
104
 
105
  def generate(prompt: str, system: str = "", max_tokens: int = 256, temperature: float = 0.7) -> str:
106
+ if _llm_status == "mock":
107
+ return mock_generate(prompt, system)
108
+ if _llm_status != "loaded":
109
+ return ""
110
+ llm = _llm
111
+ messages = []
112
+ if system:
113
+ messages.append({"role": "system", "content": system})
114
+ messages.append({"role": "user", "content": prompt})
115
+ for attempt, (mt, temp) in enumerate([(max_tokens, temperature), (max_tokens, 0.2)]):
116
+ try:
117
+ response = llm.create_chat_completion(
118
+ messages=messages, max_tokens=mt, temperature=temp,
119
+ )
120
+ except Exception as e:
121
+ print(f"Warning: LLM call failed (attempt {attempt}): {e}.")
122
+ continue
123
+ try:
124
+ content = response["choices"][0]["message"]["content"]
125
+ except (KeyError, IndexError, TypeError):
126
+ continue
127
+ if isinstance(content, str) and content.strip():
128
+ return clean_text(content)
129
+ print("Warning: LLM returned empty content after retries.")
130
+ return ""
131
+
132
+
133
+ def mock_generate(prompt: str, system: str = "") -> str:
134
  p = prompt.lower()
135
  s = system.lower()
136
  if "agent" in p and "whale" in p:
 
251
  f"Player P&L ₹{pnl:,.0f}, cash {cash_pct:.0f}%, total ₹{total:,.0f}. "
252
  f"One actionable sentence."
253
  )
254
+ try:
255
+ text = generate(prompt, system=system, max_tokens=80, temperature=0.4).strip()
256
+ except Exception:
257
+ text = ""
258
  if not text:
259
  if pnl < -50_000:
260
  text = f"Cut losers in {regime.replace('_', ' ')} regimes and rotate into defensives."
 
286
  f"unrealized P&L ₹{pnl:,.0f}. Positions: {pos_lines}.\n"
287
  f"Player: {user_message}\nReply in 2-3 short sentences."
288
  )
289
+ try:
290
+ text = generate(prompt, system=system, max_tokens=140, temperature=0.5).strip()
291
+ except Exception:
292
+ text = ""
293
  if not text:
294
  if "buy" in user_message.lower() or "should i" in user_message.lower():
295
  text = f"With cash at ₹{cash:,.0f} and P&L ₹{pnl:,.0f}, I'd wait for a confirmed trend before adding. Check the chart for support levels."
app.py CHANGED
@@ -1,6 +1,6 @@
1
  """
2
  Retro Alpha — FastAPI backend.
3
- Serves the static frontend and the deterministic endpoints (chat, mentor,
4
  insight). The game itself runs 100% in the browser (see static/engine.js);
5
  the server holds NO per-user state. This guarantees every browser tab has
6
  its own independent game.
@@ -14,11 +14,28 @@ from fastapi.responses import HTMLResponse, JSONResponse
14
  from fastapi.staticfiles import StaticFiles
15
 
16
  import agents
 
17
 
18
  app = FastAPI(title="Retro Alpha")
19
  ROOT = Path(__file__).resolve().parent
20
  STATIC_DIR = ROOT / "static"
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
23
 
24
 
@@ -30,10 +47,14 @@ async def homepage():
30
 
31
  @app.get("/api/health")
32
  def health() -> JSONResponse:
 
33
  return JSONResponse({
34
  "status": "ok",
35
  "llm": agents.llm_status(),
36
  "llm_error": agents.llm_error(),
 
 
 
37
  })
38
 
39
 
 
1
  """
2
  Retro Alpha — FastAPI backend.
3
+ Serves the static frontend and the LLM-backed endpoints (chat, mentor,
4
  insight). The game itself runs 100% in the browser (see static/engine.js);
5
  the server holds NO per-user state. This guarantees every browser tab has
6
  its own independent game.
 
14
  from fastapi.staticfiles import StaticFiles
15
 
16
  import agents
17
+ import download_model
18
 
19
  app = FastAPI(title="Retro Alpha")
20
  ROOT = Path(__file__).resolve().parent
21
  STATIC_DIR = ROOT / "static"
22
 
23
+ # Ensure the GGUF is on disk, then kick off the model load in a
24
+ # background thread. uvicorn opens the port IMMEDIATELY so HF Spaces'
25
+ # health check passes during the slow cold-start download + compile;
26
+ # /api/health reports the real status throughout.
27
+ try:
28
+ agents.MODEL_PATH = download_model.download()
29
+ print(f"Model path: {agents.MODEL_PATH}")
30
+ except Exception as e:
31
+ print(f"download_model.download() failed: {e}")
32
+
33
+
34
+ @app.on_event("startup")
35
+ def _on_startup():
36
+ agents.start_background_load()
37
+
38
+
39
  app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
40
 
41
 
 
47
 
48
  @app.get("/api/health")
49
  def health() -> JSONResponse:
50
+ mp = Path(agents.MODEL_PATH)
51
  return JSONResponse({
52
  "status": "ok",
53
  "llm": agents.llm_status(),
54
  "llm_error": agents.llm_error(),
55
+ "model_path": str(agents.MODEL_PATH),
56
+ "model_exists": mp.exists(),
57
+ "model_size_gb": round(mp.stat().st_size / 1e9, 2) if mp.exists() else 0,
58
  })
59
 
60
 
requirements.txt CHANGED
@@ -1,6 +1,7 @@
1
  # Runtime dependencies for Retro Alpha HF Space
2
  fastapi>=0.109.0
3
  uvicorn>=0.27.0
 
4
  python-dotenv>=1.0.0
5
  pydantic>=2.5.0
6
  numpy>=1.26.0,<3.0
 
1
  # Runtime dependencies for Retro Alpha HF Space
2
  fastapi>=0.109.0
3
  uvicorn>=0.27.0
4
+ llama-cpp-python>=0.3.2
5
  python-dotenv>=1.0.0
6
  pydantic>=2.5.0
7
  numpy>=1.26.0,<3.0
tests/test_agents.py CHANGED
@@ -20,5 +20,6 @@ def test_parse_news_response():
20
 
21
 
22
  def test_mock_generate():
 
23
  result = agents.generate("agent whale", "")
24
  assert "agent:" in result
 
20
 
21
 
22
  def test_mock_generate():
23
+ agents._llm_status = "mock"
24
  result = agents.generate("agent whale", "")
25
  assert "agent:" in result