spacedout-bits Oz commited on
Commit
2a7171f
Β·
1 Parent(s): dd28dca

Rebuild: finance manager chat assistant with HF CSV storage and Telegram bot

Browse files

- app.py: Gradio Blocks with Chat + Ledger tabs, HF OAuth login, streaming responses
- ledger.py: thread-safe CSV ledger with HF Hub dataset persistence (HF_LEDGER_REPO)
- agent.py: finance assistant system prompt, streaming + batch LLM calls, action parsing
- bot.py: Telegram bot running as daemon thread, per-user conversation history
- requirements.txt: huggingface-hub, pandas, python-telegram-bot, python-dotenv

Co-Authored-By: Oz <oz-agent@warp.dev>

Files changed (6) hide show
  1. README.md +24 -5
  2. agent.py +117 -0
  3. app.py +60 -62
  4. bot.py +111 -0
  5. ledger.py +137 -0
  6. requirements.txt +4 -0
README.md CHANGED
@@ -1,8 +1,8 @@
1
  ---
2
- title: Financemanager
3
- emoji: πŸ’¬
4
- colorFrom: yellow
5
- colorTo: purple
6
  sdk: gradio
7
  sdk_version: 6.5.1
8
  app_file: app.py
@@ -12,4 +12,23 @@ hf_oauth_scopes:
12
  - inference-api
13
  ---
14
 
