Dink0 commited on
Commit
4b3530b
·
verified ·
1 Parent(s): 591af20

Expose delivery failures and prepare continuous hosting

Browse files

Production logs show Medialogia ingestion and DeepSeek classification succeeding while every Telegram delivery times out. This proposal makes delivery health observable, starts AI and Telegram workers independently, retains valid webhooks during incomplete downstream configuration, waits for in-flight workers on shutdown, and provides a Render deployment with a persistent SQLite disk. Eight local regression checks passed. It does not resolve or bypass the Space network timeout; actual Telegram delivery must be verified on the target host. Kept as a proposal to avoid restarting the existing Space and losing its temporary pending queue. Existing secrets are not readable through the Hugging Face API and must be supplied on the new host.

Files changed (6) hide show
  1. .dockerignore +8 -0
  2. .gitignore +7 -0
  3. Dockerfile +1 -0
  4. README.md +43 -2
  5. receiver.py +48 -16
  6. render.yaml +25 -0
.dockerignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ .cache
2
+ .git
3
+ __pycache__
4
+ *.pyc
5
+ .env
6
+ .env.*
7
+ *.sqlite3
8
+ *.sqlite3-*
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ .cache/
2
+ __pycache__/
3
+ *.pyc
4
+ .env
5
+ .env.*
6
+ *.sqlite3
7
+ *.sqlite3-*
Dockerfile CHANGED
@@ -1,6 +1,7 @@
1
  FROM python:3.12-slim
2
  RUN useradd --create-home --uid 1000 appuser
3
  WORKDIR /app
 
4
  COPY requirements-monitor.txt /app/requirements-monitor.txt
5
  RUN pip install --no-cache-dir -r /app/requirements-monitor.txt
6
  COPY --chown=appuser:appuser receiver.py /app/receiver.py
 
1
  FROM python:3.12-slim
2
  RUN useradd --create-home --uid 1000 appuser
3
  WORKDIR /app
4
+ RUN mkdir -p /app/data && chown appuser:appuser /app/data
5
  COPY requirements-monitor.txt /app/requirements-monitor.txt
6
  RUN pip install --no-cache-dir -r /app/requirements-monitor.txt
7
  COPY --chown=appuser:appuser receiver.py /app/receiver.py
README.md CHANGED
@@ -14,8 +14,9 @@ independent DeepSeek classifier for potential topic alerts. Text only; no image,
14
  OCR, subtitle, or original-page fetching. The default model is deepseek-flash
15
  with thinking disabled. A positive result is a signal for a person to assess.
16
 
17
- Set these Space Secrets: WEBHOOK_SECRET, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID,
18
- DEEPSEEK_API_KEY and ALERT_MENTIONS. CLASSIFIER_PROMPT overrides the generic
 
19
  default instructions and belongs in a Secret when it contains internal context.
20
  Never commit credentials or confidential classifier instructions to this repo.
21
 
@@ -37,3 +38,43 @@ or an alert. AI failures remain pending and are retried, never recorded as NO.
37
  Normal posts are sent silently; alerts request a notification, which still
38
  depends on recipients' Telegram settings. Mentions are explicit @usernames.
39
  Post text is split across messages when necessary; its complete text is checked.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  OCR, subtitle, or original-page fetching. The default model is deepseek-flash
15
  with thinking disabled. A positive result is a signal for a person to assess.
16
 
17
+ Set these Space Secrets: WEBHOOK_SECRET, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID
18
+ and DEEPSEEK_API_KEY. ALERT_MENTIONS optionally adds user mentions to alerts.
19
+ CLASSIFIER_PROMPT overrides the generic
20
  default instructions and belongs in a Secret when it contains internal context.
21
  Never commit credentials or confidential classifier instructions to this repo.
22
 
 
38
  Normal posts are sent silently; alerts request a notification, which still
39
  depends on recipients' Telegram settings. Mentions are explicit @usernames.
40
  Post text is split across messages when necessary; its complete text is checked.
