fix(transcribe): rulare detașată, ca joburile video să nu mai moară la timeout

Două linkuri Facebook trimise pe Discord n-au produs niciun răspuns: scriptul
rula în interiorul turnului de chat, iar yt-dlp + ffmpeg + whisper depășeau
constant DEFAULT_TIMEOUT (300s, src/claude_session.py:37). Procesul era omorât
la mijlocul transcrierii, de cinci ori pe patru încercări.

Efecte în cascadă, toate reparate aici:
- retry-uri oarbe: linkul 1 reprocesat de două ori (notițe duplicate), linkul 2
  niciodată atins
- notițe cu TL;DR gol — arată „gata" în index dar nu sunt
- orfani de 33MB în /tmp (trap EXIT nu rulează la SIGKILL)
- userul nu primea nici rezultat, nici eroare acționabilă

Schimbări:
- --bg (setsid nohup) + --notify <channel_id>; turnul confirmă pornirea, nu
  terminarea. send_text() nou în tools/discord_send_file.py, pentru că un job
  detașat supraviețuiește turnului și nu mai poate raporta prin on_text.
- faster-whisper int8 în locul openai-whisper: 554s audio în 113s (~5x realtime)
  vs. peste 300s. Era deja în requirements.txt; openai-whisper nici măcar nu era
  declarat. cpu_threads pe core-uri fizice, nu logice.
- limba implicită „auto": rularea forțată cu `ro` pe audio englezesc producea
  transcriere halucinată (română inventată amestecată cu CJK)
- .venv/bin/python3 explicit; whisper nu există în python-ul de sistem, scriptul
  mergea doar când era chemat cu venv-ul activat
- metadata yt-dlp pe disc, parsată o dată: descrierile Facebook conțin caractere
  de control care corup variabila de shell, iar `|| echo Unknown` masca eșecul
- titlul sare segmentele de statistici: Facebook emite „830K views · 15K
  reactions | Titlul real | Pagina", iar split('|')[0] producea notițe numite
  2026-08-31_807k-views-15k-reactions.md
- curăță workdir-urile orfane mai vechi de 60 min la pornire

.gitignore: bridge/whatsapp/auth.bak-*/ — backup-urile Baileys conțin creds.json
(chei de sesiune vii), la fel ca auth/ care era deja ignorat.

Verificat end-to-end pe ambele linkuri: titlu corect, limbă en auto-detectată,
notiță + reindex + link, trap curăță /tmp, zero orfani.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188YmDTUAzVvZDVh8JL8xSa
This commit is contained in:
2026-08-31 21:52:30 +00:00
parent 9b01f52dfa
commit cb2eedac86
4 changed files with 210 additions and 70 deletions

3
.gitignore vendored
View File

@@ -34,3 +34,6 @@ approved-tasks.json
dashboard/status.json dashboard/status.json
tools/anaf-monitor/monitor.log tools/anaf-monitor/monitor.log
models/ models/
# Backup-uri de stare Baileys — conțin creds.json (chei de sesiune WhatsApp)
bridge/whatsapp/auth.bak-*/

View File