15
- An example chatbot using [Gradio](https://gradio.app), [`huggingface_hub`](https://huggingface.co/docs/huggingface_hub/v0.22.2/en/index), and the [Hugging Face Inference API](https://huggingface.co/docs/api-inference/index).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Finance Manager
3
+ emoji: πŸ’Έ
4
+ colorFrom: green
5
+ colorTo: blue
6
  sdk: gradio
7
  sdk_version: 6.5.1
8
  app_file: app.py
 
12
  - inference-api
13
  ---
14
 
15
+ # πŸ’Έ Finance Manager
16
+
17
+ A personal finance assistant with natural language expense logging, persistent CSV storage on HuggingFace Hub, and a Telegram bot interface.
18
+
19
+ ## Features
20
+
21
+ - **Chat UI** β€” describe expenses in plain English; the AI parses and logs them
22
+ - **Ledger tab** β€” view all entries, refresh on demand
23
+ - **Telegram bot** β€” same assistant available in Telegram
24
+ - **Persistent storage** β€” ledger CSV synced to a private HF dataset repo
25
+
26
+ ## Required Secrets (Space Settings β†’ Repository secrets)
27
+
28
+ | Secret | Purpose |
29
+ |---|---|
30
+ | `HF_TOKEN` | Token with write access to your ledger dataset repo |
31
+ | `HF_LEDGER_REPO` | Dataset repo ID for CSV storage, e.g. `username/finance-ledger` |
32
+ | `TELEGRAM_BOT_TOKEN` | Optional β€” Telegram bot token from @BotFather |
33
+
34
+ Without `HF_TOKEN` + `HF_LEDGER_REPO`, entries are saved to `/tmp` and will not survive a Space restart.
agent.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Finance assistant: LLM-backed expense parsing and ledger actions."""
2
+
3
+ import json
4
+ import re
5
+ import logging
6
+ from datetime import datetime
7
+ from typing import Generator
8
+ from huggingface_hub import InferenceClient
9
+
10
+ from ledger import Ledger
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ MODEL = "openai/gpt-oss-20b"
15
+
16
+ SYSTEM = """\
17
+ You are a personal finance assistant. Help the user log expenses, query spending summaries, and manage their ledger.
18
+
19
+ When the user describes an expense, extract it and include a JSON action block in your response:
20
+ ```json
21
+ {"action": "add", "date": "YYYY-MM-DD", "description": "...", "category": "Food|Transport|Utilities|Entertainment|Health|Shopping|Rent|Other", "amount": 0.00}
22
+ ```
23
+
24
+ When the user wants to undo or delete the last entry:
25
+ ```json
26
+ {"action": "delete_last"}
27
+ ```
28
+
29
+ Use today's date if none is given. Keep replies brief and friendly.
30
+ If the user asks about their spending, use the ledger context below to answer accurately.
31
+ If no ledger action is needed, just respond conversationally β€” no JSON block."""
32
+
33
+
34
+ # ── context & parsing ─────────────────────────────────────────────────────────
35
+
36
+ def _ledger_context(ledger: Ledger) -> str:
37
+ if ledger.df.empty:
38
+ return "Ledger is empty."
39
+ total = ledger.total()
40
+ by_cat = ledger.by_category()
41
+ cat_str = " | ".join(
42
+ f"{k} ${v:.2f}" for k, v in sorted(by_cat.items(), key=lambda x: -x[1])
43
+ )
44
+ recent = ledger.recent(5).to_string(index=False)
45
+ return f"Total: ${total:.2f} | {cat_str}\nRecent entries:\n{recent}"
46
+
47
+
48
+ def _parse_action(text: str) -> dict | None:
49
+ m = re.search(r"```json\s*(\{.*?\})\s*```", text, re.DOTALL)
50
+ if m:
51
+ try:
52
+ return json.loads(m.group(1))
53
+ except json.JSONDecodeError:
54
+ pass
55
+ return None
56
+
57
+
58
+ def _clean(text: str) -> str:
59
+ """Strip JSON action blocks from visible reply."""
60
+ return re.sub(r"```json.*?```", "", text, flags=re.DOTALL).strip()
61
+
62
+
63
+ def _build_messages(message: str, history: list[dict], ledger: Ledger) -> list[dict]:
64
+ system = SYSTEM + "\n\nCurrent ledger:\n" + _ledger_context(ledger)
65
+ return [{"role": "system", "content": system}] + history + [{"role": "user", "content": message}]
66
+
67
+
68
+ # ── actions ───────────────────────────────────────────────────────────────────
69
+
70
+ def execute(action: dict, ledger: Ledger, fallback_desc: str = "") -> str:
71
+ """Run a parsed action against the ledger. Returns a confirmation string."""
72
+ if action.get("action") == "add":
73
+ ok = ledger.add(
74
+ date=action.get("date", datetime.now().strftime("%Y-%m-%d")),
75
+ description=action.get("description", fallback_desc),
76
+ category=action.get("category", "Other"),
77
+ amount=float(action.get("amount", 0)),
78
+ )
79
+ if ok:
80
+ return f"βœ… Logged **{action.get('category')}** β€” ${float(action.get('amount', 0)):.2f}"
81
+ return "❌ Failed to save entry."
82
+
83
+ if action.get("action") == "delete_last":
84
+ return "πŸ—‘οΈ Last entry removed." if ledger.delete_last() else "Nothing to delete."
85
+
86
+ return ""
87
+
88
+
89
+ # ── inference ─────────────────────────────────────────────────────────────────
90
+
91
+ def stream_response(
92
+ message: str, history: list[dict], ledger: Ledger, token: str
93
+ ) -> Generator[tuple[str, dict | None], None, None]:
94
+ """
95
+ Yields (partial_reply, action) tuples.
96
+ action is None on all intermediate yields; populated only on the final yield.
97
+ """
98
+ client = InferenceClient(token=token, model=MODEL)
99
+ messages = _build_messages(message, history, ledger)
100
+
101
+ accumulated = ""
102
+ for chunk in client.chat_completion(messages, max_tokens=512, stream=True, temperature=0.2):
103
+ if chunk.choices and chunk.choices[0].delta.content:
104
+ accumulated += chunk.choices[0].delta.content
105
+ yield _clean(accumulated), None
106
+
107
+ yield _clean(accumulated), _parse_action(accumulated)
108
+
109
+
110
+ def batch_response(
111
+ message: str, history: list[dict], ledger: Ledger, token: str
112
+ ) -> tuple[str, dict | None]:
113
+ """Synchronous single-call variant used by the Telegram bot."""
114
+ client = InferenceClient(token=token, model=MODEL)
115
+ messages = _build_messages(message, history, ledger)
116
+ raw = client.chat_completion(messages, max_tokens=512, temperature=0.2).choices[0].message.content
117
+ return _clean(raw), _parse_action(raw)
app.py CHANGED
@@ -1,68 +1,66 @@
 
 
 
 
1
  import gradio as gr
2
- from huggingface_hub import InferenceClient
3
-
4
-
5
- def respond(
6
- message,
7
- history: list[dict[str, str]],
8
- system_message,
9
- max_tokens,
10
- temperature,
11
- top_p,
12
- hf_token: gr.OAuthToken,
13
- ):
14
- """
15
- For more information on `huggingface_hub` Inference API support, please check the docs: https://huggingface.co/docs/huggingface_hub/v0.22.2/en/guides/inference
16
- """
17
- client = InferenceClient(token=hf_token.token, model="openai/gpt-oss-20b")
18
-
19
- messages = [{"role": "system", "content": system_message}]
20
-
21
- messages.extend(history)
22
-
23
- messages.append({"role": "user", "content": message})
24
-
25
- response = ""
26
-
27
- for message in client.chat_completion(
28
- messages,
29
- max_tokens=max_tokens,
30
- stream=True,
31
- temperature=temperature,
32
- top_p=top_p,
33
- ):
34
- choices = message.choices
35
- token = ""
36
- if len(choices) and choices[0].delta.content:
37
- token = choices[0].delta.content
38
-
39
- response += token
40
- yield response
41
-
42
-
43
- """
44
- For information on how to customize the ChatInterface, peruse the gradio docs: https://www.gradio.app/docs/chatinterface
45
- """
46
- chatbot = gr.ChatInterface(
47
- respond,
48
- additional_inputs=[
49
- gr.Textbox(value="You are a friendly Chatbot.", label="System message"),
50
- gr.Slider(minimum=1, maximum=2048, value=512, step=1, label="Max new tokens"),
51
- gr.Slider(minimum=0.1, maximum=4.0, value=0.7, step=0.1, label="Temperature"),
52
- gr.Slider(
53
- minimum=0.1,
54
- maximum=1.0,
55
- value=0.95,
56
- step=0.05,
57
- label="Top-p (nucleus sampling)",
58
- ),
59
- ],
60
- )
61
-
62
- with gr.Blocks() as demo:
63
  with gr.Sidebar():
64
  gr.LoginButton()
65
- chatbot.render()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
 
68
  if __name__ == "__main__":
 
1
+ """Gradio web UI for the personal finance manager."""
2
+
3
+ import os
4
+ import threading
5
  import gradio as gr
6
+
7
+ import bot
8
+ from ledger import get_ledger
9
+ from agent import stream_response, execute
10
+
11
+ logging_format = "%(asctime)s %(levelname)s %(name)s: %(message)s"
12
+ import logging
13
+ logging.basicConfig(level=logging.INFO, format=logging_format)
14
+
15
+ ledger = get_ledger()
16
+ threading.Thread(target=bot.start, args=(ledger,), daemon=True).start()
17
+
18
+
19
+ # ── chat handler ──────────────────────────────────────────────────────────────
20
+
21
+ def respond(message: str, history: list[dict], hf_token: gr.OAuthToken):
22
+ token = hf_token.token if hf_token else os.getenv("HF_TOKEN", "")
23
+ if not token:
24
+ yield "Please log in with HuggingFace to use the assistant."
25
+ return
26
+
27
+ final_reply, final_action = "", None
28
+ for partial, action in stream_response(message, history, ledger, token):
29
+ final_reply, final_action = partial, action
30
+ yield partial
31
+
32
+ if final_action:
33
+ confirmation = execute(final_action, ledger, message)
34
+ if confirmation:
35
+ yield final_reply + "\n\n" + confirmation
36
+
37
+
38
+ # ── ledger tab helpers ────────────────────────────────────────────────────────
39
+
40
+ def refresh_ledger():
41
+ return ledger.recent(50), f"### πŸ’° Total: ${ledger.total():.2f}"
42
+
43
+
44
+ # ── layout ────────────────────────────────────────────────────────────────────
45
+
46
+ with gr.Blocks(theme=gr.themes.Soft(), title="πŸ’Έ Finance Manager") as demo:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  with gr.Sidebar():
48
  gr.LoginButton()
49
+ gr.Markdown(f"**Storage:** {ledger.status}")
50
+
51
+ with gr.Tabs():
52
+ with gr.Tab("πŸ’¬ Chat"):
53
+ gr.ChatInterface(
54
+ respond,
55
+ type="messages",
56
+ placeholder="e.g. 'Spent $12 on lunch' or 'How much did I spend on food?'",
57
+ )
58
+
59
+ with gr.Tab("πŸ“Š Ledger"):
60
+ refresh_btn = gr.Button("πŸ”„ Refresh", variant="secondary")
61
+ table = gr.Dataframe(value=ledger.recent(50), interactive=False)
62
+ total_md = gr.Markdown(f"### πŸ’° Total: ${ledger.total():.2f}")
63
+ refresh_btn.click(fn=refresh_ledger, outputs=[table, total_md])
64
 
65
 
66
  if __name__ == "__main__":
bot.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Telegram bot β€” runs as a daemon thread sharing the same ledger instance."""
2
+
3
+ import os
4
+ import asyncio
5
+ import logging
6
+ import threading
7
+ from telegram import Update
8
+ from telegram.ext import (
9
+ ApplicationBuilder, CommandHandler, MessageHandler,
10
+ ContextTypes, filters,
11
+ )
12
+
13
+ from ledger import Ledger
14
+ from agent import batch_response, execute
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ MAX_HISTORY = 20 # messages (10 turns) kept per user
19
+
20
+
21
+ # ── handlers ──────────────────────────────────────────────────────────────────
22
+
23
+ async def on_start(update: Update, context: ContextTypes.DEFAULT_TYPE):
24
+ await update.message.reply_text(
25
+ "πŸ‘‹ *Finance Manager*\n\n"
26
+ "Just tell me about your expenses naturally:\n"
27
+ "β€’ _Spent $12 on lunch_\n"
28
+ "β€’ _Paid $1200 rent yesterday_\n"
29
+ "β€’ _Undo_ β€” removes the last entry\n\n"
30
+ "Commands:\n"
31
+ "/summary β€” spending by category\n"
32
+ "/clear β€” reset conversation history",
33
+ parse_mode="Markdown",
34
+ )
35
+
36
+
37
+ async def on_summary(update: Update, context: ContextTypes.DEFAULT_TYPE):
38
+ ledger: Ledger = context.bot_data["ledger"]
39
+ by_cat = ledger.by_category()
40
+ total = ledger.total()
41
+
42
+ if not by_cat:
43
+ await update.message.reply_text("No entries yet. Start logging expenses!")
44
+ return
45
+
46
+ lines = [f"πŸ’° *Total: ${total:.2f}*\n"]
47
+ lines += [
48
+ f"β€’ {cat}: ${amt:.2f}"
49
+ for cat, amt in sorted(by_cat.items(), key=lambda x: -x[1])
50
+ ]
51
+ await update.message.reply_text("\n".join(lines), parse_mode="Markdown")
52
+
53
+
54
+ async def on_clear(update: Update, context: ContextTypes.DEFAULT_TYPE):
55
+ context.user_data["history"] = []
56
+ await update.message.reply_text("Conversation history cleared.")
57
+
58
+
59
+ async def on_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
60
+ ledger: Ledger = context.bot_data["ledger"]
61
+ token = os.getenv("HF_TOKEN", "")
62
+
63
+ if not token:
64
+ await update.message.reply_text("HF_TOKEN is not configured.")
65
+ return
66
+
67
+ history: list[dict] = context.user_data.get("history", [])
68
+ text = update.message.text
69
+
70
+ reply, action = await asyncio.to_thread(batch_response, text, history, ledger, token)
71
+
72
+ if action:
73
+ confirmation = execute(action, ledger, text)
74
+ if confirmation:
75
+ reply += f"\n\n{confirmation}"
76
+
77
+ # Persist last N messages for context
78
+ context.user_data["history"] = (
79
+ history + [
80
+ {"role": "user", "content": text},
81
+ {"role": "assistant", "content": reply},
82
+ ]
83
+ )[-MAX_HISTORY:]
84
+
85
+ await update.message.reply_text(reply, parse_mode="Markdown")
86
+
87
+
88
+ # ── entry point ───────────────────────────────────────────────────────────────
89
+
90
+ def start(ledger: Ledger):
91
+ """Start the Telegram bot in a daemon thread. No-op if token not set."""
92
+ bot_token = os.getenv("TELEGRAM_BOT_TOKEN")
93
+ if not bot_token:
94
+ logger.info("TELEGRAM_BOT_TOKEN not set β€” Telegram bot disabled.")
95
+ return
96
+
97
+ async def _run():
98
+ app = ApplicationBuilder().token(bot_token).build()
99
+ app.bot_data["ledger"] = ledger
100
+ app.add_handler(CommandHandler("start", on_start))
101
+ app.add_handler(CommandHandler("summary", on_summary))
102
+ app.add_handler(CommandHandler("clear", on_clear))
103
+ app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, on_message))
104
+
105
+ logger.info("Telegram bot polling started.")
106
+ async with app:
107
+ await app.start()
108
+ await app.updater.start_polling()
109
+ await asyncio.Event().wait() # run until process exits
110
+
111
+ threading.Thread(target=lambda: asyncio.run(_run()), daemon=True).start()
ledger.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Thread-safe expense ledger with HuggingFace Hub CSV persistence."""
2
+
3
+ import os
4
+ import logging
5
+ import threading
6
+ import pandas as pd
7
+ from pathlib import Path
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ COLUMNS = ["Date", "Description", "Category", "Amount"]
12
+ CSV_NAME = "ledger.csv"
13
+ CACHE_PATH = Path("/tmp/finance_ledger.csv")
14
+
15
+
16
+ class Ledger:
17
+ def __init__(self):
18
+ self._lock = threading.RLock()
19
+ self.token = os.getenv("HF_TOKEN")
20
+ self.repo = os.getenv("HF_LEDGER_REPO")
21
+ self.enabled = bool(self.token and self.repo)
22
+ self.df = self._load()
23
+
24
+ # ── persistence ──────────────────────────────────────────────────────────
25
+
26
+ def _load(self) -> pd.DataFrame:
27
+ if self.enabled:
28
+ try:
29
+ self._ensure_repo()
30
+ from huggingface_hub import hf_hub_download
31
+ path = hf_hub_download(
32
+ self.repo, CSV_NAME, repo_type="dataset",
33
+ token=self.token, local_dir="/tmp",
34
+ )
35
+ return self._read_csv(path)
36
+ except Exception as e:
37
+ logger.warning(f"HF load failed ({e}), falling back to local cache")
38
+
39
+ if CACHE_PATH.exists():
40
+ try:
41
+ return self._read_csv(CACHE_PATH)
42
+ except Exception as e:
43
+ logger.warning(f"Local cache load failed: {e}")
44
+
45
+ return pd.DataFrame(columns=COLUMNS)
46
+
47
+ def _read_csv(self, path) -> pd.DataFrame:
48
+ df = pd.read_csv(path)
49
+ df["Date"] = pd.to_datetime(df["Date"])
50
+ df["Amount"] = pd.to_numeric(df["Amount"])
51
+ return df.sort_values("Date", ascending=False).reset_index(drop=True)
52
+
53
+ def _ensure_repo(self):
54
+ from huggingface_hub import repo_exists, create_repo
55
+ if not repo_exists(self.repo, repo_type="dataset", token=self.token):
56
+ create_repo(self.repo, repo_type="dataset", private=True,
57
+ token=self.token, exist_ok=True)
58
+
59
+ def _persist(self):
60
+ df_copy = self.df.copy()
61
+ if not df_copy.empty:
62
+ df_copy["Date"] = df_copy["Date"].dt.strftime("%Y-%m-%d")
63
+ df_copy.to_csv(CACHE_PATH, index=False)
64
+
65
+ if not self.enabled:
66
+ return
67
+ try:
68
+ from huggingface_hub import upload_file
69
+ upload_file(
70
+ path_or_fileobj=str(CACHE_PATH),
71
+ path_in_repo=CSV_NAME,
72
+ repo_id=self.repo,
73
+ repo_type="dataset",
74
+ token=self.token,
75
+ commit_message="ledger update",
76
+ )
77
+ except Exception as e:
78
+ logger.error(f"HF upload failed: {e}")
79
+
80
+ # ── mutations ─────────────────────────────────────────────────────────────
81
+
82
+ def add(self, date: str, description: str, category: str, amount: float) -> bool:
83
+ with self._lock:
84
+ try:
85
+ row = pd.DataFrame({
86
+ "Date": [pd.to_datetime(date)],
87
+ "Description": [description],
88
+ "Category": [category],
89
+ "Amount": [float(amount)],
90
+ })
91
+ self.df = pd.concat([self.df, row], ignore_index=True)
92
+ self.df = self.df.sort_values("Date", ascending=False).reset_index(drop=True)
93
+ self._persist()
94
+ return True
95
+ except Exception as e:
96
+ logger.error(f"add failed: {e}")
97
+ return False
98
+
99
+ def delete_last(self) -> bool:
100
+ with self._lock:
101
+ if self.df.empty:
102
+ return False
103
+ self.df = self.df.iloc[1:].reset_index(drop=True)
104
+ self._persist()
105
+ return True
106
+
107
+ # ── queries ───────────────────────────────────────────────────────────────
108
+
109
+ def total(self) -> float:
110
+ return float(self.df["Amount"].sum()) if not self.df.empty else 0.0
111
+
112
+ def by_category(self) -> dict[str, float]:
113
+ if self.df.empty:
114
+ return {}
115
+ return self.df.groupby("Category")["Amount"].sum().to_dict()
116
+
117
+ def recent(self, n: int = 50) -> pd.DataFrame:
118
+ df = self.df.head(n).copy()
119
+ if not df.empty:
120
+ df["Date"] = df["Date"].dt.strftime("%Y-%m-%d")
121
+ return df
122
+
123
+ @property
124
+ def status(self) -> str:
125
+ return f"βœ… HF Hub: `{self.repo}`" if self.enabled else "⚠️ Local cache only"
126
+
127
+
128
+ # ── singleton ─────────────────────────────────────────────────────────────────
129
+
130
+ _instance: Ledger | None = None
131
+
132
+
133
+ def get_ledger() -> Ledger:
134
+ global _instance
135
+ if _instance is None:
136
+ _instance = Ledger()
137
+ return _instance
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ huggingface-hub>=0.20.0
2
+ pandas>=2.0.0
3
+ python-telegram-bot>=20.0
4
+ python-dotenv>=1.0.0