ranranrunforit commited on
Commit
1dba535
·
verified ·
1 Parent(s): 378dc21

Upload 15 files

Browse files
Files changed (5) hide show
  1. app.py +20 -15
  2. llm_local.py +177 -148
  3. news_watch.py +1 -1
  4. research_agent.py +6 -3
  5. rotation.py +1 -1
app.py CHANGED
@@ -127,15 +127,16 @@ def ui_explain_detail(ticker):
127
  return
128
  # keep only the ruling chain — the per-signal diagnostics block is huge and
129
  # would cost a minute of silent CPU prompt-processing for no benefit
130
- core = txt.split("日线买卖点逐项诊断")[0][:2400]
131
  prompt = ("Below is a Chan-theory (缠论) multi-timeframe decision log in Chinese "
132
  "for a US stock. Do NOT repeat or quote the log. Write a fresh plain-"
133
  "English explanation for a trader: 1) the final action; 2) why each "
134
  "timeframe gate passed/failed; 3) what price/event would invalidate "
135
  "the call. ≤160 words.\n\n" + core)
136
- yield "🤖 _Generating (local Qwen3 via llama.cpp)…_"
137
- for acc in llm_local.chat_stream(prompt, max_tokens=420):
138
- yield "🤖 **AI explanation (local Qwen3 via llama.cpp):**\n\n" + acc
 
139
 
140
 
141
  def ui_refresh_rotation():
@@ -159,9 +160,10 @@ def ui_rotation_ai():
159
  "write a crisp brief (<150 words): 1) where capital is rotating INTO/OUT "
160
  "OF; 2) do 1-day moves agree with the 5/20-day trend; 3) one watch item. "
161
  "No disclaimers.\n\nDATA:\n" + brief[:2200])
162
- yield "🤖 _Generating…_"
163
- for acc in llm_local.chat_stream(prompt, max_tokens=380):
164
- yield "🤖 **AI rotation narrative (local Qwen3 via llama.cpp):**\n\n" + acc
 
165
 
166
 
167
  def ui_save_holdings(text):
@@ -186,7 +188,7 @@ def ui_open_report(fname):
186
 
187
 
188
  def ui_load_model(name):
189
- return llm_local.load_model(name)
190
 
191
 
192
  def ui_automation_panel():
@@ -206,7 +208,7 @@ with gr.Blocks(title="Chan Compass · US", **_style_kw) as demo:
206
  <div class="chips">
207
  <span>🧠 Local GGUF · no cloud APIs</span><span>🦙 llama.cpp runtime</span>
208
  <span>📊 Yahoo Finance data</span><span>⏰ Auto-update 18:10 ET</span>
209
- <span>🤖 Multi-step research agent</span><span>💾 Persistent /data bucket</span><span>🎨 Spectrum 2 design</span>
210
  </div>
