feat(observability): phase-1 silent pipeline recorder + phase-0 Jev validation
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.
This commit is contained in:
@@ -11,6 +11,7 @@ Pipeline per update:
|
||||
import logging
|
||||
|
||||
from app import drafts as draft_store
|
||||
from app import observability as obs
|
||||
from app.config import settings
|
||||
from app.nlu import understand
|
||||
from app.runtime import BotContext, LinkedUser
|
||||
@@ -48,6 +49,16 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
||||
voice_transcripts: list[dict] | None = None
|
||||
message_id = message.get("message_id")
|
||||
|
||||
# Observability (phase 1): open a trace for every incoming message.
|
||||
_doc = message.get("document") or {}
|
||||
_audio_like = bool(
|
||||
message.get("voice") or message.get("audio")
|
||||
or (_doc.get("mime_type") or "").lower().startswith("audio/")
|
||||
)
|
||||
trace_id = await obs.trace_start(chat_id, message_id, "voice" if _audio_like else "text")
|
||||
if text:
|
||||
await obs.event(trace_id, "input.text", payload={"text": text[:500]})
|
||||
|
||||
if text.startswith("/"):
|
||||
await commands.handle_command(ctx, chat_id, text)
|
||||
return
|
||||
@@ -79,11 +90,20 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
||||
file_id = audio_media["file_id"]
|
||||
try:
|
||||
audio_bytes = await ctx.bale.download_file(file_id)
|
||||
voice_path = obs.save_voice(trace_id, message_id or 0, audio_bytes)
|
||||
await obs.event(trace_id, "input.voice", payload={
|
||||
"path": voice_path,
|
||||
"size": len(audio_bytes),
|
||||
"mime": audio_media.get("mime_type"),
|
||||
"file_id": file_id,
|
||||
"duration": audio_media.get("duration"),
|
||||
})
|
||||
transcripts = await transcribe_multi(
|
||||
settings,
|
||||
audio_bytes,
|
||||
mime_type=audio_media.get("mime_type"),
|
||||
filename=audio_media.get("file_name"),
|
||||
trace_id=trace_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
detail = "هوش مصنوعی تنظیم نشده" if "not configured" in str(exc) else str(exc)[:200]
|
||||
@@ -201,9 +221,10 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
||||
|
||||
draft_summary = _draft_summary(target_draft) if target_draft else None
|
||||
input_payload = voice_transcripts if voice_transcripts else text
|
||||
result = await understand(settings, input_payload, draft_summary)
|
||||
result = await understand(settings, input_payload, draft_summary, trace_id=trace_id)
|
||||
intent, patch = result.get("intent"), result.get("patch") or {}
|
||||
consensus_text = result.get("consensus_text") or text
|
||||
await obs.event(trace_id, "entities.extracted", payload={"patch": patch, "intent": intent})
|
||||
|
||||
if source == "voice":
|
||||
await ctx._log(chat_id, "in", "voice", consensus_text)
|
||||
@@ -216,23 +237,27 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
||||
ctx, chat_id, user, patch, source,
|
||||
incoming_msg_id=message_id,
|
||||
target_draft=target_draft,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
elif intent == "commit_draft":
|
||||
active = target_draft or await draft_store.get_active(chat_id)
|
||||
if active is None:
|
||||
await ctx.send(chat_id, NOTHING_TO_COMMIT, reply_to=message_id)
|
||||
elif active.kind == draft_store.KIND_QUOTE:
|
||||
await quote.commit(ctx, chat_id, user, active)
|
||||
await quote.commit(ctx, chat_id, user, active, trace_id=trace_id)
|
||||
elif active.kind == draft_store.KIND_SUPPLIER:
|
||||
await supplier.commit(ctx, chat_id, user, active)
|
||||
await supplier.commit(ctx, chat_id, user, active, trace_id=trace_id)
|
||||
elif active.kind == draft_store.KIND_MATERIAL:
|
||||
await material.commit(ctx, chat_id, user, active)
|
||||
await material.commit(ctx, chat_id, user, active, trace_id=trace_id)
|
||||
elif active.kind == draft_store.KIND_PROJECT:
|
||||
await project.commit(ctx, chat_id, user, active)
|
||||
await project.commit(ctx, chat_id, user, active, trace_id=trace_id)
|
||||
elif intent == "discard_draft":
|
||||
active = target_draft or await draft_store.get_active(chat_id)
|
||||
if active:
|
||||
await draft_store.set_status(active.id, "cancelled")
|
||||
await obs.event(trace_id, "draft.cancel",
|
||||
payload={"draft_id": str(active.id), "kind": active.kind})
|
||||
await obs.set_trace_status(trace_id, "cancelled")
|
||||
if active.review_message_id:
|
||||
try:
|
||||
await ctx.delete(chat_id, active.review_message_id)
|
||||
@@ -253,11 +278,11 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
||||
elif active.kind == draft_store.KIND_PROJECT:
|
||||
await project.render(ctx, chat_id, active)
|
||||
elif intent == "add_supplier":
|
||||
await supplier.handle_entities(ctx, chat_id, user, patch, source)
|
||||
await supplier.handle_entities(ctx, chat_id, user, patch, source, trace_id=trace_id)
|
||||
elif intent == "add_material":
|
||||
await material.handle_entities(ctx, chat_id, user, patch, source)
|
||||
await material.handle_entities(ctx, chat_id, user, patch, source, trace_id=trace_id)
|
||||
elif intent == "add_project":
|
||||
await project.handle_entities(ctx, chat_id, user, patch, source)
|
||||
await project.handle_entities(ctx, chat_id, user, patch, source, trace_id=trace_id)
|
||||
elif intent == "status_query":
|
||||
await queries.handle_query(ctx, chat_id, user, patch)
|
||||
elif intent == "accept_quote":
|
||||
@@ -375,18 +400,24 @@ async def _handle_ambiguity_user_reply(
|
||||
draft, amb: dict, message: dict, incoming_msg_id: int | None
|
||||
) -> None:
|
||||
"""Handle text/audio correction sent directly as a reply to an ambiguity sub-message (Requirement 2.b)."""
|
||||
trace_id = await obs.latest_trace_for_draft(draft.id)
|
||||
text = (message.get("text") or message.get("caption") or "").strip()
|
||||
if not text and (message.get("voice") or message.get("audio")):
|
||||
try:
|
||||
audio_media = message.get("voice") or message.get("audio")
|
||||
audio_bytes = await ctx.bale.download_file(audio_media["file_id"])
|
||||
text = await transcribe(settings, audio_bytes)
|
||||
obs.save_voice(trace_id, incoming_msg_id or 0, audio_bytes)
|
||||
text = await transcribe(settings, audio_bytes, trace_id=trace_id)
|
||||
except Exception:
|
||||
text = ""
|
||||
|
||||
if text:
|
||||
await obs.event(trace_id, "ambiguity.user_choice", payload={
|
||||
"amb_id": amb.get("id"), "action": "reply", "kind": amb.get("kind"),
|
||||
"reply_text": text[:500],
|
||||
})
|
||||
draft_summary = _draft_summary(draft)
|
||||
result = await understand(settings, text, draft_summary)
|
||||
result = await understand(settings, text, draft_summary, trace_id=trace_id)
|
||||
patch = result.get("patch") or {}
|
||||
|
||||
# Apply user correction specifically to the target field
|
||||
@@ -408,11 +439,11 @@ async def _handle_ambiguity_user_reply(
|
||||
draft_store.merge_quote_patch(draft.payload, patch)
|
||||
|
||||
amb["status"] = "applied"
|
||||
await quote.resolve_payload(ctx, draft.payload)
|
||||
await quote.resolve_payload(ctx, draft.payload, trace_id)
|
||||
await draft_store.save_payload(draft, mark_bale_synced=True)
|
||||
|
||||
# In-place edit main review card (Requirement 2.b)
|
||||
await quote.render(ctx, chat_id, draft, fresh=False)
|
||||
await quote.render(ctx, chat_id, draft, fresh=False, trace_id=trace_id)
|
||||
|
||||
# Delete the ambiguity message (Requirement 2.b)
|
||||
if amb.get("message_id"):
|
||||
|
||||
Reference in New Issue
Block a user