s-ttp commited on
Commit
cd4b1d2
·
verified ·
1 Parent(s): a383a8e

Upload folder using huggingface_hub

Browse files
Files changed (6) hide show
  1. README.md +6 -2
  2. src/ledger.py +1 -1
  3. src/llm.py +26 -10
  4. src/personas.py +4 -2
  5. src/portfolio.py +2 -1
  6. src/revival.py +2 -1
README.md CHANGED
@@ -46,8 +46,12 @@ entry price, quantity optional), and get:
46
 
47
  With no LLM key at all the app still runs and shows the quantitative summary.
48
 
49
- Config env vars: `LLM_MODEL` (default `claude-sonnet-5`), `RUNS_PER_DAY` (default 25),
50
- `MAX_HOLDINGS` (default 25), `CACHE_TTL_HOURS` (default 6).
 
 
 
 
51
 
52
  ## Run locally
53
 
 
46
 
47
  With no LLM key at all the app still runs and shows the quantitative summary.
48
 
49
+ Models are tier-routed by task demand (all env-overridable): synthesis & valuation
50
+ judgment on Claude Opus 5 (`LLM_MODEL_TOP`), Value/Risk lenses + import on Claude
51
+ Sonnet 5 (`LLM_MODEL`), Growth/Technical lenses on GPT-5.6 Sol (`OPENAI_MODEL`),
52
+ Macro on Gemini 3.7 Flash (`GEMINI_MODEL`), utilities on Claude Haiku 4.5
53
+ (`LLM_MODEL_FAST`). Other config: `RUNS_PER_DAY` (default 25), `MAX_HOLDINGS`
54
+ (default 25), `CACHE_TTL_HOURS` (default 6).
55
 
56
  ## Run locally
57
 
src/ledger.py CHANGED
@@ -288,7 +288,7 @@ def check_portfolio(code: str, with_events: bool = False) -> dict | None:
288
  blocks.append(t + ":\n" + "\n".join(f" - {n['headline']}" for n in heads[:6]))
289
  if blocks:
290
  out = parse_json(call_llm(_EVENT_PROMPT, "HEADLINES:\n\n" + "\n\n".join(blocks),
291
- max_tokens=200 + 120 * len(blocks))) or {}
292
  for t, v in out.items():
293
  if isinstance(v, dict) and str(v.get("severity", "none")).lower() in ("medium", "high"):