211
  </div>""")
212
 
@@ -291,12 +293,16 @@ with gr.Blocks(title="Chan Compass · US", **_style_kw) as demo:
291
  "and nothing leaves the machine. **First load installs the llama.cpp "
292
  "runtime + downloads the GGUF (one-time, usually 1–3 min; worst case "
293
  "~15 min if it has to compile).** Signals/rotation/news never depend on it.")
 
 
 
 
294
  model_pick = gr.Radio(choices=list(llm_local.MODEL_ZOO.keys()),
295
- value=llm_local.DEFAULT_MODEL, label="Model")
296
  with gr.Row():
297
  load_btn = gr.Button("⬇ Load model", variant="primary")
298
  status_btn = gr.Button("↻ Refresh status")
299
- test_btn = gr.Button("⚡ Test model (3s)")
300
  model_status = gr.Markdown(llm_local.status())
301
 
302
  gr.Markdown("Chan Compass · educational tool, not investment advice · "
@@ -327,11 +333,10 @@ automation.start_scheduler()
327
  # explanations, rotation narrative, research agent) are ready without a click.
328
  def _auto_load_model():
329
  try:
330
- automation._log("Auto-loading local model (llama.cpp)…")
331
- msg = llm_local.load_model(llm_local.DEFAULT_MODEL)
332
- automation._log(f"Model: {msg}")
333
  except Exception as e:
334
- automation._log(f"Model auto-load failed: {e}")
335
 
336
  try:
337
  import paths as _paths
 
127
  return
128
  # keep only the ruling chain — the per-signal diagnostics block is huge and
129
  # would cost a minute of silent CPU prompt-processing for no benefit
130
+ core = txt.split("日线买卖点逐项诊断")[0][:1500]
131
  prompt = ("Below is a Chan-theory (缠论) multi-timeframe decision log in Chinese "
132
  "for a US stock. Do NOT repeat or quote the log. Write a fresh plain-"
133
  "English explanation for a trader: 1) the final action; 2) why each "
134
  "timeframe gate passed/failed; 3) what price/event would invalidate "
135
  "the call. ≤160 words.\n\n" + core)
136
+ yield ("🤖 _Translator sub-agent (Qwen3-1.7B · llama.cpp) is reading the "
137
+ "decision log first words in ~5-15s…_")
138
+ for acc in llm_local.chat_stream(prompt, max_tokens=340, worker="fast"):
139
+ yield "🤖 **Translator sub-agent (Qwen3-1.7B · llama.cpp):**\n\n" + acc
140
 
141
 
142
  def ui_refresh_rotation():
 
160
  "write a crisp brief (<150 words): 1) where capital is rotating INTO/OUT "
161
  "OF; 2) do 1-day moves agree with the 5/20-day trend; 3) one watch item. "
162
  "No disclaimers.\n\nDATA:\n" + brief[:2200])
163
+ yield ("🤖 _Narrator sub-agent (Qwen3-1.7B · llama.cpp) is reading the flow "
164
+ "tables first words in ~5-15s…_")
165
+ for acc in llm_local.chat_stream(prompt, max_tokens=340, worker="fast"):
166
+ yield "🤖 **Narrator sub-agent (Qwen3-1.7B · llama.cpp):**\n\n" + acc
167
 
168
 
169
  def ui_save_holdings(text):
 
188
 
189
 
190
  def ui_load_model(name):
191
+ return llm_local.load_model(name, worker="deep")
192
 
193
 
194
  def ui_automation_panel():
 
208
  <div class="chips">
209
  <span>🧠 Local GGUF · no cloud APIs</span><span>🦙 llama.cpp runtime</span>
210
  <span>📊 Yahoo Finance data</span><span>⏰ Auto-update 18:10 ET</span>
211
+ <span>🤖 Sub-agent pool · 1.7B + 4B</span><span>💾 Persistent /data bucket</span><span>🎨 Spectrum 2 design</span>
212
  </div>
213
  </div>""")
214
 
 
293
  "and nothing leaves the machine. **First load installs the llama.cpp "
294
  "runtime + downloads the GGUF (one-time, usually 1–3 min; worst case "
295
  "~15 min if it has to compile).** Signals/rotation/news never depend on it.")
296
+ gr.Markdown("**Sub-agent pool:** `fast` Translator/Narrator (Qwen3-1.7B, "
297
+ "fixed) handles Explain / rotation narrative / news briefs; "
298
+ "`deep` Analyst writes research reports. Each has its own lock — "
299
+ "they run in parallel. Pick the Analyst model below:")
300
  model_pick = gr.Radio(choices=list(llm_local.MODEL_ZOO.keys()),
301
+ value=llm_local.DEFAULT_MODEL, label="Analyst (deep) model")
302
  with gr.Row():
303
  load_btn = gr.Button("⬇ Load model", variant="primary")
304
  status_btn = gr.Button("↻ Refresh status")
305
+ test_btn = gr.Button("⚡ Test sub-agents")
306
  model_status = gr.Markdown(llm_local.status())
307
 
