Files

638 lines
24 KiB
Python

from __future__ import annotations
import json
from datetime import datetime
from fastapi import FastAPI, HTTPException
from sqlalchemy import select
from services.shared.core import new_id, utc_now_iso
from services.shared.db import get_session
from services.shared.models import (
CustomerCreate,
CustomerNameUpdateIn,
CustomerHistoryEventOut,
CustomerHistoryOut,
CustomerHistorySummaryOut,
CustomerOut,
HealthResponse,
InteractionOut,
TelegramThreadOut,
VoiceLiveCallOut,
)
from services.shared.sql_init import init_sql_schema
from services.shared.sql_models import (
AsteriskCallLinkRow,
CallRecordingRow,
Customer,
CustomerExternalIdentity,
Interaction,
InteractionTimeline,
TelegramMessageRow,
TelegramThreadRow,
VoiceAISessionRow,
VoiceTranscriptSegmentRow,
)
app = FastAPI(title="customer-service", version="1.0.0")
init_sql_schema()
_CHANNEL_LABELS = {
"voice": "Голос",
"telegram": "Telegram",
"whatsapp": "WhatsApp",
"webchat": "Веб-чат",
"email": "Email",
}
_INTERACTION_STATUS_LABELS = {
"new": "новое",
"in_progress": "в работе",
"escalated": "эскалировано",
"closed": "закрыто",
"abandoned": "потеряно",
}
_TELEPHONY_STATUS_LABELS = {
"ringing": "Звонит",
"claimed": "Взято",
"connected": "Соединено",
"ended": "Завершено",
"failed": "Ошибка",
}
_TIMELINE_ACTION_TITLES = {
"interaction.created": "Обращение создано",
"interaction.assigned": "Обращение назначено",
"interaction.status_changed": "Статус обращения изменён",
"interaction.escalated": "Обращение эскалировано",
}
def _json_list(raw: str | None) -> list:
try:
value = json.loads(raw or "[]")
except json.JSONDecodeError:
return []
return value if isinstance(value, list) else []
def _json_dict(raw: str | None) -> dict:
try:
value = json.loads(raw or "{}")
except json.JSONDecodeError:
return {}
return value if isinstance(value, dict) else {}
def _time_value(value: str | None) -> int:
if not value:
return 0
normalized = value.strip()
if normalized.endswith("Z"):
normalized = f"{normalized[:-1]}+00:00"
try:
return int(datetime.fromisoformat(normalized).timestamp())
except ValueError:
return 0
def _channel_label(channel: str | None) -> str:
return _CHANNEL_LABELS.get(channel or "", channel or "Канал")
def _interaction_status_label(status: str | None) -> str:
return _INTERACTION_STATUS_LABELS.get(status or "", status or "неизвестно")
def _telephony_status_label(status: str | None) -> str:
return _TELEPHONY_STATUS_LABELS.get(status or "", status or "неизвестно")
def _timeline_title(action: str) -> str:
return _TIMELINE_ACTION_TITLES.get(action, action)
def _timeline_body(action: str, metadata: dict) -> str:
if action == "interaction.assigned":
assignee = metadata.get("assignee")
return f"Обращение назначено на {assignee}." if assignee else "Обращение назначено оператору."
if action == "interaction.status_changed":
status = metadata.get("status")
return f"Новый статус: {_interaction_status_label(status)}."
if action == "interaction.escalated":
target_queue_id = metadata.get("target_queue_id")
return (
f"Обращение передано в очередь {target_queue_id}."
if target_queue_id
else "Обращение эскалировано."
)
if action == "interaction.created":
channel = metadata.get("channel")
return f"Создано новое обращение по каналу {_channel_label(channel)}."
return "Событие сохранено в ленте обращения."
def _to_out(row: Customer) -> CustomerOut:
phones = _json_list(row.phones_json)
tags = _json_list(row.tags_json)
return CustomerOut(
customer_id=row.customer_id,
display_name=row.display_name,
phones=phones,
preferred_phone=row.preferred_phone,
tags=tags,
created_at=row.created_at,
)
def _normalized_display_name(value: str) -> str:
return " ".join(str(value or "").split()).strip()
def _interaction_to_out(row: Interaction) -> InteractionOut:
return InteractionOut(
interaction_id=row.interaction_id,
channel=row.channel,
subject=row.subject,
customer_id=row.customer_id,
queue_id=row.queue_id,
priority=row.priority,
status=row.status,
assigned_to=row.assigned_to,
created_at=row.created_at,
updated_at=row.updated_at,
)
def _telegram_thread_to_out(row: TelegramThreadRow) -> TelegramThreadOut:
return TelegramThreadOut(
thread_id=row.thread_id,
chat_id=row.chat_id,
interaction_id=row.interaction_id,
telegram_user_id=row.telegram_user_id,
username=row.username,
display_name=row.display_name,
queue_id=row.queue_id,
status=row.status, # type: ignore[arg-type]
claimed_by_user=row.claimed_by_user,
claimed_at=row.claimed_at,
ai_session_id=row.ai_session_id,
ai_state=row.ai_state, # type: ignore[arg-type]
ai_handoff_reason=row.ai_handoff_reason,
ai_last_model_at=row.ai_last_model_at,
last_message_at=row.last_message_at,
last_message_preview=row.last_message_preview,
created_at=row.created_at,
updated_at=row.updated_at,
)
def _voice_call_to_out(row: AsteriskCallLinkRow, *, has_recording: bool) -> VoiceLiveCallOut:
return VoiceLiveCallOut(
call_id=row.call_id,
interaction_id=row.interaction_id,
queue_id=row.queue_id,
queue_code=row.queue_code,
caller_number=row.caller_number,
caller_name=row.caller_name,
status=row.status,
telephony_status=row.telephony_status, # type: ignore[arg-type]
claimed_by_user=row.claimed_by_user,
claimed_at=row.claimed_at,
operator_extension=row.operator_extension,
channel_name=row.channel_name,
started_at=row.started_at,
connected_at=row.connected_at,
ended_at=row.ended_at,
updated_at=row.updated_at,
last_transition_at=row.ended_at or row.connected_at or row.updated_at,
hangup_cause=None,
terminal_action=None,
terminal_target=None,
voice_session_id=row.voice_session_id,
ai_session_id=row.ai_session_id,
ai_state=row.ai_state, # type: ignore[arg-type]
ai_handoff_reason=row.ai_handoff_reason,
ai_last_model_at=row.ai_last_model_at,
has_recording=has_recording,
)
def _history_event_sort_key(event: CustomerHistoryEventOut) -> tuple[int, str]:
return (_time_value(event.timestamp), event.note)
def _build_customer_history(
customer: Customer,
interactions: list[Interaction],
timelines: list[InteractionTimeline],
telegram_threads: list[TelegramThreadRow],
telegram_messages: list[TelegramMessageRow],
live_calls: list[AsteriskCallLinkRow],
transcript_segments: list[VoiceTranscriptSegmentRow],
recording_call_ids: set[str],
) -> CustomerHistoryOut:
customer_out = _to_out(customer)
interaction_out = [_interaction_to_out(row) for row in interactions]
telegram_thread_out = [_telegram_thread_to_out(row) for row in telegram_threads]
live_call_out = [_voice_call_to_out(row, has_recording=row.call_id in recording_call_ids) for row in live_calls]
interaction_by_id = {row.interaction_id: row for row in interactions}
thread_by_id = {row.thread_id: row for row in telegram_threads}
events: list[CustomerHistoryEventOut] = [
CustomerHistoryEventOut(
timestamp=customer.created_at,
kind="profile",
title="Профиль клиента активен",
body=(
f"Контакт доступен по номеру {customer_out.phones[0]}."
if customer_out.phones
else "Контакт добавлен в клиентскую базу."
),
note=" • ".join(filter(None, [customer.customer_id, customer_out.preferred_phone])),
)
]
for row in interactions:
events.append(
CustomerHistoryEventOut(
timestamp=row.updated_at or row.created_at,
kind=row.channel or "case",
title=f"{_channel_label(row.channel)}{_interaction_status_label(row.status)}",
body=row.subject or "Обращение без темы",
note=" • ".join(
filter(
None,
[
row.interaction_id,
f"оператор {row.assigned_to}" if row.assigned_to else "",
f"queue {row.queue_id}" if row.queue_id else "",
],
)
),
interaction_id=row.interaction_id,
)
)
for row in timelines:
interaction = interaction_by_id.get(row.interaction_id)
metadata = _json_dict(row.metadata_json)
events.append(
CustomerHistoryEventOut(
timestamp=row.timestamp,
kind=interaction.channel if interaction else "case",
title=_timeline_title(row.action),
body=_timeline_body(row.action, metadata),
note=" • ".join(
filter(
None,
[
row.interaction_id,
f"оператор {interaction.assigned_to}" if interaction and interaction.assigned_to else "",
],
)
),
interaction_id=row.interaction_id,
)
)
for row in telegram_threads:
events.append(
CustomerHistoryEventOut(
timestamp=row.last_message_at or row.updated_at or row.created_at,
kind="telegram",
title="Telegram диалог",
body=row.last_message_preview or "Последнее сообщение недоступно.",
note=" • ".join(
filter(
None,
[
row.display_name or row.username or f"chat {row.chat_id}",
f"оператор {row.claimed_by_user}" if row.claimed_by_user else "",
_interaction_status_label(row.status),
],
)
),
interaction_id=row.interaction_id,
thread_id=row.thread_id,
)
)
for row in telegram_messages:
thread = thread_by_id.get(row.thread_id or "")
direction = {
"outbound": "Исходящее сообщение",
"system": "Системное сообщение",
}.get(row.direction, "Входящее сообщение")
events.append(
CustomerHistoryEventOut(
timestamp=row.created_at,
kind="telegram",
title=direction,
body=row.text or "Сообщение без текста",
note=" • ".join(
filter(
None,
[
thread.display_name or thread.username if thread else "",
f"оператор {row.operator_user}" if row.operator_user else "",
row.delivery_status or "",
],
)
),
interaction_id=row.interaction_id,
thread_id=row.thread_id,
)
)
for row in live_calls:
completed = row.telephony_status == "ended" or bool(row.ended_at)
events.append(
CustomerHistoryEventOut(
timestamp=row.ended_at or row.connected_at or row.updated_at or row.started_at,
kind="voice",
title=(
f"Звонок • {_telephony_status_label('ended')}"
if completed
else f"Звонок • {_telephony_status_label(row.telephony_status)}"
),
body=f"Номер клиента: {row.caller_number or 'не определён'}",
note=" • ".join(
filter(
None,
[
row.call_id,
f"внутр. {row.operator_extension}" if row.operator_extension else "",
f"оператор {row.claimed_by_user}" if row.claimed_by_user else "",
],
)
),
interaction_id=row.interaction_id,
call_id=row.call_id,
)
)
if row.ai_state or row.ai_handoff_reason:
events.append(
CustomerHistoryEventOut(
timestamp=row.ai_last_model_at or row.updated_at or row.started_at,
kind="voice",
title=f"Голосовой AI • {row.ai_state or 'active'}",
body=row.ai_handoff_reason or "AI обновил контекст разговора для оператора.",
note=" • ".join(
filter(
None,
[
row.call_id,
f"voice {row.voice_session_id}" if row.voice_session_id else "",
f"ai {row.ai_session_id}" if row.ai_session_id else "",
],
)
),
interaction_id=row.interaction_id,
call_id=row.call_id,
)
)
for row in transcript_segments:
speaker = "AI" if row.speaker == "assistant" else "Клиент"
events.append(
CustomerHistoryEventOut(
timestamp=row.created_at,
kind="voice",
title=f"Транскрипт • {speaker}",
body=row.text or "Реплика без текста",
note=" • ".join(filter(None, [row.call_id, f"seq {row.sequence_no}"])),
interaction_id=row.interaction_id,
call_id=row.call_id,
)
)
events.sort(key=_history_event_sort_key, reverse=True)
active_channels = sorted(
{
*[row.channel for row in interactions if row.channel],
*(["telegram"] if telegram_threads else []),
*(["voice"] if live_calls else []),
}
)
latest_event = events[0] if events else None
primary_thread = max(
telegram_threads,
key=lambda row: _time_value(row.last_message_at or row.updated_at or row.created_at),
default=None,
)
summary = CustomerHistorySummaryOut(
contact_points=len(interactions) + len(telegram_threads) + len(live_calls),
open_cases=sum(1 for row in interactions if row.status != "closed"),
active_channels=active_channels,
latest_event_at=latest_event.timestamp if latest_event else None,
latest_event_title=latest_event.title if latest_event else None,
primary_phone=customer_out.phones[0] if customer_out.phones else customer_out.preferred_phone,
primary_telegram_thread_id=primary_thread.thread_id if primary_thread else None,
)
return CustomerHistoryOut(
customer=customer_out,
summary=summary,
interactions=interaction_out,
telegram_threads=telegram_thread_out,
live_calls=live_call_out,
history=events[:25],
)
@app.get("/health", response_model=HealthResponse)
def health() -> HealthResponse:
return HealthResponse(status="ok", service="customer-service")
@app.post("/customers", response_model=CustomerOut)
def create_customer(payload: CustomerCreate) -> CustomerOut:
if payload.preferred_phone and payload.preferred_phone not in payload.phones:
raise HTTPException(status_code=400, detail="preferred_phone must be in phones")
session = get_session()
try:
row = Customer(
customer_id=new_id("cus"),
display_name=payload.display_name,
phones_json=json.dumps(payload.phones, ensure_ascii=False),
preferred_phone=payload.preferred_phone,
tags_json=json.dumps(payload.tags, ensure_ascii=False),
created_at=utc_now_iso(),
)
session.add(row)
session.commit()
session.refresh(row)
return _to_out(row)
finally:
session.close()
@app.get("/customers/{customer_id}", response_model=CustomerOut)
def get_customer(customer_id: str) -> CustomerOut:
session = get_session()
try:
row = session.execute(select(Customer).where(Customer.customer_id == customer_id)).scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="Customer not found")
return _to_out(row)
finally:
session.close()
@app.get("/customers", response_model=list[CustomerOut])
def search_customers(query: str | None = None, limit: int = 100) -> list[CustomerOut]:
session = get_session()
try:
rows = session.execute(select(Customer).order_by(Customer.id.desc())).scalars().all()
out = [_to_out(r) for r in rows]
if query:
q = query.strip().lower()
out = [
c
for c in out
if q in c.display_name.lower()
or any(q in p.lower() for p in c.phones)
or any(q in t.lower() for t in c.tags)
]
return out[:limit]
finally:
session.close()
@app.patch("/customers/{customer_id}", response_model=CustomerOut)
def update_customer_name(customer_id: str, payload: CustomerNameUpdateIn) -> CustomerOut:
display_name = _normalized_display_name(payload.display_name)
if len(display_name) < 2:
raise HTTPException(status_code=400, detail="display_name must be at least 2 chars")
session = get_session()
try:
customer = session.execute(
select(Customer).where(Customer.customer_id == customer_id)
).scalar_one_or_none()
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
now = utc_now_iso()
customer.display_name = display_name
identities = session.execute(
select(CustomerExternalIdentity).where(CustomerExternalIdentity.customer_id == customer_id)
).scalars().all()
for identity in identities:
identity.display_name_snapshot = display_name
identity.updated_at = now
interaction_ids = session.execute(
select(Interaction.interaction_id).where(Interaction.customer_id == customer_id)
).scalars().all()
if interaction_ids:
live_calls = session.execute(
select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.interaction_id.in_(interaction_ids))
).scalars().all()
for row in live_calls:
row.caller_name = display_name
row.customer_name_status = "name_obtained"
row.customer_name_value = display_name
row.customer_name_source = "manual"
row.customer_name_resolved_at = now
row.updated_at = now
voice_sessions = session.execute(
select(VoiceAISessionRow).where(VoiceAISessionRow.interaction_id.in_(interaction_ids))
).scalars().all()
for row in voice_sessions:
row.customer_name_status = "name_obtained"
row.customer_name_value = display_name
row.customer_name_source = "manual"
row.customer_name_resolved_at = now
row.updated_at = now
session.commit()
session.refresh(customer)
return _to_out(customer)
finally:
session.close()
@app.get("/customers/{customer_id}/history", response_model=CustomerHistoryOut)
def customer_history(customer_id: str) -> CustomerHistoryOut:
session = get_session()
try:
customer = session.execute(
select(Customer).where(Customer.customer_id == customer_id)
).scalar_one_or_none()
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
interactions = session.execute(
select(Interaction)
.where(Interaction.customer_id == customer_id)
.order_by(Interaction.updated_at.desc(), Interaction.id.desc())
).scalars().all()
interaction_ids = [row.interaction_id for row in interactions]
timelines: list[InteractionTimeline] = []
telegram_threads: list[TelegramThreadRow] = []
telegram_messages: list[TelegramMessageRow] = []
live_calls: list[AsteriskCallLinkRow] = []
transcript_segments: list[VoiceTranscriptSegmentRow] = []
recording_call_ids: set[str] = set()
if interaction_ids:
timelines = session.execute(
select(InteractionTimeline)
.where(InteractionTimeline.interaction_id.in_(interaction_ids))
.order_by(InteractionTimeline.timestamp.desc(), InteractionTimeline.id.desc())
).scalars().all()
telegram_threads = session.execute(
select(TelegramThreadRow)
.where(TelegramThreadRow.interaction_id.in_(interaction_ids))
.order_by(TelegramThreadRow.last_message_at.desc(), TelegramThreadRow.id.desc())
).scalars().all()
live_calls = session.execute(
select(AsteriskCallLinkRow)
.where(AsteriskCallLinkRow.interaction_id.in_(interaction_ids))
.order_by(AsteriskCallLinkRow.updated_at.desc(), AsteriskCallLinkRow.id.desc())
).scalars().all()
thread_ids = [row.thread_id for row in telegram_threads if row.thread_id]
if thread_ids:
telegram_messages = session.execute(
select(TelegramMessageRow)
.where(TelegramMessageRow.thread_id.in_(thread_ids))
.order_by(TelegramMessageRow.created_at.desc(), TelegramMessageRow.id.desc())
.limit(8)
).scalars().all()
call_ids = [row.call_id for row in live_calls if row.call_id]
if call_ids:
recording_call_ids = set(
session.execute(
select(CallRecordingRow.call_id).where(CallRecordingRow.call_id.in_(call_ids))
).scalars().all()
)
transcript_segments = session.execute(
select(VoiceTranscriptSegmentRow)
.where(
VoiceTranscriptSegmentRow.call_id.in_(call_ids),
VoiceTranscriptSegmentRow.is_final.is_(True),
)
.order_by(VoiceTranscriptSegmentRow.created_at.desc(), VoiceTranscriptSegmentRow.id.desc())
.limit(8)
).scalars().all()
return _build_customer_history(
customer,
interactions,
timelines,
telegram_threads,
telegram_messages,
live_calls,
transcript_segments,
recording_call_ids,
)
finally:
session.close()