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
+3
View File
@@ -2,6 +2,9 @@
.env .env
*.local *.local
# ---- Runtime data (voices etc.) — never commit, never delete ----
data/
# ---- Python ---- # ---- Python ----
__pycache__/ __pycache__/
*.pyc *.pyc
+6
View File
@@ -133,6 +133,12 @@ class Settings(BaseSettings):
# Drafts older than this are marked expired by the sweeper loop. # Drafts older than this are marked expired by the sweeper loop.
DRAFT_TTL_HOURS: int = 24 DRAFT_TTL_HOURS: int = 24
# Observability (phase 1) — silent pipeline recorder (DB only; no external
# tools, no extra LLM tokens). Data is NEVER deleted.
OBS_ENABLED: bool = True
OBS_VOICE_DIR: str = "data/voices" # persistent volume (./bot/data) — keep forever
OBS_PROMPT_VERSION: str = "" # manual label for the NLU prompt in use
LOG_LEVEL: str = "INFO" LOG_LEVEL: str = "INFO"
@property @property
+34
View File
@@ -64,6 +64,40 @@ DDL = [
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
) )
""", """,
# ---- Observability (phase 1) — silent pipeline recorder, DB-only -------
"""
CREATE TABLE IF NOT EXISTS bot_traces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chat_id BIGINT NOT NULL,
message_id BIGINT,
kind VARCHAR(8) NOT NULL,
source VARCHAR(16),
draft_id UUID,
draft_kind VARCHAR(16),
committed_quote_id UUID,
status VARCHAR(16) NOT NULL DEFAULT 'open',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
"CREATE INDEX IF NOT EXISTS ix_bot_traces_chat ON bot_traces (chat_id, created_at)",
"CREATE INDEX IF NOT EXISTS ix_bot_traces_draft ON bot_traces (draft_id)",
"""
CREATE TABLE IF NOT EXISTS bot_trace_events (
id BIGSERIAL PRIMARY KEY,
trace_id UUID NOT NULL REFERENCES bot_traces(id) ON DELETE CASCADE,
stage VARCHAR(32) NOT NULL,
ok BOOLEAN NOT NULL DEFAULT TRUE,
latency_ms INTEGER,
model VARCHAR(64),
field VARCHAR(32),
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
"CREATE INDEX IF NOT EXISTS ix_bot_trace_events_trace ON bot_trace_events (trace_id, created_at)",
"CREATE INDEX IF NOT EXISTS ix_bot_trace_events_stage ON bot_trace_events (stage)",
"CREATE INDEX IF NOT EXISTS ix_bot_trace_events_payload ON bot_trace_events USING GIN (payload jsonb_path_ops)",
] ]
+43 -12
View File
@@ -11,6 +11,7 @@ Pipeline per update:
import logging import logging
from app import drafts as draft_store from app import drafts as draft_store
from app import observability as obs
from app.config import settings from app.config import settings
from app.nlu import understand from app.nlu import understand
from app.runtime import BotContext, LinkedUser 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 voice_transcripts: list[dict] | None = None
message_id = message.get("message_id") 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("/"): if text.startswith("/"):
await commands.handle_command(ctx, chat_id, text) await commands.handle_command(ctx, chat_id, text)
return return
@@ -79,11 +90,20 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
file_id = audio_media["file_id"] file_id = audio_media["file_id"]
try: try:
audio_bytes = await ctx.bale.download_file(file_id) 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( transcripts = await transcribe_multi(
settings, settings,
audio_bytes, audio_bytes,
mime_type=audio_media.get("mime_type"), mime_type=audio_media.get("mime_type"),
filename=audio_media.get("file_name"), filename=audio_media.get("file_name"),
trace_id=trace_id,
) )
except Exception as exc: except Exception as exc:
detail = "هوش مصنوعی تنظیم نشده" if "not configured" in str(exc) else str(exc)[:200] 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 draft_summary = _draft_summary(target_draft) if target_draft else None
input_payload = voice_transcripts if voice_transcripts else text 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 {} intent, patch = result.get("intent"), result.get("patch") or {}
consensus_text = result.get("consensus_text") or text consensus_text = result.get("consensus_text") or text
await obs.event(trace_id, "entities.extracted", payload={"patch": patch, "intent": intent})
if source == "voice": if source == "voice":
await ctx._log(chat_id, "in", "voice", consensus_text) 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, ctx, chat_id, user, patch, source,
incoming_msg_id=message_id, incoming_msg_id=message_id,
target_draft=target_draft, target_draft=target_draft,
trace_id=trace_id,
) )
elif intent == "commit_draft": elif intent == "commit_draft":
active = target_draft or await draft_store.get_active(chat_id) active = target_draft or await draft_store.get_active(chat_id)
if active is None: if active is None:
await ctx.send(chat_id, NOTHING_TO_COMMIT, reply_to=message_id) await ctx.send(chat_id, NOTHING_TO_COMMIT, reply_to=message_id)
elif active.kind == draft_store.KIND_QUOTE: 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: 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: 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: 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": elif intent == "discard_draft":
active = target_draft or await draft_store.get_active(chat_id) active = target_draft or await draft_store.get_active(chat_id)
if active: if active:
await draft_store.set_status(active.id, "cancelled") 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: if active.review_message_id:
try: try:
await ctx.delete(chat_id, active.review_message_id) 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: elif active.kind == draft_store.KIND_PROJECT:
await project.render(ctx, chat_id, active) await project.render(ctx, chat_id, active)
elif intent == "add_supplier": 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": 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": 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": elif intent == "status_query":
await queries.handle_query(ctx, chat_id, user, patch) await queries.handle_query(ctx, chat_id, user, patch)
elif intent == "accept_quote": elif intent == "accept_quote":
@@ -375,18 +400,24 @@ async def _handle_ambiguity_user_reply(
draft, amb: dict, message: dict, incoming_msg_id: int | None draft, amb: dict, message: dict, incoming_msg_id: int | None
) -> None: ) -> None:
"""Handle text/audio correction sent directly as a reply to an ambiguity sub-message (Requirement 2.b).""" """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() text = (message.get("text") or message.get("caption") or "").strip()
if not text and (message.get("voice") or message.get("audio")): if not text and (message.get("voice") or message.get("audio")):
try: try:
audio_media = message.get("voice") or message.get("audio") audio_media = message.get("voice") or message.get("audio")
audio_bytes = await ctx.bale.download_file(audio_media["file_id"]) 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: except Exception:
text = "" text = ""
if 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) 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 {} patch = result.get("patch") or {}
# Apply user correction specifically to the target field # 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) draft_store.merge_quote_patch(draft.payload, patch)
amb["status"] = "applied" 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) await draft_store.save_payload(draft, mark_bale_synced=True)
# In-place edit main review card (Requirement 2.b) # 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) # Delete the ambiguity message (Requirement 2.b)
if amb.get("message_id"): 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 import logging
from app import drafts as draft_store from app import drafts as draft_store
from app import observability as obs
from app.runtime import LinkedUser from app.runtime import LinkedUser
from app.texts import CANCELLED, UNLINKED, esc 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: if not target_amb:
return 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": if action == "ok":
proposed = target_amb.get("proposed_patch") or {} proposed = target_amb.get("proposed_patch") or {}
draft_store.merge_quote_patch(payload, proposed) draft_store.merge_quote_patch(payload, proposed)
@@ -126,6 +133,9 @@ async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
return return
if arg == "no": if arg == "no":
await draft_store.set_status(draft.id, "cancelled") 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) ctx.pending.pop(chat_id, None)
if draft.review_message_id: if draft.review_message_id:
try: try:
@@ -151,8 +161,10 @@ async def _apply_pick(ctx, chat_id: int, user: LinkedUser, draft, field: str, id
await ctx.send(chat_id, "گزینه نامعتبر است.") await ctx.send(chat_id, "گزینه نامعتبر است.")
return return
choice = options[idx] 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: 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: elif draft.kind == draft_store.KIND_SUPPLIER:
await supplier.apply_pick(ctx, chat_id, user, draft, field, choice) await supplier.apply_pick(ctx, chat_id, user, draft, field, choice)
elif draft.kind == draft_store.KIND_MATERIAL: elif draft.kind == draft_store.KIND_MATERIAL:
+18 -7
View File
@@ -2,6 +2,7 @@
import logging import logging
from app import drafts as draft_store from app import drafts as draft_store
from app import observability as obs
from app.api.client import ApiError from app.api.client import ApiError
from app.drafts import ( from app.drafts import (
KIND_MATERIAL, merge_material_patch, material_missing, new_material_payload, 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") 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) draft = await draft_store.get_active(chat_id, KIND_MATERIAL)
if draft is not None: if draft is not None:
payload = draft.payload payload = draft.payload
@@ -22,15 +24,18 @@ async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -
payload = new_material_payload() payload = new_material_payload()
merge_material_patch(payload, patch) merge_material_patch(payload, patch)
draft = await draft_store.create(chat_id, KIND_MATERIAL, payload, source=source) 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 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", {}) options = payload.setdefault("options", {})
if not payload.get("canonical_unit_id") and payload.get("unit_name"): if not payload.get("canonical_unit_id") and payload.get("unit_name"):
res = await safe_resolve(ctx.resolver, "unit", payload["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: if res.resolved:
payload["canonical_unit_id"] = res.candidates[0].id payload["canonical_unit_id"] = res.candidates[0].id
payload["unit_name"] = res.candidates[0].name 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) 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 payload = draft.payload
missing = material_missing(payload) missing = material_missing(payload)
text = material_review_text(payload, missing) text = material_review_text(payload, missing)
@@ -71,11 +76,14 @@ async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
else: else:
await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard) await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
await draft_store.save_payload(draft, missing={"fields": missing}) 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 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)} missing = {m["key"]: m["ok"] for m in material_missing(payload)}
problems = [label for key, label, ok in [ problems = [label for key, label, ok in [
("name", "نام کالا", missing.get("name")), ("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))) await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return return
await draft_store.set_status(draft.id, "committed") 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") ctx.resolver.invalidate("materials")
await ctx.send(chat_id, material_committed_report(created)) await ctx.send(chat_id, material_committed_report(created))
+13 -4
View File
@@ -2,6 +2,7 @@
import logging import logging
from app import drafts as draft_store from app import drafts as draft_store
from app import observability as obs
from app.api.client import ApiError from app.api.client import ApiError
from app.drafts import ( from app.drafts import (
KIND_PROJECT, merge_project_patch, new_project_payload, project_missing, 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") 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) draft = await draft_store.get_active(chat_id, KIND_PROJECT)
if draft is not None: if draft is not None:
payload = draft.payload payload = draft.payload
@@ -21,15 +23,16 @@ async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -
payload = new_project_payload() payload = new_project_payload()
merge_project_patch(payload, patch) merge_project_patch(payload, patch)
draft = await draft_store.create(chat_id, KIND_PROJECT, payload, source=source) 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 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: def _keyboard(payload: dict, draft_id: str) -> dict:
return simple_review_keyboard(draft_id, KIND_PROJECT, []) 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 payload = draft.payload
missing = project_missing(payload) missing = project_missing(payload)
text = project_review_text(payload, missing) text = project_review_text(payload, missing)
@@ -42,10 +45,13 @@ async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
else: else:
await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard) await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
await draft_store.save_payload(draft, missing={"fields": missing}) 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 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)} missing = {m["key"]: m["ok"] for m in project_missing(payload)}
if not missing.get("name"): if not missing.get("name"):
await ctx.send(chat_id, "ثبت ممکن نیست؛ نام پروژه مشخص نشده است.") 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="خطای سرور")) await ctx.send(chat_id, ERR_GENERIC.format(detail="خطای سرور"))
return return
await draft_store.set_status(draft.id, "committed") 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") ctx.resolver.invalidate("projects")
await ctx.send(chat_id, project_committed_report(created)) await ctx.send(chat_id, project_committed_report(created))
+45 -12
View File
@@ -2,6 +2,7 @@
import logging import logging
from app import drafts as draft_store from app import drafts as draft_store
from app import observability as obs
from app.api.client import ApiError from app.api.client import ApiError
from app.drafts import ( from app.drafts import (
KIND_QUOTE, build_quote_request, compute_quote_missing, merge_quote_patch, 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, ctx, chat_id, user, patch: dict, source: str | None,
incoming_msg_id: int | None = None, incoming_msg_id: int | None = None,
target_draft = None, target_draft = None,
trace_id: str | None = None,
) -> None: ) -> None:
prev_review_msg_id = None prev_review_msg_id = None
if target_draft is not 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 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: if prev_review_msg_id:
try: try:
@@ -55,18 +58,25 @@ async def handle_entities(
except Exception as e: except Exception as e:
logger.warning("Could not delete previous review message %s: %s", prev_review_msg_id, 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 # 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", {}) options = payload.setdefault("options", {})
if not payload.get("supplier_id") and payload.get("supplier_name"): if not payload.get("supplier_id") and payload.get("supplier_name"):
res = await safe_resolve(ctx.resolver, "supplier", payload["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: if res.resolved:
c = res.candidates[0] c = res.candidates[0]
payload["supplier_id"] = c.id 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"): if not payload.get("project_id") and payload.get("project_name"):
res = await safe_resolve(ctx.resolver, "project", payload["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: if res.resolved:
c = res.candidates[0] c = res.candidates[0]
payload["project_id"] = c.id 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 []: for p_name in payload.get("project_names") or []:
res = await safe_resolve(ctx.resolver, "project", p_name) res = await safe_resolve(ctx.resolver, "project", p_name)
await _log_res(trace_id, "project", p_name, res)
if res.resolved: if res.resolved:
c = res.candidates[0] c = res.candidates[0]
p_id = c.id 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): for i, item in enumerate(payload.get("items") or [], start=1):
if not item.get("material_id") and item.get("material_name"): if not item.get("material_id") and item.get("material_name"):
res = await safe_resolve(ctx.resolver, "material", item["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: if res.resolved:
_set_material(item, res.candidates[0].id, res.candidates[0].name, res.candidates[0].extra) _set_material(item, res.candidates[0].id, res.candidates[0].name, res.candidates[0].extra)
options.pop(f"mat{i}", None) options.pop(f"mat{i}", None)
@@ -122,7 +135,7 @@ async def resolve_payload(ctx, payload: dict) -> None:
else: else:
options.pop(f"mat{i}", None) options.pop(f"mat{i}", None)
if not item.get("unit_id"): 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: 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")} 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"): if item.get("unit_id"):
return return
if item.get("unit_name"): if item.get("unit_name"):
res = await safe_resolve(ctx.resolver, "unit", item["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: if res.resolved:
item["unit_id"] = res.candidates[0].id item["unit_id"] = res.candidates[0].id
item["unit_explicit"] = True item["unit_explicit"] = True
@@ -146,8 +160,10 @@ async def _resolve_unit(ctx, item: dict) -> None:
item["unit_explicit"] = False 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 payload = draft.payload
trace_id = trace_id or await obs.latest_trace_for_draft(draft.id)
options = payload.setdefault("options", {}) options = payload.setdefault("options", {})
options.pop(field, None) 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): if 0 <= index < len(items):
items[index]["material_name"] = choice["name"] items[index]["material_name"] = choice["name"]
_set_material(items[index], choice["id"], 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"): elif field.startswith("unit"):
index = int(field[4:]) - 1 index = int(field[4:]) - 1
items = payload.get("items") or [] 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_explicit"] = True
items[index]["unit_name"] = choice["name"] 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 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, 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.config import settings
from app.security import create_draft_token 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) 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 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: async def send_pending_ambiguities(ctx, chat_id: int, draft) -> None:
from app.keyboards import ambiguity_keyboard 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 {} payload = draft.payload or {}
ambiguities = payload.get("ambiguities") or [] ambiguities = payload.get("ambiguities") or []
updated = False updated = False
trace_id = await obs.latest_trace_for_draft(draft.id)
for amb in ambiguities: for amb in ambiguities:
if amb.get("status") == "pending" and not amb.get("message_id"): 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"): if sent and sent.get("message_id"):
amb["message_id"] = sent["message_id"] amb["message_id"] = sent["message_id"]
updated = True 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: if updated:
await draft_store.save_payload(draft, mark_bale_synced=True) 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 # 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 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 = {} newly_created: dict = {}
@@ -418,6 +444,13 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None
ctx.resolver.invalidate() ctx.resolver.invalidate()
quote_id = (created or {}).get("id") or "" 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 # If a proforma file was attached, persist it to the created ERP quote
proforma_info = payload.get("proforma") proforma_info = payload.get("proforma")
if quote_id and proforma_info and proforma_info.get("saved_path"): if quote_id and proforma_info and proforma_info.get("saved_path"):
+18 -7
View File
@@ -2,6 +2,7 @@
import logging import logging
from app import drafts as draft_store from app import drafts as draft_store
from app import observability as obs
from app.api.client import ApiError from app.api.client import ApiError
from app.drafts import ( from app.drafts import (
KIND_SUPPLIER, merge_supplier_patch, new_supplier_payload, supplier_missing, 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") 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) draft = await draft_store.get_active(chat_id, KIND_SUPPLIER)
if draft is not None: if draft is not None:
payload = draft.payload payload = draft.payload
@@ -22,15 +24,18 @@ async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -
payload = new_supplier_payload() payload = new_supplier_payload()
merge_supplier_patch(payload, patch) merge_supplier_patch(payload, patch)
draft = await draft_store.create(chat_id, KIND_SUPPLIER, payload, source=source) 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 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", {}) options = payload.setdefault("options", {})
if not payload.get("category_id") and payload.get("category_name"): if not payload.get("category_id") and payload.get("category_name"):
res = await safe_resolve(ctx.resolver, "category", payload["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: if res.resolved:
payload["category_id"] = res.candidates[0].id payload["category_id"] = res.candidates[0].id
payload["category_name"] = res.candidates[0].name 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) 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 payload = draft.payload
missing = supplier_missing(payload) missing = supplier_missing(payload)
text = supplier_review_text(payload, missing) text = supplier_review_text(payload, missing)
@@ -71,11 +76,14 @@ async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
else: else:
await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard) await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
await draft_store.save_payload(draft, missing={"fields": missing}) 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 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)} missing = {m["key"]: m["ok"] for m in supplier_missing(payload)}
problems = [label for key, label, ok in [ problems = [label for key, label, ok in [
("name", "نام تأمین‌کننده", missing.get("name")), ("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))) await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return return
await draft_store.set_status(draft.id, "committed") 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") ctx.resolver.invalidate("suppliers")
await ctx.send(chat_id, supplier_committed_report(created)) await ctx.send(chat_id, supplier_committed_report(created))
+45 -1
View File
@@ -5,7 +5,7 @@ container and owns its own state (links, drafts, message audit log).
""" """
from datetime import datetime from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Index, Integer, String, Text, func from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -83,3 +83,47 @@ class BotMeta(Base):
key: Mapped[str] = mapped_column(String(64), primary_key=True) key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[str | None] = mapped_column(Text) value: Mapped[str | None] = mapped_column(Text)
updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now()) updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now())
class BotTrace(Base):
"""Observability: one row per incoming interaction (message) — the chain root.
Events of the whole pipeline hang off it (bot_trace_events). Linked to the
draft it produced so later button clicks can be attributed to the same chain.
"""
__tablename__ = "bot_traces"
id: Mapped[str] = _uuid_pk()
chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
message_id: Mapped[int | None] = mapped_column(BigInteger)
kind: Mapped[str] = mapped_column(String(8), nullable=False) # text | voice
source: Mapped[str | None] = mapped_column(String(16)) # bale | wizard | api
draft_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False))
draft_kind: Mapped[str | None] = mapped_column(String(16))
committed_quote_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False))
status: Mapped[str] = mapped_column(String(16), nullable=False, default="open") # open|committed|cancelled
created_at: Mapped[datetime] = _timestamptz()
updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now())
class BotTraceEvent(Base):
"""Observability: append-only pipeline event log (payload schemas by `stage`).
Written ONLY through app.observability (fire-and-forget, _jsonable-safe).
Nothing is ever deleted from here by the bot.
"""
__tablename__ = "bot_trace_events"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
trace_id: Mapped[str] = mapped_column(
UUID(as_uuid=False), ForeignKey("bot_traces.id", ondelete="CASCADE"), nullable=False
)
stage: Mapped[str] = mapped_column(String(32), nullable=False)
ok: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
latency_ms: Mapped[int | None] = mapped_column(Integer)
model: Mapped[str | None] = mapped_column(String(64))
field: Mapped[str | None] = mapped_column(String(32))
payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
created_at: Mapped[datetime] = _timestamptz()
+28 -2
View File
@@ -4,12 +4,14 @@ One chat/completions call per message with JSON-mode output. The prompt embeds
the current draft summary so the model can decide between "start a new the current draft summary so the model can decide between "start a new
activity" and "refine the open draft" (update_draft with a partial patch). activity" and "refine the open draft" (update_draft with a partial patch).
""" """
import hashlib
import json import json
import logging import logging
import re import re
import httpx import httpx
from app import observability as obs
from app.config import Settings from app.config import Settings
logger = logging.getLogger("bot.nlu") logger = logging.getLogger("bot.nlu")
@@ -143,7 +145,8 @@ def _extract_json(raw: str) -> dict | None:
return None return None
async def understand(settings: Settings, text: str | list[dict], draft_summary: str | None = None) -> dict: async def understand(settings: Settings, text: str | list[dict], draft_summary: str | None = None,
trace_id: str | None = None) -> dict:
"""Return {"consensus_text": ..., "intent": ..., "patch": {...}, "confidence": ...}; never raises.""" """Return {"consensus_text": ..., "intent": ..., "patch": {...}, "confidence": ...}; never raises."""
fallback = {"consensus_text": "", "intent": "unknown", "patch": {}, "confidence": 0.0} fallback = {"consensus_text": "", "intent": "unknown", "patch": {}, "confidence": 0.0}
if not settings.AI_BASE_URL or not settings.AI_API_KEY: if not settings.AI_BASE_URL or not settings.AI_API_KEY:
@@ -172,6 +175,17 @@ async def understand(settings: Settings, text: str | list[dict], draft_summary:
fallback["consensus_text"] = first_text fallback["consensus_text"] = first_text
system_prompt = _build_system_prompt(draft_summary)
prompt_hash = hashlib.sha256(
f"{settings.OBS_PROMPT_VERSION}|{system_prompt}".encode()
).hexdigest()[:16]
await obs.event(trace_id, "nlu.request", model=settings.LLM_MODEL, payload={
"is_multi": isinstance(text, list),
"content": user_content[:2000],
"prompt_hash": prompt_hash,
"prompt_version": settings.OBS_PROMPT_VERSION,
})
try: try:
async with httpx.AsyncClient(timeout=settings.LLM_TIMEOUT_SECONDS) as client: async with httpx.AsyncClient(timeout=settings.LLM_TIMEOUT_SECONDS) as client:
resp = await client.post( resp = await client.post(
@@ -180,7 +194,7 @@ async def understand(settings: Settings, text: str | list[dict], draft_summary:
json={ json={
"model": settings.LLM_MODEL, "model": settings.LLM_MODEL,
"messages": [ "messages": [
{"role": "system", "content": _build_system_prompt(draft_summary)}, {"role": "system", "content": system_prompt},
{"role": "user", "content": user_content}, {"role": "user", "content": user_content},
], ],
"temperature": 0.1, "temperature": 0.1,
@@ -189,16 +203,28 @@ async def understand(settings: Settings, text: str | list[dict], draft_summary:
) )
if resp.status_code != 200: if resp.status_code != 200:
logger.error("LLM HTTP %s: %s", resp.status_code, resp.text[:300]) logger.error("LLM HTTP %s: %s", resp.status_code, resp.text[:300])
await obs.event(trace_id, "nlu.result", ok=False, model=settings.LLM_MODEL,
payload={"error": f"HTTP {resp.status_code}", "body": resp.text[:300]})
return fallback return fallback
content = ((resp.json() or {}).get("choices") or [{}])[0].get("message", {}).get("content", "") content = ((resp.json() or {}).get("choices") or [{}])[0].get("message", {}).get("content", "")
parsed = _extract_json(content) parsed = _extract_json(content)
if not parsed or parsed.get("intent") not in INTENTS: if not parsed or parsed.get("intent") not in INTENTS:
logger.warning("unparseable LLM output: %r", content[:200]) logger.warning("unparseable LLM output: %r", content[:200])
await obs.event(trace_id, "nlu.result", ok=False, model=settings.LLM_MODEL,
payload={"error": "unparseable", "raw": content[:500]})
return fallback return fallback
parsed.setdefault("patch", {}) parsed.setdefault("patch", {})
if not parsed.get("consensus_text"): if not parsed.get("consensus_text"):
parsed["consensus_text"] = first_text parsed["consensus_text"] = first_text
await obs.event(trace_id, "nlu.result", ok=True, model=settings.LLM_MODEL, payload={
"intent": parsed.get("intent"),
"confidence": parsed.get("confidence"),
"patch": parsed.get("patch"),
"consensus_text": parsed.get("consensus_text"),
})
return parsed return parsed
except (httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError): except (httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError):
logger.exception("NLU failure") logger.exception("NLU failure")
await obs.event(trace_id, "nlu.result", ok=False, model=settings.LLM_MODEL,
payload={"error": "exception"})
return fallback return fallback
+206
View File
@@ -0,0 +1,206 @@
"""Silent observer — records the whole bot pipeline into Postgres (phase 1).
Design rules (final plan v5):
- DB is the single source of truth; no external tools, no dashboards here.
- ZERO extra LLM tokens: we only persist values that already exist in code.
- Never raises, never blocks the reply path (fire-and-forget like runtime._log).
- Never deletes anything (no retention/cleanup tasks exist by policy).
- `_jsonable` guarantees JSONB-safety for UUID/datetime/Decimal/... payloads.
Stages captured (see plan §4.3): input.text, input.voice, stt.run, stt.result,
nlu.request, nlu.result, entities.extracted, jev.request, jev.result,
routing.decision, ambiguity.sent, ambiguity.user_choice, user_choice.pick,
draft.render, draft.edit, draft.commit, draft.cancel.
"""
import asyncio
import logging
import uuid
from datetime import date, datetime, time
from decimal import Decimal
from enum import Enum
from pathlib import Path
from sqlalchemy import select, update
from app.config import settings
from app.db import SessionLocal
from app.models import BotTrace, BotTraceEvent
logger = logging.getLogger("bot.obs")
_bg_tasks: set = set()
def _fire(coro) -> None:
"""Schedule a coroutine without awaiting; keep a ref so it is not GC'd."""
try:
task = asyncio.get_running_loop().create_task(coro)
_bg_tasks.add(task)
task.add_done_callback(_bg_tasks.discard)
except RuntimeError:
# No running loop (should not happen inside the bot) — drop silently but log.
logger.warning("obs: no running loop; dropped %r", coro)
def _jsonable(obj):
"""Recursively make any Python object JSONB-safe (UUID/datetime/Decimal/…)."""
if obj is None or isinstance(obj, (bool, int, float, str)):
return obj
if isinstance(obj, (uuid.UUID, Path)):
return str(obj)
if isinstance(obj, (datetime, date, time)):
return obj.isoformat()
if isinstance(obj, Decimal):
return str(obj) # دقت حفظ می‌شود؛ در تحلیل با ::numeric قابل تبدیل است
if isinstance(obj, Enum):
return _jsonable(obj.value)
if isinstance(obj, dict):
return {str(k): _jsonable(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple, set, frozenset)):
return [_jsonable(v) for v in obj]
if hasattr(obj, "model_dump"): # pydantic v2
return _jsonable(obj.model_dump())
if hasattr(obj, "__dataclass_fields__"):
return _jsonable(vars(obj))
return str(obj) # تور نهایی — هرگز raise نمی‌کند
# ---------------------------------------------------------------------------
# traces
# ---------------------------------------------------------------------------
async def trace_start(chat_id: int, message_id: int | None, kind: str,
source: str = "bale") -> str | None:
"""Open a new trace; returns its id (str) or None on failure."""
try:
async with SessionLocal() as s:
t = BotTrace(chat_id=chat_id, message_id=message_id, kind=kind, source=source)
s.add(t)
await s.commit()
return str(t.id)
except Exception:
logger.exception("obs: trace_start failed")
return None
async def link_draft(trace_id: str | None, draft_id, draft_kind: str | None) -> None:
"""Attach a draft to its originating trace (so later button clicks can find it)."""
if not trace_id:
return
try:
async with SessionLocal() as s:
await s.execute(
update(BotTrace)
.where(BotTrace.id == trace_id)
.values(draft_id=str(draft_id), draft_kind=draft_kind)
)
await s.commit()
except Exception:
logger.exception("obs: link_draft failed")
async def latest_trace_for_draft(draft_id) -> str | None:
"""Newest trace that produced this draft — used by button/render flows."""
try:
async with SessionLocal() as s:
row = (await s.execute(
select(BotTrace.id)
.where(BotTrace.draft_id == str(draft_id))
.order_by(BotTrace.created_at.desc())
.limit(1)
)).scalar()
return str(row) if row else None
except Exception:
logger.exception("obs: latest_trace_for_draft failed")
return None
async def set_trace_status(trace_id: str | None, status: str,
committed_quote_id: str | None = None) -> None:
if not trace_id:
return
try:
async with SessionLocal() as s:
values = {"status": status}
if committed_quote_id:
values["committed_quote_id"] = str(committed_quote_id)
await s.execute(update(BotTrace).where(BotTrace.id == trace_id).values(**values))
await s.commit()
except Exception:
logger.exception("obs: set_trace_status failed")
# ---------------------------------------------------------------------------
# events
# ---------------------------------------------------------------------------
async def event(trace_id, stage: str, *, ok: bool = True, latency_ms: int | None = None,
model: str | None = None, field: str | None = None,
payload: dict | None = None) -> None:
"""Append one pipeline event. Never raises; failures are loud in the log."""
if not trace_id or not settings.OBS_ENABLED:
return
try:
async with SessionLocal() as s:
s.add(BotTraceEvent(
trace_id=str(trace_id), stage=stage, ok=ok, latency_ms=latency_ms,
model=model, field=field, payload=_jsonable(payload or {}),
))
await s.commit()
except Exception:
logger.exception("obs: event %s failed", stage) # خطای خاموش نمی‌ماند
async def log_resolution(trace_id, field: str, mention, status: str, candidates: list) -> None:
"""Record one matching attempt (difflib baseline / fallback paths)."""
await event(trace_id, "routing.decision", field=field, payload={
"source": "difflib",
"mention": mention,
"status": status,
"candidates": candidates[:4],
})
class span:
"""with span('stt.run', trace_id=t, provider='whisper'): ... → one DB event with latency/ok."""
def __init__(self, name: str, trace_id: str | None = None, **attrs):
self.name, self.trace_id, self.attrs = name, trace_id, attrs
self._t0 = None
def __enter__(self):
import time as _t
self._t0 = _t.perf_counter_ns()
return self
def __exit__(self, exc_type, exc, tb):
import time as _t
try:
_fire(event(
self.trace_id, self.name, ok=exc_type is None,
latency_ms=int((_t.perf_counter_ns() - self._t0) / 1e6),
model=self.attrs.get("model"), field=self.attrs.get("field"),
payload=self.attrs,
))
except Exception:
logger.exception("obs: span %s failed", self.name)
return False # استثنا هرگز بلعیده نمی‌شود
# ---------------------------------------------------------------------------
# voice persistence (never deleted)
# ---------------------------------------------------------------------------
def save_voice(trace_id: str | None, message_id: int, content: bytes,
ext: str = ".ogg") -> str | None:
"""Persist a voice note under OBS_VOICE_DIR; creates the day folder safely."""
try:
day = datetime.utcnow().strftime("%Y%m%d")
base = Path(settings.OBS_VOICE_DIR) / day
base.mkdir(parents=True, exist_ok=True) # الزام سخت‌سازی: مسیر همیشه ساخته می‌شود
path = base / f"{trace_id}_{message_id}{ext}"
path.write_bytes(content)
return str(path)
except Exception:
logger.warning("obs: save_voice failed for trace %s", trace_id, exc_info=True)
return None
+32 -10
View File
@@ -9,10 +9,12 @@ import asyncio
import logging import logging
import subprocess import subprocess
import tempfile import tempfile
import time
from pathlib import Path from pathlib import Path
import httpx import httpx
from app import observability as obs
from app.config import Settings from app.config import Settings
logger = logging.getLogger("bot.stt") logger = logging.getLogger("bot.stt")
@@ -37,7 +39,8 @@ def _to_mp3(raw: bytes, suffix: str) -> bytes:
return dst.read_bytes() return dst.read_bytes()
async def _call_single_stt(client: httpx.AsyncClient, provider: dict, converted_mp3: bytes) -> dict | None: async def _call_single_stt(client: httpx.AsyncClient, provider: dict, converted_mp3: bytes) -> dict:
"""Call one provider; always returns {"name","ok","text"/"error","model","latency_ms"}."""
name = provider.get("name") or "stt" name = provider.get("name") or "stt"
base_url = (provider.get("base_url") or "").rstrip("/") base_url = (provider.get("base_url") or "").rstrip("/")
api_key = provider.get("api_key") or "" api_key = provider.get("api_key") or ""
@@ -46,6 +49,7 @@ async def _call_single_stt(client: httpx.AsyncClient, provider: dict, converted_
timeout = float(provider.get("timeout") or _STT_TIMEOUT) timeout = float(provider.get("timeout") or _STT_TIMEOUT)
url = f"{base_url}/audio/transcriptions" url = f"{base_url}/audio/transcriptions"
t0 = time.perf_counter()
try: try:
resp = await client.post( resp = await client.post(
url, url,
@@ -54,24 +58,30 @@ async def _call_single_stt(client: httpx.AsyncClient, provider: dict, converted_
data={"model": model, "language": language}, data={"model": model, "language": language},
timeout=timeout, timeout=timeout,
) )
ms = int((time.perf_counter() - t0) * 1000)
if resp.status_code == 200: if resp.status_code == 200:
txt = str((resp.json() or {}).get("text") or "").strip() txt = str((resp.json() or {}).get("text") or "").strip()
if txt: if txt:
logger.info("STT provider %s succeeded: %r", name, txt[:60]) logger.info("STT provider %s succeeded: %r", name, txt[:60])
return {"name": name, "text": txt, "model": model} return {"name": name, "ok": True, "text": txt, "model": model, "latency_ms": ms}
else: return {"name": name, "ok": False, "error": "empty transcript (HTTP 200)",
"model": model, "latency_ms": ms}
logger.warning("STT provider %s returned HTTP %s: %s", name, resp.status_code, resp.text[:200]) logger.warning("STT provider %s returned HTTP %s: %s", name, resp.status_code, resp.text[:200])
return {"name": name, "ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}",
"model": model, "latency_ms": ms}
except Exception as exc: except Exception as exc:
logger.warning("STT provider %s failed: %s", name, exc) logger.warning("STT provider %s failed: %s", name, exc)
return None return {"name": name, "ok": False, "error": str(exc)[:300],
"model": model, "latency_ms": int((time.perf_counter() - t0) * 1000)}
async def transcribe_multi(settings: Settings, audio: bytes, mime_type: str | None = None, async def transcribe_multi(settings: Settings, audio: bytes, mime_type: str | None = None,
filename: str | None = None) -> list[dict]: filename: str | None = None, trace_id: str | None = None) -> list[dict]:
"""Transcribe audio with all configured STT providers in parallel. """Transcribe audio with all configured STT providers in parallel.
Returns a list of candidate dicts: [{"name": str, "text": str, "model": str}, ...] Returns a list of candidate dicts: [{"name": str, "text": str, "model": str}, ...]
Empty list if all failed or silence. Empty list if all failed or silence. Every provider outcome (success AND
failure) is recorded in Observability when trace_id is given.
""" """
providers = settings.stt_providers providers = settings.stt_providers
if not providers: if not providers:
@@ -83,20 +93,32 @@ async def transcribe_multi(settings: Settings, audio: bytes, mime_type: str | No
"audio/mp4": ".m4a", "audio/wav": ".wav"}.get(mime_type.split(";")[0].strip(), ".ogg") "audio/mp4": ".m4a", "audio/wav": ".wav"}.get(mime_type.split(";")[0].strip(), ".ogg")
converted = await asyncio.to_thread(_to_mp3, audio, suffix) converted = await asyncio.to_thread(_to_mp3, audio, suffix)
await obs.event(trace_id, "stt.run", payload={
"providers": [p.get("name") or "stt" for p in providers],
"audio_bytes": len(converted),
})
async with httpx.AsyncClient(timeout=_STT_TIMEOUT) as client: async with httpx.AsyncClient(timeout=_STT_TIMEOUT) as client:
tasks = [_call_single_stt(client, p, converted) for p in providers] tasks = [_call_single_stt(client, p, converted) for p in providers]
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
valid_transcripts: list[dict] = [] valid_transcripts: list[dict] = []
for r in results: for r in results:
if isinstance(r, dict) and r.get("text"): if not isinstance(r, dict):
valid_transcripts.append(r) continue
await obs.event(trace_id, "stt.result", ok=bool(r.get("ok")),
latency_ms=r.get("latency_ms"), model=r.get("model"),
payload={"provider": r.get("name"), "text": r.get("text"),
"error": r.get("error")})
if r.get("ok") and r.get("text"):
valid_transcripts.append({"name": r["name"], "text": r["text"], "model": r.get("model")})
return valid_transcripts return valid_transcripts
async def transcribe(settings: Settings, audio: bytes, mime_type: str | None = None, async def transcribe(settings: Settings, audio: bytes, mime_type: str | None = None,
filename: str | None = None) -> str: filename: str | None = None, trace_id: str | None = None) -> str:
"""Return the Persian transcript for a voice note (empty string on silence).""" """Return the Persian transcript for a voice note (empty string on silence)."""
transcripts = await transcribe_multi(settings, audio, mime_type=mime_type, filename=filename) transcripts = await transcribe_multi(settings, audio, mime_type=mime_type,
filename=filename, trace_id=trace_id)
return transcripts[0]["text"] if transcripts else "" return transcripts[0]["text"] if transcripts else ""
+14
View File
@@ -8,6 +8,7 @@ If a draft was updated in the Wizard:
import asyncio import asyncio
import logging import logging
from app import drafts as draft_store from app import drafts as draft_store
from app import observability as obs
from app.handlers import quote from app.handlers import quote
logger = logging.getLogger("bot.sync") logger = logging.getLogger("bot.sync")
@@ -40,6 +41,19 @@ async def sync_drafts_loop(ctx) -> None:
logger.info("Synced cancelled draft %s to Bale message %s", draft.id, draft.review_message_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: 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 quote.render(ctx, draft.chat_id, draft, fresh=False)
await draft_store.set_bale_updated_at(draft.id) 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) logger.info("Synced active draft %s edits to Bale message %s", draft.id, draft.review_message_id)
+140
View File
@@ -0,0 +1,140 @@
"""Phase-1 tests for the Observability subsystem (run INSIDE the bot container):
docker compose exec bot python tools/test_observability.py
Covers: _jsonable safety (UUID/datetime/Decimal/set/enum/dataclass), trace
lifecycle, event round-trip through JSONB, draft linking, status updates,
save_voice (day-folder mkdir), and the span() context manager.
NOTE (policy): test rows/files are tagged `_test` and are NEVER deleted —
the observability subsystem has no retention/cleanup by design.
"""
import asyncio
import dataclasses
import sys
import uuid
from datetime import datetime, timezone
from decimal import Decimal
from enum import Enum
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from sqlalchemy import select
from app import observability as obs
from app.db import SessionLocal
from app.db_init import ensure_schema
from app.models import BotTrace, BotTraceEvent
PASSED: list[str] = []
FAILED: list[str] = []
def check(name: str, ok: bool, extra: str = "") -> None:
(PASSED if ok else FAILED).append(name)
print(f"{'PASS' if ok else 'FAIL'} {name}{' — ' + str(extra) if extra else ''}")
class SampleColor(Enum):
RED = "red"
@dataclasses.dataclass
class SampleRec:
a: int
b: str
def test_jsonable() -> None:
payload = {
"uuid": uuid.uuid4(),
"dt": datetime(2026, 10, 12, 10, 30, tzinfo=timezone.utc),
"dec": Decimal("123.45"),
"set": {"x", "y"},
"enum": SampleColor.RED,
"dc": SampleRec(a=1, b="two"),
"nested": [{"inner": uuid.UUID(int=1)}],
"none": None,
}
out = obs._jsonable(payload)
ok = (
isinstance(out["uuid"], str)
and isinstance(out["dt"], str)
and isinstance(out["dec"], str)
and isinstance(out["set"], list)
and out["enum"] == "red"
and isinstance(out["dc"], dict)
)
check("_jsonable produces JSON-serializable output", ok,
f"dt={out['dt'][:19]}, dec={out['dec']!r}")
async def main() -> None:
await ensure_schema()
test_jsonable()
# 1) trace lifecycle
tid = await obs.trace_start(chat_id=0, message_id=999001, kind="voice")
check("trace_start returns an id", bool(tid), str(tid))
# 2) events with tricky payloads (must not raise; must be stored)
await obs.event(tid, "input.voice", payload={
"_test": True, "size": 1234,
"when": datetime.now(timezone.utc),
"amount": Decimal("9.9"),
})
await obs.event(tid, "routing.decision", field="project", payload={
"_test": True,
"candidates": [{"id": uuid.uuid4(), "name": "منشانه"}],
})
await obs.log_resolution(tid, "supplier", "مصطفوی", "many",
[{"id": "x", "name": "بازرگانی مصطفوی"}])
# 3) draft linking + latest lookup
draft_id = str(uuid.uuid4())
await obs.link_draft(tid, draft_id, "quote")
latest = await obs.latest_trace_for_draft(draft_id)
check("latest_trace_for_draft returns the linked trace", latest == tid, latest)
# 4) status update + JSONB round-trip
await obs.set_trace_status(tid, "committed", str(uuid.uuid4()))
async with SessionLocal() as s:
tr = (await s.execute(select(BotTrace).where(BotTrace.id == tid))).scalar_one()
check("trace status + quote id updated",
tr.status == "committed" and tr.committed_quote_id is not None)
rows = (await s.execute(
select(BotTraceEvent).where(BotTraceEvent.trace_id == tid)
.order_by(BotTraceEvent.id)
)).scalars().all()
check("events persisted", len(rows) >= 3, f"{len(rows)} rows")
proj = next((r for r in rows if r.stage == "routing.decision"), None)
check("JSONB payload round-trip keeps values as strings",
proj is not None and isinstance(proj.payload["candidates"][0]["id"], str))
# 5) save_voice (parents mkdir + day folder)
path = obs.save_voice(tid, 999001, b"OGGFAKE", ext=".ogg")
check("save_voice writes file under day folder",
bool(path) and Path(path).exists(), str(path))
# 6) span() context manager (fire-and-forget task)
with obs.span("span.test", trace_id=tid, note="unit"):
pass
await asyncio.sleep(0.4)
async with SessionLocal() as s:
n = (await s.execute(
select(BotTraceEvent).where(
BotTraceEvent.trace_id == tid, BotTraceEvent.stage == "span.test"
)
)).scalars().all()
check("span() recorded via background task", len(n) == 1, f"{len(n)} rows")
total = len(PASSED) + len(FAILED)
print(f"\n{len(PASSED)}/{total} passed")
if FAILED:
print("FAILED:", FAILED)
raise SystemExit(1)
if __name__ == "__main__":
asyncio.run(main())
+1
View File
@@ -322,6 +322,7 @@ services:
volumes: volumes:
- ./bot/app:/srv/bot/app - ./bot/app:/srv/bot/app
- ./bot/data:/srv/bot/data
environment: environment:
PROCUREMENT_BOT_BOT_TOKEN: ${BALE_BOT_TOKEN:-1354444845:yhVMnKZBFuKaU3UW5fP1bHmknEHx99mipWw} PROCUREMENT_BOT_BOT_TOKEN: ${BALE_BOT_TOKEN:-1354444845:yhVMnKZBFuKaU3UW5fP1bHmknEHx99mipWw}