@@ -1,119 +1,201 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Descarcă un video (Facebook, YouTube etc.), extrage audio, transcrie cu Whisper. # Descarcă un video (Facebook, YouTube etc.), extrage audio, transcrie cu Whisper.
# Usage: ./transcribe_video.sh <URL> [language] #
# ./transcribe_video.sh <URL> [language] --save-kb # Usage: ./transcribe_video.sh <URL> [language] [--save-kb] [--bg] [--notify <channel_id>]
#
# language cod ISO (ro, en, ...) sau "auto" (implicit) pentru detecție automată.
# NU forța limba dacă nu ești sigur: `ro` pe audio englezesc face
# Whisper să halucineze text incoerent (incident 2026-08-31).
# --save-kb scrie notița în memory/kb/<categorie>/ și reindexează.
# --bg rulează detașat și întoarce imediat controlul. Obligatoriu când
# scriptul e chemat dintr-un turn de chat: turnul are timeout de
# 300s (DEFAULT_TIMEOUT, src/claude_session.py) iar un video lung
# îl depășește, procesul e omorât la mijloc și userul nu primește
# nimic. Cu --bg turnul răspunde imediat, jobul continuă singur.
# --notify channel ID Discord unde se raportează finalizarea (util cu --bg).
#
# Exemple: # Exemple:
# ./transcribe_video.sh "https://www.facebook.com/share/v/1EdPt3q2sq/" # ./transcribe_video.sh "https://www.facebook.com/share/v/1EdPt3q2sq/"
# ./transcribe_video.sh "https://www.facebook.com/share/r/1akfPJYvTw/" ro --save-kb # ./transcribe_video.sh "https://www.facebook.com/share/r/1akfPJYvTw/" ro --save-kb
# ./transcribe_video.sh "https://youtu.be/xyz" ro # ./transcribe_video.sh "https://youtu.be/xyz" auto --save-kb --bg --notify 1471916752119009432
set -euo pipefail set -euo pipefail
URL="${1:-}"
LANG="${2:-en}"
SAVE_KB=0
# Parse flags
for arg in "$@"; do
if [[ "$arg" == "--save-kb" ]]; then
SAVE_KB=1
fi
done
WORKDIR="/tmp/transcribe_$$"
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# Whisper trăiește în venv, nu în python-ul de sistem. Scriptul mergea înainte
# doar din noroc, când era chemat dintr-un shell cu venv-ul deja activat.
PY="$PROJECT_ROOT/.venv/bin/python3"
KB_DIR="$PROJECT_ROOT/memory/kb" KB_DIR="$PROJECT_ROOT/memory/kb"
LOG_DIR="$PROJECT_ROOT/logs/transcribe"
URL=""
LANG="auto"
SAVE_KB=0
BG=0
NOTIFY=""
# --- parse argumente (poziționale: URL, apoi limba; restul flag-uri) ---
POSITIONAL=()
while [[ $# -gt 0 ]]; do
case "$1" in
--save-kb) SAVE_KB=1; shift ;;
--bg) BG=1; shift ;;
--notify) NOTIFY="${2:-}"; shift 2 ;;
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
*) POSITIONAL+=("$1"); shift ;;
esac
done
URL="${POSITIONAL[0]:-}"
LANG="${POSITIONAL[1]:-auto}"
if [[ -z "$URL" ]]; then if [[ -z "$URL" ]]; then
echo "Usage: $0 <URL> [language (default: en)] [--save-kb]" echo "Usage: $0 <URL> [language|auto] [--save-kb] [--bg] [--notify <channel_id>]"
exit 1 exit 1
fi fi
export PATH="/home/moltbot/bin:$PATH" export PATH="/home/moltbot/bin:$PATH"
# --- mod background: re-exec detașat, întoarce imediat ---
if [[ "$BG" == "1" ]]; then
mkdir -p "$LOG_DIR"
JOB_ID="$(date +%Y%m%d-%H%M%S)-$$"
JOB_LOG="$LOG_DIR/$JOB_ID.log"
ARGS=("$URL" "$LANG")
[[ "$SAVE_KB" == "1" ]] && ARGS+=(--save-kb)
[[ -n "$NOTIFY" ]] && ARGS+=(--notify "$NOTIFY")
setsid nohup "$0" "${ARGS[@]}" > "$JOB_LOG" 2>&1 &
echo "→ Job pornit în background: $JOB_ID (pid $!)"
echo " Log: $JOB_LOG"
[[ -n "$NOTIFY" ]] && echo " Raportez pe canalul $NOTIFY la final."
exit 0
fi
# --- curăță workdir-uri orfane (trap-ul EXIT nu rulează la SIGKILL) ---
find /tmp -maxdepth 1 -type d -name 'transcribe_*' -mmin +60 -exec rm -rf {} + 2>/dev/null || true
WORKDIR="/tmp/transcribe_$$"
mkdir -p "$WORKDIR" mkdir -p "$WORKDIR"
trap 'rm -rf "$WORKDIR"' EXIT trap 'rm -rf "$WORKDIR"' EXIT
notify() {
[[ -z "$NOTIFY" ]] && return 0
"$PY" "$PROJECT_ROOT/tools/discord_send_file.py" \
--channel "$NOTIFY" --text "$1" >/dev/null 2>&1 || true
}
fail() {
echo "Eroare: $1"
notify "❌ Transcriere eșuată pentru $URL — $1"
exit 1
}
echo "→ Obțin informații video..." echo "→ Obțin informații video..."
INFO_JSON=$(yt-dlp "$URL" --dump-json --no-download -q 2>/dev/null || echo "{}") # JSON-ul merge pe disc, nu printr-o variabilă de shell: descrierile Facebook conțin
TITLE=$(echo "$INFO_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('title','Unknown'))" 2>/dev/null || echo "Unknown") # caractere de control și emoji care corup variabila (zsh: "character not in range"),
# Trunchiază titlul la primul '|' sau la 80 caractere (Facebook posts au titlul = descriere) # iar `|| echo Unknown` din versiunea veche masca eșecul în loc să-l semnaleze.
TITLE_SHORT=$(echo "$TITLE" | python3 -c " yt-dlp "$URL" --dump-json --no-download -q > "$WORKDIR/info.json" 2>/dev/null || echo '{}' > "$WORKDIR/info.json"
import sys
s = sys.stdin.read().strip() # Titlul, creatorul și durata într-o singură trecere.
if '|' in s: # Facebook împachetează titlul ca "830K views · 15K reactions | Titlul real | Pagina | ...";
s = s.split('|')[0].strip() # luarea oarbă a primului segment producea notițe numite "807k-views-15k-reactions"
if len(s) > 80: # (incident 2026-08-31), deci sărim segmentele de statistici și pe cel egal cu numele paginii.
s = s[:77].rstrip() + '...' eval "$("$PY" - "$WORKDIR/info.json" <<'PYMETA'
print(s) import json, re, shlex, sys
")
CREATOR=$(echo "$INFO_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('uploader') or d.get('channel') or '')" 2>/dev/null || echo "") try:
DURATION=$(echo "$INFO_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); s=d.get('duration',0); print(f'{s//60}:{s%60:02d}')" 2>/dev/null || echo "?") d = json.load(open(sys.argv[1], encoding="utf-8"))
except Exception:
d = {}
creator = (d.get("uploader") or d.get("channel") or "").strip()
secs = int(d.get("duration") or 0)
duration = f"{secs // 60}:{secs % 60:02d}" if secs else "?"
raw = (d.get("title") or "Unknown").strip()
STATS = re.compile(r"^[\d.,]+\s*[KMB]?\s*(views|reactions|comments|shares|likes)\b", re.I)
parts = [p.strip() for p in raw.split("|") if p.strip()]
title = next(
(p for p in parts if not STATS.match(p) and p.lower() != creator.lower() and len(p) > 3),
parts[0] if parts else raw,
)
title = " ".join(title.split())
if len(title) > 80:
title = title[:77].rstrip() + "..."
for name, val in (("TITLE_SHORT", title), ("CREATOR", creator), ("DURATION", duration)):
print(f"{name}={shlex.quote(val)}")
PYMETA
)"
echo "→ Descarc video: $TITLE_SHORT..." echo "→ Descarc video: $TITLE_SHORT..."
yt-dlp "$URL" -o "$WORKDIR/video.%(ext)s" --no-playlist -q yt-dlp "$URL" -o "$WORKDIR/video.%(ext)s" --no-playlist -q || fail "descărcarea a eșuat"
VIDEO_FILE=$(ls "$WORKDIR"/video.* 2>/dev/null | head -1) VIDEO_FILE=$(ls "$WORKDIR"/video.* 2>/dev/null | head -1)
if [[ -z "$VIDEO_FILE" ]]; then [[ -z "$VIDEO_FILE" ]] && fail "descărcarea a eșuat (niciun fișier)"
echo "Eroare: descărcarea a eșuat."
exit 1
fi
echo "→ Extrag audio..." echo "→ Extrag audio..."
ffmpeg -i "$VIDEO_FILE" -vn -acodec pcm_s16le -ar 16000 -ac 1 "$WORKDIR/audio.wav" -y -loglevel error ffmpeg -i "$VIDEO_FILE" -vn -acodec pcm_s16le -ar 16000 -ac 1 "$WORKDIR/audio.wav" -y -loglevel error \
|| fail "extragerea audio a eșuat"
echo "→ Transcriu cu Whisper (model: small, limbă: $LANG)..." # cpu_threads = core-uri FIZICE; hyperthread-urile încetinesc int8 compute-bound.
TRANSCRIPT=$(python3 -c " THREADS=$(lscpu -p=CORE 2>/dev/null | grep -v '^#' | sort -u | wc -l)
import whisper [[ -z "$THREADS" || "$THREADS" -lt 1 ]] && THREADS=4
model = whisper.load_model('small')
result = model.transcribe('$WORKDIR/audio.wav', language='$LANG') echo "→ Transcriu cu faster-whisper (model: small, int8, ${THREADS} threads, limbă: $LANG)..."
print(result['text']) # faster-whisper (declarat în requirements.txt) în locul openai-whisper: ~5x realtime
" 2>/dev/null) # pe acest CPU vs. peste 300s pentru 9 minute de audio — exact ce depășea timeout-ul.
"$PY" - "$WORKDIR/audio.wav" "$LANG" "$THREADS" > "$WORKDIR/transcript.txt" 2>"$WORKDIR/whisper.err" <<'PYEOF' || fail "transcrierea a eșuat ($(tail -1 "$WORKDIR/whisper.err" 2>/dev/null))"
import sys
from faster_whisper import WhisperModel
wav, lang, threads = sys.argv[1], sys.argv[2], int(sys.argv[3])
model = WhisperModel("small", device="cpu", compute_type="int8", cpu_threads=threads)
segments, info = model.transcribe(wav, language=None if lang == "auto" else lang, vad_filter=True)
text = " ".join(s.text.strip() for s in segments)
print(text)
print(f"[detected_language={info.language}]", file=sys.stderr)
PYEOF
TRANSCRIPT=$(cat "$WORKDIR/transcript.txt")
DETECTED=$(grep -oP 'detected_language=\K\w+' "$WORKDIR/whisper.err" 2>/dev/null || echo "$LANG")
[[ -z "$TRANSCRIPT" ]] && fail "transcrierea a ieșit goală"
echo "" echo ""
echo "=== $TITLE ===" echo "=== $TITLE_SHORT ==="
echo "$TRANSCRIPT" echo "$TRANSCRIPT"
echo "" echo ""
echo "✓ Transcriere completă." echo "✓ Transcriere completă (limbă: $DETECTED)."
if [[ "$SAVE_KB" == "1" ]]; then if [[ "$SAVE_KB" == "1" ]]; then
DATE=$(date +%Y-%m-%d) DATE=$(date +%Y-%m-%d)
# Slug din titlu scurt: lowercase, fără diacritice, doar alfanumerice și cratime SLUG=$(echo "$TITLE_SHORT" | "$PY" -c "
SLUG=$(echo "$TITLE_SHORT" | python3 -c "
import sys, re, unicodedata import sys, re, unicodedata
s = sys.stdin.read().strip() s = unicodedata.normalize('NFD', sys.stdin.read().strip())
s = unicodedata.normalize('NFD', s) s = ''.join(c for c in s if unicodedata.category(c) != 'Mn').lower()
s = ''.join(c for c in s if unicodedata.category(c) != 'Mn') print(re.sub(r'[^a-z0-9]+', '-', s).strip('-')[:50])
s = s.lower()
s = re.sub(r'[^a-z0-9]+', '-', s)
s = s.strip('-')[:50]
print(s)
") ")
[[ -z "$SLUG" ]] && SLUG="video-$(date +%H%M%S)"
# Detectează categoria din URL
if echo "$URL" | grep -qi "facebook\.com"; then if echo "$URL" | grep -qi "facebook\.com"; then
CATEGORY="facebook" CATEGORY="facebook"; FORMAT="Reel (~${DURATION} min)"
FORMAT="Reel (~${DURATION} min)"
elif echo "$URL" | grep -qi "youtube\.com\|youtu\.be"; then elif echo "$URL" | grep -qi "youtube\.com\|youtu\.be"; then
CATEGORY="youtube" CATEGORY="youtube"; FORMAT="Video (~${DURATION} min)"
FORMAT="Video (~${DURATION} min)"
else else
CATEGORY="media" CATEGORY="media"; FORMAT="Video (~${DURATION} min)"
FORMAT="Video (~${DURATION} min)"
fi fi
NOTE_DIR="$KB_DIR/$CATEGORY" NOTE_DIR="$KB_DIR/$CATEGORY"
mkdir -p "$NOTE_DIR" mkdir -p "$NOTE_DIR"
NOTE_FILE="$NOTE_DIR/${DATE}_${SLUG}.md" NOTE_FILE="$NOTE_DIR/${DATE}_${SLUG}.md"
cat > "$NOTE_FILE" << NOTEEOF cat > "$NOTE_FILE" <<NOTEEOF
# $TITLE_SHORT # $TITLE_SHORT
**Sursa:** $URL **Sursa:** $URL
**Data:** $DATE **Data:** $DATE
**Creator:** $CREATOR **Creator:** $CREATOR
**Format:** $FORMAT **Format:** $FORMAT
**Limbă:** $DETECTED
**Tags:** @coaching **Tags:** @coaching
--- ---
@@ -131,9 +213,13 @@ NOTEEOF
echo "" echo ""
echo "→ Notiță salvată: $NOTE_FILE" echo "→ Notiță salvată: $NOTE_FILE"
echo "→ Reindexez KB..." echo "→ Reindexez KB..."
python3 "$PROJECT_ROOT/tools/update_notes_index.py" "$PY" "$PROJECT_ROOT/tools/update_notes_index.py" >/dev/null
KB_LINK="https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/$CATEGORY/${DATE}_${SLUG}.md"
echo "✓ KB actualizat. Link: /echo/files.html#memory/kb/$CATEGORY/${DATE}_${SLUG}.md" echo "✓ KB actualizat. Link: $KB_LINK"
notify "✅ Transcriere gata: **$TITLE_SHORT** (${DURATION} min, $DETECTED)
$KB_LINK
⚠️ TL;DR-ul e gol — completează-l."
else
notify "✅ Transcriere gata: **$TITLE_SHORT** (${DURATION} min, $DETECTED). Rulat fără --save-kb."
fi fi

View File

@@ -63,7 +63,7 @@ Lecții capturate din corectările lui Marius. Citește acest fișier la începu
**Data:** 2026-04-29 **Data:** 2026-04-29
**Context:** Salvam o notiță din Facebook reel în memory/kb/. Am adăugat manual o intrare în index.json cu schema greșită (`id` + `path` în loc de `file`), ceea ce a blocat notes.html pe "Se încarcă..." cu un TypeError în renderNoteCard. **Context:** Salvam o notiță din Facebook reel în memory/kb/. Am adăugat manual o intrare în index.json cu schema greșită (`id` + `path` în loc de `file`), ceea ce a blocat notes.html pe "Se încarcă..." cu un TypeError în renderNoteCard.
**Greșeala:** Am editat index.json direct, cu o schemă diferită față de ce produce update_notes_index.py. **Greșeala:** Am editat index.json direct, cu o schemă diferită față de ce produce update_notes_index.py.
**Regula:** Niciodată nu scriei manual în `memory/kb/index.json`. Fluxul corect: (1) creezi fișierul `.md` în `memory/kb/<categorie>/`, (2) rulezi `python3 tools/update_notes_index.py`. Dacă ai nevoie să salvezi o notiță din Facebook/video, folosești `scripts/transcribe_video.sh <URL> <lang> --save-kb` care face totul corect. **Regula:** Niciodată nu scriei manual în `memory/kb/index.json`. Fluxul corect: (1) creezi fișierul `.md` în `memory/kb/<categorie>/`, (2) rulezi `python3 tools/update_notes_index.py`. Dacă ai nevoie să salvezi o notiță din Facebook/video, folosești `scripts/transcribe_video.sh <URL> auto --save-kb --bg --notify <channel_id>` care face totul corect (`--bg` e obligatoriu dintr-un turn de chat — vezi lecția despre timeout-ul de 300s).
**Când se aplică:** Orice salvare de notiță în KB (Facebook, YouTube, coaching, insights, orice). Dacă ești tentat să `json.dump` în index.json — stop, rulează scriptul. **Când se aplică:** Orice salvare de notiță în KB (Facebook, YouTube, coaching, insights, orice). Dacă ești tentat să `json.dump` în index.json — stop, rulează scriptul.
## Verifică că modelul/tool-ul numit chiar are capabilitatea ÎNAINTE de a planifica în jurul lui ## Verifică că modelul/tool-ul numit chiar are capabilitatea ÎNAINTE de a planifica în jurul lui
@@ -86,3 +86,20 @@ Lecții capturate din corectările lui Marius. Citește acest fișier la începu
**Greșeala:** Am optimizat pentru „să se audă ceva" fără să întreb ce dimensiune e prioritară pentru user. Vocea clonată E produsul; schimbarea ei e regresia maximă percepută. **Greșeala:** Am optimizat pentru „să se audă ceva" fără să întreb ce dimensiune e prioritară pentru user. Vocea clonată E produsul; schimbarea ei e regresia maximă percepută.
**Regula:** Când un input parțial invalid ajunge la un sistem cu identitate configurată de user (voce, persona, stil), remediază conținutul ca să treacă (transliterare, sanitizare), nu comuta pe altă identitate. Fallback pe alt engine/voce doar la eșec TEHNIC (server picat), nu la eșec de conținut. **Regula:** Când un input parțial invalid ajunge la un sistem cu identitate configurată de user (voce, persona, stil), remediază conținutul ca să treacă (transliterare, sanitizare), nu comuta pe altă identitate. Fallback pe alt engine/voce doar la eșec TEHNIC (server picat), nu la eșec de conținut.
**Când se aplică:** Orice pipeline TTS/persona cu fallback. Implementare: `fold_ro_diacritics` în tools/tts.py + push_text în src/voice/tts_stream.py. **Când se aplică:** Orice pipeline TTS/persona cu fallback. Implementare: `fold_ro_diacritics` în tools/tts.py + push_text în src/voice/tts_stream.py.
## Munca mai lungă de 300s nu are ce căuta într-un turn de chat — rulează detașat și raportează pe canal
**Data:** 2026-08-31
**Context:** Marius a trimis două linkuri de video Facebook pe Discord. Niciunul n-a produs un răspuns. În jurnal: `Claude CLI timed out after 300s`, de cinci ori, pe patru încercări diferite. `scripts/transcribe_video.sh` rula în interiorul turnului, iar yt-dlp + ffmpeg + openai-whisper pe 9 minute de audio depășeau constant `DEFAULT_TIMEOUT` (`src/claude_session.py:37`). Procesul era omorât la mijlocul Whisper-ului.
**Greșeala:** Am tratat o sarcină cu durată nemărginită (descărcare rețea + inferență ML) ca pe un apel sincron într-un turn conversațional. Efectele în cascadă au fost mai rele decât timeout-ul în sine:
- **Retry-uri oarbe pe linkul greșit** — la reîncercare am reprocesat linkul 1 de două ori (două notițe duplicate) și n-am procesat niciodată linkul 2. Când mai multe linkuri eșuează, verifică *care* a eșuat înainte să reiei.
- **Documente pe jumătate** — scriptul scrie template-ul cu TL;DR gol, iar rezumatul îl completez eu *după* ce scriptul întoarce. Procesul omorât ⇒ notițe cu TL;DR gol, care arată „gata" în index dar nu sunt.
- **Orfani pe disc** — `trap ... EXIT` nu rulează la SIGKILL; rămâneau 33MB în `/tmp/transcribe_$$`.
- **Eșec tăcut la nivel de user** — Marius n-a primit nici rezultat, nici eroare acționabilă.
**Regula:** Dacă un pas poate depăși ~2-3 minute, nu-l rula în turn. Pornește-l detașat (`setsid nohup`), întoarce imediat un confirm cu job id + cale de log, și raportează finalizarea pe canal (`tools/discord_send_file.py --text`). Turnul confirmă *pornirea*, nu *terminarea*.
**Corolare descoperite la fix:**
- **Nu forța limba.** Rularea cu `ro` pe audio englezesc a produs transcriere halucinată (română inventată amestecată cu CJK). Implicit e acum `auto`; forțează doar dacă ești sigur.
- **Alege motorul înainte de a ridica timeout-ul.** faster-whisper int8 face 554s de audio în 113s (~5x realtime); openai-whisper depășea 300s pe același fișier. `faster-whisper` era deja în `requirements.txt`, openai-whisper nici măcar nu era declarat. Un motor de 5x a fost fix mai bun decât un timeout mai mare.
- **Folosește `.venv/bin/python3` explicit în scripturi shell.** Scriptul chema `python3` bare, iar `whisper` nu există în python-ul de sistem — mergea doar din noroc, când era chemat cu venv-ul activat.
- **Nu trece JSON mare printr-o variabilă de shell.** Descrierile Facebook conțin caractere de control care corup variabila (`character not in range`); scrie-l pe disc și parsează fișierul o singură dată. `|| echo Unknown` masca eșecul.
- **Titlurile Facebook încep cu statistici,** nu cu titlul: `830K views · 15K reactions | Titlul real | Pagina`. `split('|')[0]` producea notițe numite `2026-08-31_807k-views-15k-reactions.md`. Sari segmentele de statistici și pe cel egal cu numele paginii.
**Când se aplică:** Orice transcriere video/audio, OCR pe fișiere mari, scraping multi-pagină, build sau inferență ML pornită dintr-un mesaj de chat. Implementare: `scripts/transcribe_video.sh` (`--bg`, `--notify`) + `send_text()` în `tools/discord_send_file.py`.

View File

@@ -58,13 +58,47 @@ def send_file(channel_id: str, path: str, content: str | None = None, filename:
return {"ok": False, "error": str(e)} return {"ok": False, "error": str(e)}
def send_text(channel_id: str, content: str) -> dict:
"""Trimite un mesaj text pe canalul Discord dat, via REST API.
De ce există pe lângă `send_file`: joburile lungi rulate detașat (ex.
`scripts/transcribe_video.sh --bg`) supraviețuiesc turnului de chat care
le-a pornit, deci nu mai au cum să raporteze prin `on_text`. Au nevoie de
o cale directă către canal, exact ca attachment-urile.
Returns: {"ok": True, "message_id": "..."} sau {"ok": False, "error": "..."}
"""
token = get_secret("discord_token")
if not token:
return {"ok": False, "error": "discord_token lipsește din keyring (echo-core)."}
try:
with httpx.Client(timeout=30) as client:
resp = client.post(
f"https://discord.com/api/v10/channels/{channel_id}/messages",
headers={"Authorization": f"Bot {token}"},
json={"content": content[:2000]},
)
if resp.status_code not in (200, 201):
return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:300]}"}
return {"ok": True, "message_id": resp.json().get("id")}
except Exception as e:
return {"ok": False, "error": str(e)}
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Trimite un fișier direct pe un canal Discord") parser = argparse.ArgumentParser(description="Trimite un fișier direct pe un canal Discord")
parser.add_argument("--channel", required=True, help="Channel ID Discord") parser.add_argument("--channel", required=True, help="Channel ID Discord")
parser.add_argument("--file", required=True, help="Cale către fișierul de trimis") parser.add_argument("--file", default=None, help="Cale către fișierul de trimis (opțional)")
parser.add_argument("--text", default=None, help="Text opțional care însoțește fișierul") parser.add_argument("--text", default=None, help="Text; singur = mesaj simplu, cu --file = caption")
args = parser.parse_args() args = parser.parse_args()
result = send_file(args.channel, args.file, content=args.text) if not args.file and not args.text:
parser.error("dă cel puțin --file sau --text")
if args.file:
result = send_file(args.channel, args.file, content=args.text)
else:
result = send_text(args.channel, args.text)
print(result) print(result)
sys.exit(0 if result.get("ok") else 1) sys.exit(0 if result.get("ok") else 1)