chore: auto-commit from dashboard
This commit is contained in:
@@ -53,9 +53,9 @@
|
|||||||
"report_on": "changes",
|
"report_on": "changes",
|
||||||
"timeout": 180,
|
"timeout": 180,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"last_run": "2026-05-26T03:00:00.001517+00:00",
|
"last_run": "2026-05-27T03:00:00.002190+00:00",
|
||||||
"last_status": "ok",
|
"last_status": "ok",
|
||||||
"next_run": "2026-05-27T03:00:00+00:00"
|
"next_run": "2026-05-28T03:00:00+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "kb-index-refresh",
|
"name": "kb-index-refresh",
|
||||||
@@ -69,9 +69,9 @@
|
|||||||
"report_on": "never",
|
"report_on": "never",
|
||||||
"timeout": 120,
|
"timeout": 120,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"last_run": "2026-05-26T03:30:00.002200+00:00",
|
"last_run": "2026-05-27T03:30:00.002211+00:00",
|
||||||
"last_status": "ok",
|
"last_status": "ok",
|
||||||
"next_run": "2026-05-27T03:30:00+00:00"
|
"next_run": "2026-05-28T03:30:00+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "archive-tasks-daily",
|
"name": "archive-tasks-daily",
|
||||||
@@ -85,9 +85,9 @@
|
|||||||
"report_on": "changes",
|
"report_on": "changes",
|
||||||
"timeout": 60,
|
"timeout": 60,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"last_run": "2026-05-26T03:00:00.001216+00:00",
|
"last_run": "2026-05-27T03:00:00.001552+00:00",
|
||||||
"last_status": "ok",
|
"last_status": "ok",
|
||||||
"next_run": "2026-05-27T03:00:00+00:00"
|
"next_run": "2026-05-28T03:00:00+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "backup-config",
|
"name": "backup-config",
|
||||||
@@ -101,9 +101,9 @@
|
|||||||
"report_on": "never",
|
"report_on": "never",
|
||||||
"timeout": 120,
|
"timeout": 120,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"last_run": "2026-05-26T02:00:00.001927+00:00",
|
"last_run": "2026-05-27T02:00:00.001994+00:00",
|
||||||
"last_status": "ok",
|
"last_status": "ok",
|
||||||
"next_run": "2026-05-27T02:00:00+00:00"
|
"next_run": "2026-05-28T02:00:00+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "insights-extract",
|
"name": "insights-extract",
|
||||||
@@ -285,8 +285,8 @@
|
|||||||
"Read",
|
"Read",
|
||||||
"Write"
|
"Write"
|
||||||
],
|
],
|
||||||
"last_run": "2026-05-25T23:00:00.002360+00:00",
|
"last_run": "2026-05-26T23:00:00.002049+00:00",
|
||||||
"last_status": "ok",
|
"last_status": "ok",
|
||||||
"next_run": "2026-05-26T23:00:00+00:00"
|
"next_run": "2026-05-27T23:00:00+00:00"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
19
memory/kb/facebook/2026-05-26_513k-views-4-9k-reactions.md
Normal file
19
memory/kb/facebook/2026-05-26_513k-views-4-9k-reactions.md
Normal file
File diff suppressed because one or more lines are too long
@@ -15,6 +15,21 @@
|
|||||||
"video": "",
|
"video": "",
|
||||||
"tldr": "<!-- Completează un rezumat de 2-3 rânduri -->"
|
"tldr": "<!-- Completează un rezumat de 2-3 rânduri -->"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"file": "notes-data/facebook/2026-05-26_513k-views-4-9k-reactions.md",
|
||||||
|
"title": "513K views · 4.9K reactions",
|
||||||
|
"date": "2026-05-26",
|
||||||
|
"tags": [],
|
||||||
|
"domains": [],
|
||||||
|
"types": [
|
||||||
|
"coaching"
|
||||||
|
],
|
||||||
|
"category": "facebook",
|
||||||
|
"project": null,
|
||||||
|
"subdir": null,
|
||||||
|
"video": "",
|
||||||
|
"tldr": "<!-- Completează un rezumat de 2-3 rânduri -->"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"file": "notes-data/youtube/2026-05-25_claude-prompt-caching-token-saving.md",
|
"file": "notes-data/youtube/2026-05-25_claude-prompt-caching-token-saving.md",
|
||||||
"title": "Give Me 10 Mins and I'll Save You Millions of Claude Tokens",
|
"title": "Give Me 10 Mins and I'll Save You Millions of Claude Tokens",
|
||||||
@@ -9623,7 +9638,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"stats": {
|
"stats": {
|
||||||
"total": 555,
|
"total": 556,
|
||||||
"by_domain": {
|
"by_domain": {
|
||||||
"work": 182,
|
"work": 182,
|
||||||
"health": 100,
|
"health": 100,
|
||||||
@@ -9637,7 +9652,7 @@
|
|||||||
"conversations": 0,
|
"conversations": 0,
|
||||||
"emails": 22,
|
"emails": 22,
|
||||||
"exercitii": 4,
|
"exercitii": 4,
|
||||||
"facebook": 7,
|
"facebook": 8,
|
||||||
"health": 6,
|
"health": 6,
|
||||||
"insights": 46,
|
"insights": 46,
|
||||||
"projects": 234,
|
"projects": 234,
|
||||||
|
|||||||
@@ -5,3 +5,5 @@ keyring>=25.0
|
|||||||
keyrings.alt>=5.0
|
keyrings.alt>=5.0
|
||||||
httpx>=0.27
|
httpx>=0.27
|
||||||
pytest>=8.0
|
pytest>=8.0
|
||||||
|
supertonic[serve]>=1.3.1
|
||||||
|
trafilatura>=1.8
|
||||||
|
|||||||
@@ -1104,9 +1104,19 @@ def create_bot(config: Config) -> discord.Client:
|
|||||||
# Only send the final combined response if no intermediates
|
# Only send the final combined response if no intermediates
|
||||||
# were delivered (avoids duplicating content).
|
# were delivered (avoids duplicating content).
|
||||||
if sent_count == 0:
|
if sent_count == 0:
|
||||||
chunks = split_message(response)
|
if response.startswith("__AUDIO__:"):
|
||||||
for chunk in chunks:
|
wav_path = response[len("__AUDIO__:"):]
|
||||||
await message.channel.send(chunk)
|
await message.channel.send(
|
||||||
|
file=discord.File(wav_path, filename="echo-audio.wav")
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
os.unlink(wav_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
chunks = split_message(response)
|
||||||
|
for chunk in chunks:
|
||||||
|
await message.channel.send(chunk)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error processing message from %s", message.author)
|
logger.exception("Error processing message from %s", message.author)
|
||||||
await message.channel.send(
|
await message.channel.send(
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from telegram import (
|
from telegram import (
|
||||||
@@ -10,6 +12,7 @@ from telegram import (
|
|||||||
ForceReply,
|
ForceReply,
|
||||||
InlineKeyboardButton,
|
InlineKeyboardButton,
|
||||||
InlineKeyboardMarkup,
|
InlineKeyboardMarkup,
|
||||||
|
ReactionTypeEmoji,
|
||||||
Update,
|
Update,
|
||||||
)
|
)
|
||||||
from telegram.constants import ChatAction, ChatType
|
from telegram.constants import ChatAction, ChatType
|
||||||
@@ -742,6 +745,40 @@ async def callback_ralph(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
|||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
|
# --- Audio helpers ---
|
||||||
|
|
||||||
|
_AUDIO_PREFIX = "__AUDIO__:"
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_voice_telegram(update: Update, wav_path: str) -> None:
|
||||||
|
"""Convertește WAV→OGG (ffmpeg) și trimite ca voice note Telegram."""
|
||||||
|
ogg_path = wav_path.replace(".wav", ".ogg")
|
||||||
|
try:
|
||||||
|
ffmpeg = "/home/moltbot/bin/ffmpeg"
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
ffmpeg, "-i", wav_path,
|
||||||
|
"-c:a", "libopus", "-b:a", "64k",
|
||||||
|
ogg_path, "-y",
|
||||||
|
],
|
||||||
|
check=True, capture_output=True, timeout=30,
|
||||||
|
)
|
||||||
|
with open(ogg_path, "rb") as f:
|
||||||
|
await update.message.reply_voice(voice=f)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
err = e.stderr.decode(errors="replace")[:200]
|
||||||
|
await update.message.reply_text(f"Conversie audio eșuată: {err}")
|
||||||
|
except Exception as e:
|
||||||
|
await update.message.reply_text(f"Eroare trimitere audio: {e}")
|
||||||
|
finally:
|
||||||
|
for p in [wav_path, ogg_path]:
|
||||||
|
try:
|
||||||
|
if os.path.exists(p):
|
||||||
|
os.unlink(p)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# --- Fast command handlers ---
|
# --- Fast command handlers ---
|
||||||
|
|
||||||
|
|
||||||
@@ -750,8 +787,12 @@ async def _fast_cmd(update: Update, name: str, args: list[str]) -> None:
|
|||||||
await update.message.chat.send_action(ChatAction.TYPING)
|
await update.message.chat.send_action(ChatAction.TYPING)
|
||||||
result = await asyncio.to_thread(fast_dispatch, name, args)
|
result = await asyncio.to_thread(fast_dispatch, name, args)
|
||||||
if result:
|
if result:
|
||||||
for chunk in split_message(result):
|
if result.startswith(_AUDIO_PREFIX):
|
||||||
await update.message.reply_text(chunk)
|
wav_path = result[len(_AUDIO_PREFIX):]
|
||||||
|
await _send_voice_telegram(update, wav_path)
|
||||||
|
else:
|
||||||
|
for chunk in split_message(result):
|
||||||
|
await update.message.reply_text(chunk)
|
||||||
else:
|
else:
|
||||||
await update.message.reply_text(f"Unknown command: /{name}")
|
await update.message.reply_text(f"Unknown command: /{name}")
|
||||||
|
|
||||||
@@ -862,6 +903,11 @@ async def cmd_heartbeat_tg(update: Update, context: ContextTypes.DEFAULT_TYPE) -
|
|||||||
await _fast_cmd(update, "heartbeat", [])
|
await _fast_cmd(update, "heartbeat", [])
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_audio(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
"""/audio [voce] [text|url] — TTS via Supertonic."""
|
||||||
|
await _fast_cmd(update, "audio", list(context.args or []))
|
||||||
|
|
||||||
|
|
||||||
# --- Message handler ---
|
# --- Message handler ---
|
||||||
|
|
||||||
|
|
||||||
@@ -954,6 +1000,16 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Emoji reaction: 👀 = am văzut, procesez
|
||||||
|
try:
|
||||||
|
await context.bot.set_message_reaction(
|
||||||
|
chat_id=chat_id,
|
||||||
|
message_id=message.message_id,
|
||||||
|
reaction=[ReactionTypeEmoji(emoji="👀")],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # Reactions not supported in this chat type — ignore silently
|
||||||
|
|
||||||
# Show typing indicator
|
# Show typing indicator
|
||||||
await context.bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING)
|
await context.bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING)
|
||||||
|
|
||||||
@@ -983,6 +1039,16 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
|||||||
chunks = split_message(response)
|
chunks = split_message(response)
|
||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
await message.reply_text(chunk)
|
await message.reply_text(chunk)
|
||||||
|
|
||||||
|
# Emoji reaction: ✅ = răspuns trimis
|
||||||
|
try:
|
||||||
|
await context.bot.set_message_reaction(
|
||||||
|
chat_id=chat_id,
|
||||||
|
message_id=message.message_id,
|
||||||
|
reaction=[ReactionTypeEmoji(emoji="✅")],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error processing Telegram message from %s", user_id)
|
logger.exception("Error processing Telegram message from %s", user_id)
|
||||||
await message.reply_text("Sorry, something went wrong processing your message.")
|
await message.reply_text("Sorry, something went wrong processing your message.")
|
||||||
@@ -1037,6 +1103,7 @@ def create_telegram_bot(config: Config, token: str) -> Application:
|
|||||||
app.add_handler(CommandHandler("logs", cmd_logs))
|
app.add_handler(CommandHandler("logs", cmd_logs))
|
||||||
app.add_handler(CommandHandler("doctor", cmd_doctor))
|
app.add_handler(CommandHandler("doctor", cmd_doctor))
|
||||||
app.add_handler(CommandHandler("heartbeat", cmd_heartbeat_tg))
|
app.add_handler(CommandHandler("heartbeat", cmd_heartbeat_tg))
|
||||||
|
app.add_handler(CommandHandler("audio", cmd_audio))
|
||||||
|
|
||||||
# Text message handler (must be last)
|
# Text message handler (must be last)
|
||||||
app.add_handler(
|
app.add_handler(
|
||||||
@@ -1068,6 +1135,7 @@ def create_telegram_bot(config: Config, token: str) -> Application:
|
|||||||
BotCommand("logs", "Show log lines"),
|
BotCommand("logs", "Show log lines"),
|
||||||
BotCommand("doctor", "Diagnostics"),
|
BotCommand("doctor", "Diagnostics"),
|
||||||
BotCommand("heartbeat", "Health checks"),
|
BotCommand("heartbeat", "Health checks"),
|
||||||
|
BotCommand("audio", "TTS: text → voice note"),
|
||||||
BotCommand("p", "Ralph: propose new project"),
|
BotCommand("p", "Ralph: propose new project"),
|
||||||
BotCommand("a", "Ralph: approve project for tonight"),
|
BotCommand("a", "Ralph: approve project for tonight"),
|
||||||
BotCommand("l", "Ralph: list projects status"),
|
BotCommand("l", "Ralph: list projects status"),
|
||||||
|
|||||||
@@ -21,6 +21,21 @@ TOOLS_DIR = PROJECT_ROOT / "tools"
|
|||||||
MEMORY_DIR = PROJECT_ROOT / "memory"
|
MEMORY_DIR = PROJECT_ROOT / "memory"
|
||||||
LOGS_DIR = PROJECT_ROOT / "logs"
|
LOGS_DIR = PROJECT_ROOT / "logs"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Thread-local channel context — set by router.py before fast_dispatch calls
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_ctx = threading.local()
|
||||||
|
|
||||||
|
|
||||||
|
def set_channel_context(channel_id: str) -> None:
|
||||||
|
"""Called by router.py before dispatching a fast command."""
|
||||||
|
_ctx.channel_id = channel_id
|
||||||
|
|
||||||
|
|
||||||
|
def _get_ctx_channel() -> str | None:
|
||||||
|
return getattr(_ctx, "channel_id", None)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Git
|
# Git
|
||||||
@@ -673,6 +688,14 @@ Reminders:
|
|||||||
/remind <HH:MM> <text> — Reminder today
|
/remind <HH:MM> <text> — Reminder today
|
||||||
/remind <YYYY-MM-DD> <HH:MM> <text> — Reminder on date
|
/remind <YYYY-MM-DD> <HH:MM> <text> — Reminder on date
|
||||||
|
|
||||||
|
Audio:
|
||||||
|
/audio <text> — TTS pe text
|
||||||
|
/audio <url> — Extrage articol → audio
|
||||||
|
/audio rezumat <url> — Rezumat Claude → audio
|
||||||
|
/audio — Ultimul răspuns Echo → audio
|
||||||
|
/audio M2 [text|url|gol] — Voce specificată (M1-M5, F1-F5)
|
||||||
|
/audio ajutor — Ajutor detaliat
|
||||||
|
|
||||||
Ops:
|
Ops:
|
||||||
/logs [N] — Last N log lines (default 20)
|
/logs [N] — Last N log lines (default 20)
|
||||||
/doctor — System diagnostics
|
/doctor — System diagnostics
|
||||||
@@ -685,6 +708,155 @@ Session:
|
|||||||
/model [name] — Show/change model"""
|
/model [name] — Show/change model"""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Audio / TTS
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_VOICES = {"M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5"}
|
||||||
|
_AUDIO_PREFIX = "__AUDIO__:"
|
||||||
|
_MAX_TTS_CHARS = 3000
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_audio(args: list[str]) -> str:
|
||||||
|
"""TTS via Supertonic. Returnează __AUDIO__:/cale sau text de eroare.
|
||||||
|
|
||||||
|
Sintaxa:
|
||||||
|
/audio → ultimul răspuns Echo → audio
|
||||||
|
/audio <text> → text explicit → audio
|
||||||
|
/audio <url> → extrage text principal din URL → audio
|
||||||
|
/audio rezumat <url> → Claude rezumă URL → audio
|
||||||
|
/audio M2 [text|url|gol] → voce specificată (M1-M5, F1-F5)
|
||||||
|
/audio ajutor → ajutor
|
||||||
|
"""
|
||||||
|
voice = "M1"
|
||||||
|
remaining = list(args)
|
||||||
|
|
||||||
|
# Detectare voce ca prim token
|
||||||
|
if remaining and remaining[0].upper() in _VOICES:
|
||||||
|
voice = remaining[0].upper()
|
||||||
|
remaining = remaining[1:]
|
||||||
|
|
||||||
|
channel_id = _get_ctx_channel()
|
||||||
|
|
||||||
|
# Determinare text sursă
|
||||||
|
text: str | None = None
|
||||||
|
|
||||||
|
if not remaining:
|
||||||
|
from src.last_response_store import get_last
|
||||||
|
text = get_last(channel_id or "")
|
||||||
|
if not text:
|
||||||
|
return "Nu există un răspuns anterior. Folosește: /audio <text>"
|
||||||
|
|
||||||
|
elif len(remaining) == 1 and remaining[0].lower() == "ajutor":
|
||||||
|
return (
|
||||||
|
"🎙️ /audio — Text-to-Speech local (Supertonic)\n\n"
|
||||||
|
" /audio <text> — TTS pe text dat\n"
|
||||||
|
" /audio <url> — extrage articol → audio\n"
|
||||||
|
" /audio rezumat <url> — rezumat Claude → audio\n"
|
||||||
|
" /audio — ultimul răspuns Echo → audio\n"
|
||||||
|
" /audio M2 <...> — voce specifică (M1-M5, F1-F5)\n\n"
|
||||||
|
"Voci: M1 M2 M3 M4 M5 (masculin) · F1 F2 F3 F4 F5 (feminin)"
|
||||||
|
)
|
||||||
|
|
||||||
|
elif (len(remaining) >= 2
|
||||||
|
and remaining[0].lower() == "rezumat"
|
||||||
|
and remaining[1].startswith("http")):
|
||||||
|
url = remaining[1]
|
||||||
|
extracted = _extract_url_text(url)
|
||||||
|
if not extracted:
|
||||||
|
return f"Nu am putut extrage text din URL: {url}"
|
||||||
|
text = _claude_summarize(extracted)
|
||||||
|
if not text:
|
||||||
|
return "Rezumatul a eșuat. Încearcă /audio <url> pentru extragere directă."
|
||||||
|
|
||||||
|
elif len(remaining) == 1 and remaining[0].startswith("http"):
|
||||||
|
url = remaining[0]
|
||||||
|
text = _extract_url_text(url)
|
||||||
|
if not text:
|
||||||
|
return f"Nu am putut extrage text din URL: {url}"
|
||||||
|
|
||||||
|
else:
|
||||||
|
text = " ".join(remaining)
|
||||||
|
|
||||||
|
# Trunchiere text lung
|
||||||
|
if len(text) > _MAX_TTS_CHARS:
|
||||||
|
text = text[:_MAX_TTS_CHARS] + "..."
|
||||||
|
|
||||||
|
# Apel TTS (import direct din tools/tts.py)
|
||||||
|
result = _tts_synthesize(text, voice)
|
||||||
|
if result.get("ok"):
|
||||||
|
return f"{_AUDIO_PREFIX}{result['path']}"
|
||||||
|
return f"TTS error: {result.get('error', 'necunoscut')}"
|
||||||
|
|
||||||
|
|
||||||
|
def _tts_synthesize(text: str, voice: str) -> dict:
|
||||||
|
"""Import tools/tts.py și apelează synthesize()."""
|
||||||
|
import sys as _sys
|
||||||
|
_tools_dir = str(TOOLS_DIR)
|
||||||
|
if _tools_dir not in _sys.path:
|
||||||
|
_sys.path.insert(0, _tools_dir)
|
||||||
|
try:
|
||||||
|
import importlib
|
||||||
|
import tts as _tts_mod
|
||||||
|
# Re-import pentru a prinde modificări la hot-reload
|
||||||
|
importlib.reload(_tts_mod)
|
||||||
|
return _tts_mod.synthesize(text, voice=voice)
|
||||||
|
except ImportError as e:
|
||||||
|
return {"ok": False, "error": f"tools/tts.py nu poate fi importat: {e}"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_url_text(url: str) -> str | None:
|
||||||
|
"""Extrage textul principal dintr-un URL cu trafilatura."""
|
||||||
|
try:
|
||||||
|
import trafilatura
|
||||||
|
downloaded = trafilatura.fetch_url(url)
|
||||||
|
if not downloaded:
|
||||||
|
return None
|
||||||
|
return trafilatura.extract(downloaded)
|
||||||
|
except ImportError:
|
||||||
|
log.warning("trafilatura nu e instalat; folosesc fallback httpx")
|
||||||
|
return _extract_url_text_fallback(url)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Extragere URL eșuată pentru %s: %s", url, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_url_text_fallback(url: str) -> str | None:
|
||||||
|
"""Fallback simplu: fetch + strip HTML."""
|
||||||
|
try:
|
||||||
|
import re
|
||||||
|
import httpx as _httpx
|
||||||
|
resp = _httpx.get(url, timeout=15, follow_redirects=True)
|
||||||
|
resp.raise_for_status()
|
||||||
|
text = re.sub(r"<[^>]+>", " ", resp.text)
|
||||||
|
text = re.sub(r"\s+", " ", text).strip()
|
||||||
|
return text[:5000] if text else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _claude_summarize(text: str) -> str | None:
|
||||||
|
"""Rezumă text cu claude -p (one-shot, fără sesiune)."""
|
||||||
|
prompt = (
|
||||||
|
"Rezumă în maxim 200 de cuvinte, în română, "
|
||||||
|
f"păstrând informațiile cheie:\n\n{text[:5000]}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["claude", "-p", prompt],
|
||||||
|
capture_output=True, text=True, timeout=90,
|
||||||
|
cwd=str(PROJECT_ROOT),
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
return result.stdout.strip()
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Claude summarize eșuat: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Dispatch
|
# Dispatch
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -705,6 +877,7 @@ COMMANDS: dict[str, Callable] = {
|
|||||||
"doctor": cmd_doctor,
|
"doctor": cmd_doctor,
|
||||||
"heartbeat": cmd_heartbeat,
|
"heartbeat": cmd_heartbeat,
|
||||||
"help": cmd_help,
|
"help": cmd_help,
|
||||||
|
"audio": cmd_audio,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
26
src/last_response_store.py
Normal file
26
src/last_response_store.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""Thread-safe in-memory store for the last Claude response per channel.
|
||||||
|
|
||||||
|
Router.py updates this after every Claude response; fast_commands.cmd_audio
|
||||||
|
reads from it when /audio is called without arguments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_store: dict[str, str] = {} # channel_id → last response text
|
||||||
|
|
||||||
|
|
||||||
|
def set_last(channel_id: str, text: str) -> None:
|
||||||
|
"""Store the most recent Claude response for a channel."""
|
||||||
|
if not channel_id or not text:
|
||||||
|
return
|
||||||
|
with _lock:
|
||||||
|
_store[channel_id] = text
|
||||||
|
|
||||||
|
|
||||||
|
def get_last(channel_id: str) -> str | None:
|
||||||
|
"""Return the last stored response for a channel, or None if missing."""
|
||||||
|
if not channel_id:
|
||||||
|
return None
|
||||||
|
with _lock:
|
||||||
|
return _store.get(channel_id)
|
||||||
@@ -9,7 +9,8 @@ from pathlib import Path
|
|||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from src.config import Config
|
from src.config import Config
|
||||||
from src.fast_commands import dispatch as fast_dispatch
|
from src.fast_commands import dispatch as fast_dispatch, set_channel_context
|
||||||
|
from src.last_response_store import set_last as _set_last_response
|
||||||
from src.claude_session import (
|
from src.claude_session import (
|
||||||
send_message,
|
send_message,
|
||||||
clear_session,
|
clear_session,
|
||||||
@@ -137,6 +138,7 @@ def route_message(
|
|||||||
parts = text[1:].split()
|
parts = text[1:].split()
|
||||||
cmd_name = parts[0].lower()
|
cmd_name = parts[0].lower()
|
||||||
cmd_args = parts[1:]
|
cmd_args = parts[1:]
|
||||||
|
set_channel_context(channel_id)
|
||||||
result = fast_dispatch(cmd_name, cmd_args)
|
result = fast_dispatch(cmd_name, cmd_args)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
return result, True
|
return result, True
|
||||||
@@ -154,6 +156,7 @@ def route_message(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
response = send_message(channel_id, text, model=model, on_text=on_text)
|
response = send_message(channel_id, text, model=model, on_text=on_text)
|
||||||
|
_set_last_response(channel_id, response)
|
||||||
return response, False
|
return response, False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("Claude error for channel %s: %s", channel_id, e)
|
log.error("Claude error for channel %s: %s", channel_id, e)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class TestDispatch:
|
|||||||
expected = {
|
expected = {
|
||||||
"commit", "push", "pull", "test", "email", "calendar",
|
"commit", "push", "pull", "test", "email", "calendar",
|
||||||
"note", "jurnal", "search", "kb", "remind", "logs",
|
"note", "jurnal", "search", "kb", "remind", "logs",
|
||||||
"doctor", "heartbeat", "help",
|
"doctor", "heartbeat", "help", "audio",
|
||||||
}
|
}
|
||||||
assert set(COMMANDS.keys()) == expected
|
assert set(COMMANDS.keys()) == expected
|
||||||
|
|
||||||
|
|||||||
95
tools/tts.py
Normal file
95
tools/tts.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Text-to-speech via Supertonic local server.
|
||||||
|
|
||||||
|
CLI:
|
||||||
|
python3 tools/tts.py --text "Salut Marius" [--voice M1] [--lang ro]
|
||||||
|
→ stdout: {"ok": true, "path": "/tmp/echo-tts-xxx.wav", "size_bytes": 12345}
|
||||||
|
→ stdout: {"ok": false, "error": "..."}
|
||||||
|
|
||||||
|
Module:
|
||||||
|
from tools.tts import synthesize
|
||||||
|
result = synthesize("text", voice="M1", lang="ro")
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
SUPERTONIC_URL = "http://127.0.0.1:7788"
|
||||||
|
VOICES = {"M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5"}
|
||||||
|
DEFAULT_VOICE = "M1"
|
||||||
|
DEFAULT_LANG = "ro"
|
||||||
|
|
||||||
|
|
||||||
|
def synthesize(text: str, voice: str = DEFAULT_VOICE, lang: str = DEFAULT_LANG) -> dict:
|
||||||
|
"""Call Supertonic server and save audio to a temp WAV file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"ok": True, "path": "/tmp/echo-tts-xxx.wav", "size_bytes": N}
|
||||||
|
{"ok": False, "error": "mesaj eroare"}
|
||||||
|
"""
|
||||||
|
if not text or not text.strip():
|
||||||
|
return {"ok": False, "error": "Text gol."}
|
||||||
|
|
||||||
|
voice = voice.upper()
|
||||||
|
if voice not in VOICES:
|
||||||
|
voice = DEFAULT_VOICE
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = httpx.post(
|
||||||
|
f"{SUPERTONIC_URL}/v1/audio/speech",
|
||||||
|
json={
|
||||||
|
"model": "supertonic-3",
|
||||||
|
"input": text,
|
||||||
|
"voice": voice,
|
||||||
|
"response_format": "wav",
|
||||||
|
},
|
||||||
|
timeout=60.0,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
except httpx.ConnectError:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": (
|
||||||
|
"Serverul Supertonic nu rulează pe :7788. "
|
||||||
|
"Pornește cu: systemctl --user start supertonic-tts"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
body = e.response.text[:300]
|
||||||
|
# Fallback: dacă lang=ro eșuează, încearcă na (language-agnostic)
|
||||||
|
if lang != "na":
|
||||||
|
return synthesize(text, voice=voice, lang="na")
|
||||||
|
return {"ok": False, "error": f"HTTP {e.response.status_code}: {body}"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
# Salvează în fișier temp
|
||||||
|
try:
|
||||||
|
fd, path = tempfile.mkstemp(prefix="echo-tts-", suffix=".wav")
|
||||||
|
with open(fd, "wb") as f:
|
||||||
|
f.write(resp.content)
|
||||||
|
return {"ok": True, "path": path, "size_bytes": len(resp.content)}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": f"Scriere fișier: {e}"}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description="Supertonic TTS CLI")
|
||||||
|
parser.add_argument("--text", required=True, help="Text de convertit în audio")
|
||||||
|
parser.add_argument(
|
||||||
|
"--voice", default=DEFAULT_VOICE,
|
||||||
|
help="Voce: M1-M5 (masculin) sau F1-F5 (feminin). Default: M1"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--lang", default=DEFAULT_LANG,
|
||||||
|
help="Limbă (ro, en, na). Default: ro. Fallback automat la na dacă ro eșuează."
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
result = synthesize(args.text, voice=args.voice, lang=args.lang)
|
||||||
|
print(json.dumps(result, ensure_ascii=False))
|
||||||
|
sys.exit(0 if result.get("ok") else 1)
|
||||||
Reference in New Issue
Block a user