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.
116 lines
4.2 KiB
Python
116 lines
4.2 KiB
Python
"""Idempotent DDL for the bot's own tables (mirrors the api's init_db.py
|
|
convention — the ORM never creates tables; this script does, IF NOT EXISTS).
|
|
"""
|
|
import logging
|
|
|
|
from sqlalchemy import text
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models import Base
|
|
|
|
logger = logging.getLogger("bot.db_init")
|
|
|
|
DDL = [
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS bot_links (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
chat_id BIGINT UNIQUE NOT NULL,
|
|
user_id UUID,
|
|
casdoor_username TEXT,
|
|
display_name TEXT,
|
|
role VARCHAR(32),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
""",
|
|
"CREATE INDEX IF NOT EXISTS ix_bot_links_chat_id ON bot_links (chat_id)",
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS bot_drafts (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
chat_id BIGINT NOT NULL,
|
|
kind VARCHAR(16) NOT NULL,
|
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
missing JSONB,
|
|
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
|
source VARCHAR(16),
|
|
source_message_id BIGINT,
|
|
review_message_id BIGINT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
""",
|
|
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_id ON bot_drafts (chat_id)",
|
|
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_status ON bot_drafts (chat_id, status)",
|
|
"ALTER TABLE bot_drafts ADD COLUMN IF NOT EXISTS last_bale_update_at TIMESTAMPTZ",
|
|
"ALTER TABLE bot_drafts ADD COLUMN IF NOT EXISTS source_message_id BIGINT",
|
|
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_review_msg ON bot_drafts (review_message_id)",
|
|
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_source_msg ON bot_drafts (source_message_id)",
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS bot_messages (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
chat_id BIGINT NOT NULL,
|
|
direction VARCHAR(4) NOT NULL,
|
|
kind VARCHAR(16) NOT NULL DEFAULT 'text',
|
|
body TEXT,
|
|
payload JSONB,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
""",
|
|
"CREATE INDEX IF NOT EXISTS ix_bot_messages_chat_id ON bot_messages (chat_id)",
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS bot_meta (
|
|
key VARCHAR(64) PRIMARY KEY,
|
|
value TEXT,
|
|
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)",
|
|
]
|
|
|
|
|
|
async def ensure_bot_tables(session: AsyncSession) -> None:
|
|
for stmt in DDL:
|
|
await session.execute(text(stmt))
|
|
await session.commit()
|
|
logger.info("bot tables ready")
|
|
|
|
|
|
async def ensure_schema() -> None:
|
|
from app.db import SessionLocal
|
|
|
|
async with SessionLocal() as session:
|
|
await ensure_bot_tables(session)
|