Phase 0 — Jev validated with the real key (5 Persian scenarios, api.typesafe.ai): stale project name «منشیانه»->«منشانه» conf 0.73 (ambiguity band), STT spelling variants «مصطفوی/مستفدی/مسطفی»->«بازرگانی مصطفوی» conf 0.93, exact «دفتر» 0.88, proper _none rejection for «کاغذ A4» 0.92, and two mentions in ONE request (0.77/0.64). Exact model version to pin: jev-1.13.0. Latency 0.7-1.1s per field request; ~450-650 in / 64-133 out tokens. Phase 1 — Observability (DB-only, zero extra LLM tokens, nothing is ever deleted): - new tables bot_traces + bot_trace_events (idempotent DDL in db_init.py; ORM in models.py) - new app/observability.py: _jsonable (UUID/datetime/Decimal/set/enum/dataclass-safe), trace_start, event, log_resolution, link_draft, latest_trace_for_draft, set_trace_status, span() context manager, save_voice (Path.mkdir parents+exist_ok, day folders) - capture points wired: input.text; input.voice (+voice persisted under OBS_VOICE_DIR); stt.run + per-provider stt.result (success AND failure, latency, model); nlu.request + nlu.result with prompt_hash/prompt_version; entities.extracted; routing.decision (difflib baseline for the phase-6 A/B); ambiguity.sent; ambiguity.user_choice (buttons + free-text reply path); user_choice.pick; draft.render; draft.edit (wizard-side edits detected by the sync loop); draft.commit (quote/supplier/material/project + trace status/quote id); draft.cancel - compose: persistent ./bot/data:/srv/bot/data volume; bot/.gitignore: data/ (runtime data never committed, never deleted) - tests: bot/tools/test_observability.py -> 8/8 pass (jsonable cases, trace lifecycle, JSONB round-trip, draft linking, status update, save_voice day-folder mkdir, span task). Verified live: voice file lands on the host volume and is gitignored; rows + files persist across bot restart.
74 lines
3.6 KiB
Python
74 lines
3.6 KiB
Python
"""Background sync loop: watches bot_drafts for updates from Web Wizard or external API.
|
|
|
|
If a draft was updated in the Wizard:
|
|
- If status == 'active': re-renders review text & keyboard in Bale so chat reflects edits.
|
|
- If status == 'committed': edits Bale message to show it was committed and removes buttons.
|
|
- If status == 'cancelled': edits Bale message to show it was cancelled and removes buttons.
|
|
"""
|
|
import asyncio
|
|
import logging
|
|
from app import drafts as draft_store
|
|
from app import observability as obs
|
|
from app.handlers import quote
|
|
|
|
logger = logging.getLogger("bot.sync")
|
|
|
|
|
|
async def sync_drafts_loop(ctx) -> None:
|
|
while True:
|
|
try:
|
|
pending = await draft_store.get_pending_bale_updates()
|
|
for draft in pending:
|
|
try:
|
|
if not draft.review_message_id:
|
|
await draft_store.set_bale_updated_at(draft.id)
|
|
continue
|
|
|
|
if draft.status == "committed":
|
|
payload = draft.payload or {}
|
|
quote_id = payload.get("committed_quote_id") or ""
|
|
msg = "✅ این پیشنهاد با موفقیت در سیستم ثبت نهایی شد."
|
|
if quote_id:
|
|
msg += f"\nشناسه پیشنهاد: {quote_id}"
|
|
await ctx.bale.edit_message_text(draft.chat_id, draft.review_message_id, msg, reply_markup=None)
|
|
await draft_store.set_bale_updated_at(draft.id)
|
|
logger.info("Synced committed draft %s to Bale message %s", draft.id, draft.review_message_id)
|
|
|
|
elif draft.status == "cancelled":
|
|
msg = "❌ این پیشنویس لغو شد."
|
|
await ctx.bale.edit_message_text(draft.chat_id, draft.review_message_id, msg, reply_markup=None)
|
|
await draft_store.set_bale_updated_at(draft.id)
|
|
logger.info("Synced cancelled draft %s to Bale message %s", draft.id, draft.review_message_id)
|
|
|
|
elif draft.status == "active" and draft.kind == draft_store.KIND_QUOTE:
|
|
import hashlib
|
|
import json as _json
|
|
await obs.event(
|
|
await obs.latest_trace_for_draft(draft.id), "draft.edit",
|
|
payload={
|
|
"source": "wizard",
|
|
"kind": draft.kind,
|
|
"payload_hash": hashlib.sha1(
|
|
_json.dumps(draft.payload, ensure_ascii=False,
|
|
sort_keys=True, default=str).encode()
|
|
).hexdigest()[:16],
|
|
},
|
|
)
|
|
await quote.render(ctx, draft.chat_id, draft, fresh=False)
|
|
await draft_store.set_bale_updated_at(draft.id)
|
|
logger.info("Synced active draft %s edits to Bale message %s", draft.id, draft.review_message_id)
|
|
|
|
else:
|
|
await draft_store.set_bale_updated_at(draft.id)
|
|
|
|
except Exception as exc:
|
|
logger.warning("Failed to sync draft %s to Bale: %s", draft.id, exc)
|
|
await draft_store.set_bale_updated_at(draft.id)
|
|
|
|
except asyncio.CancelledError:
|
|
break
|
|
except Exception:
|
|
logger.exception("Error in sync_drafts_loop")
|
|
|
|
await asyncio.sleep(1.5)
|