chore: auto-commit from dashboard

This commit is contained in:
2026-08-22 08:40:56 +00:00
parent 4d2dbfb8a7
commit fe500a7227
35 changed files with 1372 additions and 144 deletions

View File

@@ -148,6 +148,15 @@ def create_bot(config: Config) -> discord.Client:
"**Reminders**",
"`/remind <time> <text> [date]` — Create reminder",
"",
"**Financiar (roa2web)**",
"`/sold [firma]` — Sold casă + bancă",
"`/trezorerie [firma]` — Detaliu pe conturi",
"`/facturi [firma]` — Facturi neîncasate (top sold)",
"`/firme [nume]` — Listă firme roa2web",
"",
"**Vremea**",
"`/vremea [oras]` — Vremea curentă (implicit Constanța)",
"",
"**Git**",
"`/commit [message]` — Commit all changes",
"`/push` — Push to remote",
@@ -564,6 +573,50 @@ def create_bot(config: Config) -> discord.Client:
result = await asyncio.to_thread(fast_dispatch, "remind", args)
await interaction.followup.send(result, ephemeral=True)
# --- Financiar (roa2web) ---
@tree.command(name="sold", description="Sold casă + bancă (roa2web)")
@app_commands.describe(firma="Cod sau nume firmă (implicit: Romfast)")
async def sold_cmd(interaction: discord.Interaction, firma: str | None = None) -> None:
await interaction.response.defer(ephemeral=True)
args = firma.split() if firma else []
result = await asyncio.to_thread(fast_dispatch, "sold", args)
await interaction.followup.send(result, ephemeral=True)
@tree.command(name="trezorerie", description="Trezorerie detaliată pe conturi (roa2web)")
@app_commands.describe(firma="Cod sau nume firmă (implicit: Romfast)")
async def trezorerie_cmd(interaction: discord.Interaction, firma: str | None = None) -> None:
await interaction.response.defer(ephemeral=True)
args = firma.split() if firma else []
result = await asyncio.to_thread(fast_dispatch, "trezorerie", args)
await interaction.followup.send(result, ephemeral=True)
@tree.command(name="facturi", description="Facturi neîncasate, top după sold (roa2web)")
@app_commands.describe(firma="Cod sau nume firmă (implicit: Romfast)")
async def facturi_cmd(interaction: discord.Interaction, firma: str | None = None) -> None:
await interaction.response.defer(ephemeral=True)
args = firma.split() if firma else []
result = await asyncio.to_thread(fast_dispatch, "facturi", args)
await interaction.followup.send(result, ephemeral=True)
@tree.command(name="firme", description="Listă firme roa2web, opțional filtrate după nume")
@app_commands.describe(nume="Filtru după nume (opțional)")
async def firme_cmd(interaction: discord.Interaction, nume: str | None = None) -> None:
await interaction.response.defer()
args = nume.split() if nume else []
result = await asyncio.to_thread(fast_dispatch, "firme", args)
await interaction.followup.send(result)
# --- Vremea ---
@tree.command(name="vremea", description="Vremea curentă (implicit Constanța)")
@app_commands.describe(oras="Oraș (opțional, implicit Constanța)")
async def vremea_cmd(interaction: discord.Interaction, oras: str | None = None) -> None:
await interaction.response.defer()
args = oras.split() if oras else []
result = await asyncio.to_thread(fast_dispatch, "vremea", args)
await interaction.followup.send(result)
# --- Git commands ---
@tree.command(name="commit", description="Git commit all changes")
@@ -776,9 +829,9 @@ def create_bot(config: Config) -> discord.Client:
if cost > 0:
tokens_line += f" | ${cost:.4f}"
# Context window usage
# Context window usage (Sonnet/Opus: 1M, Haiku: 200K)
ctx = session.get("context_tokens", 0)
max_ctx = 200_000
max_ctx = 200_000 if model == "haiku" else 1_000_000
pct = round(ctx / max_ctx * 100) if ctx else 0
context_line = f"Context: {_fmt_tokens(ctx)}/{_fmt_tokens(max_ctx)} ({pct}%)"
@@ -1155,10 +1208,13 @@ def create_bot(config: Config) -> discord.Client:
@client.event
async def on_ready() -> None:
# Sync to each guild instantly, then global (global can take up to 1h)
# Sync guild-scoped copies instantly, then clear+sync the global
# scope so it stays empty — leaving both registered makes every
# command show up twice in Discord's autocomplete UI.
for guild in client.guilds:
tree.copy_global_to(guild=guild)
await tree.sync(guild=guild)
tree.clear_commands(guild=None)
await tree.sync()
scheduler = getattr(client, "scheduler", None)
if scheduler is not None:

