Initial import with GitLab CI/CD and registry deploy flow
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
import uuid
|
||||
|
||||
|
||||
def utc_now_iso() -> str:
|
||||
return datetime.now(tz=timezone.utc).replace(microsecond=0).isoformat()
|
||||
|
||||
|
||||
def new_id(prefix: str) -> str:
|
||||
return f"{prefix}_{uuid.uuid4().hex[:10]}"
|
||||
|
||||
|
||||
class Role(str, Enum):
|
||||
ADMIN = "admin"
|
||||
SUPERVISOR = "supervisor"
|
||||
OPERATOR = "operator"
|
||||
ANALYST = "analyst"
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
|
||||
def _normalize_database_url(url: str) -> str:
|
||||
if url.startswith("postgres://"):
|
||||
return "postgresql+psycopg://" + url[len("postgres://") :]
|
||||
if url.startswith("postgresql://") and "+psycopg" not in url:
|
||||
return "postgresql+psycopg://" + url[len("postgresql://") :]
|
||||
return url
|
||||
|
||||
|
||||
def _default_sqlite_url() -> str:
|
||||
data_dir = Path(os.getenv("CC_DATA_DIR", ".data")).resolve()
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
return f"sqlite:///{(data_dir / 'mvp_cc.db').as_posix()}"
|
||||
|
||||
|
||||
DATABASE_URL = _normalize_database_url(os.getenv("DATABASE_URL", _default_sqlite_url()))
|
||||
|
||||
_connect_args: dict[str, Any] = {}
|
||||
_engine_kwargs: dict[str, Any] = {"pool_pre_ping": True, "future": True, "connect_args": _connect_args}
|
||||
if DATABASE_URL.startswith("sqlite"):
|
||||
# SQLite is used in local/demo mode where multiple services may write concurrently.
|
||||
# Use NullPool so short-lived requests across many local services don't exhaust
|
||||
# a tiny per-process QueuePool during UI polling. Keep WAL + busy timeout to
|
||||
# reduce "database is locked" failures.
|
||||
_connect_args = {"check_same_thread": False, "timeout": float(os.getenv("SQLITE_BUSY_TIMEOUT_SECONDS", "120"))}
|
||||
_engine_kwargs["connect_args"] = _connect_args
|
||||
_engine_kwargs["poolclass"] = NullPool
|
||||
else:
|
||||
_engine_kwargs.update(
|
||||
{
|
||||
"pool_size": int(os.getenv("DB_POOL_SIZE", "10")),
|
||||
"max_overflow": int(os.getenv("DB_MAX_OVERFLOW", "20")),
|
||||
"pool_timeout": int(os.getenv("DB_POOL_TIMEOUT_SECONDS", "30")),
|
||||
"pool_recycle": int(os.getenv("DB_POOL_RECYCLE_SECONDS", "1800")),
|
||||
}
|
||||
)
|
||||
|
||||
engine = create_engine(DATABASE_URL, **_engine_kwargs)
|
||||
|
||||
if DATABASE_URL.startswith("sqlite"):
|
||||
@event.listens_for(engine, "connect")
|
||||
def _configure_sqlite_connection(dbapi_connection: Any, _connection_record: Any) -> None:
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL;")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL;")
|
||||
cursor.execute(f"PRAGMA busy_timeout={int(float(os.getenv('SQLITE_BUSY_TIMEOUT_SECONDS', '120')) * 1000)};")
|
||||
cursor.close()
|
||||
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||
|
||||
|
||||
def get_session() -> Session:
|
||||
return SessionLocal()
|
||||
@@ -0,0 +1,399 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterator
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from services.shared.core import new_id, utc_now_iso
|
||||
from services.shared.models import EventEnvelope, EventOutboxItem
|
||||
from services.shared.sql_models import EventInboxRow, EventOutboxRow
|
||||
|
||||
EXCHANGE_NAME = "mvpcc.domain.events"
|
||||
AUDIT_QUEUE = "mvpcc.audit.events"
|
||||
REPORTING_QUEUE = "mvpcc.reporting.events"
|
||||
PREFETCH_COUNT = 20
|
||||
RETRY_SCHEDULE = (5, 15, 60, 300)
|
||||
|
||||
|
||||
def _bool_env(name: str, default: bool) -> bool:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def event_bus_enabled() -> bool:
|
||||
return _bool_env("EVENT_BUS_ENABLED", False)
|
||||
|
||||
|
||||
def consumer_enabled() -> bool:
|
||||
return _bool_env("EVENT_BUS_CONSUMER_ENABLED", True)
|
||||
|
||||
|
||||
def event_bus_url() -> str:
|
||||
return os.getenv("EVENT_BUS_URL", "amqp://guest:guest@localhost:5672/").strip()
|
||||
|
||||
|
||||
def event_bus_exchange() -> str:
|
||||
return os.getenv("EVENT_BUS_EXCHANGE", EXCHANGE_NAME).strip() or EXCHANGE_NAME
|
||||
|
||||
|
||||
def event_bus_audit_queue() -> str:
|
||||
return os.getenv("EVENT_BUS_AUDIT_QUEUE", AUDIT_QUEUE).strip() or AUDIT_QUEUE
|
||||
|
||||
|
||||
def event_bus_reporting_queue() -> str:
|
||||
return os.getenv("EVENT_BUS_REPORTING_QUEUE", REPORTING_QUEUE).strip() or REPORTING_QUEUE
|
||||
|
||||
|
||||
def event_bus_dispatch_batch_size() -> int:
|
||||
try:
|
||||
return max(1, int(os.getenv("EVENT_BUS_DISPATCH_BATCH_SIZE", "50")))
|
||||
except ValueError:
|
||||
return 50
|
||||
|
||||
|
||||
def event_bus_max_attempts() -> int:
|
||||
try:
|
||||
return max(1, int(os.getenv("EVENT_BUS_MAX_ATTEMPTS", "5")))
|
||||
except ValueError:
|
||||
return 5
|
||||
|
||||
|
||||
def event_bus_poll_interval_seconds() -> float:
|
||||
try:
|
||||
return max(0.25, float(os.getenv("EVENT_BUS_POLL_INTERVAL_SECONDS", "2")))
|
||||
except ValueError:
|
||||
return 2.0
|
||||
|
||||
|
||||
def _lazy_import_pika():
|
||||
try:
|
||||
import pika # type: ignore
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("pika is required for EVENT_BUS_ENABLED=1") from exc
|
||||
return pika
|
||||
|
||||
|
||||
def _iso_in(seconds: int) -> str:
|
||||
dt = datetime.now(timezone.utc).timestamp() + seconds
|
||||
return datetime.fromtimestamp(dt, tz=timezone.utc).replace(microsecond=0).isoformat()
|
||||
|
||||
|
||||
def retry_delay_seconds(attempt_count: int) -> int:
|
||||
if attempt_count <= 1:
|
||||
return RETRY_SCHEDULE[0]
|
||||
if attempt_count == 2:
|
||||
return RETRY_SCHEDULE[1]
|
||||
if attempt_count == 3:
|
||||
return RETRY_SCHEDULE[2]
|
||||
return RETRY_SCHEDULE[3]
|
||||
|
||||
|
||||
def routing_key_for(event_type: str) -> str:
|
||||
return event_type.strip()
|
||||
|
||||
|
||||
def build_envelope(
|
||||
*,
|
||||
event_type: str,
|
||||
producer: str,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
payload: dict[str, Any],
|
||||
correlation_id: str | None = None,
|
||||
event_id: str | None = None,
|
||||
event_version: int = 1,
|
||||
) -> EventEnvelope:
|
||||
return EventEnvelope(
|
||||
event_id=event_id or new_id("evt"),
|
||||
event_type=event_type,
|
||||
event_version=event_version,
|
||||
occurred_at=utc_now_iso(),
|
||||
producer=producer,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
correlation_id=correlation_id,
|
||||
routing_key=routing_key_for(event_type),
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def outbox_item_from_row(row: EventOutboxRow) -> EventOutboxItem:
|
||||
envelope = json.loads(row.payload_json or "{}")
|
||||
return EventOutboxItem(
|
||||
event_id=row.event_id,
|
||||
event_type=row.event_type,
|
||||
event_version=row.event_version,
|
||||
producer_service=row.producer_service,
|
||||
entity_type=row.entity_type,
|
||||
entity_id=row.entity_id,
|
||||
correlation_id=row.correlation_id,
|
||||
routing_key=row.routing_key,
|
||||
payload=envelope.get("payload") if isinstance(envelope.get("payload"), dict) else {},
|
||||
status=row.status,
|
||||
attempt_count=row.attempt_count,
|
||||
last_error=row.last_error,
|
||||
available_at=row.available_at,
|
||||
published_at=row.published_at,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def append_outbox_event(
|
||||
session: Session,
|
||||
*,
|
||||
event_type: str,
|
||||
producer_service: str,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
payload: dict[str, Any],
|
||||
correlation_id: str | None = None,
|
||||
) -> EventOutboxRow:
|
||||
envelope = build_envelope(
|
||||
event_type=event_type,
|
||||
producer=producer_service,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
payload=payload,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
now = utc_now_iso()
|
||||
row = EventOutboxRow(
|
||||
event_id=envelope.event_id,
|
||||
event_type=envelope.event_type,
|
||||
event_version=envelope.event_version,
|
||||
producer_service=envelope.producer,
|
||||
entity_type=envelope.entity_type,
|
||||
entity_id=envelope.entity_id,
|
||||
correlation_id=envelope.correlation_id,
|
||||
routing_key=envelope.routing_key,
|
||||
payload_json=json.dumps(envelope.model_dump(), ensure_ascii=False),
|
||||
status="pending",
|
||||
attempt_count=0,
|
||||
last_error=None,
|
||||
available_at=now,
|
||||
published_at=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
session.add(row)
|
||||
return row
|
||||
|
||||
|
||||
def list_publishable_outbox(session: Session, limit: int | None = None) -> list[EventOutboxRow]:
|
||||
now = utc_now_iso()
|
||||
stmt = (
|
||||
select(EventOutboxRow)
|
||||
.where(
|
||||
EventOutboxRow.available_at <= now,
|
||||
EventOutboxRow.status.in_(["pending", "failed"]),
|
||||
)
|
||||
.order_by(EventOutboxRow.id.asc())
|
||||
)
|
||||
rows = session.execute(stmt).scalars().all()
|
||||
if limit is None:
|
||||
limit = event_bus_dispatch_batch_size()
|
||||
return rows[:limit]
|
||||
|
||||
|
||||
def mark_outbox_published(session: Session, row: EventOutboxRow) -> None:
|
||||
now = utc_now_iso()
|
||||
row.status = "published"
|
||||
row.published_at = now
|
||||
row.updated_at = now
|
||||
row.last_error = None
|
||||
|
||||
|
||||
def mark_outbox_failed(session: Session, row: EventOutboxRow, error: str) -> None:
|
||||
row.attempt_count += 1
|
||||
row.status = "failed"
|
||||
row.last_error = error[:2000]
|
||||
row.available_at = _iso_in(retry_delay_seconds(row.attempt_count))
|
||||
row.updated_at = utc_now_iso()
|
||||
|
||||
|
||||
def retry_outbox_event(session: Session, row: EventOutboxRow) -> None:
|
||||
now = utc_now_iso()
|
||||
row.status = "pending"
|
||||
row.last_error = None
|
||||
row.available_at = now
|
||||
row.updated_at = now
|
||||
|
||||
|
||||
def inbox_seen(session: Session, consumer_name: str, event_id: str) -> bool:
|
||||
row = session.execute(
|
||||
select(EventInboxRow).where(
|
||||
EventInboxRow.consumer_name == consumer_name,
|
||||
EventInboxRow.event_id == event_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return row is not None
|
||||
|
||||
|
||||
def record_inbox(
|
||||
session: Session,
|
||||
*,
|
||||
consumer_name: str,
|
||||
event_id: str,
|
||||
event_type: str,
|
||||
status: str,
|
||||
notes: str | None = None,
|
||||
) -> EventInboxRow:
|
||||
row = EventInboxRow(
|
||||
consumer_name=consumer_name,
|
||||
event_id=event_id,
|
||||
event_type=event_type,
|
||||
processed_at=utc_now_iso(),
|
||||
status=status,
|
||||
notes=notes,
|
||||
)
|
||||
session.add(row)
|
||||
return row
|
||||
|
||||
|
||||
@contextmanager
|
||||
def rabbitmq_connection() -> Iterator[tuple[Any, Any]]:
|
||||
pika = _lazy_import_pika()
|
||||
params = pika.URLParameters(event_bus_url())
|
||||
connection = pika.BlockingConnection(params)
|
||||
channel = connection.channel()
|
||||
try:
|
||||
yield connection, channel
|
||||
finally:
|
||||
try:
|
||||
connection.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def ensure_rabbitmq_topology(channel: Any) -> None:
|
||||
exchange = event_bus_exchange()
|
||||
audit_queue = event_bus_audit_queue()
|
||||
reporting_queue = event_bus_reporting_queue()
|
||||
channel.exchange_declare(exchange=exchange, exchange_type="topic", durable=True)
|
||||
for queue_name, bindings in (
|
||||
(audit_queue, ["interaction.*", "agent.state.changed", "call.recording.ready", "ivr.completed"]),
|
||||
(
|
||||
reporting_queue,
|
||||
["interaction.created", "interaction.closed", "agent.state.changed", "ivr.completed", "call.recording.ready"],
|
||||
),
|
||||
):
|
||||
dlq_name = f"{queue_name}.dlq"
|
||||
channel.queue_declare(queue=dlq_name, durable=True)
|
||||
channel.queue_declare(
|
||||
queue=queue_name,
|
||||
durable=True,
|
||||
arguments={
|
||||
"x-dead-letter-exchange": "",
|
||||
"x-dead-letter-routing-key": dlq_name,
|
||||
},
|
||||
)
|
||||
for binding in bindings:
|
||||
channel.queue_bind(queue=queue_name, exchange=exchange, routing_key=binding)
|
||||
|
||||
|
||||
def publish_envelope(channel: Any, envelope: dict[str, Any], headers: dict[str, Any] | None = None) -> None:
|
||||
pika = _lazy_import_pika()
|
||||
body = json.dumps(envelope, ensure_ascii=False).encode("utf-8")
|
||||
properties = pika.BasicProperties(
|
||||
content_type="application/json",
|
||||
delivery_mode=2,
|
||||
headers=headers or {},
|
||||
)
|
||||
channel.basic_publish(
|
||||
exchange=event_bus_exchange(),
|
||||
routing_key=envelope["routing_key"],
|
||||
body=body,
|
||||
properties=properties,
|
||||
)
|
||||
|
||||
|
||||
def dispatch_outbox_batch(session: Session, limit: int | None = None) -> dict[str, int]:
|
||||
rows = list_publishable_outbox(session, limit=limit)
|
||||
if not rows:
|
||||
return {"claimed": 0, "published": 0, "failed": 0}
|
||||
|
||||
published = 0
|
||||
failed = 0
|
||||
try:
|
||||
with rabbitmq_connection() as (_, channel):
|
||||
ensure_rabbitmq_topology(channel)
|
||||
for row in rows:
|
||||
try:
|
||||
publish_envelope(channel, json.loads(row.payload_json or "{}"))
|
||||
mark_outbox_published(session, row)
|
||||
published += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
mark_outbox_failed(session, row, str(exc))
|
||||
failed += 1
|
||||
session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
for row in rows:
|
||||
mark_outbox_failed(session, row, str(exc))
|
||||
session.commit()
|
||||
failed = len(rows)
|
||||
published = 0
|
||||
return {"claimed": len(rows), "published": published, "failed": failed}
|
||||
|
||||
|
||||
def consume_one_message(
|
||||
queue_name: str,
|
||||
handler,
|
||||
*,
|
||||
max_attempts: int | None = None,
|
||||
) -> bool:
|
||||
if max_attempts is None:
|
||||
max_attempts = event_bus_max_attempts()
|
||||
with rabbitmq_connection() as (_, channel):
|
||||
ensure_rabbitmq_topology(channel)
|
||||
channel.basic_qos(prefetch_count=PREFETCH_COUNT)
|
||||
method, properties, body = channel.basic_get(queue=queue_name, auto_ack=False)
|
||||
if not method:
|
||||
return False
|
||||
|
||||
headers = dict((properties.headers or {}))
|
||||
try:
|
||||
envelope = json.loads(body.decode("utf-8"))
|
||||
handler(envelope)
|
||||
channel.basic_ack(delivery_tag=method.delivery_tag)
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
retry_count = int(headers.get("x-retry-count", 0))
|
||||
if retry_count + 1 < max_attempts:
|
||||
next_headers = dict(headers)
|
||||
next_headers["x-retry-count"] = retry_count + 1
|
||||
publish_envelope(channel, json.loads(body.decode("utf-8")), headers=next_headers)
|
||||
channel.basic_ack(delivery_tag=method.delivery_tag)
|
||||
else:
|
||||
dlq_name = f"{queue_name}.dlq"
|
||||
pika = _lazy_import_pika()
|
||||
channel.basic_publish(
|
||||
exchange="",
|
||||
routing_key=dlq_name,
|
||||
body=body,
|
||||
properties=pika.BasicProperties(
|
||||
content_type="application/json",
|
||||
delivery_mode=2,
|
||||
headers={**headers, "error": str(exc)[:2000]},
|
||||
),
|
||||
)
|
||||
channel.basic_ack(delivery_tag=method.delivery_tag)
|
||||
return False
|
||||
|
||||
|
||||
def poll_forever(loop_fn) -> None:
|
||||
while True:
|
||||
try:
|
||||
loop_fn()
|
||||
except Exception:
|
||||
time.sleep(event_bus_poll_interval_seconds())
|
||||
else:
|
||||
time.sleep(event_bus_poll_interval_seconds())
|
||||
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
DEFAULT_KB_LANGUAGE = "ru"
|
||||
SUPPORTED_KB_LANGUAGES = {"ru", "kz"}
|
||||
|
||||
|
||||
def normalize_kb_language(value: str | None) -> str:
|
||||
normalized = str(value or DEFAULT_KB_LANGUAGE).strip().lower()
|
||||
return normalized if normalized in SUPPORTED_KB_LANGUAGES else DEFAULT_KB_LANGUAGE
|
||||
|
||||
|
||||
def resolve_article_group_id(article_id: str, article_group_id: str | None) -> str:
|
||||
normalized_group = str(article_group_id or "").strip()
|
||||
return normalized_group or str(article_id).strip()
|
||||
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Protocol, Sequence, TypeVar
|
||||
|
||||
_FIELD_WEIGHTS = {
|
||||
"tags": 3.0,
|
||||
"title": 2.0,
|
||||
"body": 1.0,
|
||||
}
|
||||
_EXACT_TOKEN_SCORE = 10.0
|
||||
_SOFT_TOKEN_SCORE = 6.0
|
||||
_PHRASE_BASE_SCORE = 12.0
|
||||
_MIN_TOKEN_LENGTH = 2
|
||||
_MIN_SOFT_MATCH_LENGTH = 4
|
||||
|
||||
_NON_WORD_RE = re.compile(r"[^\w]+", re.UNICODE)
|
||||
_SPACE_RE = re.compile(r"\s+")
|
||||
|
||||
|
||||
class KBSearchRow(Protocol):
|
||||
id: int
|
||||
title: str
|
||||
body: str
|
||||
tags_json: str
|
||||
|
||||
|
||||
T = TypeVar("T", bound=KBSearchRow)
|
||||
|
||||
|
||||
def normalize_kb_text(value: str | None) -> str:
|
||||
text = unicodedata.normalize("NFKC", str(value or "")).lower().replace("\u0451", "\u0435")
|
||||
text = _NON_WORD_RE.sub(" ", text)
|
||||
return _SPACE_RE.sub(" ", text).strip()
|
||||
|
||||
|
||||
def tokenize_kb_text(value: str | None) -> list[str]:
|
||||
return [token for token in normalize_kb_text(value).split(" ") if len(token) >= _MIN_TOKEN_LENGTH]
|
||||
|
||||
|
||||
def search_kb_rows(
|
||||
rows: Sequence[T],
|
||||
query: str | None,
|
||||
*,
|
||||
limit: int,
|
||||
empty_query_returns_all: bool = False,
|
||||
) -> list[T]:
|
||||
ordered_rows = sorted(rows, key=lambda row: int(getattr(row, "id", 0) or 0), reverse=True)
|
||||
normalized_query = normalize_kb_text(query)
|
||||
if not normalized_query:
|
||||
return ordered_rows[:limit] if empty_query_returns_all else []
|
||||
|
||||
query_tokens = list(dict.fromkeys(tokenize_kb_text(normalized_query)))
|
||||
if not query_tokens:
|
||||
return ordered_rows[:limit] if empty_query_returns_all else []
|
||||
|
||||
ranked: list[tuple[float, int, T]] = []
|
||||
for row in ordered_rows:
|
||||
score = _score_row(row, normalized_query, query_tokens)
|
||||
if score <= 0:
|
||||
continue
|
||||
ranked.append((score, int(getattr(row, "id", 0) or 0), row))
|
||||
|
||||
ranked.sort(key=lambda item: (-item[0], -item[1]))
|
||||
return [row for _, _, row in ranked[:limit]]
|
||||
|
||||
|
||||
def _score_row(row: T, normalized_query: str, query_tokens: list[str]) -> float:
|
||||
tags_text = " ".join(_parse_tags(getattr(row, "tags_json", "[]")))
|
||||
fields = {
|
||||
"tags": {
|
||||
"text": normalize_kb_text(tags_text),
|
||||
"tokens": tokenize_kb_text(tags_text),
|
||||
},
|
||||
"title": {
|
||||
"text": normalize_kb_text(getattr(row, "title", "")),
|
||||
"tokens": tokenize_kb_text(getattr(row, "title", "")),
|
||||
},
|
||||
"body": {
|
||||
"text": normalize_kb_text(getattr(row, "body", "")),
|
||||
"tokens": tokenize_kb_text(getattr(row, "body", "")),
|
||||
},
|
||||
}
|
||||
|
||||
score = 0.0
|
||||
for field_name, payload in fields.items():
|
||||
field_tokens = payload["tokens"]
|
||||
if not field_tokens:
|
||||
continue
|
||||
field_weight = _FIELD_WEIGHTS[field_name]
|
||||
score += _field_phrase_bonus(query_tokens, payload["text"], field_weight)
|
||||
score += _field_token_score(query_tokens, field_tokens, field_weight)
|
||||
return score
|
||||
|
||||
|
||||
def _field_phrase_bonus(query_tokens: list[str], field_text: str, field_weight: float) -> float:
|
||||
max_window = min(4, len(query_tokens))
|
||||
for size in range(max_window, 1, -1):
|
||||
for start in range(0, len(query_tokens) - size + 1):
|
||||
phrase = " ".join(query_tokens[start : start + size])
|
||||
if phrase and phrase in field_text:
|
||||
return field_weight * (_PHRASE_BASE_SCORE + size)
|
||||
return 0.0
|
||||
|
||||
|
||||
def _field_token_score(query_tokens: list[str], field_tokens: list[str], field_weight: float) -> float:
|
||||
score = 0.0
|
||||
field_token_set = set(field_tokens)
|
||||
for query_token in query_tokens:
|
||||
if query_token in field_token_set:
|
||||
score += field_weight * _EXACT_TOKEN_SCORE
|
||||
continue
|
||||
if _has_soft_token_match(query_token, field_tokens):
|
||||
score += field_weight * _SOFT_TOKEN_SCORE
|
||||
return score
|
||||
|
||||
|
||||
def _has_soft_token_match(query_token: str, field_tokens: list[str]) -> bool:
|
||||
return any(_is_soft_token_match(query_token, field_token) for field_token in field_tokens)
|
||||
|
||||
|
||||
def _is_soft_token_match(query_token: str, field_token: str) -> bool:
|
||||
min_length = min(len(query_token), len(field_token))
|
||||
if min_length < _MIN_SOFT_MATCH_LENGTH:
|
||||
return False
|
||||
common_prefix = _common_prefix_len(query_token, field_token)
|
||||
return common_prefix >= _MIN_SOFT_MATCH_LENGTH and common_prefix >= min_length - 1
|
||||
|
||||
|
||||
def _common_prefix_len(left: str, right: str) -> int:
|
||||
index = 0
|
||||
for left_char, right_char in zip(left, right):
|
||||
if left_char != right_char:
|
||||
break
|
||||
index += 1
|
||||
return index
|
||||
|
||||
|
||||
def _parse_tags(raw_tags: str | None) -> list[str]:
|
||||
try:
|
||||
data = json.loads(raw_tags or "[]")
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return [str(item) for item in data if str(item).strip()]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from services.shared.core import utc_now_iso
|
||||
from services.shared.sql_models import ReportingInteractionFactRow
|
||||
|
||||
|
||||
_STRING_FIELDS = (
|
||||
"interaction_id",
|
||||
"channel",
|
||||
"queue_id",
|
||||
"agent_id",
|
||||
"status",
|
||||
"created_at",
|
||||
"closed_at",
|
||||
"source",
|
||||
)
|
||||
_BOOL_FIELDS = ("answered", "abandoned", "resolved_first_contact")
|
||||
_INT_FIELDS = ("wait_seconds", "handle_seconds")
|
||||
|
||||
|
||||
def _clean_string(value: Any) -> str | None:
|
||||
raw = str(value or "").strip()
|
||||
return raw or None
|
||||
|
||||
|
||||
def _clean_bool(value: Any) -> bool | None:
|
||||
if value is None:
|
||||
return None
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _clean_int(value: Any) -> int | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
parsed = int(value)
|
||||
return max(parsed, 0)
|
||||
|
||||
|
||||
def normalize_reporting_fact_payload(payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||
normalized: dict[str, Any] = {}
|
||||
for field in _STRING_FIELDS:
|
||||
value = _clean_string(payload.get(field))
|
||||
if value is not None:
|
||||
normalized[field] = value
|
||||
for field in _BOOL_FIELDS:
|
||||
value = _clean_bool(payload.get(field))
|
||||
if value is not None:
|
||||
normalized[field] = value
|
||||
for field in _INT_FIELDS:
|
||||
value = _clean_int(payload.get(field))
|
||||
if value is not None:
|
||||
normalized[field] = value
|
||||
interaction_id = normalized.get("interaction_id")
|
||||
if not interaction_id:
|
||||
raise ValueError("interaction_id is required")
|
||||
source = normalized.get("source")
|
||||
if not source:
|
||||
raise ValueError("source is required")
|
||||
return normalized
|
||||
|
||||
|
||||
def upsert_reporting_interaction_fact(session: Session, payload: Mapping[str, Any]) -> ReportingInteractionFactRow:
|
||||
normalized = normalize_reporting_fact_payload(payload)
|
||||
row = session.execute(
|
||||
select(ReportingInteractionFactRow).where(
|
||||
ReportingInteractionFactRow.interaction_id == normalized["interaction_id"]
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
row = ReportingInteractionFactRow(
|
||||
interaction_id=normalized["interaction_id"],
|
||||
source=normalized["source"],
|
||||
updated_at=utc_now_iso(),
|
||||
)
|
||||
session.add(row)
|
||||
for field, value in normalized.items():
|
||||
setattr(row, field, value)
|
||||
row.updated_at = utc_now_iso()
|
||||
return row
|
||||
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
from services.shared.db import engine
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MIGRATIONS_DIR = ROOT / "migrations" / "sql"
|
||||
_MODE_ENV = "SCHEMA_MANAGEMENT_MODE"
|
||||
_VALID_MODES = {"legacy", "migrations"}
|
||||
|
||||
|
||||
def database_backend_name(db_engine=engine) -> str:
|
||||
return db_engine.url.get_backend_name()
|
||||
|
||||
|
||||
def migration_suffix(db_engine=engine) -> str:
|
||||
return "postgres" if database_backend_name(db_engine) == "postgresql" else "sqlite"
|
||||
|
||||
|
||||
def list_migration_files(*, db_engine=engine, migrations_dir: Path = MIGRATIONS_DIR) -> list[Path]:
|
||||
return sorted(migrations_dir.glob(f"*_{migration_suffix(db_engine)}.sql"))
|
||||
|
||||
|
||||
def resolve_schema_management_mode(
|
||||
raw_mode: str | None,
|
||||
*,
|
||||
backend_name: str,
|
||||
) -> str:
|
||||
normalized = str(raw_mode or "").strip().lower()
|
||||
if not normalized:
|
||||
normalized = "legacy" if backend_name == "sqlite" else "migrations"
|
||||
if normalized not in _VALID_MODES:
|
||||
allowed = ", ".join(sorted(_VALID_MODES))
|
||||
raise RuntimeError(f"{_MODE_ENV} must be one of: {allowed}")
|
||||
if backend_name != "sqlite" and normalized != "migrations":
|
||||
raise RuntimeError(
|
||||
f"{_MODE_ENV}=legacy is supported only for SQLite. "
|
||||
"Run `python scripts/migrate_core_db.py` and use SCHEMA_MANAGEMENT_MODE=migrations."
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def schema_management_mode(
|
||||
*,
|
||||
db_engine=engine,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
) -> str:
|
||||
source = environ or os.environ
|
||||
return resolve_schema_management_mode(
|
||||
source.get(_MODE_ENV),
|
||||
backend_name=database_backend_name(db_engine),
|
||||
)
|
||||
|
||||
|
||||
def ensure_schema_migrations_table(*, db_engine=engine) -> None:
|
||||
with db_engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def has_schema_migrations_table(*, db_engine=engine) -> bool:
|
||||
return inspect(db_engine).has_table("schema_migrations")
|
||||
|
||||
|
||||
def applied_migration_versions(*, db_engine=engine) -> set[str]:
|
||||
if not has_schema_migrations_table(db_engine=db_engine):
|
||||
return set()
|
||||
with db_engine.begin() as conn:
|
||||
rows = conn.execute(text("SELECT version FROM schema_migrations")).fetchall()
|
||||
return {str(row[0]) for row in rows}
|
||||
|
||||
|
||||
def missing_migration_versions(*, db_engine=engine, migrations_dir: Path = MIGRATIONS_DIR) -> list[str]:
|
||||
expected = [path.name for path in list_migration_files(db_engine=db_engine, migrations_dir=migrations_dir)]
|
||||
applied = applied_migration_versions(db_engine=db_engine)
|
||||
return [name for name in expected if name not in applied]
|
||||
|
||||
|
||||
def validate_schema_migrations_applied(*, db_engine=engine, migrations_dir: Path = MIGRATIONS_DIR) -> None:
|
||||
expected = list_migration_files(db_engine=db_engine, migrations_dir=migrations_dir)
|
||||
if not expected:
|
||||
raise RuntimeError(f"No migration files found for backend {database_backend_name(db_engine)} in {migrations_dir}")
|
||||
|
||||
if not has_schema_migrations_table(db_engine=db_engine):
|
||||
raise RuntimeError(
|
||||
"Database schema is not initialized via migrations. "
|
||||
"Run `python scripts/migrate_core_db.py` before starting services."
|
||||
)
|
||||
|
||||
missing = missing_migration_versions(db_engine=db_engine, migrations_dir=migrations_dir)
|
||||
if not missing:
|
||||
return
|
||||
|
||||
preview = ", ".join(missing[:5])
|
||||
remainder = "" if len(missing) <= 5 else f" ... (+{len(missing) - 5} more)"
|
||||
raise RuntimeError(
|
||||
"Database schema is behind the checked-in migrations. "
|
||||
f"Missing: {preview}{remainder}. "
|
||||
"Run `python scripts/migrate_core_db.py` before starting services."
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from base64 import urlsafe_b64decode, urlsafe_b64encode
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import Depends, Header, HTTPException
|
||||
|
||||
from services.shared.core import Role
|
||||
|
||||
|
||||
def _bool_env(name: str, default: bool) -> bool:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def legacy_header_auth_allowed() -> bool:
|
||||
return _bool_env("ALLOW_LEGACY_HEADER_AUTH", True)
|
||||
|
||||
|
||||
def _app_token_secret() -> str:
|
||||
return os.getenv("APP_TOKEN_SECRET", "dev-secret-change-me")
|
||||
|
||||
|
||||
def _app_token_ttl_seconds() -> int:
|
||||
raw = os.getenv("APP_TOKEN_TTL_SECONDS", "3600").strip()
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
value = 3600
|
||||
return max(value, 60)
|
||||
|
||||
|
||||
def _b64url_encode(raw: bytes) -> str:
|
||||
return urlsafe_b64encode(raw).decode("utf-8").rstrip("=")
|
||||
|
||||
|
||||
def _b64url_decode(raw: str) -> bytes:
|
||||
padding = "=" * (-len(raw) % 4)
|
||||
return urlsafe_b64decode((raw + padding).encode("utf-8"))
|
||||
|
||||
|
||||
def _sign(message: str) -> str:
|
||||
digest = hmac.new(
|
||||
_app_token_secret().encode("utf-8"),
|
||||
message.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
return _b64url_encode(digest)
|
||||
|
||||
|
||||
def issue_app_token(
|
||||
*,
|
||||
subject: str,
|
||||
username: str,
|
||||
role: str,
|
||||
auth_source: str,
|
||||
provider: str | None = None,
|
||||
full_name: str | None = None,
|
||||
email: str | None = None,
|
||||
ttl_seconds: int | None = None,
|
||||
) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
ttl = ttl_seconds if ttl_seconds is not None else _app_token_ttl_seconds()
|
||||
header = {"alg": "HS256", "typ": "JWT"}
|
||||
payload = {
|
||||
"sub": subject,
|
||||
"username": username,
|
||||
"role": role,
|
||||
"auth_source": auth_source,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + timedelta(seconds=ttl)).timestamp()),
|
||||
}
|
||||
if provider:
|
||||
payload["provider"] = provider
|
||||
if full_name:
|
||||
payload["full_name"] = full_name
|
||||
if email:
|
||||
payload["email"] = email
|
||||
|
||||
encoded_header = _b64url_encode(json.dumps(header, separators=(",", ":")).encode("utf-8"))
|
||||
encoded_payload = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
|
||||
message = f"{encoded_header}.{encoded_payload}"
|
||||
signature = _sign(message)
|
||||
return f"{message}.{signature}"
|
||||
|
||||
|
||||
def decode_app_token(token: str) -> dict:
|
||||
try:
|
||||
encoded_header, encoded_payload, encoded_signature = token.split(".")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=401, detail="Invalid token format") from exc
|
||||
|
||||
message = f"{encoded_header}.{encoded_payload}"
|
||||
expected_signature = _sign(message)
|
||||
if not hmac.compare_digest(encoded_signature, expected_signature):
|
||||
raise HTTPException(status_code=401, detail="Invalid token signature")
|
||||
|
||||
try:
|
||||
payload = json.loads(_b64url_decode(encoded_payload).decode("utf-8"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=401, detail="Invalid token payload") from exc
|
||||
|
||||
exp = int(payload.get("exp", 0) or 0)
|
||||
now_ts = int(datetime.now(timezone.utc).timestamp())
|
||||
if exp and exp < now_ts:
|
||||
raise HTTPException(status_code=401, detail="Token expired")
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def get_actor(
|
||||
authorization: str | None = Header(default=None, alias="Authorization"),
|
||||
x_user: str | None = Header(default=None, alias="X-User"),
|
||||
x_role: str | None = Header(default=None, alias="X-Role"),
|
||||
) -> dict:
|
||||
if authorization:
|
||||
scheme, _, value = authorization.partition(" ")
|
||||
if scheme.lower() != "bearer" or not value.strip():
|
||||
raise HTTPException(status_code=401, detail="Invalid authorization header")
|
||||
payload = decode_app_token(value.strip())
|
||||
return {
|
||||
"sub": payload.get("sub", ""),
|
||||
"user": payload.get("username", "anonymous"),
|
||||
"role": str(payload.get("role", "anonymous")).strip().lower() or "anonymous",
|
||||
"auth_source": payload.get("auth_source", "token"),
|
||||
"provider": payload.get("provider"),
|
||||
"full_name": payload.get("full_name"),
|
||||
"email": payload.get("email"),
|
||||
}
|
||||
|
||||
if legacy_header_auth_allowed():
|
||||
role = (x_role or "").strip().lower()
|
||||
return {"user": (x_user or "anonymous").strip(), "role": role or "anonymous", "auth_source": "legacy"}
|
||||
|
||||
return {"user": "anonymous", "role": "anonymous", "auth_source": "none"}
|
||||
|
||||
|
||||
def require_roles(*allowed: Role) -> Callable:
|
||||
allowed_values = {a.value for a in allowed}
|
||||
|
||||
def dependency(actor: dict = Depends(get_actor)) -> dict:
|
||||
if actor["role"] not in allowed_values:
|
||||
raise HTTPException(status_code=403, detail="Insufficient role")
|
||||
return actor
|
||||
|
||||
return dependency
|
||||
@@ -0,0 +1,536 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from services.shared.db import engine
|
||||
from services.shared.schema_migrations import schema_management_mode, validate_schema_migrations_applied
|
||||
from services.shared.sql_models import Base
|
||||
|
||||
|
||||
def _table_columns(inspector, table_name: str) -> set[str]:
|
||||
return {item["name"] for item in inspector.get_columns(table_name)}
|
||||
|
||||
|
||||
def _table_indexes(inspector, table_name: str) -> set[str]:
|
||||
return {item["name"] for item in inspector.get_indexes(table_name)}
|
||||
|
||||
|
||||
def _add_column_if_missing(conn, columns: set[str], table_name: str, column_name: str, ddl: str) -> None:
|
||||
if column_name in columns:
|
||||
return
|
||||
conn.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {ddl}"))
|
||||
columns.add(column_name)
|
||||
|
||||
|
||||
def _apply_runtime_schema_compatibility() -> None:
|
||||
inspector = inspect(engine)
|
||||
table_names = set(inspector.get_table_names())
|
||||
|
||||
with engine.begin() as conn:
|
||||
if "telegram_messages" in table_names:
|
||||
columns = _table_columns(inspector, "telegram_messages")
|
||||
_add_column_if_missing(conn, columns, "telegram_messages", "thread_id", "VARCHAR(64)")
|
||||
_add_column_if_missing(conn, columns, "telegram_messages", "interaction_id", "VARCHAR(64)")
|
||||
_add_column_if_missing(
|
||||
conn,
|
||||
columns,
|
||||
"telegram_messages",
|
||||
"direction",
|
||||
"VARCHAR(16) DEFAULT 'inbound'",
|
||||
)
|
||||
_add_column_if_missing(
|
||||
conn,
|
||||
columns,
|
||||
"telegram_messages",
|
||||
"telegram_message_id_external",
|
||||
"VARCHAR(128)",
|
||||
)
|
||||
_add_column_if_missing(conn, columns, "telegram_messages", "operator_user", "VARCHAR(128)")
|
||||
_add_column_if_missing(conn, columns, "telegram_messages", "delivery_status", "VARCHAR(32)")
|
||||
_add_column_if_missing(conn, columns, "telegram_messages", "delivery_attempts", "INTEGER DEFAULT 0")
|
||||
_add_column_if_missing(
|
||||
conn,
|
||||
columns,
|
||||
"telegram_messages",
|
||||
"next_delivery_attempt_at",
|
||||
"VARCHAR(64)",
|
||||
)
|
||||
_add_column_if_missing(conn, columns, "telegram_messages", "delivery_locked_until", "VARCHAR(64)")
|
||||
_add_column_if_missing(conn, columns, "telegram_messages", "last_delivery_error", "TEXT")
|
||||
if "author_type" not in columns:
|
||||
_add_column_if_missing(
|
||||
conn,
|
||||
columns,
|
||||
"telegram_messages",
|
||||
"author_type",
|
||||
"VARCHAR(32) DEFAULT 'customer'",
|
||||
)
|
||||
if "direction" in columns:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE telegram_messages
|
||||
SET author_type = CASE
|
||||
WHEN direction = 'outbound' THEN 'human'
|
||||
WHEN direction = 'system' THEN 'system'
|
||||
ELSE 'customer'
|
||||
END
|
||||
WHERE author_type IS NULL OR TRIM(author_type) = ''
|
||||
"""
|
||||
)
|
||||
)
|
||||
if "author_id" not in columns:
|
||||
_add_column_if_missing(conn, columns, "telegram_messages", "author_id", "VARCHAR(128)")
|
||||
if "operator_user" in columns:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE telegram_messages
|
||||
SET author_id = operator_user
|
||||
WHERE operator_user IS NOT NULL
|
||||
AND (
|
||||
direction = 'outbound'
|
||||
OR direction IS NULL
|
||||
OR TRIM(direction) = ''
|
||||
)
|
||||
AND (author_id IS NULL OR TRIM(author_id) = '')
|
||||
"""
|
||||
)
|
||||
)
|
||||
indexes = _table_indexes(inspector, "telegram_messages")
|
||||
if "idx_telegram_messages_thread_id" not in indexes:
|
||||
conn.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_telegram_messages_thread_id ON telegram_messages(thread_id)")
|
||||
)
|
||||
if "idx_telegram_messages_interaction_id" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_telegram_messages_interaction_id ON telegram_messages(interaction_id)"
|
||||
)
|
||||
)
|
||||
if "idx_telegram_messages_direction" not in indexes:
|
||||
conn.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_telegram_messages_direction ON telegram_messages(direction)")
|
||||
)
|
||||
if "idx_telegram_messages_telegram_message_id_external" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_telegram_messages_telegram_message_id_external "
|
||||
"ON telegram_messages(telegram_message_id_external)"
|
||||
)
|
||||
)
|
||||
if "idx_telegram_messages_operator_user" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_telegram_messages_operator_user ON telegram_messages(operator_user)"
|
||||
)
|
||||
)
|
||||
if "idx_telegram_messages_delivery_status" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_telegram_messages_delivery_status ON telegram_messages(delivery_status)"
|
||||
)
|
||||
)
|
||||
if "idx_telegram_messages_next_delivery_attempt_at" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_telegram_messages_next_delivery_attempt_at "
|
||||
"ON telegram_messages(next_delivery_attempt_at)"
|
||||
)
|
||||
)
|
||||
if "idx_telegram_messages_delivery_locked_until" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_telegram_messages_delivery_locked_until "
|
||||
"ON telegram_messages(delivery_locked_until)"
|
||||
)
|
||||
)
|
||||
if "idx_telegram_messages_author_type" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_telegram_messages_author_type ON telegram_messages(author_type)"
|
||||
)
|
||||
)
|
||||
if "idx_telegram_messages_author_id" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_telegram_messages_author_id ON telegram_messages(author_id)"
|
||||
)
|
||||
)
|
||||
|
||||
if "telegram_threads" in table_names:
|
||||
columns = _table_columns(inspector, "telegram_threads")
|
||||
_add_column_if_missing(conn, columns, "telegram_threads", "ai_session_id", "VARCHAR(64)")
|
||||
_add_column_if_missing(conn, columns, "telegram_threads", "ai_state", "VARCHAR(32)")
|
||||
_add_column_if_missing(conn, columns, "telegram_threads", "ai_handoff_reason", "TEXT")
|
||||
_add_column_if_missing(conn, columns, "telegram_threads", "ai_last_model_at", "VARCHAR(64)")
|
||||
indexes = _table_indexes(inspector, "telegram_threads")
|
||||
if "idx_telegram_threads_ai_session_id" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_telegram_threads_ai_session_id ON telegram_threads(ai_session_id)"
|
||||
)
|
||||
)
|
||||
if "idx_telegram_threads_ai_state" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_telegram_threads_ai_state ON telegram_threads(ai_state)"
|
||||
)
|
||||
)
|
||||
if "idx_telegram_threads_ai_last_model_at" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_telegram_threads_ai_last_model_at ON telegram_threads(ai_last_model_at)"
|
||||
)
|
||||
)
|
||||
|
||||
if "whatsapp_messages" in table_names:
|
||||
columns = _table_columns(inspector, "whatsapp_messages")
|
||||
_add_column_if_missing(conn, columns, "whatsapp_messages", "thread_id", "VARCHAR(64)")
|
||||
_add_column_if_missing(conn, columns, "whatsapp_messages", "interaction_id", "VARCHAR(64)")
|
||||
_add_column_if_missing(conn, columns, "whatsapp_messages", "customer_id", "VARCHAR(64)")
|
||||
_add_column_if_missing(
|
||||
conn,
|
||||
columns,
|
||||
"whatsapp_messages",
|
||||
"direction",
|
||||
"VARCHAR(16) DEFAULT 'inbound'",
|
||||
)
|
||||
_add_column_if_missing(
|
||||
conn,
|
||||
columns,
|
||||
"whatsapp_messages",
|
||||
"whatsapp_message_id_external",
|
||||
"VARCHAR(128)",
|
||||
)
|
||||
_add_column_if_missing(conn, columns, "whatsapp_messages", "operator_user", "VARCHAR(128)")
|
||||
_add_column_if_missing(conn, columns, "whatsapp_messages", "delivery_status", "VARCHAR(32)")
|
||||
_add_column_if_missing(conn, columns, "whatsapp_messages", "delivery_attempts", "INTEGER DEFAULT 0")
|
||||
_add_column_if_missing(
|
||||
conn,
|
||||
columns,
|
||||
"whatsapp_messages",
|
||||
"next_delivery_attempt_at",
|
||||
"VARCHAR(64)",
|
||||
)
|
||||
_add_column_if_missing(conn, columns, "whatsapp_messages", "delivery_locked_until", "VARCHAR(64)")
|
||||
_add_column_if_missing(conn, columns, "whatsapp_messages", "last_delivery_error", "TEXT")
|
||||
if "author_type" not in columns:
|
||||
_add_column_if_missing(
|
||||
conn,
|
||||
columns,
|
||||
"whatsapp_messages",
|
||||
"author_type",
|
||||
"VARCHAR(32) DEFAULT 'customer'",
|
||||
)
|
||||
if "direction" in columns:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE whatsapp_messages
|
||||
SET author_type = CASE
|
||||
WHEN direction = 'outbound' THEN 'human'
|
||||
WHEN direction = 'system' THEN 'system'
|
||||
ELSE 'customer'
|
||||
END
|
||||
WHERE author_type IS NULL OR TRIM(author_type) = ''
|
||||
"""
|
||||
)
|
||||
)
|
||||
if "author_id" not in columns:
|
||||
_add_column_if_missing(conn, columns, "whatsapp_messages", "author_id", "VARCHAR(128)")
|
||||
if "operator_user" in columns:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE whatsapp_messages
|
||||
SET author_id = operator_user
|
||||
WHERE operator_user IS NOT NULL
|
||||
AND (
|
||||
direction = 'outbound'
|
||||
OR direction IS NULL
|
||||
OR TRIM(direction) = ''
|
||||
)
|
||||
AND (author_id IS NULL OR TRIM(author_id) = '')
|
||||
"""
|
||||
)
|
||||
)
|
||||
indexes = _table_indexes(inspector, "whatsapp_messages")
|
||||
if "ix_whatsapp_messages_chat_external_unique" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ix_whatsapp_messages_chat_external_unique "
|
||||
"ON whatsapp_messages(chat_id, whatsapp_message_id_external)"
|
||||
)
|
||||
)
|
||||
if "idx_whatsapp_messages_thread_id" not in indexes:
|
||||
conn.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_thread_id ON whatsapp_messages(thread_id)")
|
||||
)
|
||||
if "idx_whatsapp_messages_interaction_id" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_interaction_id ON whatsapp_messages(interaction_id)"
|
||||
)
|
||||
)
|
||||
if "idx_whatsapp_messages_customer_id" not in indexes:
|
||||
conn.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_customer_id ON whatsapp_messages(customer_id)")
|
||||
)
|
||||
if "idx_whatsapp_messages_direction" not in indexes:
|
||||
conn.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_direction ON whatsapp_messages(direction)")
|
||||
)
|
||||
if "idx_whatsapp_messages_whatsapp_message_id_external" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_whatsapp_message_id_external "
|
||||
"ON whatsapp_messages(whatsapp_message_id_external)"
|
||||
)
|
||||
)
|
||||
if "idx_whatsapp_messages_operator_user" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_operator_user ON whatsapp_messages(operator_user)"
|
||||
)
|
||||
)
|
||||
if "idx_whatsapp_messages_delivery_status" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_delivery_status ON whatsapp_messages(delivery_status)"
|
||||
)
|
||||
)
|
||||
if "idx_whatsapp_messages_next_delivery_attempt_at" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_next_delivery_attempt_at "
|
||||
"ON whatsapp_messages(next_delivery_attempt_at)"
|
||||
)
|
||||
)
|
||||
if "idx_whatsapp_messages_delivery_locked_until" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_delivery_locked_until "
|
||||
"ON whatsapp_messages(delivery_locked_until)"
|
||||
)
|
||||
)
|
||||
if "idx_whatsapp_messages_author_type" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_author_type ON whatsapp_messages(author_type)"
|
||||
)
|
||||
)
|
||||
if "idx_whatsapp_messages_author_id" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_author_id ON whatsapp_messages(author_id)"
|
||||
)
|
||||
)
|
||||
|
||||
if "whatsapp_threads" in table_names:
|
||||
columns = _table_columns(inspector, "whatsapp_threads")
|
||||
_add_column_if_missing(conn, columns, "whatsapp_threads", "phone_number", "VARCHAR(64)")
|
||||
_add_column_if_missing(conn, columns, "whatsapp_threads", "is_group", "BOOLEAN DEFAULT 0")
|
||||
_add_column_if_missing(conn, columns, "whatsapp_threads", "ai_session_id", "VARCHAR(64)")
|
||||
_add_column_if_missing(conn, columns, "whatsapp_threads", "ai_state", "VARCHAR(32)")
|
||||
_add_column_if_missing(conn, columns, "whatsapp_threads", "ai_handoff_reason", "TEXT")
|
||||
_add_column_if_missing(conn, columns, "whatsapp_threads", "ai_last_model_at", "VARCHAR(64)")
|
||||
indexes = _table_indexes(inspector, "whatsapp_threads")
|
||||
if "idx_whatsapp_threads_phone_number" not in indexes:
|
||||
conn.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_phone_number ON whatsapp_threads(phone_number)")
|
||||
)
|
||||
if "idx_whatsapp_threads_is_group" not in indexes:
|
||||
conn.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_is_group ON whatsapp_threads(is_group)")
|
||||
)
|
||||
if "idx_whatsapp_threads_ai_session_id" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_ai_session_id ON whatsapp_threads(ai_session_id)"
|
||||
)
|
||||
)
|
||||
if "idx_whatsapp_threads_ai_state" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_ai_state ON whatsapp_threads(ai_state)"
|
||||
)
|
||||
)
|
||||
if "idx_whatsapp_threads_ai_last_model_at" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_ai_last_model_at ON whatsapp_threads(ai_last_model_at)"
|
||||
)
|
||||
)
|
||||
|
||||
if "ai_sessions" in table_names:
|
||||
columns = _table_columns(inspector, "ai_sessions")
|
||||
_add_column_if_missing(conn, columns, "ai_sessions", "call_id", "VARCHAR(128)")
|
||||
indexes = _table_indexes(inspector, "ai_sessions")
|
||||
if "idx_ai_sessions_call_id" not in indexes:
|
||||
conn.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_ai_sessions_call_id ON ai_sessions(call_id)")
|
||||
)
|
||||
|
||||
if "voice_events" in table_names:
|
||||
columns = _table_columns(inspector, "voice_events")
|
||||
_add_column_if_missing(conn, columns, "voice_events", "source_event_id", "VARCHAR(64)")
|
||||
indexes = _table_indexes(inspector, "voice_events")
|
||||
if "ix_voice_events_source_event_id" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ix_voice_events_source_event_id "
|
||||
"ON voice_events(source_event_id)"
|
||||
)
|
||||
)
|
||||
|
||||
if "asterisk_call_links" in table_names:
|
||||
columns = _table_columns(inspector, "asterisk_call_links")
|
||||
_add_column_if_missing(conn, columns, "asterisk_call_links", "voice_session_id", "VARCHAR(64)")
|
||||
_add_column_if_missing(conn, columns, "asterisk_call_links", "ai_session_id", "VARCHAR(64)")
|
||||
_add_column_if_missing(conn, columns, "asterisk_call_links", "ai_state", "VARCHAR(32)")
|
||||
_add_column_if_missing(conn, columns, "asterisk_call_links", "ai_handoff_reason", "TEXT")
|
||||
_add_column_if_missing(conn, columns, "asterisk_call_links", "ai_last_model_at", "VARCHAR(64)")
|
||||
indexes = _table_indexes(inspector, "asterisk_call_links")
|
||||
if "idx_asterisk_call_links_voice_session_id" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_voice_session_id "
|
||||
"ON asterisk_call_links(voice_session_id)"
|
||||
)
|
||||
)
|
||||
if "idx_asterisk_call_links_ai_session_id" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_ai_session_id "
|
||||
"ON asterisk_call_links(ai_session_id)"
|
||||
)
|
||||
)
|
||||
if "idx_asterisk_call_links_ai_state" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_ai_state "
|
||||
"ON asterisk_call_links(ai_state)"
|
||||
)
|
||||
)
|
||||
if "idx_asterisk_call_links_ai_last_model_at" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_ai_last_model_at "
|
||||
"ON asterisk_call_links(ai_last_model_at)"
|
||||
)
|
||||
)
|
||||
|
||||
if "voice_ai_sessions" in table_names:
|
||||
columns = _table_columns(inspector, "voice_ai_sessions")
|
||||
_add_column_if_missing(conn, columns, "voice_ai_sessions", "media_uuid", "VARCHAR(64)")
|
||||
_add_column_if_missing(conn, columns, "voice_ai_sessions", "media_status", "VARCHAR(32)")
|
||||
_add_column_if_missing(conn, columns, "voice_ai_sessions", "media_connected_at", "VARCHAR(64)")
|
||||
_add_column_if_missing(conn, columns, "voice_ai_sessions", "media_ended_at", "VARCHAR(64)")
|
||||
_add_column_if_missing(conn, columns, "voice_ai_sessions", "last_media_frame_at", "VARCHAR(64)")
|
||||
indexes = _table_indexes(inspector, "voice_ai_sessions")
|
||||
if "idx_voice_ai_sessions_media_uuid" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_media_uuid "
|
||||
"ON voice_ai_sessions(media_uuid)"
|
||||
)
|
||||
)
|
||||
if "idx_voice_ai_sessions_media_status" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_media_status "
|
||||
"ON voice_ai_sessions(media_status)"
|
||||
)
|
||||
)
|
||||
if "idx_voice_ai_sessions_media_connected_at" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_media_connected_at "
|
||||
"ON voice_ai_sessions(media_connected_at)"
|
||||
)
|
||||
)
|
||||
if "idx_voice_ai_sessions_media_ended_at" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_media_ended_at "
|
||||
"ON voice_ai_sessions(media_ended_at)"
|
||||
)
|
||||
)
|
||||
if "idx_voice_ai_sessions_last_media_frame_at" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_last_media_frame_at "
|
||||
"ON voice_ai_sessions(last_media_frame_at)"
|
||||
)
|
||||
)
|
||||
|
||||
if "ivr_sessions" in table_names:
|
||||
columns = _table_columns(inspector, "ivr_sessions")
|
||||
_add_column_if_missing(conn, columns, "ivr_sessions", "resolved_queue_code", "VARCHAR(64)")
|
||||
indexes = _table_indexes(inspector, "ivr_sessions")
|
||||
if "idx_ivr_sessions_resolved_queue_code" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ivr_sessions_resolved_queue_code "
|
||||
"ON ivr_sessions(resolved_queue_code)"
|
||||
)
|
||||
)
|
||||
|
||||
if "kb_articles" in table_names:
|
||||
columns = _table_columns(inspector, "kb_articles")
|
||||
_add_column_if_missing(conn, columns, "kb_articles", "article_group_id", "VARCHAR(64)")
|
||||
_add_column_if_missing(conn, columns, "kb_articles", "language", "VARCHAR(8) DEFAULT 'ru'")
|
||||
if "language" in columns:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE kb_articles
|
||||
SET language = 'ru'
|
||||
WHERE language IS NULL OR TRIM(language) = ''
|
||||
"""
|
||||
)
|
||||
)
|
||||
if "article_group_id" in columns:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE kb_articles
|
||||
SET article_group_id = article_id
|
||||
WHERE article_group_id IS NULL OR TRIM(article_group_id) = ''
|
||||
"""
|
||||
)
|
||||
)
|
||||
indexes = _table_indexes(inspector, "kb_articles")
|
||||
if "idx_kb_articles_article_group_id" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_kb_articles_article_group_id "
|
||||
"ON kb_articles(article_group_id)"
|
||||
)
|
||||
)
|
||||
if "idx_kb_articles_language" not in indexes:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_kb_articles_language "
|
||||
"ON kb_articles(language)"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def init_sql_schema() -> None:
|
||||
if schema_management_mode() == "migrations":
|
||||
validate_schema_migrations_applied()
|
||||
return
|
||||
Base.metadata.create_all(bind=engine)
|
||||
for attempt in range(5):
|
||||
try:
|
||||
_apply_runtime_schema_compatibility()
|
||||
return
|
||||
except OperationalError as exc:
|
||||
if "database is locked" not in str(exc).lower() or attempt >= 4:
|
||||
raise
|
||||
time.sleep(0.25 * (attempt + 1))
|
||||
@@ -0,0 +1,748 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Boolean, Float, Index, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class AuthUser(Base):
|
||||
__tablename__ = "auth_users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
username: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
password: Mapped[str] = mapped_column(String(256))
|
||||
full_name: Mapped[str] = mapped_column(String(256))
|
||||
role: Mapped[str] = mapped_column(String(32), index=True)
|
||||
created_at: Mapped[str] = mapped_column(String(64))
|
||||
|
||||
|
||||
class AuthExternalIdentity(Base):
|
||||
__tablename__ = "auth_external_identities"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
provider: Mapped[str] = mapped_column(String(64), index=True)
|
||||
external_subject: Mapped[str] = mapped_column(String(256), index=True)
|
||||
username: Mapped[str] = mapped_column(String(128), index=True)
|
||||
email: Mapped[str | None] = mapped_column(String(256), nullable=True, index=True)
|
||||
full_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
role: Mapped[str] = mapped_column(String(32), index=True)
|
||||
linked_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
last_login_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class AuthOIDCState(Base):
|
||||
__tablename__ = "auth_oidc_states"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
state: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
nonce: Mapped[str] = mapped_column(String(128))
|
||||
code_verifier: Mapped[str] = mapped_column(String(256))
|
||||
redirect_uri: Mapped[str] = mapped_column(String(512))
|
||||
return_mode: Mapped[str] = mapped_column(String(32), default="popup")
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
expires_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
consumed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
|
||||
|
||||
class Customer(Base):
|
||||
__tablename__ = "customers"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
customer_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
display_name: Mapped[str] = mapped_column(String(256), index=True)
|
||||
phones_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||
preferred_phone: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
tags_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||
created_at: Mapped[str] = mapped_column(String(64))
|
||||
|
||||
|
||||
class CustomerExternalIdentity(Base):
|
||||
__tablename__ = "customer_external_identities"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_customer_external_identities_channel_subject_unique",
|
||||
"channel",
|
||||
"external_subject",
|
||||
unique=True,
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
identity_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
customer_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
channel: Mapped[str] = mapped_column(String(32), index=True)
|
||||
external_subject: Mapped[str] = mapped_column(String(256), index=True)
|
||||
display_name_snapshot: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class Interaction(Base):
|
||||
__tablename__ = "interactions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
interaction_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
channel: Mapped[str] = mapped_column(String(32), index=True)
|
||||
subject: Mapped[str] = mapped_column(String(512))
|
||||
customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
queue_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
priority: Mapped[int] = mapped_column(Integer, default=3, index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), index=True)
|
||||
assigned_to: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
created_at: Mapped[str] = mapped_column(String(64))
|
||||
updated_at: Mapped[str] = mapped_column(String(64))
|
||||
|
||||
|
||||
class InteractionTimeline(Base):
|
||||
__tablename__ = "interaction_timelines"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
interaction_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
timestamp: Mapped[str] = mapped_column(String(64), index=True)
|
||||
action: Mapped[str] = mapped_column(String(128))
|
||||
metadata_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
|
||||
|
||||
class Queue(Base):
|
||||
__tablename__ = "queues"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
queue_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(256), index=True)
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
rules_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||
created_at: Mapped[str] = mapped_column(String(64))
|
||||
|
||||
|
||||
class RoutingCounter(Base):
|
||||
__tablename__ = "routing_counters"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
channel: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
counter: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
|
||||
class AuditEventRow(Base):
|
||||
__tablename__ = "audit_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
event_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
actor: Mapped[str] = mapped_column(String(128), index=True)
|
||||
action: Mapped[str] = mapped_column(String(128), index=True)
|
||||
entity: Mapped[str] = mapped_column(String(128), index=True)
|
||||
metadata_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class EventOutboxRow(Base):
|
||||
__tablename__ = "event_outbox"
|
||||
__table_args__ = (
|
||||
Index("idx_event_outbox_status_available_at_id", "status", "available_at", "id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
event_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
event_type: Mapped[str] = mapped_column(String(128), index=True)
|
||||
event_version: Mapped[int] = mapped_column(Integer, default=1)
|
||||
producer_service: Mapped[str] = mapped_column(String(128), index=True)
|
||||
entity_type: Mapped[str] = mapped_column(String(128), index=True)
|
||||
entity_id: Mapped[str] = mapped_column(String(128), index=True)
|
||||
correlation_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
routing_key: Mapped[str] = mapped_column(String(128), index=True)
|
||||
payload_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
status: Mapped[str] = mapped_column(String(32), index=True)
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
available_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
published_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class EventInboxRow(Base):
|
||||
__tablename__ = "event_inbox"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
consumer_name: Mapped[str] = mapped_column(String(128), index=True)
|
||||
event_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
event_type: Mapped[str] = mapped_column(String(128), index=True)
|
||||
processed_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), index=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class AsteriskEventLogRow(Base):
|
||||
__tablename__ = "asterisk_event_log"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"idx_asterisk_event_log_name_call_status_id",
|
||||
"ami_event_name",
|
||||
"call_id",
|
||||
"forward_status",
|
||||
"id",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
bridge_event_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
ami_event_name: Mapped[str] = mapped_column(String(128), index=True)
|
||||
call_id: Mapped[str] = mapped_column(String(128), index=True)
|
||||
linked_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
recording_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
forward_status: Mapped[str] = mapped_column(String(32), index=True)
|
||||
payload_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class AsteriskCallLinkRow(Base):
|
||||
__tablename__ = "asterisk_call_links"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
call_id: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
linked_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
queue_code: Mapped[str] = mapped_column(String(64), index=True)
|
||||
queue_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
interaction_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
caller_number: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
caller_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32), index=True)
|
||||
telephony_status: Mapped[str] = mapped_column(String(32), index=True, default="ringing")
|
||||
claimed_by_user: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
claimed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
operator_extension: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
channel_name: Mapped[str | None] = mapped_column(String(256), nullable=True, index=True)
|
||||
voice_session_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
ai_session_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
ai_state: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
ai_handoff_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ai_last_model_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
started_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
connected_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
ended_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class AsteriskCallActionLogRow(Base):
|
||||
__tablename__ = "asterisk_call_action_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
action_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
call_id: Mapped[str] = mapped_column(String(128), index=True)
|
||||
interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
action_type: Mapped[str] = mapped_column(String(64), index=True)
|
||||
actor_user: Mapped[str] = mapped_column(String(128), index=True)
|
||||
actor_role: Mapped[str] = mapped_column(String(32), index=True)
|
||||
request_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
result_status: Mapped[str] = mapped_column(String(32), index=True)
|
||||
ami_action_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class VoiceEventRow(Base):
|
||||
__tablename__ = "voice_events"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_voice_events_source_event_id",
|
||||
"source_event_id",
|
||||
unique=True,
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
event_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
event_type: Mapped[str] = mapped_column(String(64), index=True)
|
||||
call_id: Mapped[str] = mapped_column(String(128), index=True)
|
||||
interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
source_event_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
payload_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class CallRecordingRow(Base):
|
||||
__tablename__ = "call_recordings"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_call_recordings_source_event_id",
|
||||
"source_event_id",
|
||||
unique=True,
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
recording_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
channel: Mapped[str] = mapped_column(String(32), index=True)
|
||||
call_id: Mapped[str] = mapped_column(String(128), index=True)
|
||||
interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
source_event_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
file_name: Mapped[str] = mapped_column(String(256))
|
||||
storage_backend: Mapped[str] = mapped_column(String(32), index=True)
|
||||
storage_path: Mapped[str] = mapped_column(Text)
|
||||
mime_type: Mapped[str] = mapped_column(String(128))
|
||||
size_bytes: Mapped[int] = mapped_column(Integer)
|
||||
duration_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
checksum_sha256: Mapped[str] = mapped_column(String(64), index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), index=True)
|
||||
recorded_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
archived_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
|
||||
|
||||
class IvrFlowRow(Base):
|
||||
__tablename__ = "ivr_flows"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
flow_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(256), index=True)
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
channel: Mapped[str] = mapped_column(String(32), index=True)
|
||||
queue_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
version: Mapped[int] = mapped_column(Integer, default=1)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||
entry_node_id: Mapped[str] = mapped_column(String(64))
|
||||
flow_json: Mapped[str] = mapped_column(Text)
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class IvrSessionRow(Base):
|
||||
__tablename__ = "ivr_sessions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
session_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
call_id: Mapped[str] = mapped_column(String(128), index=True)
|
||||
interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
flow_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
queue_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
current_node_id: Mapped[str] = mapped_column(String(64))
|
||||
entered_digits_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||
status: Mapped[str] = mapped_column(String(32), index=True)
|
||||
outcome_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
resolved_queue_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
resolved_queue_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
completed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class AISessionRow(Base):
|
||||
__tablename__ = "ai_sessions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
session_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
channel: Mapped[str] = mapped_column(String(32), index=True)
|
||||
call_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
thread_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
agent_profile: Mapped[str] = mapped_column(String(64), index=True, default="telegram_support")
|
||||
language: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), index=True, default="active")
|
||||
summary_text: Mapped[str] = mapped_column(Text, default="")
|
||||
last_user_message_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
last_ai_message_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
handoff_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
closed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
|
||||
|
||||
class AIJobRow(Base):
|
||||
__tablename__ = "ai_jobs"
|
||||
__table_args__ = (
|
||||
Index("idx_ai_jobs_thread_status_id", "thread_id", "status", "id"),
|
||||
Index("idx_ai_jobs_thread_trigger_id", "thread_id", "trigger_message_id", "id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
job_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
session_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
thread_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
trigger_message_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), index=True, default="pending")
|
||||
attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||
next_attempt_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
locked_until: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class VoiceAISessionRow(Base):
|
||||
__tablename__ = "voice_ai_sessions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
session_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
call_id: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
linked_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
queue_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
ai_session_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
agent_profile: Mapped[str] = mapped_column(String(64), index=True, default="voice_support")
|
||||
language: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
|
||||
asr_provider: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
tts_provider: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), index=True, default="queued")
|
||||
handoff_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
handoff_target_queue_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
media_uuid: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
media_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
media_connected_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
media_ended_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
last_media_frame_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
disclosure_played_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
last_user_utterance_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
last_ai_reply_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
started_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
ended_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
|
||||
|
||||
class AITurnRow(Base):
|
||||
__tablename__ = "ai_turns"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
turn_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
session_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
thread_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
role: Mapped[str] = mapped_column(String(32), index=True)
|
||||
source_type: Mapped[str] = mapped_column(String(32), index=True)
|
||||
text: Mapped[str] = mapped_column(Text)
|
||||
payload_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
model: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
finish_reason: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
latency_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class VoiceTranscriptSegmentRow(Base):
|
||||
__tablename__ = "voice_transcript_segments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"session_id",
|
||||
"sequence_no",
|
||||
name="uq_voice_transcript_segments_session_sequence",
|
||||
),
|
||||
Index(
|
||||
"idx_voice_transcript_segments_session_speaker_final_id",
|
||||
"session_id",
|
||||
"speaker",
|
||||
"is_final",
|
||||
"id",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
segment_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
session_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
call_id: Mapped[str] = mapped_column(String(128), index=True)
|
||||
interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
speaker: Mapped[str] = mapped_column(String(32), index=True)
|
||||
source_type: Mapped[str] = mapped_column(String(32), index=True)
|
||||
sequence_no: Mapped[int] = mapped_column(Integer, index=True)
|
||||
text: Mapped[str] = mapped_column(Text)
|
||||
confidence: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
is_final: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||
barge_in_interrupted: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
payload_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class TelegramMessageRow(Base):
|
||||
__tablename__ = "telegram_messages"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_telegram_messages_chat_external_unique",
|
||||
"chat_id",
|
||||
"telegram_message_id_external",
|
||||
unique=True,
|
||||
),
|
||||
Index(
|
||||
"idx_telegram_messages_thread_direction_author_created_id",
|
||||
"thread_id",
|
||||
"direction",
|
||||
"author_type",
|
||||
"created_at",
|
||||
"id",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
message_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
thread_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
chat_id: Mapped[str] = mapped_column(String(128), index=True)
|
||||
text: Mapped[str] = mapped_column(Text)
|
||||
customer_external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
direction: Mapped[str] = mapped_column(String(16), index=True, default="inbound")
|
||||
telegram_message_id_external: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
operator_user: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
author_type: Mapped[str] = mapped_column(String(32), index=True, default="customer")
|
||||
author_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
delivery_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
delivery_attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||
next_delivery_attempt_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
delivery_locked_until: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
last_delivery_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
payload_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class TelegramThreadRow(Base):
|
||||
__tablename__ = "telegram_threads"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
thread_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
chat_id: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
interaction_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
telegram_user_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
username: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
display_name: Mapped[str | None] = mapped_column(String(256), nullable=True, index=True)
|
||||
queue_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), index=True)
|
||||
claimed_by_user: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
claimed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
ai_session_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
ai_state: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
ai_handoff_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ai_last_model_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
last_message_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
last_message_preview: Mapped[str] = mapped_column(Text, default="")
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class WhatsAppMessageRow(Base):
|
||||
__tablename__ = "whatsapp_messages"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_whatsapp_messages_chat_external_unique",
|
||||
"chat_id",
|
||||
"whatsapp_message_id_external",
|
||||
unique=True,
|
||||
),
|
||||
Index(
|
||||
"idx_whatsapp_messages_thread_direction_author_created_id",
|
||||
"thread_id",
|
||||
"direction",
|
||||
"author_type",
|
||||
"created_at",
|
||||
"id",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
message_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
thread_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
chat_id: Mapped[str] = mapped_column(String(128), index=True)
|
||||
text: Mapped[str] = mapped_column(Text)
|
||||
customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
direction: Mapped[str] = mapped_column(String(16), index=True, default="inbound")
|
||||
whatsapp_message_id_external: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
operator_user: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
author_type: Mapped[str] = mapped_column(String(32), index=True, default="customer")
|
||||
author_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
delivery_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
delivery_attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||
next_delivery_attempt_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
delivery_locked_until: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
last_delivery_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
payload_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class WhatsAppThreadRow(Base):
|
||||
__tablename__ = "whatsapp_threads"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
thread_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
chat_id: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
interaction_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
whatsapp_user_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
phone_number: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
display_name: Mapped[str | None] = mapped_column(String(256), nullable=True, index=True)
|
||||
queue_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
is_group: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), index=True)
|
||||
claimed_by_user: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
claimed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
ai_session_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
ai_state: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
ai_handoff_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ai_last_model_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
last_message_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
last_message_preview: Mapped[str] = mapped_column(Text, default="")
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class WebchatMessageRow(Base):
|
||||
__tablename__ = "webchat_messages"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
message_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
session_id: Mapped[str] = mapped_column(String(128), index=True)
|
||||
text: Mapped[str] = mapped_column(Text)
|
||||
visitor_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
customer_external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
queue_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
priority: Mapped[int] = mapped_column(Integer, default=3, index=True)
|
||||
payload_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class EmailMessageRow(Base):
|
||||
__tablename__ = "email_messages"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
message_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
from_email: Mapped[str] = mapped_column(String(256), index=True)
|
||||
subject: Mapped[str] = mapped_column(String(512), index=True)
|
||||
body: Mapped[str] = mapped_column(Text)
|
||||
customer_external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
queue_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
priority: Mapped[int] = mapped_column(Integer, default=3, index=True)
|
||||
payload_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class KBCategoryRow(Base):
|
||||
__tablename__ = "kb_categories"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
category_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(256), index=True)
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class KBArticleRow(Base):
|
||||
__tablename__ = "kb_articles"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
article_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
category_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
article_group_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
language: Mapped[str] = mapped_column(String(8), index=True, default="ru")
|
||||
title: Mapped[str] = mapped_column(String(512), index=True)
|
||||
body: Mapped[str] = mapped_column(Text)
|
||||
tags_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class ReportingEventRow(Base):
|
||||
__tablename__ = "reporting_events"
|
||||
__table_args__ = (
|
||||
Index("idx_reporting_events_queue_channel_created_at", "queue_id", "channel", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
queue_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
channel: Mapped[str] = mapped_column(String(32), index=True, default="voice")
|
||||
agent_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
answered: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
wait_seconds: Mapped[int] = mapped_column(Integer, default=0)
|
||||
handle_seconds: Mapped[int] = mapped_column(Integer, default=0)
|
||||
abandoned: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
resolved_first_contact: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class ReportingEventLogRow(Base):
|
||||
__tablename__ = "reporting_event_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
event_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
event_type: Mapped[str] = mapped_column(String(128), index=True)
|
||||
queue_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
channel: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
payload_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class ReportingInteractionFactRow(Base):
|
||||
__tablename__ = "reporting_interaction_facts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("interaction_id", name="uq_reporting_interaction_facts_interaction_id"),
|
||||
Index(
|
||||
"idx_reporting_interaction_facts_created_queue_channel",
|
||||
"created_at",
|
||||
"queue_id",
|
||||
"channel",
|
||||
),
|
||||
Index(
|
||||
"idx_reporting_interaction_facts_status_agent",
|
||||
"status",
|
||||
"agent_id",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
interaction_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
channel: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
queue_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
agent_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
created_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
closed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
answered: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
abandoned: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
wait_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
handle_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
resolved_first_contact: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
source: Mapped[str] = mapped_column(String(64), index=True, default="unknown")
|
||||
|
||||
|
||||
class ReportingSavedViewRow(Base):
|
||||
__tablename__ = "reporting_saved_views"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("owner_user", "view_id", name="uq_reporting_saved_views_owner_view"),
|
||||
Index("idx_reporting_saved_views_owner_updated", "owner_user", "updated_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
owner_user: Mapped[str] = mapped_column(String(128), index=True)
|
||||
owner_role: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
view_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
name: Mapped[str] = mapped_column(String(160))
|
||||
snapshot_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
created_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class SupervisorAgentStateRow(Base):
|
||||
__tablename__ = "supervisor_agent_states"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
agent_id: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
state: Mapped[str] = mapped_column(String(32), index=True)
|
||||
queue_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class SupervisorQueueSnapshotRow(Base):
|
||||
__tablename__ = "supervisor_queue_snapshots"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
queue_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
in_queue: Mapped[int] = mapped_column(Integer, default=0)
|
||||
avg_wait_seconds: Mapped[int] = mapped_column(Integer, default=0)
|
||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _data_dir() -> Path:
|
||||
root = Path(os.getenv("CC_DATA_DIR", ".data")).resolve()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def _file_path(name: str) -> Path:
|
||||
safe = name.replace("\\", "_").replace("/", "_")
|
||||
return _data_dir() / safe
|
||||
|
||||
|
||||
def load_json(name: str, default: Any) -> Any:
|
||||
path = _file_path(name)
|
||||
if not path.exists():
|
||||
return copy.deepcopy(default)
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return copy.deepcopy(default)
|
||||
|
||||
|
||||
def save_json(name: str, data: Any) -> None:
|
||||
path = _file_path(name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with NamedTemporaryFile("w", encoding="utf-8", delete=False, dir=str(path.parent)) as tmp:
|
||||
json.dump(data, tmp, ensure_ascii=False, indent=2)
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
tmp_path.replace(path)
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from services.shared.core import new_id, utc_now_iso
|
||||
from services.shared.sql_models import VoiceTranscriptSegmentRow
|
||||
|
||||
|
||||
_SEQUENCE_CONFLICT_MARKERS = (
|
||||
"uq_voice_transcript_segments_session_sequence",
|
||||
"voice_transcript_segments.session_id, voice_transcript_segments.sequence_no",
|
||||
"voice_transcript_segments.session_id, sequence_no",
|
||||
)
|
||||
|
||||
|
||||
def next_transcript_sequence(session, session_id: str) -> int:
|
||||
row = session.execute(
|
||||
select(VoiceTranscriptSegmentRow)
|
||||
.where(VoiceTranscriptSegmentRow.session_id == session_id)
|
||||
.order_by(VoiceTranscriptSegmentRow.sequence_no.desc())
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return 1
|
||||
return int(row.sequence_no or 0) + 1
|
||||
|
||||
|
||||
def _is_sequence_conflict(exc: IntegrityError) -> bool:
|
||||
message = str(exc).lower()
|
||||
return any(marker in message for marker in _SEQUENCE_CONFLICT_MARKERS)
|
||||
|
||||
|
||||
def add_transcript_segment(
|
||||
session,
|
||||
*,
|
||||
session_id: str,
|
||||
call_id: str,
|
||||
interaction_id: str | None,
|
||||
speaker: str,
|
||||
source_type: str,
|
||||
text: str,
|
||||
confidence: float | None = None,
|
||||
payload: dict | None = None,
|
||||
is_final: bool = True,
|
||||
barge_in_interrupted: bool = False,
|
||||
sequence_no: int | None = None,
|
||||
created_at: str | None = None,
|
||||
max_attempts: int = 5,
|
||||
) -> VoiceTranscriptSegmentRow:
|
||||
for attempt in range(max_attempts):
|
||||
candidate_sequence = sequence_no if attempt == 0 and sequence_no is not None else next_transcript_sequence(session, session_id)
|
||||
row = VoiceTranscriptSegmentRow(
|
||||
segment_id=new_id("vts"),
|
||||
session_id=session_id,
|
||||
call_id=call_id,
|
||||
interaction_id=interaction_id,
|
||||
speaker=speaker,
|
||||
source_type=source_type,
|
||||
sequence_no=candidate_sequence,
|
||||
text=text,
|
||||
confidence=confidence,
|
||||
is_final=is_final,
|
||||
barge_in_interrupted=barge_in_interrupted,
|
||||
payload_json=json.dumps(payload or {}, ensure_ascii=False),
|
||||
created_at=created_at or utc_now_iso(),
|
||||
)
|
||||
try:
|
||||
with session.begin_nested():
|
||||
session.add(row)
|
||||
session.flush()
|
||||
return row
|
||||
except IntegrityError as exc:
|
||||
if not _is_sequence_conflict(exc) or attempt >= max_attempts - 1:
|
||||
raise
|
||||
session.expire_all()
|
||||
raise RuntimeError("Voice transcript segment insert retry exhausted")
|
||||
Reference in New Issue
Block a user