- Pipeline-down fallback: dialplan plays gogo-fallback after a failed AudioSocket (§15) + script to synthesize the Bosnian prompt via TTS - Voice heartbeat file → /health/voice endpoint + live status in admin panel - Retention job also purges stale call registrations; chat sessions metered; 100%-usage super-admin alert email (GOGO_ADMIN_ALERT_EMAIL) - .env.example, docs/DEPLOY.md (compose stack, GPU node, backups pg_dump, update procedure, security notes) - §16 Definition of Done as an automated test: softphone-style call → fake partner availability → request pushed → webhook confirm → console SMS - M0 PoC scripts: STT accuracy harness (CER/WER + spoken-digit check; dry-run verified with espeak-ng samples + faster-whisper small on CPU — phone number extracted exactly), TTS bake-off (Azure vs ElevenLabs, latency+cost), and end-to-end latency smoke test speaking real AudioSocket to the live pipeline Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
808 lines
26 KiB
Python
808 lines
26 KiB
Python
"""Super-admin panel (§11): tenants, provider config + connectivity test,
|
||
SIM/number mapping, prompt template versions + playground, usage, health."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import uuid
|
||
from datetime import UTC, datetime
|
||
|
||
from fastapi import APIRouter, Depends, Form, Request
|
||
from fastapi.responses import HTMLResponse
|
||
from sqlalchemy import desc, func, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from gogo.auth import (
|
||
current_admin,
|
||
hash_password,
|
||
read_session,
|
||
set_session,
|
||
verify_password,
|
||
)
|
||
from gogo.config import get_settings
|
||
from gogo.crypto import encrypt
|
||
from gogo.db import get_session
|
||
from gogo.metering import current_month
|
||
from gogo.models import (
|
||
Admin,
|
||
BookingRequest,
|
||
EmailLog,
|
||
PhoneNumber,
|
||
PromptTemplate,
|
||
ProviderConfig,
|
||
SmsLog,
|
||
Tenant,
|
||
TenantPromptOverride,
|
||
UsageCounter,
|
||
User,
|
||
)
|
||
from gogo.telephony.forwarding import OPERATORS
|
||
from gogo.web import redirect, templates
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
def ctx(request: Request, admin: Admin, **extra) -> dict:
|
||
return {"request": request, "admin": admin, "base_url": get_settings().base_url, **extra}
|
||
|
||
|
||
# -- auth --------------------------------------------------------------------
|
||
|
||
|
||
@router.get("/admin/login", response_class=HTMLResponse)
|
||
async def admin_login_page(request: Request):
|
||
return templates.TemplateResponse(request, "admin/login.html", {"request": request})
|
||
|
||
|
||
@router.post("/admin/login")
|
||
async def admin_login(
|
||
email: str = Form(...),
|
||
password: str = Form(...),
|
||
session: AsyncSession = Depends(get_session),
|
||
):
|
||
admin = (
|
||
await session.execute(select(Admin).where(Admin.email == email.strip().lower()))
|
||
).scalar_one_or_none()
|
||
if admin is None or not admin.active or not verify_password(password, admin.password_hash):
|
||
return redirect("/admin/login", err="Pogrešan email ili lozinka.")
|
||
resp = redirect("/admin")
|
||
set_session(resp, {"kind": "admin", "admin_id": str(admin.id), "email": admin.email})
|
||
return resp
|
||
|
||
|
||
@router.get("/admin/logout")
|
||
async def admin_logout():
|
||
from gogo.auth import clear_session
|
||
|
||
resp = redirect("/admin/login")
|
||
clear_session(resp)
|
||
return resp
|
||
|
||
|
||
@router.post("/admin/impersonate/{tenant_id}")
|
||
async def impersonate(
|
||
tenant_id: str,
|
||
request: Request,
|
||
admin: Admin = Depends(current_admin),
|
||
):
|
||
data = read_session(request) or {}
|
||
data["impersonate_tenant"] = tenant_id
|
||
resp = redirect("/dash", msg="Otvoren pregled kao salon (admin).")
|
||
set_session(resp, data)
|
||
return resp
|
||
|
||
|
||
# -- tenants ------------------------------------------------------------------
|
||
|
||
|
||
@router.get("/admin", response_class=HTMLResponse)
|
||
async def tenants_list(
|
||
request: Request,
|
||
admin: Admin = Depends(current_admin),
|
||
session: AsyncSession = Depends(get_session),
|
||
):
|
||
tenants = (
|
||
(await session.execute(select(Tenant).order_by(Tenant.name))).scalars().all()
|
||
)
|
||
month = current_month()
|
||
rows = []
|
||
for t in tenants:
|
||
counter = (
|
||
await session.execute(
|
||
select(UsageCounter).where(
|
||
UsageCounter.tenant_id == t.id, UsageCounter.month == month
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
number = (
|
||
await session.execute(select(PhoneNumber).where(PhoneNumber.tenant_id == t.id))
|
||
).scalar_one_or_none()
|
||
rows.append(
|
||
{
|
||
"tenant": t,
|
||
"used_minutes": (counter.agent_seconds // 60) if counter else 0,
|
||
"msisdn": number.msisdn if number else None,
|
||
}
|
||
)
|
||
return templates.TemplateResponse(
|
||
request, "admin/tenants.html", ctx(request, admin, rows=rows, active="tenants")
|
||
)
|
||
|
||
|
||
async def _tenant_edit_context(
|
||
request: Request, admin: Admin, session: AsyncSession, t: Tenant | None, test_result=None
|
||
) -> dict:
|
||
pconfig = {}
|
||
override_body = ""
|
||
if t:
|
||
cfg = (
|
||
await session.execute(select(ProviderConfig).where(ProviderConfig.tenant_id == t.id))
|
||
).scalar_one_or_none()
|
||
pconfig = cfg.config if cfg else {}
|
||
ov = (
|
||
await session.execute(
|
||
select(TenantPromptOverride).where(TenantPromptOverride.tenant_id == t.id)
|
||
)
|
||
).scalar_one_or_none()
|
||
override_body = ov.body if ov else ""
|
||
numbers = (
|
||
(await session.execute(select(PhoneNumber).order_by(PhoneNumber.msisdn))).scalars().all()
|
||
)
|
||
return ctx(
|
||
request,
|
||
admin,
|
||
t=t,
|
||
pconfig=pconfig,
|
||
override_body=override_body,
|
||
numbers=numbers,
|
||
test_result=test_result,
|
||
active="tenants",
|
||
)
|
||
|
||
|
||
@router.get("/admin/tenants/new", response_class=HTMLResponse)
|
||
async def tenant_new_page(
|
||
request: Request,
|
||
admin: Admin = Depends(current_admin),
|
||
session: AsyncSession = Depends(get_session),
|
||
):
|
||
return templates.TemplateResponse(
|
||
request, "admin/tenant_edit.html",
|
||
await _tenant_edit_context(request, admin, session, None),
|
||
)
|
||
|
||
|
||
@router.get("/admin/tenants/{tenant_id}", response_class=HTMLResponse)
|
||
async def tenant_edit_page(
|
||
tenant_id: str,
|
||
request: Request,
|
||
admin: Admin = Depends(current_admin),
|
||
session: AsyncSession = Depends(get_session),
|
||
):
|
||
t = (
|
||
await session.execute(select(Tenant).where(Tenant.id == uuid.UUID(tenant_id)))
|
||
).scalar_one_or_none()
|
||
if t is None:
|
||
return redirect("/admin", err="Salon nije pronađen.")
|
||
return templates.TemplateResponse(
|
||
request, "admin/tenant_edit.html",
|
||
await _tenant_edit_context(request, admin, session, t),
|
||
)
|
||
|
||
|
||
async def _apply_tenant_form(session: AsyncSession, t: Tenant, form) -> str | None:
|
||
"""Shared create/update logic. Returns error message or None."""
|
||
t.name = str(form.get("name", t.name)).strip() or t.name
|
||
t.city = str(form.get("city", "")).strip()
|
||
t.status = "disabled" if form.get("status") == "disabled" else "active"
|
||
t.plan = str(form.get("plan", "gogo_start")).strip() or "gogo_start"
|
||
t.included_minutes = int(form.get("included_minutes") or 300)
|
||
t.hard_cutoff = bool(form.get("hard_cutoff"))
|
||
t.llm_model = str(form.get("llm_model", "")).strip()
|
||
t.proposal_ttl_hours = max(1, int(form.get("proposal_ttl_hours") or 24))
|
||
paid = str(form.get("paid_until", "")).strip()
|
||
if paid:
|
||
try:
|
||
t.paid_until = datetime.strptime(paid, "%Y-%m-%d").replace(tzinfo=UTC)
|
||
except ValueError:
|
||
return "Neispravan datum 'plaćeno do' (očekivano YYYY-MM-DD)."
|
||
else:
|
||
t.paid_until = None
|
||
provider = str(form.get("scheduling_provider", t.scheduling_provider))
|
||
if provider in ("google_calendar", "partner_api", "mock"):
|
||
t.scheduling_provider = provider
|
||
|
||
# provider config (partner)
|
||
cfg_row = (
|
||
await session.execute(select(ProviderConfig).where(ProviderConfig.tenant_id == t.id))
|
||
).scalar_one_or_none()
|
||
if cfg_row is None:
|
||
cfg_row = ProviderConfig(tenant_id=t.id, provider_type=provider, config={})
|
||
session.add(cfg_row)
|
||
cfg = dict(cfg_row.config or {})
|
||
cfg_row.provider_type = provider
|
||
cfg["base_url"] = str(form.get("partner_base_url", "")).strip()
|
||
api_key = str(form.get("partner_api_key", "")).strip()
|
||
if api_key:
|
||
cfg["api_key_encrypted"] = encrypt(api_key)
|
||
secret = str(form.get("partner_webhook_secret", "")).strip()
|
||
if secret:
|
||
cfg["webhook_secret_encrypted"] = encrypt(secret)
|
||
cfg["catalog_sync"] = bool(form.get("catalog_sync"))
|
||
cfg["email_to_owner"] = bool(form.get("email_to_owner"))
|
||
cfg["polling_fallback"] = bool(form.get("polling_fallback"))
|
||
cfg_row.config = cfg
|
||
|
||
# number assignment
|
||
number_id = str(form.get("phone_number_id", "")).strip()
|
||
current = (
|
||
await session.execute(select(PhoneNumber).where(PhoneNumber.tenant_id == t.id))
|
||
).scalar_one_or_none()
|
||
if current and (not number_id or str(current.id) != number_id):
|
||
current.tenant_id = None
|
||
current.sim_status = "unassigned"
|
||
if number_id:
|
||
target = (
|
||
await session.execute(
|
||
select(PhoneNumber).where(PhoneNumber.id == uuid.UUID(number_id))
|
||
)
|
||
).scalar_one_or_none()
|
||
if target is not None:
|
||
if target.tenant_id and target.tenant_id != t.id:
|
||
return "Taj broj je već dodijeljen drugom salonu."
|
||
target.tenant_id = t.id
|
||
target.sim_status = "active"
|
||
|
||
# prompt override
|
||
override_body = str(form.get("prompt_override", "")).strip()
|
||
ov = (
|
||
await session.execute(
|
||
select(TenantPromptOverride).where(TenantPromptOverride.tenant_id == t.id)
|
||
)
|
||
).scalar_one_or_none()
|
||
if override_body:
|
||
if ov:
|
||
ov.body = override_body
|
||
else:
|
||
session.add(TenantPromptOverride(tenant_id=t.id, body=override_body))
|
||
elif ov:
|
||
await session.delete(ov)
|
||
return None
|
||
|
||
|
||
@router.post("/admin/tenants/new")
|
||
async def tenant_create(
|
||
request: Request,
|
||
admin: Admin = Depends(current_admin),
|
||
session: AsyncSession = Depends(get_session),
|
||
):
|
||
form = await request.form()
|
||
slug = str(form.get("slug", "")).strip().lower()
|
||
if not slug:
|
||
return redirect("/admin/tenants/new", err="Slug je obavezan.")
|
||
existing = (
|
||
await session.execute(select(Tenant).where(Tenant.slug == slug))
|
||
).scalar_one_or_none()
|
||
if existing:
|
||
return redirect("/admin/tenants/new", err="Slug je zauzet.")
|
||
t = Tenant(name=str(form.get("name", "")).strip() or slug, slug=slug)
|
||
session.add(t)
|
||
await session.flush()
|
||
err = await _apply_tenant_form(session, t, form)
|
||
if err:
|
||
return redirect("/admin/tenants/new", err=err)
|
||
owner_email = str(form.get("owner_email", "")).strip().lower()
|
||
owner_password = str(form.get("owner_password", "")).strip()
|
||
if owner_email and owner_password:
|
||
session.add(
|
||
User(tenant_id=t.id, email=owner_email, password_hash=hash_password(owner_password))
|
||
)
|
||
await session.commit()
|
||
return redirect(f"/admin/tenants/{t.id}", msg="Salon kreiran.")
|
||
|
||
|
||
@router.post("/admin/tenants/{tenant_id}")
|
||
async def tenant_update(
|
||
tenant_id: str,
|
||
request: Request,
|
||
admin: Admin = Depends(current_admin),
|
||
session: AsyncSession = Depends(get_session),
|
||
):
|
||
form = await request.form()
|
||
t = (
|
||
await session.execute(select(Tenant).where(Tenant.id == uuid.UUID(tenant_id)))
|
||
).scalar_one_or_none()
|
||
if t is None:
|
||
return redirect("/admin", err="Salon nije pronađen.")
|
||
err = await _apply_tenant_form(session, t, form)
|
||
if err:
|
||
await session.rollback()
|
||
return redirect(f"/admin/tenants/{tenant_id}", err=err)
|
||
await session.commit()
|
||
return redirect(f"/admin/tenants/{tenant_id}", msg="Sačuvano.")
|
||
|
||
|
||
@router.post("/admin/tenants/{tenant_id}/test-connection", response_class=HTMLResponse)
|
||
async def tenant_test_connection(
|
||
tenant_id: str,
|
||
request: Request,
|
||
admin: Admin = Depends(current_admin),
|
||
session: AsyncSession = Depends(get_session),
|
||
):
|
||
"""Connectivity test (§8.3): live availability probe + dry-run delivery."""
|
||
from datetime import date, timedelta
|
||
|
||
from gogo.domain import BookingRequestData
|
||
from gogo.models import Service, utcnow
|
||
from gogo.scheduling.base import get_provider
|
||
|
||
t = (
|
||
await session.execute(select(Tenant).where(Tenant.id == uuid.UUID(tenant_id)))
|
||
).scalar_one_or_none()
|
||
if t is None:
|
||
return redirect("/admin", err="Salon nije pronađen.")
|
||
|
||
lines = []
|
||
try:
|
||
provider = await get_provider(session, t)
|
||
svc = (
|
||
await session.execute(
|
||
select(Service).where(Service.tenant_id == t.id, Service.active.is_(True)).limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
service_id = str(svc.id) if svc else "TEST"
|
||
lines.append(f"→ GET /availability (service {service_id})")
|
||
slots = await provider.get_availability(
|
||
service_id, date.today(), date.today() + timedelta(days=7)
|
||
)
|
||
lines.append(f"← OK, {len(slots)} slots")
|
||
for s in slots[:5]:
|
||
lines.append(f" {s.start.isoformat()} – {s.end.isoformat()}"
|
||
+ (f" ({s.staff_name})" if s.staff_name else ""))
|
||
except Exception as e: # noqa: BLE001
|
||
lines.append(f"← ERROR: {type(e).__name__}: {e}")
|
||
|
||
try:
|
||
lines.append("→ POST /booking-requests (dry_run=true)")
|
||
result = await provider.deliver_request(
|
||
BookingRequestData(
|
||
gogo_request_id=str(uuid.uuid4()),
|
||
tenant_id=str(t.id),
|
||
created_at=utcnow(),
|
||
source="chat",
|
||
client_name="Test Konekcije",
|
||
client_phone="+38700000000",
|
||
summary="Dry-run test veze iz admin panela.",
|
||
dry_run=True,
|
||
)
|
||
)
|
||
lines.append(f"← ok={result.ok} detail={result.detail}")
|
||
except Exception as e: # noqa: BLE001
|
||
lines.append(f"← ERROR: {type(e).__name__}: {e}")
|
||
|
||
return templates.TemplateResponse(
|
||
request,
|
||
"admin/tenant_edit.html",
|
||
await _tenant_edit_context(request, admin, session, t, test_result="\n".join(lines)),
|
||
)
|
||
|
||
|
||
# -- numbers / SIM --------------------------------------------------------------
|
||
|
||
|
||
@router.get("/admin/numbers", response_class=HTMLResponse)
|
||
async def numbers_page(
|
||
request: Request,
|
||
admin: Admin = Depends(current_admin),
|
||
session: AsyncSession = Depends(get_session),
|
||
):
|
||
numbers = (
|
||
(await session.execute(select(PhoneNumber).order_by(PhoneNumber.msisdn))).scalars().all()
|
||
)
|
||
rows = []
|
||
for n in numbers:
|
||
tenant_name = None
|
||
if n.tenant_id:
|
||
tn = (
|
||
await session.execute(select(Tenant).where(Tenant.id == n.tenant_id))
|
||
).scalar_one_or_none()
|
||
tenant_name = tn.name if tn else None
|
||
rows.append({"n": n, "tenant_name": tenant_name})
|
||
return templates.TemplateResponse(
|
||
request,
|
||
"admin/numbers.html",
|
||
ctx(request, admin, rows=rows, operators=OPERATORS, active="numbers"),
|
||
)
|
||
|
||
|
||
@router.post("/admin/numbers")
|
||
async def number_add(
|
||
msisdn: str = Form(...),
|
||
gateway_port: str = Form(""),
|
||
operator: str = Form(""),
|
||
admin: Admin = Depends(current_admin),
|
||
session: AsyncSession = Depends(get_session),
|
||
):
|
||
session.add(
|
||
PhoneNumber(
|
||
msisdn=msisdn.strip(),
|
||
gateway_port=int(gateway_port) if gateway_port.strip() else None,
|
||
operator=operator if operator in OPERATORS else "",
|
||
)
|
||
)
|
||
await session.commit()
|
||
return redirect("/admin/numbers", msg="Broj dodan.")
|
||
|
||
|
||
@router.post("/admin/numbers/{number_id}")
|
||
async def number_update(
|
||
number_id: str,
|
||
gateway_port: str = Form(""),
|
||
operator: str = Form(""),
|
||
sim_status: str = Form("unassigned"),
|
||
admin: Admin = Depends(current_admin),
|
||
session: AsyncSession = Depends(get_session),
|
||
):
|
||
n = (
|
||
await session.execute(
|
||
select(PhoneNumber).where(PhoneNumber.id == uuid.UUID(number_id))
|
||
)
|
||
).scalar_one_or_none()
|
||
if n:
|
||
n.gateway_port = int(gateway_port) if gateway_port.strip() else None
|
||
n.operator = operator if operator in OPERATORS else ""
|
||
n.sim_status = sim_status
|
||
await session.commit()
|
||
return redirect("/admin/numbers", msg="Sačuvano.")
|
||
|
||
|
||
@router.post("/admin/numbers/{number_id}/delete")
|
||
async def number_delete(
|
||
number_id: str,
|
||
admin: Admin = Depends(current_admin),
|
||
session: AsyncSession = Depends(get_session),
|
||
):
|
||
n = (
|
||
await session.execute(
|
||
select(PhoneNumber).where(PhoneNumber.id == uuid.UUID(number_id))
|
||
)
|
||
).scalar_one_or_none()
|
||
if n:
|
||
await session.delete(n)
|
||
await session.commit()
|
||
return redirect("/admin/numbers", msg="Obrisano.")
|
||
|
||
|
||
# -- prompt templates -------------------------------------------------------------
|
||
|
||
|
||
@router.get("/admin/templates", response_class=HTMLResponse)
|
||
async def templates_page(
|
||
request: Request,
|
||
admin: Admin = Depends(current_admin),
|
||
session: AsyncSession = Depends(get_session),
|
||
):
|
||
from gogo.agent.prompt import DEFAULT_PROMPT_TEMPLATE
|
||
|
||
versions = (
|
||
(
|
||
await session.execute(
|
||
select(PromptTemplate).order_by(desc(PromptTemplate.version))
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
load = request.query_params.get("load")
|
||
draft_body = None
|
||
if load:
|
||
for v in versions:
|
||
if str(v.version) == load:
|
||
draft_body = v.body
|
||
if draft_body is None:
|
||
published = next((v for v in versions if v.published), None)
|
||
draft_body = published.body if published else DEFAULT_PROMPT_TEMPLATE
|
||
return templates.TemplateResponse(
|
||
request,
|
||
"admin/templates.html",
|
||
ctx(request, admin, versions=versions, draft_body=draft_body, active="templates"),
|
||
)
|
||
|
||
|
||
@router.post("/admin/templates")
|
||
async def template_save(
|
||
body: str = Form(...),
|
||
notes: str = Form(""),
|
||
publish: str = Form(""),
|
||
admin: Admin = Depends(current_admin),
|
||
session: AsyncSession = Depends(get_session),
|
||
):
|
||
max_version = (
|
||
await session.execute(select(func.max(PromptTemplate.version)))
|
||
).scalar() or 0
|
||
if publish:
|
||
# unpublish the rest BEFORE adding the new one (autoflush would catch it too)
|
||
others = (
|
||
(
|
||
await session.execute(
|
||
select(PromptTemplate).where(PromptTemplate.published.is_(True))
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
for o in others:
|
||
o.published = False
|
||
version = PromptTemplate(
|
||
version=max_version + 1, body=body, notes=notes.strip(), published=bool(publish)
|
||
)
|
||
session.add(version)
|
||
await session.commit()
|
||
return redirect(
|
||
"/admin/templates",
|
||
msg=f"Verzija v{version.version} sačuvana{' i objavljena' if publish else ''}.",
|
||
)
|
||
|
||
|
||
@router.post("/admin/templates/{version}/publish")
|
||
async def template_publish(
|
||
version: int,
|
||
admin: Admin = Depends(current_admin),
|
||
session: AsyncSession = Depends(get_session),
|
||
):
|
||
target = (
|
||
await session.execute(select(PromptTemplate).where(PromptTemplate.version == version))
|
||
).scalar_one_or_none()
|
||
if target is None:
|
||
return redirect("/admin/templates", err="Verzija nije pronađena.")
|
||
all_versions = (await session.execute(select(PromptTemplate))).scalars().all()
|
||
for v in all_versions:
|
||
v.published = v.version == version
|
||
await session.commit()
|
||
return redirect("/admin/templates", msg=f"Verzija v{version} je sada aktivna.")
|
||
|
||
|
||
# -- playground --------------------------------------------------------------------
|
||
|
||
|
||
@router.get("/admin/playground", response_class=HTMLResponse)
|
||
async def playground_page(
|
||
request: Request,
|
||
admin: Admin = Depends(current_admin),
|
||
session: AsyncSession = Depends(get_session),
|
||
):
|
||
tenants = (
|
||
(await session.execute(select(Tenant).order_by(Tenant.name))).scalars().all()
|
||
)
|
||
return templates.TemplateResponse(
|
||
request,
|
||
"admin/playground.html",
|
||
ctx(
|
||
request,
|
||
admin,
|
||
tenants=tenants,
|
||
selected_tenant=None,
|
||
transcript=[],
|
||
history_json="[]",
|
||
channel="voice",
|
||
system_prompt=None,
|
||
error=None,
|
||
active="playground",
|
||
),
|
||
)
|
||
|
||
|
||
@router.post("/admin/playground", response_class=HTMLResponse)
|
||
async def playground_send(
|
||
request: Request,
|
||
tenant_id: str = Form(...),
|
||
channel: str = Form("voice"),
|
||
history: str = Form("[]"),
|
||
message: str = Form(""),
|
||
admin: Admin = Depends(current_admin),
|
||
session: AsyncSession = Depends(get_session),
|
||
):
|
||
from gogo.agent.loop import AgentConversation
|
||
from gogo.agent.prompt import compose_system_prompt
|
||
|
||
tenants = (
|
||
(await session.execute(select(Tenant).order_by(Tenant.name))).scalars().all()
|
||
)
|
||
tenant = next((t for t in tenants if str(t.id) == tenant_id), None)
|
||
if tenant is None:
|
||
return redirect("/admin/playground", err="Salon nije pronađen.")
|
||
|
||
try:
|
||
past: list[dict] = json.loads(history) if history.strip() else []
|
||
except json.JSONDecodeError:
|
||
past = []
|
||
|
||
error = None
|
||
convo = AgentConversation(
|
||
session, tenant, channel=channel, caller_phone="+38765000111"
|
||
)
|
||
# rebuild plain-text history (tool traffic is not replayed; fine for a playground)
|
||
for m in past:
|
||
convo.messages.append({"role": m["role"], "content": m["text"]})
|
||
from gogo.agent.loop import Turn
|
||
|
||
convo.turns.append(Turn(m["role"], m["text"]))
|
||
if not past:
|
||
await convo.greeting()
|
||
|
||
if message.strip():
|
||
if not get_settings().anthropic_api_key:
|
||
error = "GOGO_ANTHROPIC_API_KEY nije postavljen — playground treba LLM ključ."
|
||
else:
|
||
try:
|
||
await convo.user_turn(message.strip())
|
||
await session.commit()
|
||
except Exception as e: # noqa: BLE001
|
||
error = f"LLM greška: {type(e).__name__}: {e}"
|
||
|
||
transcript = convo.transcript
|
||
history_json = json.dumps(
|
||
[{"role": t["role"], "text": t["text"]} for t in transcript], ensure_ascii=False
|
||
)
|
||
system_prompt = await compose_system_prompt(session, tenant, channel)
|
||
return templates.TemplateResponse(
|
||
request,
|
||
"admin/playground.html",
|
||
ctx(
|
||
request,
|
||
admin,
|
||
tenants=tenants,
|
||
selected_tenant=tenant,
|
||
transcript=transcript,
|
||
history_json=history_json,
|
||
channel=channel,
|
||
system_prompt=system_prompt,
|
||
error=error,
|
||
active="playground",
|
||
),
|
||
)
|
||
|
||
|
||
# -- usage + health ------------------------------------------------------------------
|
||
|
||
|
||
@router.get("/admin/usage", response_class=HTMLResponse)
|
||
async def usage_overview(
|
||
request: Request,
|
||
admin: Admin = Depends(current_admin),
|
||
session: AsyncSession = Depends(get_session),
|
||
):
|
||
month = current_month()
|
||
tenants = (
|
||
(await session.execute(select(Tenant).order_by(Tenant.name))).scalars().all()
|
||
)
|
||
rows = []
|
||
# rough per-minute cost estimate: LLM ~0.02 KM + TTS ~0.05 KM per agent minute
|
||
llm_km, tts_km = 0.02, 0.05
|
||
for t in tenants:
|
||
counter = (
|
||
await session.execute(
|
||
select(UsageCounter).where(
|
||
UsageCounter.tenant_id == t.id, UsageCounter.month == month
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
used = (counter.agent_seconds // 60) if counter else 0
|
||
requests_count = (
|
||
await session.execute(
|
||
select(func.count())
|
||
.select_from(BookingRequest)
|
||
.where(BookingRequest.tenant_id == t.id)
|
||
)
|
||
).scalar() or 0
|
||
rows.append(
|
||
{
|
||
"tenant": t,
|
||
"used_minutes": used,
|
||
"calls": counter.calls if counter else 0,
|
||
"sms": counter.sms_sent if counter else 0,
|
||
"chats": counter.chat_sessions if counter else 0,
|
||
"requests": requests_count,
|
||
"est_cost_km": round(used * (llm_km + tts_km), 2),
|
||
"over_80": t.included_minutes and used >= t.included_minutes * 0.8,
|
||
"over_100": t.included_minutes and used >= t.included_minutes,
|
||
}
|
||
)
|
||
return templates.TemplateResponse(
|
||
request,
|
||
"admin/usage.html",
|
||
ctx(
|
||
request,
|
||
admin,
|
||
rows=rows,
|
||
month=month,
|
||
cost_note=f"LLM ~{llm_km} KM/min + TTS ~{tts_km} KM/min (gruba procjena).",
|
||
active="usage",
|
||
),
|
||
)
|
||
|
||
|
||
@router.get("/admin/health", response_class=HTMLResponse)
|
||
async def health_page(
|
||
request: Request,
|
||
admin: Admin = Depends(current_admin),
|
||
session: AsyncSession = Depends(get_session),
|
||
):
|
||
from datetime import timedelta
|
||
|
||
from sqlalchemy import text
|
||
|
||
s = get_settings()
|
||
try:
|
||
await session.execute(text("SELECT 1"))
|
||
db_ok = True
|
||
except Exception: # noqa: BLE001
|
||
db_ok = False
|
||
|
||
week_ago = datetime.now(UTC) - timedelta(days=7)
|
||
email_errors = (
|
||
await session.execute(
|
||
select(func.count())
|
||
.select_from(EmailLog)
|
||
.where(EmailLog.status == "failed", EmailLog.at > week_ago)
|
||
)
|
||
).scalar() or 0
|
||
sms_errors = (
|
||
await session.execute(
|
||
select(func.count())
|
||
.select_from(SmsLog)
|
||
.where(SmsLog.status == "failed", SmsLog.at > week_ago)
|
||
)
|
||
).scalar() or 0
|
||
|
||
failed_rows = []
|
||
for row in (
|
||
(
|
||
await session.execute(
|
||
select(SmsLog)
|
||
.where(SmsLog.status == "failed")
|
||
.order_by(desc(SmsLog.at))
|
||
.limit(10)
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
):
|
||
failed_rows.append(
|
||
{"at": row.at, "kind": f"SMS/{row.kind}", "to": row.to_msisdn, "error": row.error}
|
||
)
|
||
|
||
# voice pipeline heartbeat (written by the voice service, M6)
|
||
import os
|
||
import time as time_mod
|
||
|
||
from gogo.voice.server import heartbeat_path
|
||
|
||
try:
|
||
age = time_mod.time() - os.path.getmtime(heartbeat_path())
|
||
if age <= 60:
|
||
voice_status, voice_badge = f"radi (heartbeat prije {int(age)}s)", "confirmed"
|
||
else:
|
||
voice_status, voice_badge = f"NE RADI (heartbeat star {int(age)}s)", "rejected"
|
||
except OSError:
|
||
voice_status, voice_badge = "nije pokrenut", "pending"
|
||
|
||
return templates.TemplateResponse(
|
||
request,
|
||
"admin/health.html",
|
||
ctx(
|
||
request,
|
||
admin,
|
||
db_ok=db_ok,
|
||
llm_configured=bool(s.anthropic_api_key),
|
||
email_mode=s.email_provider,
|
||
sms_mode=s.sms_provider,
|
||
voice_status=voice_status,
|
||
voice_badge=voice_badge,
|
||
email_errors=email_errors,
|
||
sms_errors=sms_errors,
|
||
failed_rows=failed_rows,
|
||
active="health",
|
||
),
|
||
)
|