41
+
42
+ ## Delivery health and independent workers
43
+
44
+ `/health` now distinguishes successful delivery, an untested Telegram connection,
45
+ delivery errors, missing configuration, and stopped workers. The public response
46
+ contains fixed error categories, never tokens, chat identifiers or post content.
47
+ It is not an end-to-end delivery guarantee. `/live` is a separate process liveness
48
+ check, so a Telegram outage does not restart the process and discard its queue.
49
+
50
+ Telegram forwarding runs whenever its token and chat ID are configured, even if
51
+ DeepSeek is not configured yet. AI processing starts independently when its key
52
+ is available. `ALERT_MENTIONS` is optional. Valid authenticated webhooks are stored
53
+ even while downstream configuration is incomplete, and workers resume delivery
54
+ after configuration is corrected and the service restarted with the same database.
55
+
56
+ ## Continuous hosting on Render
57
+
58
+ The included `render.yaml` describes one paid Docker web service with a 1 GB
59
+ persistent disk at `/app/data`. Check the actual compute and disk prices before
60
+ creating the service. Free services are unsuitable for continuous monitoring.
61
+ Keep exactly one instance. The existing Dockerfile runs as an unprivileged user.
62
+
63
+ Deploy this directory as the repository root. Enter the existing values for
64
+ `WEBHOOK_SECRET`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` and `DEEPSEEK_API_KEY`
65
+ as secret environment variables. Hugging Face does not return stored secret
66
+ values through its API; copy them from your original secure source. Preserve
67
+ `DEEPSEEK_MODEL`, `CLASSIFIER_PROMPT`, `ALERT_MENTIONS` and `TELEGRAM_THREAD_ID`
68
+ if customized. Do not put secrets in source control or Blueprint YAML.
69
+
70
+ Before switching Medialogia, run the authenticated `/diagnostics/WEBHOOK_SECRET`
71
+ checks on the new service and confirm bot membership and chat permissions. Then
72
+ send a test publication through Medialogia and verify its arrival in Telegram.
73
+ The diagnostic itself sends no messages. Update Medialogia to the new HTTPS
74
+ `/medialogia/WEBHOOK_SECRET` URL only when ready to switch. Do not run two active
75
+ webhook destinations for the same monitoring feed unless duplicates are desired.
76
+
77
+ The old Hugging Face queue is in temporary storage and is not automatically
78
+ migrated. Do not rebuild or delete that Space until any required pending
79
+ publications have been recovered or replayed from Medialogia. A persistent disk
80
+ retains the new service's queue across deploys; maintain separate backups.
receiver.py CHANGED
@@ -28,7 +28,7 @@ from urllib.parse import urlsplit
28
  from zoneinfo import ZoneInfo
29
 
30
  LOG = logging.getLogger("monitor")
31
- VERSION = "1.1-diagnostics"
32
  MAX_BYTES = 1024 * 1024
33
  AI_CHUNK = 12000
34
  DEFAULT_PROMPT = """Ты классификатор публикаций для мониторинга нового внешнего вида
@@ -189,8 +189,7 @@ class Config:
189
 
190
  def missing(self):
191
  return [name for name, value in (("TELEGRAM_BOT_TOKEN", self.telegram_token),
192
- ("TELEGRAM_CHAT_ID", self.chat_id), ("DEEPSEEK_API_KEY", self.deepseek_key),
193
- ("ALERT_MENTIONS", self.mentions)) if not value]
194
 
195
 
196
  class Store:
@@ -353,6 +352,7 @@ class Clients:
353
  def __init__(self, config):
354
  self.config, self.last_tg = config, 0.0
355
  self.diagnostics_lock = threading.Lock()
 
356
 
357
  def diagnose_telegram(self):
358
  """Read-only Bot API checks; no publication, queue changes or AI calls."""
@@ -423,10 +423,15 @@ class Clients:
423
  data = {"chat_id": self.config.chat_id, **payload}
424
  if method == "sendMessage" and self.config.thread_id:
425
  data["message_thread_id"] = int(self.config.thread_id)
426
- result = request_json("https://api.telegram.org/bot" + self.config.telegram_token + "/" + method,
427
- data, service="telegram", timeout=35)
428
- if not isinstance(result, dict) or not result.get("ok"):
429
- raise ServiceError("telegram_invalid_response")
 
 
 
 
 
430
  return result.get("result")
431
 
432
 
@@ -509,15 +514,40 @@ class Monitor:
509
  self.stop.wait(0.25 if row else 1)
510
 
511
  def start(self):
512
- if not self.config.missing():
513
- for lane in ("ai", "tg"):
 
514
  thread = threading.Thread(target=self.worker, args=(lane,), daemon=True, name=lane)
515
  self.threads.append(thread)
516
  thread.start()
517
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
518
  def status(self):
519
  return {"mode": "monitor", "version": VERSION, "missing": self.config.missing(), "model": self.config.model,
520
- "storage": "postgresql" if self.config.database_url else "temporary_sqlite",
521
  "queue": self.store.stats(), "workers": {t.name: t.is_alive() for t in self.threads}}
522
 
523
 
@@ -547,10 +577,11 @@ def make_server(monitor, host="0.0.0.0", port=7860):
547
  return secrets.compare_digest(urlsplit(self.path).path.encode(), (prefix + monitor.config.secret).encode())
548
 
549
  def do_GET(self):
 
 
550
  if urlsplit(self.path).path in ("/", "/health"):
551
- missing = monitor.config.missing()
552
- return self.reply(200, {"status": "configuration_required" if missing else "monitor_ready",
553
- "version": VERSION, "missing": missing, "storage": "postgresql" if monitor.config.database_url else "temporary_sqlite"})
554
  if self.authorized("/diagnostics/"):
555
  return self.reply(200, monitor.clients.diagnose_telegram())
556
  if self.authorized("/status/") or self.authorized("/samples/"):
@@ -563,8 +594,6 @@ def make_server(monitor, host="0.0.0.0", port=7860):
563
  def do_POST(self):
564
  if not self.authorized("/medialogia/"):
565
  return self.reply(404, {"error": "not_found"})
566
- if monitor.config.missing():
567
- return self.reply(503, {"error": "configuration_required"})
568
  try:
569
  size = int(self.headers["Content-Length"])
570
  except (ValueError, TypeError):
@@ -598,7 +627,7 @@ if __name__ == "__main__":
598
  app = Monitor(Config.from_env())
599
  server = make_server(app, port=int(os.getenv("PORT", "7860")))
600
  app.start()
601
- LOG.info("Monitor started; storage=%s; missing=%s", "postgresql" if app.config.database_url else "temporary_sqlite", app.config.missing())
602
 
603
  def shutdown(*_args):
604
  app.stop.set()
@@ -610,5 +639,8 @@ if __name__ == "__main__":
610
  server.serve_forever()
611
  finally:
612
  app.stop.set()
 
 
 
613
  server.server_close()
614
 
 
28
  from zoneinfo import ZoneInfo
29
 
30
  LOG = logging.getLogger("monitor")
31
+ VERSION = "1.2-delivery-health"
32
  MAX_BYTES = 1024 * 1024
33
  AI_CHUNK = 12000
34
  DEFAULT_PROMPT = """Ты классификатор публикаций для мониторинга нового внешнего вида
 
189
 
190
  def missing(self):
191
  return [name for name, value in (("TELEGRAM_BOT_TOKEN", self.telegram_token),
192
+ ("TELEGRAM_CHAT_ID", self.chat_id), ("DEEPSEEK_API_KEY", self.deepseek_key)) if not value]
 
193
 
194
 
195
  class Store:
 
352
  def __init__(self, config):
353
  self.config, self.last_tg = config, 0.0
354
  self.diagnostics_lock = threading.Lock()
355
+ self.telegram_health = {"status": "not_checked"}
356
 
357
  def diagnose_telegram(self):
358
  """Read-only Bot API checks; no publication, queue changes or AI calls."""
 
423
  data = {"chat_id": self.config.chat_id, **payload}
424
  if method == "sendMessage" and self.config.thread_id:
425
  data["message_thread_id"] = int(self.config.thread_id)
426
+ try:
427
+ result = request_json("https://api.telegram.org/bot" + self.config.telegram_token + "/" + method,
428
+ data, service="telegram", timeout=35)
429
+ if not isinstance(result, dict) or not result.get("ok"):
430
+ raise ServiceError("telegram_invalid_response")
431
+ except ServiceError as exc:
432
+ self.telegram_health = {"status": "error", "error": exc.code}
433
+ raise
434
+ self.telegram_health = {"status": "ok"}
435
  return result.get("result")
436
 
437
 
 
514
  self.stop.wait(0.25 if row else 1)
515
 
516
  def start(self):
517
+ for lane, enabled in (("ai", bool(self.config.deepseek_key)),
518
+ ("tg", bool(self.config.telegram_token and self.config.chat_id))):
519
+ if enabled:
520
  thread = threading.Thread(target=self.worker, args=(lane,), daemon=True, name=lane)
521
  self.threads.append(thread)
522
  thread.start()
523
 
524
+ def health(self):
525
+ missing = self.config.missing()
526
+ telegram = dict(self.clients.telegram_health)
527
+ try:
528
+ queue = self.store.stats()
529
+ except Exception:
530
+ return {"status": "storage_unavailable", "version": VERSION}
531
+ workers = {thread.name: thread.is_alive() for thread in self.threads}
532
+ expected = {lane for lane, enabled in (("ai", bool(self.config.deepseek_key)),
533
+ ("tg", bool(self.config.telegram_token and self.config.chat_id))) if enabled}
534
+ if missing:
535
+ state = "configuration_required"
536
+ elif not all(workers.get(lane, False) for lane in expected):
537
+ state = "worker_unavailable"
538
+ elif queue["telegram_errors"] or queue["ai_errors"] or telegram["status"] == "error":
539
+ state = "monitor_degraded"
540
+ elif telegram["status"] == "not_checked":
541
+ state = "monitor_waiting_for_delivery"
542
+ else:
543
+ state = "monitor_ready"
544
+ return {"status": state, "version": VERSION, "missing": missing,
545
+ "storage": "postgresql" if self.config.database_url else "sqlite",
546
+ "telegram": telegram, "workers": workers}
547
+
548
  def status(self):
549
  return {"mode": "monitor", "version": VERSION, "missing": self.config.missing(), "model": self.config.model,
550
+ "storage": "postgresql" if self.config.database_url else "sqlite",
551
  "queue": self.store.stats(), "workers": {t.name: t.is_alive() for t in self.threads}}
552
 
553
 
 
577
  return secrets.compare_digest(urlsplit(self.path).path.encode(), (prefix + monitor.config.secret).encode())
578
 
579
  def do_GET(self):
580
+ if urlsplit(self.path).path == "/live":
581
+ return self.reply(200, {"status": "alive", "version": VERSION})
582
  if urlsplit(self.path).path in ("/", "/health"):
583
+ health = monitor.health()
584
+ return self.reply(503 if health["status"] in ("storage_unavailable", "worker_unavailable") else 200, health)
 
585
  if self.authorized("/diagnostics/"):
586
  return self.reply(200, monitor.clients.diagnose_telegram())
587
  if self.authorized("/status/") or self.authorized("/samples/"):
 
594
  def do_POST(self):
595
  if not self.authorized("/medialogia/"):
596
  return self.reply(404, {"error": "not_found"})
 
 
597
  try:
598
  size = int(self.headers["Content-Length"])
599
  except (ValueError, TypeError):
 
627
  app = Monitor(Config.from_env())
628
  server = make_server(app, port=int(os.getenv("PORT", "7860")))
629
  app.start()
630
+ LOG.info("Monitor started; storage=%s; missing=%s", "postgresql" if app.config.database_url else "sqlite", app.config.missing())
631
 
632
  def shutdown(*_args):
633
  app.stop.set()
 
639
  server.serve_forever()
640
  finally:
641
  app.stop.set()
642
+ deadline = time.monotonic() + 75
643
+ for thread in app.threads:
644
+ thread.join(timeout=max(0, deadline - time.monotonic()))
645
  server.server_close()
646
 
render.yaml ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ - type: web
3
+ name: medialogia-monitor
4
+ runtime: docker
5
+ plan: starter
6
+ numInstances: 1
7
+ healthCheckPath: /live
8
+ maxShutdownDelaySeconds: 90
9
+ disk:
10
+ name: monitor-queue
11
+ mountPath: /app/data
12
+ sizeGB: 1
13
+ envVars:
14
+ - key: MONITOR_DB_PATH
15
+ value: /app/data/queue.sqlite3
16
+ - key: WEBHOOK_SECRET
17
+ sync: false
18
+ - key: TELEGRAM_BOT_TOKEN
19
+ sync: false
20
+ - key: TELEGRAM_CHAT_ID
21
+ sync: false
22
+ - key: DEEPSEEK_API_KEY
23
+ sync: false
24
+ - key: DEEPSEEK_MODEL
25
+ value: deepseek-flash