Initial import with GitLab CI/CD and registry deploy flow

This commit is contained in:
Yera All
2026-04-02 17:05:45 +05:00
commit 5374c202d9
338 changed files with 81297 additions and 0 deletions
View File
+20
View File
@@ -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"
+63
View File
@@ -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()
+399
View File
@@ -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())
+14
View File
@@ -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()
+146
View File
@@ -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
+85
View File
@@ -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
+114
View File
@@ -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."
)
+153
View File
@@ -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
+536
View File
@@ -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))
+748
View File
@@ -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)
+40
View File
@@ -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)
+79
View File
@@ -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")