View File

@@ -14,6 +14,8 @@ from datetime import datetime, date, timedelta
from pathlib import Path
from typing import Callable
import requests
log = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).resolve().parent.parent
@@ -559,6 +561,179 @@ def cmd_remind(args: list[str]) -> str:
sys.path.remove(str(TOOLS_DIR))
# ---------------------------------------------------------------------------
# Financiar (roa2web)
# ---------------------------------------------------------------------------
def _roa2web():
from tools.roa2web_client import ROA2WebClient, resolve_company, ROA2WebError
return ROA2WebClient(), resolve_company, ROA2WebError
def _fmt_lei(n: float) -> str:
"""Rotunjit la sute, fără zecimale — convenție stabilită cu Marius."""
n = round(float(n) / 100) * 100
return f"{n:,.0f}".replace(",", ".")
_RO_MONTHS = [
"", "ianuarie", "februarie", "martie", "aprilie", "mai", "iunie",
"iulie", "august", "septembrie", "octombrie", "noiembrie", "decembrie",
]
def _company_name(client, company: str) -> str:
for c in client.companies():
if str(c["id_firma"]) == str(company):
return c["name"]
return f"firma {company}"
def _accounting_period_label(client, company: str) -> str:
"""Luna contabilă curentă (an/lună) — dedusă din /reports/invoices."""
try:
period = client.get("/reports/invoices", {"company": company, "page_size": 1}).get("accounting_period") or {}
except Exception:
return ""
luna, an = period.get("luna"), period.get("an")
if not luna or not an:
return ""
return f"{_RO_MONTHS[luna]} {an}"
def _header(name: str, period: str) -> str:
return f"{name} — {period}" if period else name
def cmd_sold(args: list[str]) -> str:
"""Sold casă + bancă (roa2web). Args: [firmă] (cod sau nume parțial)."""
client, resolve_company, ROA2WebError = _roa2web()
try:
company = resolve_company(client, " ".join(args) if args else None)
data = client.dashboard_summary(company=company)
header = _header(_company_name(client, company), _accounting_period_label(client, company))
except ROA2WebError as e:
return f"Eroare roa2web: {e}"
accounts = data.get("treasury_accounts", [])
casa = sum(a["sold"] for a in accounts if a.get("cont") == "5311")
banca = sum(a["sold"] for a in accounts if a.get("cont") == "5121")
return f"{header}\nSold casă: ~{_fmt_lei(casa)} lei\nSold bancă: ~{_fmt_lei(banca)} lei"
def cmd_trezorerie(args: list[str]) -> str:
"""Trezorerie detaliată pe conturi (roa2web). Args: [firmă]."""
client, resolve_company, ROA2WebError = _roa2web()
try:
company = resolve_company(client, " ".join(args) if args else None)
data = client.dashboard_summary(company=company)
header = _header(_company_name(client, company), _accounting_period_label(client, company))
except ROA2WebError as e:
return f"Eroare roa2web: {e}"
accounts = data.get("treasury_accounts", [])
if not accounts:
return f"{header}\nNiciun cont de trezorerie găsit."
lines = [f" {a.get('nume_banca') or a.get('nume_cont', '?')}: ~{_fmt_lei(a['sold'])} lei" for a in accounts]
totals = data.get("treasury_totals_by_currency", {})
total_line = ", ".join(f"~{_fmt_lei(v)} {cur}" for cur, v in totals.items()) or "n/a"
return f"{header}\nTrezorerie:\n" + "\n".join(lines) + f"\nTotal: {total_line}"
def cmd_facturi(args: list[str]) -> str:
"""Facturi neîncasate, top după sold (roa2web). Args: [firmă]."""
client, resolve_company, ROA2WebError = _roa2web()
try:
company = resolve_company(client, " ".join(args) if args else None)
data = client.get("/reports/invoices", {"company": company})
name = _company_name(client, company)
except ROA2WebError as e:
return f"Eroare roa2web: {e}"
period = data.get("accounting_period") or {}
luna, an = period.get("luna"), period.get("an")
period_label = f"{_RO_MONTHS[luna]} {an}" if luna and an else ""
header = _header(name, period_label)
invoices = data.get("invoices", [])
total_count = data.get("total_count", len(invoices))
total_sold = data.get("total_sold_all", sum(float(i.get("soldfinal", 0)) for i in invoices))
summary_line = f"Facturi neîncasate: {total_count}, sold total ~{_fmt_lei(total_sold)} lei"
if not invoices:
return f"{header}\n{summary_line}"
top = sorted(invoices, key=lambda i: float(i.get("soldfinal", 0)), reverse=True)[:8]
lines = [
f" #{i['nract']} {i['nume']} — ~{_fmt_lei(i['soldfinal'])} lei (scad. {i['datascad']})"
for i in top
]
return f"{header}\n{summary_line}\nTop:\n" + "\n".join(lines)
def cmd_firme(args: list[str]) -> str:
"""Listă firme roa2web, opțional filtrate după nume."""
client, resolve_company, ROA2WebError = _roa2web()
companies = client.companies()
if args:
needle = " ".join(args).lower()
companies = [c for c in companies if needle in c["name"].lower()]
if not companies:
return "Nicio firmă găsită."
lines = [f"{c['id_firma']}\t{c['name']}" for c in companies[:25]]
suffix = f"\n… ({len(companies) - 25} în plus, rafinează căutarea)" if len(companies) > 25 else ""
return "\n".join(lines) + suffix
# ---------------------------------------------------------------------------
# Vremea (Open-Meteo — fără API key)
# ---------------------------------------------------------------------------
_WMO_CODES = {
0: "cer senin", 1: "cer parțial senin", 2: "înnorat parțial", 3: "cer acoperit",
45: "ceață", 48: "ceață cu chiciură",
51: "burniță slabă", 53: "burniță", 55: "burniță intensă",
56: "burniță înghețată", 57: "burniță înghețată intensă",
61: "ploaie slabă", 63: "ploaie", 65: "ploaie puternică",
66: "ploaie înghețată", 67: "ploaie înghețată intensă",
71: "ninsoare slabă", 73: "ninsoare", 75: "ninsoare puternică", 77: "lapoviță",
80: "averse slabe", 81: "averse", 82: "averse puternice",
85: "averse de ninsoare", 86: "averse de ninsoare puternice",
95: "furtună", 96: "furtună cu grindină", 99: "furtună cu grindină puternică",
}
_DEFAULT_CITY = "Constanța"
def cmd_vremea(args: list[str]) -> str:
"""Vremea curentă (Open-Meteo). Args: [oraș] (implicit Constanța)."""
city = " ".join(args).strip() if args else _DEFAULT_CITY
try:
geo = requests.get(
"https://geocoding-api.open-meteo.com/v1/search",
params={"name": city, "count": 1, "language": "ro", "country": "RO"},
timeout=10,
).json()
results = geo.get("results")
if not results:
return f"Nu am găsit orașul '{city}'."
loc = results[0]
name = loc.get("name", city)
wx = requests.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": loc["latitude"],
"longitude": loc["longitude"],
"current": "temperature_2m,weather_code,wind_speed_10m",
"timezone": "Europe/Bucharest",
},
timeout=10,
).json()
cur = wx["current"]
except Exception as e:
return f"Eroare vremea: {e}"
temp = round(cur["temperature_2m"])
wind = round(cur["wind_speed_10m"])
cond = _WMO_CODES.get(cur["weather_code"], "")
return f"{name}: {temp}°C, {cond}, vânt {wind} km/h"
# ---------------------------------------------------------------------------
# Ops & Diagnostics
# ---------------------------------------------------------------------------
@@ -688,6 +863,15 @@ Reminders:
/remind <HH:MM> <text> — Reminder today
/remind <YYYY-MM-DD> <HH:MM> <text> — Reminder on date
Financiar (roa2web):
/sold [firmă] — Sold casă + bancă
/trezorerie [firmă] — Detaliu pe conturi
/facturi [firmă] — Facturi neîncasate (top sold)
/firme [nume] — Listă firme roa2web
Vremea:
/vremea [oraș] — Vremea curentă (implicit Constanța)
Audio:
/audio <text> — TTS pe text
/audio <url> — Extrage articol → audio
@@ -924,6 +1108,17 @@ def _claude_summarize(text: str) -> str | None:
return None
def cmd_testfallback(args: list[str]) -> str:
"""Testează fallback-ul local (Qwen3.5-2B pe LXC 104) fără să fie nevoie de un rate-limit real la Claude."""
from src.router import _local_fallback_reply # local import: evită circular import cu router.py
text = " ".join(args) if args else "Salut! Ce mai faci?"
reply = _local_fallback_reply(text)
if reply is None:
return "Fallback local indisponibil — verifică serviciul llama-qwen35 pe LXC 104 (10.0.20.161:8091)."
return reply
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
@@ -940,11 +1135,17 @@ COMMANDS: dict[str, Callable] = {
"search": cmd_search,
"kb": cmd_kb,
"remind": cmd_remind,
"sold": cmd_sold,
"trezorerie": cmd_trezorerie,
"facturi": cmd_facturi,
"firme": cmd_firme,
"vremea": cmd_vremea,
"logs": cmd_logs,
"doctor": cmd_doctor,
"heartbeat": cmd_heartbeat,
"help": cmd_help,
"audio": cmd_audio,
"testfallback": cmd_testfallback,
}

