Upload folder using huggingface_hub
Browse files- README.md +6 -2
- src/ledger.py +1 -1
- src/llm.py +26 -10
- src/personas.py +4 -2
- src/portfolio.py +2 -1
- 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 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 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=
|
|
|
|
| 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),
|
|
|
|
| 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}",
|
|
|
|
| 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),
|
|
|
|
| 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
|