Download apps/api/mbf/app.py from Sandy-sys/finai: direct link, hf CLI and curl.
- Browser
- Download file 11.4 kB
-
https://huggingface.co/spaces/Sandy-sys/finai/resolve/main/apps/api/mbf/app.py
- Command line
-
hf download hf://spaces/Sandy-sys/finai/apps/api/mbf/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Sandy-sys/finai/resolve/main/apps/api/mbf/app.py
11.4 kB
| """FastAPI entrypoint for MBF Insight.""" | |
| from __future__ import annotations | |
| import asyncio | |
| import io | |
| import json | |
| import re | |
| from typing import AsyncIterator | |
| from fastapi import FastAPI, File, HTTPException, UploadFile | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel | |
| from sse_starlette.sse import EventSourceResponse | |
| from .config import get_settings | |
| from .graph.agent import run_agent | |
| from .graph.live_dashboard import build_live_dashboard | |
| from .graph.suggest import get_followups, get_suggestions | |
| from .retrieval.duck import get_con, schema_version, with_writer | |
| app = FastAPI(title="MBF Insight API", version="0.1.0") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=False, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| class AskBody(BaseModel): | |
| question: str | |
| thread_id: str | None = None | |
| class FollowupBody(BaseModel): | |
| question: str | |
| narrative: str | None = "" | |
| component_titles: list[str] | None = None | |
| n: int = 4 | |
| def health() -> dict: | |
| from .keys.pool import get_pool | |
| from .retrieval.duck import get_con | |
| s = get_settings() | |
| try: | |
| n = get_con().execute( | |
| "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='main'" | |
| ).fetchone()[0] | |
| db_ok = True | |
| except Exception as e: # noqa: BLE001 | |
| n = 0 | |
| db_ok = False | |
| pool = get_pool() | |
| from .keys.pool import ROLE_RANGES, Role | |
| role_map = { | |
| r.value: {"keys": list(range(lo, hi + 1)), | |
| "active": sum(1 for st in pool._states if lo <= st.idx <= hi and st.idx not in pool._dead)} | |
| for r, (lo, hi) in ROLE_RANGES.items() | |
| } | |
| return { | |
| "status": "ok", | |
| "duckdb": s.duckdb_path, | |
| "duckdb_ok": db_ok, | |
| "tables": n, | |
| "llm_keys": len(pool._states), | |
| "dead_keys": sorted(pool._dead), | |
| "mode": "groq" if pool.available() else "offline", | |
| "roles": role_map, | |
| } | |
| async def ask(body: AskBody) -> dict: | |
| state = await run_agent(body.question) | |
| return { | |
| "question": body.question, | |
| "mode": state.mode, | |
| "intent": state.intent, | |
| "sql": state.sql, | |
| "row_count": len(state.rows), | |
| "plan": state.plan.model_dump() if state.plan else None, | |
| "trace": state.trace, | |
| "keys_used": state.keys_used, | |
| } | |
| async def ask_stream(body: AskBody) -> EventSourceResponse: | |
| """Real-time pipeline streaming. Emits one SSE event per stage transition. | |
| Event types: | |
| - `stage`: incremental updates (status: running|done|error) | |
| - `final`: terminal payload with full plan + sql + trace + keys_used | |
| """ | |
| queue: asyncio.Queue[dict] = asyncio.Queue() | |
| STOP = object() | |
| async def emit(evt: dict) -> None: | |
| await queue.put(evt) | |
| async def runner() -> None: | |
| try: | |
| state = await run_agent(body.question, emit=emit) | |
| await queue.put({ | |
| "__final__": True, | |
| "question": body.question, | |
| "mode": state.mode, | |
| "intent": state.intent, | |
| "sql": state.sql, | |
| "row_count": len(state.rows), | |
| "plan": state.plan.model_dump() if state.plan else None, | |
| "trace": state.trace, | |
| "keys_used": state.keys_used, | |
| "confidence": state.confidence, | |
| "followups": state.followups, | |
| }) | |
| except Exception as e: # noqa: BLE001 | |
| await queue.put({"__error__": True, "error": str(e)[:300]}) | |
| finally: | |
| await queue.put(STOP) | |
| async def gen() -> AsyncIterator[dict]: | |
| # heartbeat at start so client transitions out of "connecting" | |
| yield {"event": "open", "data": json.dumps({"ok": True})} | |
| task = asyncio.create_task(runner()) | |
| try: | |
| while True: | |
| item = await queue.get() | |
| if item is STOP: | |
| break | |
| if isinstance(item, dict) and item.get("__final__"): | |
| payload = {k: v for k, v in item.items() if k != "__final__"} | |
| yield {"event": "final", "data": json.dumps(payload, default=str)} | |
| elif isinstance(item, dict) and item.get("__error__"): | |
| yield {"event": "error", "data": json.dumps({"error": item["error"]})} | |
| else: | |
| yield {"event": "stage", "data": json.dumps(item, default=str)} | |
| finally: | |
| if not task.done(): | |
| task.cancel() | |
| return EventSourceResponse(gen()) | |
| async def suggest(n: int = 8, seed: str = "default") -> dict: | |
| items = await get_suggestions(seed=seed, n=n) | |
| return {"items": items} | |
| async def followups(body: FollowupBody) -> dict: | |
| items = await get_followups( | |
| question=body.question, | |
| narrative=body.narrative or "", | |
| component_titles=body.component_titles or [], | |
| n=body.n, | |
| ) | |
| return {"items": items} | |
| async def dashboard_live(seed: int | None = None) -> dict: | |
| """Live, ever-changing snapshot of the warehouse β no question required. | |
| The plan adapts to which tables hold data, their shape, and cardinality. | |
| Pass `seed` to get a deterministic layout; omit it for a fresh shuffle. | |
| """ | |
| # CPU-bound profiling, run in default loop thread (DuckDB is sync) | |
| return await asyncio.to_thread(build_live_dashboard, seed) | |
| # βββ Knowledge Base management ββββββββββββββββββββββββββββββββββββββββββ | |
| _TABLE_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,62}$") | |
| def _ident(name: str) -> str: | |
| """Quote a SQL identifier safely (DuckDB).""" | |
| return '"' + name.replace('"', '""') + '"' | |
| def _list_kb_tables() -> list[dict]: | |
| con = get_con() | |
| rows = con.execute( | |
| "SELECT table_name FROM information_schema.tables " | |
| "WHERE table_schema='main' ORDER BY table_name" | |
| ).fetchall() | |
| out: list[dict] = [] | |
| for (t,) in rows: | |
| try: | |
| n = con.execute(f"SELECT COUNT(*) FROM {_ident(t)}").fetchone()[0] | |
| except Exception: | |
| n = None | |
| try: | |
| cols = [r[1] for r in con.execute(f"PRAGMA table_info({_ident(t)})").fetchall()] | |
| except Exception: | |
| cols = [] | |
| out.append({"name": t, "rows": n, "columns": cols}) | |
| return out | |
| def kb_version() -> dict: | |
| return {"version": schema_version()} | |
| async def kb_tables() -> dict: | |
| items = await asyncio.to_thread(_list_kb_tables) | |
| return {"version": schema_version(), "tables": items} | |
| class RenameBody(BaseModel): | |
| old_name: str | |
| new_name: str | |
| async def kb_rename(body: RenameBody) -> dict: | |
| if not _TABLE_NAME_RE.match(body.new_name): | |
| raise HTTPException(status_code=400, detail="Invalid new table name") | |
| if not _TABLE_NAME_RE.match(body.old_name): | |
| raise HTTPException(status_code=400, detail="Invalid current table name") | |
| def _do(w): | |
| w.execute(f"ALTER TABLE {_ident(body.old_name)} RENAME TO {_ident(body.new_name)}") | |
| try: | |
| await asyncio.to_thread(with_writer, _do) | |
| except Exception as e: # noqa: BLE001 | |
| raise HTTPException(status_code=400, detail=str(e)[:300]) from e | |
| return {"ok": True, "version": schema_version()} | |
| async def kb_delete(name: str) -> dict: | |
| if not _TABLE_NAME_RE.match(name): | |
| raise HTTPException(status_code=400, detail="Invalid table name") | |
| def _do(w): | |
| w.execute(f"DROP TABLE IF EXISTS {_ident(name)}") | |
| try: | |
| await asyncio.to_thread(with_writer, _do) | |
| except Exception as e: # noqa: BLE001 | |
| raise HTTPException(status_code=400, detail=str(e)[:300]) from e | |
| return {"ok": True, "version": schema_version()} | |
| async def kb_upload( | |
| file: UploadFile = File(...), | |
| table: str | None = None, | |
| mode: str = "replace", # "replace" | "append" | |
| ) -> dict: | |
| """Upload a CSV file and load it as a DuckDB table. | |
| The table name is derived from `table` (preferred) or the filename stem. | |
| Modes: `replace` (drop+create) or `append` (insert into existing). | |
| """ | |
| raw = await file.read() | |
| if not raw: | |
| raise HTTPException(status_code=400, detail="Empty file") | |
| if len(raw) > 50 * 1024 * 1024: | |
| raise HTTPException(status_code=413, detail="File exceeds 50MB limit") | |
| # Derive a safe table name | |
| candidate = (table or file.filename or "uploaded").rsplit("/", 1)[-1] | |
| candidate = candidate.rsplit(".", 1)[0] | |
| candidate = re.sub(r"[^A-Za-z0-9_]+", "_", candidate).strip("_") | |
| if not candidate or candidate[0].isdigit(): | |
| candidate = f"t_{candidate}" if candidate else "uploaded" | |
| if not _TABLE_NAME_RE.match(candidate): | |
| raise HTTPException(status_code=400, detail="Could not derive a valid table name") | |
| if mode not in {"replace", "append"}: | |
| raise HTTPException(status_code=400, detail="mode must be replace|append") | |
| # Quick sniff: only allow CSV / TSV for now | |
| head = raw[:4].lower() | |
| if head.startswith(b"pk\x03\x04") or head.startswith(b"\xd0\xcf\x11\xe0"): | |
| raise HTTPException(status_code=400, detail="Only CSV/TSV files are supported") | |
| def _do(w): | |
| # Register the bytes as a temporary CSV source via DuckDB's read_csv_auto | |
| # by writing to a temp file (simpler & supports large files). | |
| import tempfile | |
| import os | |
| tmp = tempfile.NamedTemporaryFile(suffix=".csv", delete=False) | |
| try: | |
| tmp.write(raw) | |
| tmp.close() | |
| tmp_path = tmp.name.replace("\\", "/") | |
| if mode == "replace": | |
| w.execute(f"DROP TABLE IF EXISTS {_ident(candidate)}") | |
| w.execute( | |
| f"CREATE TABLE {_ident(candidate)} AS " | |
| f"SELECT * FROM read_csv_auto('{tmp_path}', SAMPLE_SIZE=-1, IGNORE_ERRORS=TRUE)" | |
| ) | |
| else: | |
| w.execute( | |
| f"INSERT INTO {_ident(candidate)} " | |
| f"SELECT * FROM read_csv_auto('{tmp_path}', SAMPLE_SIZE=-1, IGNORE_ERRORS=TRUE)" | |
| ) | |
| n = w.execute(f"SELECT COUNT(*) FROM {_ident(candidate)}").fetchone()[0] | |
| return n | |
| finally: | |
| try: | |
| os.unlink(tmp.name) | |
| except Exception: | |
| pass | |
| try: | |
| rows = await asyncio.to_thread(with_writer, _do) | |
| except HTTPException: | |
| raise | |
| except Exception as e: # noqa: BLE001 | |
| raise HTTPException(status_code=400, detail=f"Load failed: {str(e)[:300]}") from e | |
| return { | |
| "ok": True, | |
| "table": candidate, | |
| "rows": rows, | |
| "mode": mode, | |
| "version": schema_version(), | |
| } | |