View File

@@ -9,6 +9,8 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Callable
import requests
from src.config import Config
from src.fast_commands import dispatch as fast_dispatch, set_channel_context
from src.last_response_store import set_last as _set_last_response
@@ -87,6 +89,63 @@ def _get_config() -> Config:
return _config
# Claude CLI rate-limit errors look like:
# "Claude CLI error (exit 1): You've hit your session limit · resets 10:50am (UTC)"
# (also seen for the total subscription limit, not just per-session — same phrasing).
_RATE_LIMIT_RE = re.compile(r"hit your .*limit", re.IGNORECASE)
_LOCAL_FALLBACK_SYSTEM_PROMPT = (
"Ești Echo, asistentul personal al lui Marius, dar rulezi temporar pe un "
"model local mic pentru că Claude a atins limita de rate. Nu ai acces la "
"unelte, memorie sau istoricul conversației — răspunde scurt și direct, "
"doar la mesajul curent, în limba în care a fost scris."
)
_LOCAL_FALLBACK_PREFIX = (
"⚠️ Claude e la limită — răspund temporar pe un model local, mai simplu "
"(fără istoric, fără unelte):\n\n"
)
def _is_rate_limit_error(err: Exception) -> bool:
return bool(_RATE_LIMIT_RE.search(str(err)))
def _local_fallback_reply(text: str) -> str | None:
"""Best-effort reply from the local llama.cpp fallback (LXC 104, Qwen3.5-2B).
Returns None if the fallback itself is unreachable/fails, so the caller
can fall back further to surfacing the original Claude error.
"""
cfg = _get_config().get("local_fallback", {}) or {}
if not cfg.get("enabled", False):
return None
url = cfg.get("url")
if not url:
return None
try:
resp = requests.post(
url,
json={
"messages": [
{"role": "system", "content": _LOCAL_FALLBACK_SYSTEM_PROMPT},
{"role": "user", "content": text},
],
"temperature": 0.3,
"max_tokens": 500,
},
timeout=45,
)
resp.raise_for_status()
content = resp.json()["choices"][0]["message"]["content"].strip()
if not content:
return None
return _LOCAL_FALLBACK_PREFIX + content
except Exception as e: # noqa: BLE001
log.error("Local fallback LLM failed: %s", e)
return None
def route_message(
channel_id: str,
user_id: str,
@@ -218,6 +277,12 @@ def route_message(
return response, False
except Exception as e:
log.error("Claude error for channel %s: %s", channel_id, e)
if _is_rate_limit_error(e):
log.warning("Rate limit detected for channel %s — trying local fallback", channel_id)
fallback = _local_fallback_reply(text)
if fallback is not None:
_set_last_response(channel_id, fallback)
return fallback, False
return f"Error: {e}", False

View File

@@ -418,9 +418,15 @@ class Scheduler:
)
if proc.returncode != 0:
error_msg = proc.stderr[:500] if proc.stderr else "unknown error"
error_msg = proc.stderr[:500].strip() if proc.stderr else ""
if not error_msg and proc.stdout:
try:
error_msg = json.loads(proc.stdout).get("result", "")
except json.JSONDecodeError:
pass
error_msg = (error_msg or proc.stdout)[:500].strip()
raise RuntimeError(
f"Claude CLI error (exit {proc.returncode}): {error_msg}"
f"Claude CLI error (exit {proc.returncode}): {error_msg or 'unknown error'}"
)
data = json.loads(proc.stdout)

View File

@@ -94,6 +94,31 @@ def expand_numbers_ro(text: str) -> str:
return _NUM_TOKEN.sub(_sub, text)
def _decimal_to_en(s: str) -> str:
"""Convert decimal string 'X.Y' to English words ('3.14' -> 'three point one four')."""
int_part, dec_part = s.split('.', 1)
int_words = num2words(int(int_part), lang='en')
dec_words = ' '.join(num2words(int(d), lang='en') for d in dec_part)
return f"{int_words} point {dec_words}"
def expand_numbers_en(text: str) -> str:
"""Expand bare numeric tokens to English words.
Mirrors expand_numbers_ro for the pocket-tts (English-only) path.
Must run after normalize_thousands so Romanian-style grouped
integers ("105.300") read as one magnitude ("one hundred five
thousand three hundred") instead of a decimal ("105.3").
"""
def _sub(match: re.Match) -> str:
token = match.group(1)
if '.' in token:
return _decimal_to_en(token)
return num2words(int(token), lang='en')
return _NUM_TOKEN.sub(_sub, text)
# ---------- Thousands separator ----------
# Romanian uses dot or space as thousands separator: 384.000 / 384 000. The
@@ -268,8 +293,8 @@ def expand_currency(text: str) -> str:
# ---------- Symbols ----------
_SYMBOL_WORDS = {
'ro': {'%': ' la sută', '&': ' și ', '@': ' la ', '°': ' grade'},
'en': {'%': ' percent', '&': ' and ', '@': ' at ', '°': ' degrees'},
'ro': {'%': ' la sută', '&': ' și ', '@': ' la ', '°': ' grade', '~': ' aproximativ '},
'en': {'%': ' percent', '&': ' and ', '@': ' at ', '°': ' degrees', '~': ' about '},
}
@@ -322,11 +347,14 @@ def expand_for_tts(text: str, lang: str = 'ro') -> str:
"Restul l-am scris în chat." suffix from normalize_for_tts() would be
misleading (no live chat mirror exists for that flow).
The RO-specific expansions (abbreviations, thousands, time, currency,
units, numbers-to-words) only make sense for Romanian text — for other
languages (e.g. English text routed to pocket-tts, which is
English-only) they'd inject Romanian words/diacritics into text the
target engine can't speak. Skip them when lang != 'ro'.
The RO-specific expansions (abbreviations, time, currency wording,
units) only make sense for Romanian text — for other languages (e.g.
English text routed to pocket-tts, which is English-only) they'd
inject Romanian words/diacritics into text the target engine can't
speak, so they're skipped when lang != 'ro'. Thousands-grouping and
numbers-to-words still run for lang == 'en', with English wording,
since replies on English turns often carry Romanian-formatted figures
("105.300 lei") that pocket-tts would otherwise misread as decimals.
"""
text = strip_markdown(text)
text = sanitize_punctuation(text)
@@ -337,6 +365,16 @@ def expand_for_tts(text: str, lang: str = 'ro') -> str:
text = expand_currency(text)
text = expand_units(text)
text = expand_numbers_ro(text)
elif lang == 'en':
# RO-specific expansions (abbreviations, time, currency wording,
# units) are skipped here — see docstring above. But bare numbers
# still need expanding: replies to Marius often carry Romanian
# thousands-grouped figures ("105.300 lei") even on English turns,
# and pocket-tts reads the dot as an English decimal point if left
# alone ("one hundred five point three" instead of "one hundred
# five thousand three hundred").
text = normalize_thousands(text)
text = expand_numbers_en(text)
text = expand_symbols(text, lang=lang)
return text.strip()