294
  fresh.append({"kind": "event", "ticker": str(t).upper(),
 
288
  blocks.append(t + ":\n" + "\n".join(f" - {n['headline']}" for n in heads[:6]))
289
  if blocks:
290
  out = parse_json(call_llm(_EVENT_PROMPT, "HEADLINES:\n\n" + "\n\n".join(blocks),
291
+ max_tokens=200 + 120 * len(blocks), tier="fast")) or {}
292
  for t, v in out.items():
293
  if isinstance(v, dict) and str(v.get("severity", "none")).lower() in ("medium", "high"):
294
  fresh.append({"kind": "event", "ticker": str(t).upper(),
src/llm.py CHANGED
@@ -17,9 +17,20 @@ try:
17
  except Exception:
18
  CTX = ssl.create_default_context()
19
 
 
 
 
 
 
 
 
20
  LLM_MODEL = os.environ.get("LLM_MODEL", "claude-sonnet-5")
21
- GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-flash-lite-latest")
22
- OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5.1")
 
 
 
 
23
 
24
  # Prompt-injection guard + hard rails, prepended to every persona system prompt.
25
  RAILS = (
@@ -31,13 +42,16 @@ RAILS = (
31
  )
32
 
33
 
34
- def _call_anthropic(system: str, user: str, max_tokens: int) -> str | None:
35
  key = os.environ.get("ANTHROPIC_API_KEY")
36
  if not key:
37
  return None
 
 
 
38
  # NOTE: Claude 5 models reject the `temperature` param — determinism is enforced by the
39
  # deterministic validator that gates every action, not by sampling settings.
40
- body = {"model": LLM_MODEL, "max_tokens": max_tokens,
41
  "system": system, "messages": [{"role": "user", "content": user}]}
42
  req = urllib.request.Request(
43
  "https://api.anthropic.com/v1/messages", data=json.dumps(body).encode(),
@@ -45,7 +59,8 @@ def _call_anthropic(system: str, user: str, max_tokens: int) -> str | None:
45
  "content-type": "application/json"})
46
  for _ in range(3):
47
  try:
48
- with urllib.request.urlopen(req, timeout=150, context=CTX) as r:
 
49
  out = json.load(r)
50
  return "".join(b.get("text", "") for b in out.get("content", []))
51
  except Exception as e:
@@ -53,7 +68,7 @@ def _call_anthropic(system: str, user: str, max_tokens: int) -> str | None:
53
  return None
54
 
55
 
56
- def _call_gemini(system: str, user: str, max_tokens: int) -> str | None:
57
  key = os.environ.get("GEMINI_API_KEY")
58
  if not key:
59
  return None
@@ -74,7 +89,7 @@ def _call_gemini(system: str, user: str, max_tokens: int) -> str | None:
74
  return None
75
 
76
 
77
- def _call_openai(system: str, user: str, max_tokens: int) -> str | None:
78
  key = os.environ.get("OPENAI_API_KEY")
79
  if not key:
80
  return None
@@ -98,16 +113,17 @@ _DEFAULT_ORDER = ("anthropic", "openai", "gemini")
98
 
99
 
100
  def call_llm(system: str, user: str, max_tokens: int = 2000,
101
- prefer: str | None = None) -> str | None:
102
  """Try `prefer`'s provider first (persona-level model diversity — a committee of
103
  different models gives genuinely independent opinions), then cascade through the
104
- rest so a missing key or outage never silences a persona."""
 
105
  order: list[str] = list(_DEFAULT_ORDER)
106
  if prefer in _PROVIDERS:
107
  order.remove(prefer)
108
  order.insert(0, prefer)
109
  for name in order:
110
- text = _PROVIDERS[name](system, user, max_tokens)
111
  if text:
112
  return text
113
  return None
 
17
  except Exception:
18
  CTX = ssl.create_default_context()
19
 
20
+ # Model tiers, selected per task demands (researched 2026-09; all ids probed live):
21
+ # - top: Claude Opus 5 ($5/$25, adaptive thinking on by default) — synthesis &
22
+ # valuation judgment: CIO verdict, plan narrative, strategist, DCF assumptions
23
+ # - standard: Claude Sonnet 5 ($3/$15) — Value/Risk lenses, file import
24
+ # GPT-5.6 Sol (~$2/$10, OpenAI flagship) — Growth/Technical lenses
25
+ # Gemini 3.7 Flash ($0.75/$3.75) — Macro lens
26
+ # - fast: Claude Haiku 4.5 ($1/$5) — ticker resolution, event scans
27
  LLM_MODEL = os.environ.get("LLM_MODEL", "claude-sonnet-5")
28
+ LLM_MODEL_TOP = os.environ.get("LLM_MODEL_TOP", "claude-opus-5")
29
+ LLM_MODEL_FAST = os.environ.get("LLM_MODEL_FAST", "claude-haiku-4-5")
30
+ GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-3.7-flash")
31
+ OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5.6-sol")
32
+ _THINKING_HEADROOM = 6000 # Opus 5 thinks by default; thinking tokens count against
33
+ # max_tokens, so top-tier calls get headroom or JSON truncates
34
 
35
  # Prompt-injection guard + hard rails, prepended to every persona system prompt.
36
  RAILS = (
 
42
  )
43
 
44
 
45
+ def _call_anthropic(system: str, user: str, max_tokens: int, tier: str = "standard") -> str | None:
46
  key = os.environ.get("ANTHROPIC_API_KEY")
47
  if not key:
48
  return None
49
+ model = {"top": LLM_MODEL_TOP, "fast": LLM_MODEL_FAST}.get(tier, LLM_MODEL)
50
+ if tier == "top":
51
+ max_tokens += _THINKING_HEADROOM
52
  # NOTE: Claude 5 models reject the `temperature` param — determinism is enforced by the
53
  # deterministic validator that gates every action, not by sampling settings.
54
+ body = {"model": model, "max_tokens": max_tokens,
55
  "system": system, "messages": [{"role": "user", "content": user}]}
56
  req = urllib.request.Request(
57
  "https://api.anthropic.com/v1/messages", data=json.dumps(body).encode(),
 
59
  "content-type": "application/json"})
60
  for _ in range(3):
61
  try:
62
+ with urllib.request.urlopen(req, timeout=300 if tier == "top" else 150,
63
+ context=CTX) as r:
64
  out = json.load(r)
65
  return "".join(b.get("text", "") for b in out.get("content", []))
66
  except Exception as e:
 
68
  return None
69
 
70
 
71
+ def _call_gemini(system: str, user: str, max_tokens: int, tier: str = "standard") -> str | None:
72
  key = os.environ.get("GEMINI_API_KEY")
73
  if not key:
74
  return None
 
89
  return None
90
 
91
 
92
+ def _call_openai(system: str, user: str, max_tokens: int, tier: str = "standard") -> str | None:
93
  key = os.environ.get("OPENAI_API_KEY")
94
  if not key:
95
  return None
 
113
 
114
 
115
  def call_llm(system: str, user: str, max_tokens: int = 2000,
116
+ prefer: str | None = None, tier: str = "standard") -> str | None:
117
  """Try `prefer`'s provider first (persona-level model diversity — a committee of
118
  different models gives genuinely independent opinions), then cascade through the
119
+ rest so a missing key or outage never silences a persona. `tier` selects the model
120
+ class by task demand: "top" for synthesis/judgment, "fast" for mechanical utilities."""
121
  order: list[str] = list(_DEFAULT_ORDER)
122
  if prefer in _PROVIDERS:
123
  order.remove(prefer)
124
  order.insert(0, prefer)
125
  for name in order:
126
+ text = _PROVIDERS[name](system, user, max_tokens, tier)
127
  if text:
128
  return text
129
  return None
src/personas.py CHANGED
@@ -225,7 +225,8 @@ def damodaran_assumptions(holdings: list[dict]) -> dict:
225
  chunk = lines[i:i + 6]
226
  user = "COMPANIES:\n" + "\n".join(chunk) + "\n\nJSON only, every ticker."
227
  for attempt in range(2):
228
- got = parse_json(call_llm(_DAMODARAN_SYSTEM, user, max_tokens=500 + 250 * len(chunk)))
 
229
  if isinstance(got, dict) and got:
230
  out.update(got)
231
  break
@@ -235,7 +236,8 @@ def damodaran_assumptions(holdings: list[dict]) -> dict:
235
 
236
  def _call_and_parse(system: str, payload: dict, max_tokens: int, label: str) -> dict | None:
237
  for attempt in range(2):
238
- out = parse_json(call_llm(system, json.dumps(payload, default=str), max_tokens=max_tokens))
 
239
  if isinstance(out, dict) and out:
240
  return out
241
  print(f" {label} reply unparseable (attempt {attempt + 1})", flush=True)
 
225
  chunk = lines[i:i + 6]
226
  user = "COMPANIES:\n" + "\n".join(chunk) + "\n\nJSON only, every ticker."
227
  for attempt in range(2):
228
+ got = parse_json(call_llm(_DAMODARAN_SYSTEM, user, max_tokens=500 + 250 * len(chunk),
229
+ tier="top"))
230
  if isinstance(got, dict) and got:
231
  out.update(got)
232
  break
 
236
 
237
  def _call_and_parse(system: str, payload: dict, max_tokens: int, label: str) -> dict | None:
238
  for attempt in range(2):
239
+ out = parse_json(call_llm(system, json.dumps(payload, default=str),
240
+ max_tokens=max_tokens, tier="top"))
241
  if isinstance(out, dict) and out:
242
  return out
243
  print(f" {label} reply unparseable (attempt {attempt + 1})", flush=True)
src/portfolio.py CHANGED
@@ -43,7 +43,8 @@ def resolve_symbol(query: str) -> dict:
43
  if d:
44
  return {"symbol": qU, "name": d.get("name"), "price": d["price"], "data": d}
45
  # not a live ticker — treat as a company name/alias
46
- out = parse_json(call_llm(_RESOLVER_SYSTEM, f"Company reference: {q!r}", max_tokens=120))
 
47
  t = str((out or {}).get("ticker") or "").strip().upper()
48
  if t and _TICKER_RE.fullmatch(t):
49
  d = _quote_exists(t)
 
43
  if d:
44
  return {"symbol": qU, "name": d.get("name"), "price": d["price"], "data": d}
45
  # not a live ticker — treat as a company name/alias
46
+ out = parse_json(call_llm(_RESOLVER_SYSTEM, f"Company reference: {q!r}",
47
+ max_tokens=120, tier="fast"))
48
  t = str((out or {}).get("ticker") or "").strip().upper()
49
  if t and _TICKER_RE.fullmatch(t):
50
  d = _quote_exists(t)
src/revival.py CHANGED
@@ -192,7 +192,8 @@ def narrative(plan: dict) -> dict | None:
192
  return None
193
  import json
194
  for _ in range(2):
195
- out = parse_json(call_llm(_PLAN_SYSTEM, json.dumps(plan, default=str), max_tokens=1200))
 
196
  if isinstance(out, dict) and out.get("moves"):
197
  return out
198
  return None
 
192
  return None
193
  import json
194
  for _ in range(2):
195
+ out = parse_json(call_llm(_PLAN_SYSTEM, json.dumps(plan, default=str),
196
+ max_tokens=1200, tier="top"))
197
  if isinstance(out, dict) and out.get("moves"):
198
  return out
199
  return None