bit-atlas / app.py
Bit-Trading-Company's picture
CI deploy local
d8733da verified
Raw
History Blame Contribute Delete
19.7 kB
"""Finance Atlas — a Hugging Face Space by The Bit Trading Company.
An index of finance models on the Hub: what they claim, what they document,
and what nobody has checked.
Architecture, in one paragraph: the visible layer is the design's own markup,
rendered by `src/ui/shell.py` from a plain state dict. Gradio owns the
transport and nothing that is seen -- one `gr.HTML` sink, a hidden textbox the
click bridge writes into, and a hidden button it clicks. Every interaction is
`state -> apply_action -> state -> render`, which keeps the whole UI a pure
function of state and makes it testable without a browser.
All reads come from the dataset repo. There is no Hub API call in the request
path: `atlas.parquet` is loaded once at boot and every filter, sort and lineage
walk is an in-memory operation. That is what keeps this usable on CPU basic.
"""
from __future__ import annotations
import logging
import os
import gradio as gr
from src import atlas, suggest # noqa: F401 - sets the vendor path
from src.atlas import growth
from src.ui import chrome, shell
from bit_ui import nav as bit_nav
from bit_ui import bridge, theme
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s")
log = logging.getLogger("atlas.app")
ON_SPACE = bool(os.environ.get("SPACE_ID"))
BACKTEST_URL = "https://huggingface.co/spaces/Bit-Trading-Company/bit-backtest-lab"
DATASET_URL = f"https://huggingface.co/datasets/{atlas.DATASET_REPO}"
ORG_URL = "https://huggingface.co/Bit-Trading-Company"
# Only the destinations that exist are linked. The rest of the design's nav is
# rendered but inert -- see `shell.sidebar`.
LINKS = {
"Backtest Lab": BACKTEST_URL,
"Dataset": DATASET_URL,
"Hugging Face org": ORG_URL,
}
TAPE_SIZE = 12
TRENDING_SIZE = 5
# How many rows are rendered into the table at once. The design's table is a
# scroll container, not a paginated list, so this is a payload bound rather
# than a UX choice.
#
# Measured over the real index: 300 rows is ~1.1 MB of HTML, which gzips to
# ~35 KB because the design's inline styles repeat heavily (32:1). Rendering
# takes ~5 ms. The count the UI reports is always the true number of matches,
# never this cap -- see `build_view`.
MAX_ROWS = 300
# --------------------------------------------------------------------------
# Boot
# --------------------------------------------------------------------------
# `ATLAS_LOCAL_DIR` points the app at a dataset tree on disk instead of the Hub.
# Used for local development and by the tests; unset in production.
INDEX = atlas.build_index(local_dir=os.environ.get("ATLAS_LOCAL_DIR") or None)
# The navigation is shared config, not code: it lives in a dataset every BTC
# Space reads at boot. Flipping a module from "coming soon" to live is one edit
# there and reaches every Space on its next restart -- no redeploy, no
# submodule bump. `nav.load` falls back to the built-in nav if it cannot read
# it, so an unreachable config never stops the Space from booting.
NAV = bit_nav.load(local_path=os.environ.get("ATLAS_LOCAL_NAV") or None)
# The renderers need to link back to the dataset; carrying it on the index
# keeps the shell from importing app config.
INDEX.dataset_repo = atlas.DATASET_REPO
SUGGESTIONS = suggest.Suggestions(
local_dir=os.environ.get("ATLAS_SUGGEST_DIR", "/tmp/atlas-suggestions"),
)
# --------------------------------------------------------------------------
# Derived view
# --------------------------------------------------------------------------
def tape_rows(index):
"""The header ticker: the most downloaded models, with real change."""
top = sorted(index.rows, key=lambda r: int(r.get("downloads_30d") or 0),
reverse=True)[:TAPE_SIZE]
out = []
for row in top:
series = index.trends.get(row["id"])
out.append({
"id": row["id"],
"downloads_30d": row.get("downloads_30d"),
"series": series,
"change": growth(series),
})
return out
def trending_rows(index):
"""Top real movers. Empty until two snapshots exist, by design."""
scored = []
for model_id, series in index.trends.items():
change = growth(series)
if change is None or change <= 0:
continue
scored.append((model_id, change, series))
scored.sort(key=lambda item: item[1], reverse=True)
return scored[:TRENDING_SIZE]
def palette_groups(index, query: str):
"""Results for the ⌘K palette, ranked and grouped.
Three kinds of thing are searchable, in the order someone is most likely
to want them: a specific model, a filter that narrows the table, and the
other Spaces. Matching uses the same fields the table's own search uses,
so the palette and the table never disagree about what "finbert" means.
"""
from bit_ui import palette as bit_palette
query = (query or "").strip().lower()
labels = index.taxonomy.get("task_labels", {})
assets = index.taxonomy.get("asset_labels", {})
groups = []
# -- models --------------------------------------------------------
if query:
scored = []
for row in index.rows:
model_id = (row.get("id") or "")
lowered = model_id.lower()
position = lowered.find(query)
if position < 0:
if query not in (row.get("author") or "").lower():
continue
position = 50
# Earlier match first, then popularity. An exact prefix on the
# model name beats a big download count on a loose match.
scored.append((position, -int(row.get("downloads_30d") or 0), row))
scored.sort(key=lambda item: (item[0], item[1]))
results = []
for _, _, row in scored[:40]:
results.append(bit_palette.Result(
label=row["id"],
action=f"open:{row['id']}",
sub=labels.get(row.get("task"), row.get("task") or ""),
meta=f"{int(row.get('downloads_30d') or 0):,}",
glyph=shell.task_glyph(row.get("task")),
badge="✓" if row.get("verified") else "",
))
groups.append(bit_palette.Group("Models", results))
# -- filters -------------------------------------------------------
filters = []
for key in index.taxonomy.get("task_chips", ()):
label = labels.get(key, key)
if query and query not in label.lower():
continue
filters.append(bit_palette.Result(
label=f"Task: {label}", action=f"task:{key}",
meta=f"{index.count_by('task', key):,}",
glyph=shell.task_glyph(key)))
for key in index.taxonomy.get("asset_chips", ()):
label = assets.get(key, key)
if query and query not in label.lower():
continue
filters.append(bit_palette.Result(
label=f"Asset: {label}", action=f"asset:{key}",
meta=f"{index.count_by('asset_class', key):,}",
glyph=shell.asset_glyph(key)))
if not query or "verified" in query:
filters.append(bit_palette.Result(
label="Only human-verified models", action="verified:toggle",
meta=f"{index.verified_count:,}", glyph="✓"))
if not query or "graveyard" in query or "unmaintained" in query:
filters.append(bit_palette.Result(
label="Show the graveyard (unmaintained)", action="graveyard:",
meta=f"{index.unmaintained_count:,}", glyph="†"))
if filters:
groups.append(bit_palette.Group("Filters", filters))
# -- other Spaces --------------------------------------------------
spaces = []
for group in NAV.get("groups") or ():
for item in group.get("items") or ():
if item.get("status") != "live" or not item.get("url"):
continue
if item["name"] == "Finance Atlas":
continue
if query and query not in item["name"].lower():
continue
spaces.append(bit_palette.Result(
label=item["name"], href=item["url"], glyph="↗", meta="OPEN"))
if spaces:
groups.append(bit_palette.Group("Go to", spaces))
return groups
def build_view(index, state) -> dict:
"""Everything derived from state, computed once per render.
`matched` is the true number of rows passing the filters; `rows` is the
capped slice actually rendered. Both are carried because the UI must
report the real count -- a table that says "300 shown" when 800 matched
is telling the user their filter is narrower than it is.
"""
matched = index.sorted(index.filtered(state), state.get("sort", "Downloads"))
return {
"rows": matched[:MAX_ROWS],
"matched": len(matched),
"truncated": max(0, len(matched) - MAX_ROWS),
"hidden": index.hidden_by_maintained(state),
"tape": tape_rows(index),
"trending": trending_rows(index),
"links": LINKS,
"nav": NAV,
# Only computed when the palette is actually open -- it scans every row.
"palette_groups": (palette_groups(index, state.get("palette_q"))
if state.get("palette_open") else []),
}
def render(state) -> str:
return shell.page(INDEX, state, build_view(INDEX, state))
# --------------------------------------------------------------------------
# Actions
# --------------------------------------------------------------------------
def _toggle_in(state, key, value, allowed):
"""Add/remove `value` in a list-valued filter, validated against `allowed`.
Actions arrive from the DOM and are user input. An unknown value is
dropped rather than stored, so a hand-crafted click cannot inject a filter
term the UI never offered.
"""
if value not in allowed:
return state
current = list(state.get(key) or ())
if value in current:
current.remove(value)
else:
current.append(value)
state[key] = current
return state
def apply_action(state: dict, raw: str) -> dict:
"""Fold one bridge action into the state. Unknown actions change nothing."""
action = chrome.parse_action(raw)
if action is None or action.is_noop:
return state
state = dict(state)
key, value = action.key, action.value
taxonomy = INDEX.taxonomy
if key == "task":
state = _toggle_in(state, "tasks", value,
set(taxonomy.get("task_chips", ())))
elif key == "asset":
state = _toggle_in(state, "assets", value,
set(taxonomy.get("asset_chips", ())))
elif key == "lic":
state = _toggle_in(state, "lic", value,
set(taxonomy.get("license_buckets", ())))
elif key == "verified":
state["verified_only"] = not state.get("verified_only")
elif key == "maintained":
state["maintained_only"] = not state.get("maintained_only")
elif key == "haseval":
state["has_eval"] = not state.get("has_eval")
elif key == "hasdata":
state["has_data"] = not state.get("has_data")
elif key == "sort":
if value in atlas.SORTS:
state["sort"] = value
elif key == "clear":
state.update(q="", tasks=[], assets=[], lic=[], verified_only=False,
has_eval=False, has_data=False)
elif key == "graveyard":
state["maintained_only"] = False
elif key == "open":
# Only ids actually in the index can open a drawer.
state["sel"] = value if value in INDEX.by_id else None
elif key == "close":
state["sel"] = None
elif key == "q":
state["q"] = value[:120]
elif key == "suggestbox":
state["suggest"] = value[:120]
state["suggest_note"] = ""
state["suggest_ok"] = False
elif key == "suggest":
state = _submit_suggestion(state)
# ---- shared chrome ------------------------------------------------
elif key == "palette":
if value == "open":
# Seed the palette with whatever is already in the search box, so
# ⌘K continues a search rather than discarding it.
state.update(palette_open=True,
palette_q=state.get("palette_q") or state.get("q") or "")
else:
state["palette_open"] = False
elif key == "pq":
state["palette_q"] = value[:120]
elif key == "soon":
if value == "close":
state.update(soon_module="", notify_note="")
elif bit_nav.find(NAV, value):
# Only modules the shared nav actually declares. A hand-crafted
# click cannot invent a product.
state.update(soon_module=value, notify_note="", palette_open=False)
elif key == "nemail":
state["notify_email"] = value[:160]
elif key == "notify":
state = _register_interest(state, value)
elif key == "contact":
if value == "open":
state.update(contact_open=True, contact_note="", contact_ok=False,
palette_open=False, soon_module="")
elif value == "close":
state.update(contact_open=False, contact_form_open=False)
elif value == "form":
state["contact_form_open"] = not state.get("contact_form_open")
elif value == "send":
state = _send_contact(state)
elif key == "topic":
topics = (NAV.get("contact") or {}).get("topics") or []
if value in topics:
state.update(contact_topic=value, contact_note="", contact_ok=False)
elif key == "cname":
state["contact_name"] = value[:120]
elif key == "cemail":
state["contact_email"] = value[:160]
elif key == "cmsg":
state["contact_message"] = value[:4000]
return state
def _register_interest(state: dict, module: str) -> dict:
""""Notify me when this launches" from the coming-soon dialog.
Written to the dataset alongside suggestions. It says RECORDED rather than
"you're on the list", because there is no mailing list yet -- only a file
somebody has to read.
"""
if not bit_nav.find(NAV, module):
return state
email = (state.get("notify_email") or "").strip()
try:
SUGGESTIONS.submit_interest(email, module)
except suggest.SuggestionError as exc:
state["notify_note"] = str(exc).upper()
return state
state["notify_note"] = "RECORDED — WE HAVE NO MAILING LIST YET"
return state
def _send_contact(state: dict) -> dict:
"""Append a contact message to the dataset.
Deliberately not "SENT · TICKET #4821 OPENED" as the design writes it:
there is no ticketing system behind this, and inventing a ticket number
would be the most confidently false thing on the page.
"""
try:
SUGGESTIONS.submit_contact(
name=state.get("contact_name", ""),
email=state.get("contact_email", ""),
topic=state.get("contact_topic")
or ((NAV.get("contact") or {}).get("topics") or ["Other"])[0],
message=state.get("contact_message", ""),
)
except suggest.SuggestionError as exc:
state.update(contact_note=str(exc).upper(), contact_ok=False)
return state
state.update(
contact_note=("RECEIVED" if SUGGESTIONS.syncing
else "RECEIVED (NOT SYNCED)"),
contact_ok=True, contact_message="",
)
return state
def _submit_suggestion(state: dict) -> dict:
try:
model_id = SUGGESTIONS.submit(state.get("suggest", ""))
except suggest.SuggestionError as exc:
state["suggest_note"] = str(exc).upper()
state["suggest_ok"] = False
return state
if model_id in INDEX.by_id:
state["suggest_note"] = "ALREADY INDEXED"
elif SUGGESTIONS.syncing:
state["suggest_note"] = "QUEUED FOR NEXT CRAWL"
else:
# Honest about the difference: accepted locally is not the same as
# committed to the dataset.
state["suggest_note"] = "RECEIVED (NOT SYNCED)"
state["suggest_ok"] = True
state["suggest"] = ""
return state
def on_action(raw, state):
state = apply_action(state or atlas.default_state(), raw or "")
return render(state), state
# --------------------------------------------------------------------------
# App
# --------------------------------------------------------------------------
def build_app() -> gr.Blocks:
with gr.Blocks(
title="Finance Atlas — The Bit Trading Company",
theme=theme.bit_theme(),
# chrome.full_css(), not theme.full_css(): the latter is bit-ui's
# shared CSS only and silently drops this Space's own -- the table
# row styling, the sticky header, the responsive breakpoints.
css=chrome.full_css(),
analytics_enabled=False,
fill_width=True,
) as demo:
state = gr.State(atlas.default_state())
# The one visible component. Everything the user sees is inside it.
view = gr.HTML(render(atlas.default_state()), elem_id="bit-view")
# The bridge's transport. Both must be rendered -- `visible=False`
# removes an element from the DOM entirely, and the delegated listener
# would then be writing into nothing. They are hidden in CSS instead.
action = gr.Textbox("", elem_id=bridge.ACTION_ELEMENT_ID,
label="", show_label=False, container=False,
interactive=True)
trigger = gr.Button("", elem_id=bridge.TRIGGER_ELEMENT_ID)
# Two paths for one click: the textbox's own change event, and the
# hidden button. Gradio always honours a real click on a real Button,
# and the nonce on every action means a duplicate delivery folds to the
# same state rather than acting twice.
action.change(on_action, [action, state], [view, state],
show_progress="hidden")
# `api_name` keeps this reachable from `gradio_client`, which is how CI
# smoke-tests the deployed Space: a green build only proves the image
# started, not that an action still renders a page.
trigger.click(on_action, [action, state], [view, state],
show_progress="hidden", api_name="action")
# Installed via its own `load`, with no outputs: Gradio treats a `js=`
# return value as the output values, so sharing a load that has outputs
# would wipe them. Spaces also drops `gr.Blocks(head=...)`, which is why
# this is the path that actually works once deployed.
demo.load(fn=None, inputs=None, outputs=None, js=bridge.BRIDGE_LOAD_JS)
return demo
demo = build_app()
if __name__ == "__main__":
demo.launch(
server_name="0.0.0.0" if ON_SPACE else "127.0.0.1",
server_port=int(os.environ.get("PORT", 7860)),
show_api=False,
allowed_paths=theme.static_paths(),
# Gradio 5 defaults to SSR. The page then renders server-side and only
# becomes interactive once the client hydrates -- and when hydration
# does not complete, every handler stays unbound: the markup is all
# there, buttons look fine, and nothing does anything. That is exactly
# what happened here, and it is a silent failure with no console error.
# This app renders its own markup and needs only the event wiring, so
# there is nothing for SSR to win and a whole failure mode to avoid.
ssr_mode=False,
)