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:
@@ -2,6 +2,9 @@
|
|||||||
.env
|
.env
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# ---- Runtime data (voices etc.) — never commit, never delete ----
|
||||||
|
data/
|
||||||
|
|
||||||
# ---- Python ----
|
# ---- Python ----
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
@@ -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"):
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
+33
-11
@@ -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)",
|
||||||
logger.warning("STT provider %s returned HTTP %s: %s", name, resp.status_code, resp.text[:200])
|
"model": model, "latency_ms": ms}
|
||||||
|
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 ""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user