308
  gr.Markdown("Chan Compass · educational tool, not investment advice · "
 
333
  # explanations, rotation narrative, research agent) are ready without a click.
334
  def _auto_load_model():
335
  try:
336
+ automation._log("Auto-loading sub-agents (llama.cpp)…")
337
+ llm_local.auto_load_all()
 
338
  except Exception as e:
339
+ automation._log(f"Sub-agent auto-load failed: {e}")
340
 
341
  try:
342
  import paths as _paths
llm_local.py CHANGED
@@ -1,17 +1,21 @@
1
  """
2
- llm_local.py — local small-model brain (llama.cpp runtime, no cloud APIs).
3
-
4
- Earns the hackathon "Off the Grid" + "Llama Champion" badges:
5
- * Models are Qwen3 GGUF files (all far below the 32B parameter cap)
6
- * Inference runs through llama.cpp via llama-cpp-python
7
- * The GGUF is downloaded once from the HF Hub, then everything is local
8
-
9
- The model's job in this app is language work the rule engine can't do:
10
- 1. Brief today's news on held US tickers, tagging POSITIVE/NEGATIVE/NEUTRAL.
11
- 2. Read the sector ETF flow tables and narrate where capital is rotating.
12
- 3. Write the Research Note (beta): valuation, moat, bull/bear case.
13
- 4. Translate the (Chinese) Chan-theory decision chain into plain English.
 
 
14
  """
 
 
15
  import os
16
  import re
17
  import threading
@@ -19,7 +23,7 @@ import threading
19
  import paths # sets HF_HOME + sys.path for /data persistence
20
  from huggingface_hub import hf_hub_download
21
 
22
- # name -> (HF repo, gguf filename, note)
23
  MODEL_ZOO = {
24
  "Qwen3-1.7B · Tiny Titan (≤4B award class)": (
25
  "Qwen/Qwen3-1.7B-GGUF", "Qwen3-1.7B-Q8_0.gguf"),
@@ -30,41 +34,47 @@ MODEL_ZOO = {
30
  "Qwen3-14B · max quality (still far under 32B cap)": (
31
  "Qwen/Qwen3-14B-GGUF", "Qwen3-14B-Q4_K_M.gguf"),
32
  }
 
33
  DEFAULT_MODEL = "Qwen3-4B · default — fast + smart, still ≤4B"
34
 
35
- _lock = threading.Lock() # serializes inference only
36
- _load_lock = threading.Lock() # serializes install/download/load
37
- _llm = None
38
- _loaded_name = None
 
39
 
40
- # Visible progress for the Model tab / Automation log — every stage updates this,
41
- # so "nothing happens" can never happen again: the status always says WHY.
42
- PROGRESS = {"stage": "idle", "detail": "", "ts": None}
 
 
 
43
 
 
44
 
45
- def _set_stage(stage: str, detail: str = ""):
 
46
  import datetime as _dt
47
- PROGRESS.update(stage=stage, detail=detail[:400],
48
- ts=_dt.datetime.utcnow().strftime("%H:%M:%S UTC"))
 
49
  try:
50
  import automation
51
- automation._log(f"[model] {stage}: {detail[:160]}")
52
  except Exception:
53
  pass
54
 
55
- _THINK_RE = re.compile(r"<think>.*?</think>", re.S)
56
 
57
- # llama-cpp-python is installed at RUNTIME, not at Space build time.
58
- # Why: the HF build container has little RAM and gets OOM-killed compiling the
59
- # C++ extension; the runtime container has the real hardware (8 vCPU / 32 GB).
60
- # We try the official prebuilt CPU wheel first (seconds), and only compile from
61
- # source as a fallback with capped parallelism so memory stays bounded.
62
  _WHEEL_INDEX = "https://abetlen.github.io/llama-cpp-python/whl/cpu"
63
  _LLAMA_REQ = "llama-cpp-python>=0.3.8" # >=0.3.8 → Qwen3 architecture support
64
 
65
 
66
- def _ensure_llama_cpp() -> str:
67
- """Install llama-cpp-python on first use. Returns '' on success, else error."""
68
  try:
69
  import llama_cpp # noqa: F401
70
  return ""
@@ -72,123 +82,137 @@ def _ensure_llama_cpp() -> str:
72
  pass
73
  import subprocess
74
  import sys
75
- env = dict(os.environ)
76
- env["CMAKE_BUILD_PARALLEL_LEVEL"] = "4" # bound memory if a compile happens
77
- _set_stage("installing llama.cpp runtime",
78
- "trying official prebuilt CPU wheel (≈1 min)…")
79
- if paths.PERSISTENT:
80
- base = [sys.executable, "-m", "pip", "install", "--prefer-binary",
81
- "--target", paths.PYLIBS_DIR]
82
- else:
83
- base = [sys.executable, "-m", "pip", "install", "--user", "--prefer-binary"]
84
- # 1) prebuilt CPU wheel from the official index (fast path)
85
- r = subprocess.run(base + ["--extra-index-url", _WHEEL_INDEX,
86
- "--only-binary", "llama-cpp-python", _LLAMA_REQ],
87
- capture_output=True, text=True, env=env, timeout=600)
88
- if r.returncode != 0:
89
- # 2) fallback: allow source build (runtime box has plenty of RAM)
90
- _set_stage("installing llama.cpp runtime",
91
- "no prebuilt wheel matched — compiling from source "
92
- "(one-time, ~10-15 min; other tabs keep working)…")
93
- r = subprocess.run(base + ["--extra-index-url", _WHEEL_INDEX, _LLAMA_REQ],
94
- capture_output=True, text=True, env=env, timeout=2400)
95
- if r.returncode != 0:
96
- err = (r.stderr or r.stdout or "")[-800:]
97
- _set_stage("install FAILED", err)
98
- return "Could not install llama-cpp-python at runtime:\n" + err
99
- # make the freshly installed package importable in this process
100
- import importlib
101
- import site
102
- cands = [paths.PYLIBS_DIR] if paths.PERSISTENT else []
103
- usp = site.getusersitepackages()
104
- cands += usp if isinstance(usp, list) else [usp]
105
- for p in cands:
106
- if p and p not in sys.path:
107
- sys.path.append(p)
108
- importlib.invalidate_caches()
109
- try:
110
- import llama_cpp # noqa: F401
111
- return ""
112
- except Exception as e:
113
- return f"Installed but import failed: {e}"
114
-
115
-
116
- def status() -> str:
117
- if _llm is not None:
118
- return (f"✅ **Model ready:** {_loaded_name} · runtime: llama.cpp "
119
- f"(local, no cloud APIs)")
120
- st, dt_, ts = PROGRESS["stage"], PROGRESS["detail"], PROGRESS["ts"]
121
- if st == "idle":
122
- return ("No model loaded yet. It auto-loads at startup press "
123
- "“↻ Refresh status”, or “⬇ Load model”.")
124
- line1 = f"⏳ **{st}** ({ts}) — {dt_ or '...'}"
125
- return line1 + "\n\nOther tabs keep working; press “↻ Refresh status” to update."
126
-
127
-
128
- def available() -> bool:
129
- return _llm is not None
130
-
131
-
132
- def load_model(name: str) -> str:
133
- """Download (once) and load a GGUF model. Returns a status string.
134
- Non-blocking: if another install/load is already running (e.g. the startup
135
- auto-load), this returns its live status instead of hanging the click."""
136
- global _llm, _loaded_name
137
- repo, fname = MODEL_ZOO[name]
138
- if not _load_lock.acquire(timeout=2):
139
- return ("⏳ A model install/load is already running in the background — "
140
- f"current stage: **{PROGRESS['stage']}** ({PROGRESS['detail'] or '…'}). "
141
- "Press “↻ Refresh status” to watch it.")
142
  try:
143
- if _loaded_name == name and _llm is not None:
144
- return f"Already loaded: {name}"
145
- err = _ensure_llama_cpp()
146
  if err:
147
  return err
148
  try:
149
  from llama_cpp import Llama
150
- except Exception as e: # llama-cpp-python missing / failed to build
151
- _set_stage("import FAILED", str(e))
152
  return f"llama-cpp-python is not available: {e}"
 
153
  try:
154
- _set_stage("downloading GGUF", f"{repo}/{fname} (cached on /data after first time)")
 
155
  path = hf_hub_download(repo_id=repo, filename=fname)
156
  except Exception as e:
157
- _set_stage("download FAILED", str(e))
158
  return f"Could not download {repo}/{fname}: {e}"
159
  try:
160
- _set_stage("loading model into RAM", name)
161
- _llm = None
162
- _llm = Llama(
163
  model_path=path,
164
- n_ctx=6144,
165
- n_threads=max(2, (os.cpu_count() or 4)),
166
- n_threads_batch=max(2, (os.cpu_count() or 4)),
167
  n_batch=512,
168
  verbose=False,
169
  )
170
- _loaded_name = name
171
- _set_stage("ready", name)
172
- return f"✅ Loaded: {name}"
173
  except Exception as e:
174
- _llm = None
175
- _loaded_name = None
176
- _set_stage("load FAILED", str(e))
177
  return f"Failed to load model: {e}"
178
  finally:
179
- _load_lock.release()
 
180
 
 
 
 
 
 
181
 
182
- DEFAULT_SYSTEM = ("You are the analysis brain of Chan Compass, a US-equity "
183
- "dashboard. Answer in clear, concise English.")
184
 
 
 
 
 
 
185
 
186
- def is_loaded() -> bool:
187
- return _llm is not None
 
188
 
189
 
190
- # CPU reality check: prompt prefill is the slow part on a Space (≈100-200 tok/s).
191
- # Keep prompts SHORT — every kilobyte of prompt is seconds of silence for the user.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  MAX_PROMPT_CHARS = 3200
193
 
194
 
@@ -198,14 +222,15 @@ def _messages(user: str, system: str):
198
 
199
 
200
  def chat(user: str, max_tokens: int = 500, temperature: float = 0.3,
201
- system: str = DEFAULT_SYSTEM) -> str:
202
- """One blocking chat turn (used by the pipeline/agent)."""
203
- if _llm is None:
 
204
  return ""
205
- if not _lock.acquire(timeout=180):
206
- return "(model busy — another generation is running, try again in a moment)"
207
  try:
208
- out = _llm.create_chat_completion(
209
  messages=_messages(user, system),
210
  max_tokens=max_tokens, temperature=temperature)
211
  txt = out["choices"][0]["message"]["content"] or ""
@@ -213,22 +238,23 @@ def chat(user: str, max_tokens: int = 500, temperature: float = 0.3,
213
  except Exception as e:
214
  return f"(model error: {e})"
215
  finally:
216
- _lock.release()
217
 
218
 
219
  def chat_stream(user: str, max_tokens: int = 500, temperature: float = 0.3,
220
- system: str = DEFAULT_SYSTEM):
221
- """Streaming chat turn — yields the cumulative text so the UI shows tokens
222
- immediately instead of a dead spinner."""
223
- if _llm is None:
224
- yield "⏳ The local model isn't loaded yet — check the **Model** tab."
 
225
  return
226
- if not _lock.acquire(timeout=5):
227
- yield "⏳ Model is busy with another generation — try again in a moment."
228
  return
229
  try:
230
  acc = ""
231
- for chunk in _llm.create_chat_completion(
232
  messages=_messages(user, system),
233
  max_tokens=max_tokens, temperature=temperature, stream=True):
234
  delta = chunk["choices"][0]["delta"].get("content") or ""
@@ -241,15 +267,18 @@ def chat_stream(user: str, max_tokens: int = 500, temperature: float = 0.3,
241
  except Exception as e:
242
  yield f"(model error: {e})"
243
  finally:
244
- _lock.release()
245
 
246
 
247
  def quick_test() -> str:
248
- """3-second sanity check that inference works."""
249
  import time
250
- if _llm is None:
251
- return status()
252
- t0 = time.time()
253
- out = chat("Reply with exactly: Chan Compass model OK", max_tokens=12,
254
- temperature=0.0)
255
- return f"**{out or '(no output)'}** · {time.time()-t0:.1f}s"
 
 
 
 
1
  """
2
+ llm_local.py — local sub-agent pool (llama.cpp runtime, no cloud APIs).
3
+
4
+ Two independent model instances ("sub-agents"), each with its own lock, so
5
+ features never block each other with "model busy":
6
+
7
+ fast · Translator/Narrator Qwen3-1.7B Q8 (≈2 GB RAM)
8
+ → Explain-in-English, sector-rotation narrative, news briefs.
9
+ Small = quick CPU prefill, answers start streaming in seconds.
10
+ deep · Analyst Qwen3-4B Q4_K_M by default (swappable in the Model tab)
11
+ the multi-step Auto Research agent's report writing.
12
+
13
+ Both run through llama.cpp (llama-cpp-python) and are far below the 32B cap;
14
+ the fast worker doubles as the "Tiny Titan" (≤4B) story. ~5 GB RAM total on a
15
+ 32 GB Space. Earns "Off the Grid" + "Llama Champion".
16
  """
17
+ from __future__ import annotations
18
+
19
  import os
20
  import re
21
  import threading
 
23
  import paths # sets HF_HOME + sys.path for /data persistence
24
  from huggingface_hub import hf_hub_download
25
 
26
+ # name -> (HF repo, gguf filename)
27
  MODEL_ZOO = {
28
  "Qwen3-1.7B · Tiny Titan (≤4B award class)": (
29
  "Qwen/Qwen3-1.7B-GGUF", "Qwen3-1.7B-Q8_0.gguf"),
 
34
  "Qwen3-14B · max quality (still far under 32B cap)": (
35
  "Qwen/Qwen3-14B-GGUF", "Qwen3-14B-Q4_K_M.gguf"),
36
  }
37
+ FAST_MODEL = "Qwen3-1.7B · Tiny Titan (≤4B award class)"
38
  DEFAULT_MODEL = "Qwen3-4B · default — fast + smart, still ≤4B"
39
 
40
+ _THINK_RE = re.compile(r"<think>.*?</think>", re.S)
41
+ _NCPU = max(2, (os.cpu_count() or 4))
42
+
43
+ WORKER_LABEL = {"fast": "Translator/Narrator sub-agent",
44
+ "deep": "Analyst sub-agent"}
45
 
46
+ WORKERS = {
47
+ "fast": {"model": FAST_MODEL, "llm": None, "lock": threading.Lock(),
48
+ "load_lock": threading.Lock(), "stage": "idle", "detail": "", "ts": None},
49
+ "deep": {"model": DEFAULT_MODEL, "llm": None, "lock": threading.Lock(),
50
+ "load_lock": threading.Lock(), "stage": "idle", "detail": "", "ts": None},
51
+ }
52
 
53
+ _install_lock = threading.Lock()
54
 
55
+
56
+ def _set_stage(worker: str, stage: str, detail: str = ""):
57
  import datetime as _dt
58
+ w = WORKERS[worker]
59
+ w.update(stage=stage, detail=detail[:400],
60
+ ts=_dt.datetime.utcnow().strftime("%H:%M:%S UTC"))
61
  try:
62
  import automation
63
+ automation._log(f"[{WORKER_LABEL[worker]}] {stage}: {detail[:140]}")
64
  except Exception:
65
  pass
66
 
 
67
 
68
+ # ─────────────────────── runtime install (once, persisted) ───────────────────────
69
+ # Installed at RUNTIME, not at Space build time: the HF build container has
70
+ # little RAM and gets OOM-killed compiling the C++ extension; the runtime
71
+ # container has the real hardware. Prebuilt CPU wheel first, capped-parallelism
72
+ # source build as fallback. Persisted to /data/pylibs.
73
  _WHEEL_INDEX = "https://abetlen.github.io/llama-cpp-python/whl/cpu"
74
  _LLAMA_REQ = "llama-cpp-python>=0.3.8" # >=0.3.8 → Qwen3 architecture support
75
 
76
 
77
+ def _ensure_llama_cpp(worker: str) -> str:
 
78
  try:
79
  import llama_cpp # noqa: F401
80
  return ""
 
82
  pass
83
  import subprocess
84
  import sys
85
+ with _install_lock:
86
+ try: # another thread may have finished it while we waited
87
+ import llama_cpp # noqa: F401
88
+ return ""
89
+ except ImportError:
90
+ pass
91
+ env = dict(os.environ)
92
+ env["CMAKE_BUILD_PARALLEL_LEVEL"] = "4"
93
+ _set_stage(worker, "installing llama.cpp runtime",
94
+ "trying official prebuilt CPU wheel (≈1 min)…")
95
+ if paths.PERSISTENT:
96
+ base = [sys.executable, "-m", "pip", "install", "--prefer-binary",
97
+ "--target", paths.PYLIBS_DIR]
98
+ else:
99
+ base = [sys.executable, "-m", "pip", "install", "--user", "--prefer-binary"]
100
+ r = subprocess.run(base + ["--extra-index-url", _WHEEL_INDEX,
101
+ "--only-binary", "llama-cpp-python", _LLAMA_REQ],
102
+ capture_output=True, text=True, env=env, timeout=600)
103
+ if r.returncode != 0:
104
+ _set_stage(worker, "installing llama.cpp runtime",
105
+ "no prebuilt wheel matched — compiling from source "
106
+ "(one-time ~10-15 min; other tabs keep working)…")
107
+ r = subprocess.run(base + ["--extra-index-url", _WHEEL_INDEX, _LLAMA_REQ],
108
+ capture_output=True, text=True, env=env, timeout=2400)
109
+ if r.returncode != 0:
110
+ err = (r.stderr or r.stdout or "")[-800:]
111
+ _set_stage(worker, "install FAILED", err)
112
+ return "Could not install llama-cpp-python at runtime:\n" + err
113
+ import importlib
114
+ import site
115
+ cands = [paths.PYLIBS_DIR] if paths.PERSISTENT else []
116
+ usp = site.getusersitepackages()
117
+ cands += usp if isinstance(usp, list) else [usp]
118
+ for p in cands:
119
+ if p and p not in sys.path:
120
+ sys.path.append(p)
121
+ importlib.invalidate_caches()
122
+ try:
123
+ import llama_cpp # noqa: F401
124
+ return ""
125
+ except Exception as e:
126
+ _set_stage(worker, "install FAILED", f"installed but import failed: {e}")
127
+ return f"Installed but import failed: {e}"
128
+
129
+
130
+ # ─────────────────────── loading ───────────────────────
131
+ def load_model(name: str, worker: str = "deep") -> str:
132
+ """Load a GGUF into a worker slot. Non-blocking: if that worker is already
133
+ installing/loading, returns its live stage instead of hanging the click."""
134
+ w = WORKERS[worker]
135
+ if not w["load_lock"].acquire(timeout=2):
136
+ return (f"⏳ {WORKER_LABEL[worker]} is busy — current stage: "
137
+ f"**{w['stage']}** ({w['detail'] or '…'}). Press “↻ Refresh status”.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  try:
139
+ if w["llm"] is not None and w["model"] == name:
140
+ return f"Already loaded on {WORKER_LABEL[worker]}: {name}"
141
+ err = _ensure_llama_cpp(worker)
142
  if err:
143
  return err
144
  try:
145
  from llama_cpp import Llama
146
+ except Exception as e:
147
+ _set_stage(worker, "import FAILED", str(e))
148
  return f"llama-cpp-python is not available: {e}"
149
+ repo, fname = MODEL_ZOO[name]
150
  try:
151
+ _set_stage(worker, "downloading GGUF",
152
+ f"{repo}/{fname} (cached on /data after first time)")
153
  path = hf_hub_download(repo_id=repo, filename=fname)
154
  except Exception as e:
155
+ _set_stage(worker, "download FAILED", str(e))
156
  return f"Could not download {repo}/{fname}: {e}"
157
  try:
158
+ _set_stage(worker, "loading model into RAM", name)
159
+ w["llm"] = None
160
+ w["llm"] = Llama(
161
  model_path=path,
162
+ n_ctx=4096 if worker == "fast" else 6144,
163
+ n_threads=_NCPU,
164
+ n_threads_batch=_NCPU,
165
  n_batch=512,
166
  verbose=False,
167
  )
168
+ w["model"] = name
169
+ _set_stage(worker, "ready", name)
170
+ return f"✅ {WORKER_LABEL[worker]} ready: {name}"
171
  except Exception as e:
172
+ w["llm"] = None
173
+ _set_stage(worker, "load FAILED", str(e))
 
174
  return f"Failed to load model: {e}"
175
  finally:
176
+ w["load_lock"].release()
177
+
178
 
179
+ def auto_load_all():
180
+ """Startup: bring the fast sub-agent up first (small download, features go
181
+ live quickly), then the deep one. Runs in a background thread."""
182
+ load_model(WORKERS["fast"]["model"], worker="fast")
183
+ load_model(WORKERS["deep"]["model"], worker="deep")
184
 
 
 
185
 
186
+ # ─────────────────────── status ───────────────────────
187
+ def is_loaded(worker: str = None) -> bool:
188
+ if worker:
189
+ return WORKERS[worker]["llm"] is not None
190
+ return any(w["llm"] is not None for w in WORKERS.values())
191
 
192
+
193
+ def available() -> bool:
194
+ return is_loaded()
195
 
196
 
197
+ def status() -> str:
198
+ lines = []
199
+ for key in ("fast", "deep"):
200
+ w = WORKERS[key]
201
+ label = WORKER_LABEL[key]
202
+ if w["llm"] is not None:
203
+ lines.append(f"✅ **{label}** — {w['model']} · llama.cpp, local")
204
+ elif w["stage"] == "idle":
205
+ lines.append(f"⚪ **{label}** — not loaded yet (auto-loads at startup)")
206
+ else:
207
+ lines.append(f"⏳ **{label}** — {w['stage']} ({w['ts']}): {w['detail'] or '…'}")
208
+ lines.append("\n_Each sub-agent has its own lock — Explain / narrative / "
209
+ "research run in parallel without “model busy”._")
210
+ return "\n\n".join(lines)
211
+
212
+
213
+ # ─────────────────────── inference ───────────────────────
214
+ DEFAULT_SYSTEM = ("You are a sub-agent of Chan Compass, a US-equity dashboard. "
215
+ "Answer in clear, concise English.")
216
  MAX_PROMPT_CHARS = 3200
217
 
218
 
 
222
 
223
 
224
  def chat(user: str, max_tokens: int = 500, temperature: float = 0.3,
225
+ system: str = DEFAULT_SYSTEM, worker: str = "fast") -> str:
226
+ """Blocking chat on one sub-agent (used by pipeline/agent code)."""
227
+ w = WORKERS[worker]
228
+ if w["llm"] is None:
229
  return ""
230
+ if not w["lock"].acquire(timeout=180):
231
+ return f"({WORKER_LABEL[worker]} busy — try again in a moment)"
232
  try:
233
+ out = w["llm"].create_chat_completion(
234
  messages=_messages(user, system),
235
  max_tokens=max_tokens, temperature=temperature)
236
  txt = out["choices"][0]["message"]["content"] or ""
 
238
  except Exception as e:
239
  return f"(model error: {e})"
240
  finally:
241
+ w["lock"].release()
242
 
243
 
244
  def chat_stream(user: str, max_tokens: int = 500, temperature: float = 0.3,
245
+ system: str = DEFAULT_SYSTEM, worker: str = "fast"):
246
+ """Streaming chat on one sub-agent — yields cumulative text immediately."""
247
+ w = WORKERS[worker]
248
+ if w["llm"] is None:
249
+ yield (f"⏳ {WORKER_LABEL[worker]} isn't ready yet — "
250
+ f"stage: {w['stage']}. Check the **Model** tab.")
251
  return
252
+ if not w["lock"].acquire(timeout=5):
253
+ yield f"⏳ {WORKER_LABEL[worker]} is finishing another answer — try again in a few seconds."
254
  return
255
  try:
256
  acc = ""
257
+ for chunk in w["llm"].create_chat_completion(
258
  messages=_messages(user, system),
259
  max_tokens=max_tokens, temperature=temperature, stream=True):
260
  delta = chunk["choices"][0]["delta"].get("content") or ""
 
267
  except Exception as e:
268
  yield f"(model error: {e})"
269
  finally:
270
+ w["lock"].release()
271
 
272
 
273
  def quick_test() -> str:
274
+ """Sanity check both sub-agents."""
275
  import time
276
+ outs = []
277
+ for key in ("fast", "deep"):
278
+ if WORKERS[key]["llm"] is None:
279
+ outs.append(f"{WORKER_LABEL[key]}: not loaded ({WORKERS[key]['stage']})")
280
+ continue
281
+ t0 = time.time()
282
+ out = chat("Reply with exactly: OK", max_tokens=6, temperature=0.0, worker=key)
283
+ outs.append(f"{WORKER_LABEL[key]}: **{out or '(no output)'}** · {time.time()-t0:.1f}s")
284
+ return "\n\n".join(outs)
news_watch.py CHANGED
@@ -91,7 +91,7 @@ def _llm_brief(ticker: str, items: list) -> str:
91
  "action (e.g. 'no action', 'review stop level', 'watch earnings'). "
92
  "Be specific, no disclaimers."
93
  )
94
- return llm_local.chat(prompt, max_tokens=240)
95
  except Exception:
96
  return ""
97
 
 
91
  "action (e.g. 'no action', 'review stop level', 'watch earnings'). "
92
  "Be specific, no disclaimers."
93
  )
94
+ return llm_local.chat(prompt, max_tokens=240, worker="fast")
95
  except Exception:
96
  return ""
97
 
research_agent.py CHANGED
@@ -80,8 +80,10 @@ class Trace:
80
  def _llm(prompt: str, max_tokens: int = 500) -> str:
81
  try:
82
  import llm_local
83
- if llm_local.is_loaded():
84
- return llm_local.chat(prompt, max_tokens=max_tokens)
 
 
85
  except Exception:
86
  pass
87
  return ""
@@ -337,7 +339,8 @@ def run_research_stream(ticker: str):
337
  body = ""
338
  try:
339
  import llm_local
340
- for acc in llm_local.chat_stream(prompt, max_tokens=900):
 
341
  body = acc
342
  yield "\n".join(log_lines), head + body
343
  except Exception as e:
 
80
  def _llm(prompt: str, max_tokens: int = 500) -> str:
81
  try:
82
  import llm_local
83
+ if llm_local.is_loaded("deep"):
84
+ return llm_local.chat(prompt, max_tokens=max_tokens, worker="deep")
85
+ if llm_local.is_loaded("fast"): # deep still loading → fall back
86
+ return llm_local.chat(prompt, max_tokens=max_tokens, worker="fast")
87
  except Exception:
88
  pass
89
  return ""
 
339
  body = ""
340
  try:
341
  import llm_local
342
+ wk = "deep" if llm_local.is_loaded("deep") else "fast"
343
+ for acc in llm_local.chat_stream(prompt, max_tokens=900, worker=wk):
344
  body = acc
345
  yield "\n".join(log_lines), head + body
346
  except Exception as e:
rotation.py CHANGED
@@ -150,6 +150,6 @@ def llm_narrative(df_1d, df_5d, df_20d) -> str:
150
  "trend (rotation vs one-day noise); 3) one actionable watch item. "
151
  "No disclaimers.\n\nDATA:\n" + brief
152
  )
153
- return llm_local.chat(prompt, max_tokens=420)
154
  except Exception as e:
155
  return f"**Raw read:**\n{brief}\n\n_(LLM unavailable: {e})_"
 
150
  "trend (rotation vs one-day noise); 3) one actionable watch item. "
151
  "No disclaimers.\n\nDATA:\n" + brief
152
  )
153
+ return llm_local.chat(prompt, max_tokens=380, worker="fast")
154
  except Exception as e:
155
  return f"**Raw read:**\n{brief}\n\n_(LLM unavailable: {e})_"