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:
2026-10-12 21:04:50 +03:30
parent 693e7511c6
commit d58b619107
16 changed files with 660 additions and 57 deletions
+43 -12
View File
@@ -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"):
+13 -1
View File
@@ -6,6 +6,7 @@ Data grammar (≤64 bytes): op:{arg}:{d8} / pk:{d8}:{field}:{idx} / qc:{idx
import logging
from app import drafts as draft_store
from app import observability as obs
from app.runtime import LinkedUser
from app.texts import CANCELLED, UNLINKED, esc
@@ -77,6 +78,12 @@ async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
if not target_amb:
return
tid = await obs.latest_trace_for_draft(draft.id)
await obs.event(tid, "ambiguity.user_choice", payload={
"amb_id": amb_id, "action": action, "kind": target_amb.get("kind"),
"title": target_amb.get("title"),
})
if action == "ok":
proposed = target_amb.get("proposed_patch") or {}
draft_store.merge_quote_patch(payload, proposed)
@@ -126,6 +133,9 @@ async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
return
if arg == "no":
await draft_store.set_status(draft.id, "cancelled")
tid = await obs.latest_trace_for_draft(draft.id)
await obs.event(tid, "draft.cancel", payload={"draft_id": str(draft.id), "kind": draft.kind})
await obs.set_trace_status(tid, "cancelled")
ctx.pending.pop(chat_id, None)
if draft.review_message_id:
try:
@@ -151,8 +161,10 @@ async def _apply_pick(ctx, chat_id: int, user: LinkedUser, draft, field: str, id
await ctx.send(chat_id, "گزینه نامعتبر است.")
return
choice = options[idx]
tid = await obs.latest_trace_for_draft(draft.id)
await obs.event(tid, "user_choice.pick", payload={"field": field, "idx": idx, "choice": choice})
if draft.kind == draft_store.KIND_QUOTE:
await quote.apply_pick(ctx, chat_id, user, draft, field, choice)
await quote.apply_pick(ctx, chat_id, user, draft, field, choice, trace_id=tid)
elif draft.kind == draft_store.KIND_SUPPLIER:
await supplier.apply_pick(ctx, chat_id, user, draft, field, choice)
elif draft.kind == draft_store.KIND_MATERIAL:
+18 -7
View File
@@ -2,6 +2,7 @@
import logging
from app import drafts as draft_store
from app import observability as obs
from app.api.client import ApiError
from app.drafts import (
KIND_MATERIAL, merge_material_patch, material_missing, new_material_payload,
@@ -13,7 +14,8 @@ from app.texts import ERR_GENERIC, esc, material_committed_report, material_revi
logger = logging.getLogger("bot.material")
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None:
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None,
trace_id: str | None = None) -> None:
draft = await draft_store.get_active(chat_id, KIND_MATERIAL)
if draft is not None:
payload = draft.payload
@@ -22,15 +24,18 @@ async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -
payload = new_material_payload()
merge_material_patch(payload, patch)
draft = await draft_store.create(chat_id, KIND_MATERIAL, payload, source=source)
await resolve_payload(ctx, payload)
await obs.link_draft(trace_id, draft.id, draft.kind)
await resolve_payload(ctx, payload, trace_id)
await draft_store.save_payload(draft, missing={"fields": material_missing(payload)})
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None)
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None, trace_id=trace_id)
async def resolve_payload(ctx, payload: dict) -> None:
async def resolve_payload(ctx, payload: dict, trace_id: str | None = None) -> None:
options = payload.setdefault("options", {})
if not payload.get("canonical_unit_id") and payload.get("unit_name"):
res = await safe_resolve(ctx.resolver, "unit", payload["unit_name"])
await obs.log_resolution(trace_id, "unit", payload["unit_name"], res.status,
[{"id": c.id, "name": c.name} for c in (res.candidates or [])[:4]])
if res.resolved:
payload["canonical_unit_id"] = res.candidates[0].id
payload["unit_name"] = res.candidates[0].name
@@ -58,7 +63,7 @@ def _keyboard(payload: dict, draft_id: str) -> dict:
return simple_review_keyboard(draft_id, KIND_MATERIAL, rows)
async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
async def render(ctx, chat_id, draft, fresh: bool = True, trace_id: str | None = None) -> None:
payload = draft.payload
missing = material_missing(payload)
text = material_review_text(payload, missing)
@@ -71,11 +76,14 @@ async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
else:
await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
await draft_store.save_payload(draft, missing={"fields": missing})
await obs.event(trace_id or await obs.latest_trace_for_draft(draft.id), "draft.render",
payload={"fresh": fresh, "missing": [m.get("key") for m in missing if not m.get("ok")]})
async def commit(ctx, chat_id, user, draft) -> None:
async def commit(ctx, chat_id, user, draft, trace_id: str | None = None) -> None:
payload = draft.payload
await resolve_payload(ctx, payload)
trace_id = trace_id or await obs.latest_trace_for_draft(draft.id)
await resolve_payload(ctx, payload, trace_id)
missing = {m["key"]: m["ok"] for m in material_missing(payload)}
problems = [label for key, label, ok in [
("name", "نام کالا", missing.get("name")),
@@ -94,5 +102,8 @@ async def commit(ctx, chat_id, user, draft) -> None:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return
await draft_store.set_status(draft.id, "committed")
await obs.event(trace_id, "draft.commit", payload={"kind": "material",
"material_id": (created or {}).get("id")})
await obs.set_trace_status(trace_id, "committed")
ctx.resolver.invalidate("materials")
await ctx.send(chat_id, material_committed_report(created))
+13 -4
View File
@@ -2,6 +2,7 @@
import logging
from app import drafts as draft_store
from app import observability as obs
from app.api.client import ApiError
from app.drafts import (
KIND_PROJECT, merge_project_patch, new_project_payload, project_missing,
@@ -12,7 +13,8 @@ from app.texts import ERR_GENERIC, esc, project_committed_report, project_review
logger = logging.getLogger("bot.project")
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None:
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None,
trace_id: str | None = None) -> None:
draft = await draft_store.get_active(chat_id, KIND_PROJECT)
if draft is not None:
payload = draft.payload
@@ -21,15 +23,16 @@ async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -
payload = new_project_payload()
merge_project_patch(payload, patch)
draft = await draft_store.create(chat_id, KIND_PROJECT, payload, source=source)
await obs.link_draft(trace_id, draft.id, draft.kind)
await draft_store.save_payload(draft, missing={"fields": project_missing(payload)})
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None)
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None, trace_id=trace_id)
def _keyboard(payload: dict, draft_id: str) -> dict:
return simple_review_keyboard(draft_id, KIND_PROJECT, [])
async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
async def render(ctx, chat_id, draft, fresh: bool = True, trace_id: str | None = None) -> None:
payload = draft.payload
missing = project_missing(payload)
text = project_review_text(payload, missing)
@@ -42,10 +45,13 @@ async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
else:
await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
await draft_store.save_payload(draft, missing={"fields": missing})
await obs.event(trace_id or await obs.latest_trace_for_draft(draft.id), "draft.render",
payload={"fresh": fresh, "missing": [m.get("key") for m in missing if not m.get("ok")]})
async def commit(ctx, chat_id, user, draft) -> None:
async def commit(ctx, chat_id, user, draft, trace_id: str | None = None) -> None:
payload = draft.payload
trace_id = trace_id or await obs.latest_trace_for_draft(draft.id)
missing = {m["key"]: m["ok"] for m in project_missing(payload)}
if not missing.get("name"):
await ctx.send(chat_id, "ثبت ممکن نیست؛ نام پروژه مشخص نشده است.")
@@ -63,5 +69,8 @@ async def commit(ctx, chat_id, user, draft) -> None:
await ctx.send(chat_id, ERR_GENERIC.format(detail="خطای سرور"))
return
await draft_store.set_status(draft.id, "committed")
await obs.event(trace_id, "draft.commit", payload={"kind": "project",
"project_id": (created or {}).get("id")})
await obs.set_trace_status(trace_id, "committed")
ctx.resolver.invalidate("projects")
await ctx.send(chat_id, project_committed_report(created))
+45 -12
View File
@@ -2,6 +2,7 @@
import logging
from app import drafts as draft_store
from app import observability as obs
from app.api.client import ApiError
from app.drafts import (
KIND_QUOTE, build_quote_request, compute_quote_missing, merge_quote_patch,
@@ -20,6 +21,7 @@ async def handle_entities(
ctx, chat_id, user, patch: dict, source: str | None,
incoming_msg_id: int | None = None,
target_draft = None,
trace_id: str | None = None,
) -> None:
prev_review_msg_id = None
if target_draft is not None:
@@ -47,7 +49,8 @@ async def handle_entities(
chat_id, KIND_QUOTE, payload, source=source, source_message_id=incoming_msg_id
)
await resolve_payload(ctx, payload)
await obs.link_draft(trace_id, draft.id, draft.kind)
await resolve_payload(ctx, payload, trace_id)
if prev_review_msg_id:
try:
@@ -55,18 +58,25 @@ async def handle_entities(
except Exception as e:
logger.warning("Could not delete previous review message %s: %s", prev_review_msg_id, e)
await render(ctx, chat_id, draft, fresh=True, reply_to=incoming_msg_id)
await render(ctx, chat_id, draft, fresh=True, reply_to=incoming_msg_id, trace_id=trace_id)
# ---------------------------------------------------------------------------
# resolution
# ---------------------------------------------------------------------------
async def resolve_payload(ctx, payload: dict) -> None:
async def _log_res(trace_id, field: str, mention, res) -> None:
"""Observability: record one difflib resolution attempt (baseline for phase 6)."""
await obs.log_resolution(trace_id, field, mention, res.status,
[{"id": c.id, "name": c.name} for c in (res.candidates or [])[:4]])
async def resolve_payload(ctx, payload: dict, trace_id: str | None = None) -> None:
options = payload.setdefault("options", {})
if not payload.get("supplier_id") and payload.get("supplier_name"):
res = await safe_resolve(ctx.resolver, "supplier", payload["supplier_name"])
await _log_res(trace_id, "supplier", payload["supplier_name"], res)
if res.resolved:
c = res.candidates[0]
payload["supplier_id"] = c.id
@@ -85,6 +95,7 @@ async def resolve_payload(ctx, payload: dict) -> None:
if not payload.get("project_id") and payload.get("project_name"):
res = await safe_resolve(ctx.resolver, "project", payload["project_name"])
await _log_res(trace_id, "project", payload["project_name"], res)
if res.resolved:
c = res.candidates[0]
payload["project_id"] = c.id
@@ -101,6 +112,7 @@ async def resolve_payload(ctx, payload: dict) -> None:
for p_name in payload.get("project_names") or []:
res = await safe_resolve(ctx.resolver, "project", p_name)
await _log_res(trace_id, "project", p_name, res)
if res.resolved:
c = res.candidates[0]
p_id = c.id
@@ -114,6 +126,7 @@ async def resolve_payload(ctx, payload: dict) -> None:
for i, item in enumerate(payload.get("items") or [], start=1):
if not item.get("material_id") and item.get("material_name"):
res = await safe_resolve(ctx.resolver, "material", item["material_name"])
await _log_res(trace_id, "material", item["material_name"], res)
if res.resolved:
_set_material(item, res.candidates[0].id, res.candidates[0].name, res.candidates[0].extra)
options.pop(f"mat{i}", None)
@@ -122,7 +135,7 @@ async def resolve_payload(ctx, payload: dict) -> None:
else:
options.pop(f"mat{i}", None)
if not item.get("unit_id"):
await _resolve_unit(ctx, item)
await _resolve_unit(ctx, item, trace_id)
def _set_material(item: dict, material_id: str, name: str, extra: dict | None = None) -> None:
@@ -130,11 +143,12 @@ def _set_material(item: dict, material_id: str, name: str, extra: dict | None =
item["material_row"] = {"id": material_id, "canonical_unit_id": (extra or {}).get("canonical_unit_id")}
async def _resolve_unit(ctx, item: dict) -> None:
async def _resolve_unit(ctx, item: dict, trace_id: str | None = None) -> None:
if item.get("unit_id"):
return
if item.get("unit_name"):
res = await safe_resolve(ctx.resolver, "unit", item["unit_name"])
await _log_res(trace_id, "unit", item["unit_name"], res)
if res.resolved:
item["unit_id"] = res.candidates[0].id
item["unit_explicit"] = True
@@ -146,8 +160,10 @@ async def _resolve_unit(ctx, item: dict) -> None:
item["unit_explicit"] = False
async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> None:
async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict,
trace_id: str | None = None) -> None:
payload = draft.payload
trace_id = trace_id or await obs.latest_trace_for_draft(draft.id)
options = payload.setdefault("options", {})
options.pop(field, None)
@@ -163,7 +179,7 @@ async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> Non
if 0 <= index < len(items):
items[index]["material_name"] = choice["name"]
_set_material(items[index], choice["id"], choice["name"])
await _resolve_unit(ctx, items[index])
await _resolve_unit(ctx, items[index], trace_id)
elif field.startswith("unit"):
index = int(field[4:]) - 1
items = payload.get("items") or []
@@ -172,9 +188,9 @@ async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> Non
items[index]["unit_explicit"] = True
items[index]["unit_name"] = choice["name"]
await resolve_payload(ctx, payload)
await resolve_payload(ctx, payload, trace_id)
await draft_store.save_payload(draft, missing={"fields": compute_quote_missing(payload)})
await render(ctx, chat_id, draft, fresh=False)
await render(ctx, chat_id, draft, fresh=False, trace_id=trace_id)
# ---------------------------------------------------------------------------
@@ -214,7 +230,7 @@ def _keyboard(payload: dict, draft_id: str, wizard_url: str | None = None) -> di
async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = None,
reply_to: int | None = None) -> None:
reply_to: int | None = None, trace_id: str | None = None) -> None:
from app.config import settings
from app.security import create_draft_token
@@ -263,6 +279,9 @@ async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = Non
logger.warning("Could not edit review message %s for draft %s", draft.review_message_id, draft.id)
await draft_store.set_bale_updated_at(draft.id)
await obs.event(trace_id or await obs.latest_trace_for_draft(draft.id), "draft.render",
payload={"fresh": fresh, "missing": [m.get("key") for m in missing if not m.get("ok")]})
async def send_pending_ambiguities(ctx, chat_id: int, draft) -> None:
from app.keyboards import ambiguity_keyboard
@@ -275,6 +294,7 @@ async def send_pending_ambiguities(ctx, chat_id: int, draft) -> None:
payload = draft.payload or {}
ambiguities = payload.get("ambiguities") or []
updated = False
trace_id = await obs.latest_trace_for_draft(draft.id)
for amb in ambiguities:
if amb.get("status") == "pending" and not amb.get("message_id"):
@@ -289,6 +309,10 @@ async def send_pending_ambiguities(ctx, chat_id: int, draft) -> None:
if sent and sent.get("message_id"):
amb["message_id"] = sent["message_id"]
updated = True
await obs.event(trace_id, "ambiguity.sent", payload={
"amb_id": amb.get("id"), "title": amb.get("title"),
"kind": amb.get("kind"), "message_id": sent["message_id"],
})
if updated:
await draft_store.save_payload(draft, mark_bale_synced=True)
@@ -298,9 +322,11 @@ async def send_pending_ambiguities(ctx, chat_id: int, draft) -> None:
# commit
# ---------------------------------------------------------------------------
async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None:
async def commit(ctx, chat_id, user, draft, only_resolved: bool = False,
trace_id: str | None = None) -> None:
payload = draft.payload
await resolve_payload(ctx, payload)
trace_id = trace_id or await obs.latest_trace_for_draft(draft.id)
await resolve_payload(ctx, payload, trace_id)
newly_created: dict = {}
@@ -418,6 +444,13 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None
ctx.resolver.invalidate()
quote_id = (created or {}).get("id") or ""
await obs.event(trace_id, "draft.commit", payload={
"quote_id": quote_id,
"items": len(body.get("line_items") or []),
"newly_created": newly_created,
"only_resolved": only_resolved,
})
await obs.set_trace_status(trace_id, "committed", quote_id)
# If a proforma file was attached, persist it to the created ERP quote
proforma_info = payload.get("proforma")
if quote_id and proforma_info and proforma_info.get("saved_path"):
+18 -7
View File
@@ -2,6 +2,7 @@
import logging
from app import drafts as draft_store
from app import observability as obs
from app.api.client import ApiError
from app.drafts import (
KIND_SUPPLIER, merge_supplier_patch, new_supplier_payload, supplier_missing,
@@ -13,7 +14,8 @@ from app.texts import ERR_GENERIC, esc, supplier_committed_report, supplier_revi
logger = logging.getLogger("bot.supplier")
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None:
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None,
trace_id: str | None = None) -> None:
draft = await draft_store.get_active(chat_id, KIND_SUPPLIER)
if draft is not None:
payload = draft.payload
@@ -22,15 +24,18 @@ async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -
payload = new_supplier_payload()
merge_supplier_patch(payload, patch)
draft = await draft_store.create(chat_id, KIND_SUPPLIER, payload, source=source)
await resolve_payload(ctx, payload)
await obs.link_draft(trace_id, draft.id, draft.kind)
await resolve_payload(ctx, payload, trace_id)
await draft_store.save_payload(draft, missing={"fields": supplier_missing(payload)})
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None)
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None, trace_id=trace_id)
async def resolve_payload(ctx, payload: dict) -> None:
async def resolve_payload(ctx, payload: dict, trace_id: str | None = None) -> None:
options = payload.setdefault("options", {})
if not payload.get("category_id") and payload.get("category_name"):
res = await safe_resolve(ctx.resolver, "category", payload["category_name"])
await obs.log_resolution(trace_id, "category", payload["category_name"], res.status,
[{"id": c.id, "name": c.name} for c in (res.candidates or [])[:4]])
if res.resolved:
payload["category_id"] = res.candidates[0].id
payload["category_name"] = res.candidates[0].name
@@ -58,7 +63,7 @@ def _keyboard(payload: dict, draft_id: str) -> dict:
return simple_review_keyboard(draft_id, KIND_SUPPLIER, rows)
async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
async def render(ctx, chat_id, draft, fresh: bool = True, trace_id: str | None = None) -> None:
payload = draft.payload
missing = supplier_missing(payload)
text = supplier_review_text(payload, missing)
@@ -71,11 +76,14 @@ async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
else:
await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
await draft_store.save_payload(draft, missing={"fields": missing})
await obs.event(trace_id or await obs.latest_trace_for_draft(draft.id), "draft.render",
payload={"fresh": fresh, "missing": [m.get("key") for m in missing if not m.get("ok")]})
async def commit(ctx, chat_id, user, draft) -> None:
async def commit(ctx, chat_id, user, draft, trace_id: str | None = None) -> None:
payload = draft.payload
await resolve_payload(ctx, payload)
trace_id = trace_id or await obs.latest_trace_for_draft(draft.id)
await resolve_payload(ctx, payload, trace_id)
missing = {m["key"]: m["ok"] for m in supplier_missing(payload)}
problems = [label for key, label, ok in [
("name", "نام تأمین‌کننده", missing.get("name")),
@@ -99,5 +107,8 @@ async def commit(ctx, chat_id, user, draft) -> None:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return
await draft_store.set_status(draft.id, "committed")
await obs.event(trace_id, "draft.commit", payload={"kind": "supplier",
"supplier_id": (created or {}).get("id")})
await obs.set_trace_status(trace_id, "committed")
ctx.resolver.invalidate("suppliers")
await ctx.send(chat_id, supplier_committed_report(created))