From e855650f09e1a2e5aaabe13131057613c70519de Mon Sep 17 00:00:00 2001 From: Senad Uka Date: Sat, 11 Jul 2026 09:45:06 +0200 Subject: [PATCH] M1: backend core + proposal engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FastAPI skeleton, SQLAlchemy models (§13), Alembic initial migration - SchedulingProvider interface with google_calendar (free/busy read-only), partner_api (Appendix B client) and mock implementations - Proposal engine: create → provider-routed delivery → owner actions (resolve/confirm+SMS/reject) → expiry + reminders (§9) - Signed single-use action links, .ics METHOD:REQUEST attachment - Partner outcome webhook with HMAC verification + polling fallback - SmsProvider (console) with Bosnian templates (§5.5), EmailProvider (console/SMTP) - Fake partner API server in tests/ — Appendix B reference implementation - 43 tests: slot math, proposal lifecycle, action links, partner contract Co-Authored-By: Claude Fable 5 --- .gitignore | 10 + Dockerfile | 12 + README.md | 55 ++ alembic.ini | 37 ++ alembic/env.py | 50 ++ alembic/script.py.mako | 25 + .../versions/62fcb10345fa_initial_schema.py | 352 ++++++++++ docker-compose.yml | 38 ++ docs/SPEC.md | 606 ++++++++++++++++++ gogo/__init__.py | 3 + gogo/api/__init__.py | 0 gogo/api/actions.py | 190 ++++++ gogo/api/health.py | 23 + gogo/api/webhooks.py | 120 ++++ gogo/config.py | 59 ++ gogo/crypto.py | 24 + gogo/db.py | 43 ++ gogo/domain.py | 87 +++ gogo/email/__init__.py | 1 + gogo/email/sender.py | 92 +++ gogo/hours.py | 79 +++ gogo/i18n.py | 65 ++ gogo/jobs.py | 110 ++++ gogo/main.py | 39 ++ gogo/models.py | 359 +++++++++++ gogo/proposals/__init__.py | 0 gogo/proposals/email.py | 217 +++++++ gogo/proposals/engine.py | 251 ++++++++ gogo/proposals/ics.py | 42 ++ gogo/proposals/tokens.py | 44 ++ gogo/scheduling/__init__.py | 1 + gogo/scheduling/base.py | 72 +++ gogo/scheduling/google_calendar.py | 211 ++++++ gogo/scheduling/mock.py | 81 +++ gogo/scheduling/partner_api.py | 226 +++++++ gogo/scheduling/slots.py | 77 +++ gogo/sms/__init__.py | 1 + gogo/sms/base.py | 72 +++ gogo/sms/templates.py | 37 ++ pyproject.toml | 61 ++ tests/__init__.py | 0 tests/conftest.py | 165 +++++ tests/fake_partner.py | 176 +++++ tests/test_action_links.py | 133 ++++ tests/test_partner_api.py | 263 ++++++++ tests/test_proposal_lifecycle.py | 169 +++++ tests/test_slots.py | 96 +++ tests/test_units.py | 67 ++ 48 files changed, 4941 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/62fcb10345fa_initial_schema.py create mode 100644 docker-compose.yml create mode 100644 docs/SPEC.md create mode 100644 gogo/__init__.py create mode 100644 gogo/api/__init__.py create mode 100644 gogo/api/actions.py create mode 100644 gogo/api/health.py create mode 100644 gogo/api/webhooks.py create mode 100644 gogo/config.py create mode 100644 gogo/crypto.py create mode 100644 gogo/db.py create mode 100644 gogo/domain.py create mode 100644 gogo/email/__init__.py create mode 100644 gogo/email/sender.py create mode 100644 gogo/hours.py create mode 100644 gogo/i18n.py create mode 100644 gogo/jobs.py create mode 100644 gogo/main.py create mode 100644 gogo/models.py create mode 100644 gogo/proposals/__init__.py create mode 100644 gogo/proposals/email.py create mode 100644 gogo/proposals/engine.py create mode 100644 gogo/proposals/ics.py create mode 100644 gogo/proposals/tokens.py create mode 100644 gogo/scheduling/__init__.py create mode 100644 gogo/scheduling/base.py create mode 100644 gogo/scheduling/google_calendar.py create mode 100644 gogo/scheduling/mock.py create mode 100644 gogo/scheduling/partner_api.py create mode 100644 gogo/scheduling/slots.py create mode 100644 gogo/sms/__init__.py create mode 100644 gogo/sms/base.py create mode 100644 gogo/sms/templates.py create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/fake_partner.py create mode 100644 tests/test_action_links.py create mode 100644 tests/test_partner_api.py create mode 100644 tests/test_proposal_lifecycle.py create mode 100644 tests/test_slots.py create mode 100644 tests/test_units.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..be196fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.venv/ +__pycache__/ +*.pyc +.env +.pytest_cache/ +.ruff_cache/ +*.egg-info/ +dist/ +recordings/ +*.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bf9e11d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY pyproject.toml README.md* ./ +COPY gogo ./gogo +COPY alembic ./alembic +COPY alembic.ini ./ + +RUN pip install --no-cache-dir -e . + +EXPOSE 8000 +CMD ["uvicorn", "gogo.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a2d9e6d --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# Gogo Telefon + +Voice-first AI receptionist for small service businesses in Bosnia and Herzegovina. +The agent answers forwarded calls (and web chat), speaks Bosnian, checks real +availability and sends **booking proposals** to the salon owner — it never books +appointments itself. See `docs/` and the product spec for details. + +## Architecture (short version) + +``` +caller → salon number (conditional forwarding) → GSM gateway → Asterisk + → ring group (staff softphones) → no answer → voice agent (Pipecat) + → tools HTTP → FastAPI backend → SchedulingProvider (Google Calendar | Partner API) + → proposal email / partner push → owner action → (optional) SMS to client +``` + +Everything self-hosted except LLM + TTS APIs (swappable interfaces). + +## Development + +```bash +uv venv && uv pip install -e '.[dev]' +docker compose up -d db # Postgres 16 on localhost:5433 (or use your own) +export GOGO_DATABASE_URL=postgresql+psycopg://gogo:gogo@localhost:5432/gogo +.venv/bin/alembic upgrade head +.venv/bin/uvicorn gogo.main:app --reload +``` + +Run tests (SQLite, no services needed): + +```bash +.venv/bin/python -m pytest +``` + +The fake partner booking API used in tests (`tests/fake_partner.py`) doubles as +the **reference implementation of Appendix B** for partner IT teams: + +```bash +.venv/bin/uvicorn tests.fake_partner:app --port 9100 +``` + +## Layout + +| Path | What | +|---|---| +| `gogo/scheduling/` | `SchedulingProvider` interface + `google_calendar`, `partner_api`, `mock` | +| `gogo/proposals/` | proposal engine, action tokens, owner emails, .ics | +| `gogo/sms/` | `SmsProvider` (console now, GSM gateway in M5) + Bosnian templates | +| `gogo/api/` | action links, partner webhook, health | +| `gogo/agent/` | LLM agent: prompt composition, tools, conversation loop (M2) | +| `gogo/voice/` | Pipecat voice pipeline (M4) | +| `gogo/telephony/` | Asterisk/GSM/forwarding codes (M4/M5) | +| `tests/fake_partner.py` | Appendix B reference implementation | + +All code/comments in English; all client- and owner-facing text in Bosnian (Latin). diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..f45d8d0 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,37 @@ +[alembic] +script_location = alembic +prepend_sys_path = . + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..fe5887c --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,50 @@ +import re +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from gogo.config import get_settings +from gogo.db import Base +from gogo import models # noqa: F401 — register all tables + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def _sync_url() -> str: + # alembic runs sync; strip async driver suffixes + url = get_settings().database_url + return re.sub(r"\+(asyncpg|aiosqlite)", "", url) + + +def run_migrations_offline() -> None: + context.configure( + url=_sync_url(), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + {"sqlalchemy.url": _sync_url()}, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..17dcba0 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/62fcb10345fa_initial_schema.py b/alembic/versions/62fcb10345fa_initial_schema.py new file mode 100644 index 0000000..9862b5a --- /dev/null +++ b/alembic/versions/62fcb10345fa_initial_schema.py @@ -0,0 +1,352 @@ +"""initial schema + +Revision ID: 62fcb10345fa +Revises: +Create Date: 2026-07-11 09:43:04.542280 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = '62fcb10345fa' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('admins', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('email', sa.String(length=200), nullable=False), + sa.Column('password_hash', sa.String(length=200), nullable=False), + sa.Column('active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email') + ) + op.create_table('prompt_templates', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('body', sa.Text(), nullable=False), + sa.Column('notes', sa.Text(), nullable=False), + sa.Column('published', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('version') + ) + op.create_table('tenants', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('slug', sa.String(length=80), nullable=False), + sa.Column('address', sa.String(length=300), nullable=False), + sa.Column('city', sa.String(length=100), nullable=False), + sa.Column('phone', sa.String(length=40), nullable=False), + sa.Column('website', sa.String(length=200), nullable=False), + sa.Column('locale', sa.String(length=20), nullable=False), + sa.Column('timezone', sa.String(length=50), nullable=False), + sa.Column('currency', sa.String(length=10), nullable=False), + sa.Column('working_hours', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False), + sa.Column('scheduling_provider', sa.String(length=30), nullable=False), + sa.Column('plan', sa.String(length=40), nullable=False), + sa.Column('included_minutes', sa.Integer(), nullable=False), + sa.Column('hard_cutoff', sa.Boolean(), nullable=False), + sa.Column('paid_until', sa.DateTime(timezone=True), nullable=True), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('tts_voice', sa.String(length=80), nullable=False), + sa.Column('price_mode', sa.String(length=20), nullable=False), + sa.Column('agent_notes', sa.Text(), nullable=False), + sa.Column('llm_model', sa.String(length=80), nullable=False), + sa.Column('proposal_ttl_hours', sa.Integer(), nullable=False), + sa.Column('notify_emails', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False), + sa.Column('sms_request_received', sa.Boolean(), nullable=False), + sa.Column('sms_templates', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False), + sa.Column('min_notice_hours', sa.Integer(), nullable=False), + sa.Column('max_days_ahead', sa.Integer(), nullable=False), + sa.Column('ring_first_enabled', sa.Boolean(), nullable=False), + sa.Column('ring_timeout_s', sa.Integer(), nullable=False), + sa.Column('ring_strategy', sa.String(length=20), nullable=False), + sa.Column('widget_public_key', sa.String(length=64), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('slug'), + sa.UniqueConstraint('widget_public_key') + ) + op.create_table('calendar_connections', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tenant_id', sa.Uuid(), nullable=False), + sa.Column('google_account', sa.String(length=200), nullable=False), + sa.Column('scopes', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False), + sa.Column('token_data_encrypted', sa.Text(), nullable=False), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant_id') + ) + op.create_table('calls', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tenant_id', sa.Uuid(), nullable=False), + sa.Column('started_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('caller_msisdn', sa.String(length=30), nullable=False), + sa.Column('duration_s', sa.Integer(), nullable=False), + sa.Column('outcome', sa.String(length=30), nullable=False), + sa.Column('recording_path', sa.String(length=400), nullable=True), + sa.Column('transcript', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False), + sa.Column('agent_seconds_charged', sa.Integer(), nullable=False), + sa.Column('trace', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_calls_tenant_id'), 'calls', ['tenant_id'], unique=False) + op.create_table('chat_sessions', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tenant_id', sa.Uuid(), nullable=False), + sa.Column('started_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('resume_token', sa.String(length=64), nullable=False), + sa.Column('client_ip', sa.String(length=60), nullable=False), + sa.Column('outcome', sa.String(length=30), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_chat_sessions_tenant_id'), 'chat_sessions', ['tenant_id'], unique=False) + op.create_table('email_log', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tenant_id', sa.Uuid(), nullable=True), + sa.Column('at', sa.DateTime(timezone=True), nullable=False), + sa.Column('to_addr', sa.String(length=300), nullable=False), + sa.Column('subject', sa.String(length=400), nullable=False), + sa.Column('kind', sa.String(length=30), nullable=False), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('error', sa.Text(), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('messages_for_owner', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tenant_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('client_name', sa.String(length=200), nullable=False), + sa.Column('client_phone', sa.String(length=40), nullable=False), + sa.Column('text', sa.Text(), nullable=False), + sa.Column('source', sa.String(length=10), nullable=False), + sa.Column('read', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_messages_for_owner_tenant_id'), 'messages_for_owner', ['tenant_id'], unique=False) + op.create_table('phone_numbers', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tenant_id', sa.Uuid(), nullable=True), + sa.Column('msisdn', sa.String(length=30), nullable=False), + sa.Column('gateway_port', sa.Integer(), nullable=True), + sa.Column('operator', sa.String(length=30), nullable=False), + sa.Column('sim_status', sa.String(length=20), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('msisdn'), + sa.UniqueConstraint('tenant_id') + ) + op.create_table('provider_configs', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tenant_id', sa.Uuid(), nullable=False), + sa.Column('provider_type', sa.String(length=30), nullable=False), + sa.Column('config', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant_id') + ) + op.create_table('services', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tenant_id', sa.Uuid(), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('duration_min', sa.Integer(), nullable=False), + sa.Column('price_min', sa.Float(), nullable=True), + sa.Column('price_max', sa.Float(), nullable=True), + sa.Column('home_visit', sa.Boolean(), nullable=False), + sa.Column('agent_note', sa.Text(), nullable=False), + sa.Column('buffer_min', sa.Integer(), nullable=False), + sa.Column('partner_service_id', sa.String(length=80), nullable=True), + sa.Column('active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_services_tenant_id'), 'services', ['tenant_id'], unique=False) + op.create_table('sms_log', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tenant_id', sa.Uuid(), nullable=False), + sa.Column('at', sa.DateTime(timezone=True), nullable=False), + sa.Column('to_msisdn', sa.String(length=30), nullable=False), + sa.Column('body', sa.Text(), nullable=False), + sa.Column('kind', sa.String(length=30), nullable=False), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('error', sa.Text(), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_sms_log_tenant_id'), 'sms_log', ['tenant_id'], unique=False) + op.create_table('tenant_prompt_overrides', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tenant_id', sa.Uuid(), nullable=False), + sa.Column('body', sa.Text(), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant_id') + ) + op.create_table('usage_counters', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tenant_id', sa.Uuid(), nullable=False), + sa.Column('month', sa.String(length=7), nullable=False), + sa.Column('agent_seconds', sa.Integer(), nullable=False), + sa.Column('calls', sa.Integer(), nullable=False), + sa.Column('sms_sent', sa.Integer(), nullable=False), + sa.Column('chat_sessions', sa.Integer(), nullable=False), + sa.Column('warned_80', sa.Boolean(), nullable=False), + sa.Column('warned_100', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant_id', 'month') + ) + op.create_index(op.f('ix_usage_counters_tenant_id'), 'usage_counters', ['tenant_id'], unique=False) + op.create_table('users', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tenant_id', sa.Uuid(), nullable=False), + sa.Column('email', sa.String(length=200), nullable=False), + sa.Column('password_hash', sa.String(length=200), nullable=False), + sa.Column('active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email'), + sa.UniqueConstraint('tenant_id') + ) + op.create_table('workers', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tenant_id', sa.Uuid(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('sip_username', sa.String(length=80), nullable=False), + sa.Column('sip_password', sa.String(length=80), nullable=False), + sa.Column('active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('sip_username') + ) + op.create_index(op.f('ix_workers_tenant_id'), 'workers', ['tenant_id'], unique=False) + op.create_table('booking_requests', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tenant_id', sa.Uuid(), nullable=False), + sa.Column('source', sa.String(length=10), nullable=False), + sa.Column('client_name', sa.String(length=200), nullable=False), + sa.Column('client_phone', sa.String(length=40), nullable=False), + sa.Column('service_id', sa.Uuid(), nullable=True), + sa.Column('service_name_raw', sa.String(length=300), nullable=False), + sa.Column('slots', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False), + sa.Column('time_preference_text', sa.String(length=300), nullable=False), + sa.Column('home_visit', sa.Boolean(), nullable=False), + sa.Column('address', sa.String(length=300), nullable=True), + sa.Column('summary', sa.Text(), nullable=False), + sa.Column('status', sa.String(length=30), nullable=False), + sa.Column('confirmed_slot', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True), + sa.Column('partner_request_id', sa.String(length=120), nullable=True), + sa.Column('call_id', sa.Uuid(), nullable=True), + sa.Column('chat_session_id', sa.Uuid(), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('reminder_sent', sa.Boolean(), nullable=False), + sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('resolution_note', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['call_id'], ['calls.id'], ), + sa.ForeignKeyConstraint(['chat_session_id'], ['chat_sessions.id'], ), + sa.ForeignKeyConstraint(['service_id'], ['services.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_booking_requests_status'), 'booking_requests', ['status'], unique=False) + op.create_index(op.f('ix_booking_requests_tenant_id'), 'booking_requests', ['tenant_id'], unique=False) + op.create_table('calendar_mappings', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tenant_id', sa.Uuid(), nullable=False), + sa.Column('service_id', sa.Uuid(), nullable=True), + sa.Column('google_calendar_id', sa.String(length=300), nullable=False), + sa.ForeignKeyConstraint(['service_id'], ['services.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant_id', 'service_id') + ) + op.create_index(op.f('ix_calendar_mappings_tenant_id'), 'calendar_mappings', ['tenant_id'], unique=False) + op.create_table('chat_messages', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('session_id', sa.Uuid(), nullable=False), + sa.Column('at', sa.DateTime(timezone=True), nullable=False), + sa.Column('role', sa.String(length=20), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.ForeignKeyConstraint(['session_id'], ['chat_sessions.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_chat_messages_session_id'), 'chat_messages', ['session_id'], unique=False) + op.create_table('action_tokens', + sa.Column('token', sa.String(length=64), nullable=False), + sa.Column('request_id', sa.Uuid(), nullable=False), + sa.Column('action', sa.String(length=30), nullable=False), + sa.Column('slot_index', sa.Integer(), nullable=True), + sa.Column('used_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['request_id'], ['booking_requests.id'], ), + sa.PrimaryKeyConstraint('token') + ) + op.create_index(op.f('ix_action_tokens_request_id'), 'action_tokens', ['request_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_action_tokens_request_id'), table_name='action_tokens') + op.drop_table('action_tokens') + op.drop_index(op.f('ix_chat_messages_session_id'), table_name='chat_messages') + op.drop_table('chat_messages') + op.drop_index(op.f('ix_calendar_mappings_tenant_id'), table_name='calendar_mappings') + op.drop_table('calendar_mappings') + op.drop_index(op.f('ix_booking_requests_tenant_id'), table_name='booking_requests') + op.drop_index(op.f('ix_booking_requests_status'), table_name='booking_requests') + op.drop_table('booking_requests') + op.drop_index(op.f('ix_workers_tenant_id'), table_name='workers') + op.drop_table('workers') + op.drop_table('users') + op.drop_index(op.f('ix_usage_counters_tenant_id'), table_name='usage_counters') + op.drop_table('usage_counters') + op.drop_table('tenant_prompt_overrides') + op.drop_index(op.f('ix_sms_log_tenant_id'), table_name='sms_log') + op.drop_table('sms_log') + op.drop_index(op.f('ix_services_tenant_id'), table_name='services') + op.drop_table('services') + op.drop_table('provider_configs') + op.drop_table('phone_numbers') + op.drop_index(op.f('ix_messages_for_owner_tenant_id'), table_name='messages_for_owner') + op.drop_table('messages_for_owner') + op.drop_table('email_log') + op.drop_index(op.f('ix_chat_sessions_tenant_id'), table_name='chat_sessions') + op.drop_table('chat_sessions') + op.drop_index(op.f('ix_calls_tenant_id'), table_name='calls') + op.drop_table('calls') + op.drop_table('calendar_connections') + op.drop_table('tenants') + op.drop_table('prompt_templates') + op.drop_table('admins') + # ### end Alembic commands ### diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..02b5b55 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: gogo + POSTGRES_PASSWORD: gogo + POSTGRES_DB: gogo + ports: + - "127.0.0.1:5433:5432" # 5433 on the host to avoid clashing with a system postgres + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U gogo"] + interval: 5s + timeout: 3s + retries: 10 + + app: + build: . + depends_on: + db: + condition: service_healthy + environment: + GOGO_DATABASE_URL: postgresql+psycopg://gogo:gogo@db:5432/gogo + env_file: + - path: .env + required: false + ports: + - "127.0.0.1:8000:8000" + volumes: + - recordings:/data/recordings + command: > + sh -c "alembic upgrade head && + uvicorn gogo.main:app --host 0.0.0.0 --port 8000" + +volumes: + pgdata: + recordings: diff --git a/docs/SPEC.md b/docs/SPEC.md new file mode 100644 index 0000000..b552585 --- /dev/null +++ b/docs/SPEC.md @@ -0,0 +1,606 @@ +# Gogo Telefon — Product Specification (v1.0) + +**Product name:** Gogo Telefon +**Market:** Bosnia and Herzegovina (architecture must support later expansion to Serbia and Kosovo) +**Spec status:** Draft v1.4 — living document agreed in planning session; open questions in §17 +**Intended reader:** Claude Code (implementation agent) and the founder + +--- + +## 1. Product Overview + +Gogo Telefon is a **voice-first AI receptionist** for small service businesses in Bosnia and Herzegovina (beauty salons, hairdressers, pedicure/manicure studios, and similar businesses with up to ~15 employees). These businesses lose bookings because nobody can answer the phone while working, and especially outside working hours. + +The product: + +1. **Voice agent** answers phone calls forwarded from the salon's existing number, speaks Bosnian/Serbian/Croatian, knows the salon's services and prices, checks real availability in the salon's scheduling system (Google Calendar or the salon's own booking software via Partner API), and collects a **booking request** from the caller. +2. **Chat agent** — an embeddable web widget on the salon's website that does the same over text, using the same backend logic. +3. **Crucially, the agent never books appointments itself.** It sends a **proposal** (booking request) to the owner by email. The primary flow: the owner contacts the client directly, arranges the appointment themselves, and marks the request as **resolved** with one click (no SMS is sent by us in that case). A secondary one-click option lets the owner confirm a proposed slot and have Gogo send the confirmation SMS to the client. Gogo never writes to the owner's calendar. +4. **Humans first:** before the agent answers, the call rings the salon staff's softphones (SIP clients on their mobile phones). The agent picks up only if nobody answers, or immediately outside working hours. + +**Business model:** flat rate ≈ **30 KM/month** per salon (target; annual prepay option ~300 KM/yr), with a fair-use limit on agent voice minutes (see §12). Payment is handled offline via B2B bank transfer — **no online payments / card processing in this product.** + +**Distribution:** word of mouth. There is **no public sign-up.** A super-admin creates and configures every salon. + +--- + +## 2. Key Design Principles + +1. **Propose, don't book.** The agent gathers intent + availability and produces a structured proposal. The owner is always the decision-maker. +2. **The owner keeps their tools.** Salon keeps its phone number (conditional call forwarding) and keeps its scheduling tool — Google Calendar (often multiple color-coded calendars per service type) or its own custom booking software (Gogo integrates via the Partner API, Appendix B). Proposals arrive where the owner already works: plain email, or directly inside their booking software. The dashboard is for setup and occasional review, not daily work. +3. **Voice is the primary product.** Chat is a secondary channel sharing the same backend. +4. **Own the infrastructure, minimize per-minute costs.** Self-hosted pipeline in the founder's own data center from day one. Only LLM and TTS remain paid APIs (both behind swappable interfaces). +5. **Short calls by design.** Target call length 60–120 seconds. The agent is efficient and polite, not chatty. +6. **Multi-country-ready foundations.** Currency, language, phone formats, and locale are per-tenant configuration, not hardcoded (BiH now; Serbia/Kosovo later). + +--- + +## 3. System Architecture + +``` + Caller (PSTN, salon's customers) + │ + salon's own number (m:tel / BH Telecom / HT Eronet) + │ conditional call forwarding + │ (no answer / busy / out of hours) + ▼ + Our SIM number (per salon or pooled) + │ + GSM Gateway (multi-SIM device, + e.g. GoIP/Yeastar class) — also used + for outbound SMS to clients + │ SIP + ▼ + SIP Server (Asterisk or FreeSWITCH) — founder's DC + │ dial-plan: ring group first (staff softphones, + │ N seconds, within working hours only) + │ no answer → route to agent + ▼ + Voice Pipeline (Pipecat, Python) — founder's DC + ┌──────────────────────────────────────────────┐ + │ STT: faster-whisper (large-v3) on local GPU │ + │ LLM: API (Claude Haiku class) — swappable │ + │ TTS: API (Azure Neural or ElevenLabs Flash) │ + │ — swappable, decided by Phase-0 PoC │ + │ Turn-taking / barge-in: Pipecat built-ins │ + └──────────────────────────────────────────────┘ + │ tool calls (HTTP) + ▼ + Backend API (FastAPI, Python) — founder's DC + ┌──────────────┬───────────────┬──────────────┬───────────────┐ + │ Scheduling │ Proposal │ SMS sender │ Tenant config │ + │ providers: │ engine + │ (via GSM │ services, │ + │ GCal free/ │ email (.ics) │ gateway API) │ hours, limits │ + │ busy or │ or partner │ │ │ + │ Partner API │ push │ │ │ + └──────────────┴───────────────┴──────────────┴───────────────┘ + ▲ + │ same tools API + Chat Agent (web widget, WebSocket) + ▲ + Dashboard (owner) + Super-admin panel +``` + +**Deployment:** everything runs in the founder's own data center. GPU node for Whisper (one RTX 4090 / L4-class GPU handles 10–15 concurrent calls). Docker Compose for MVP (no Kubernetes). PostgreSQL as the primary database. + +--- + +## 4. Tech Stack + +| Layer | Choice | Notes | +|---|---|---| +| Language | Python 3.12+ | one language across the whole repo | +| Voice orchestration | Pipecat | open source; handles turn-taking, barge-in, SIP integration | +| SIP server | Asterisk (or FreeSWITCH if Pipecat integration proves simpler) | dial-plan: ring group → agent fallback; call recording | +| STT | faster-whisper, large-v3, on local GPU | language hint: `sr` (covers spoken bs/sr/hr); Phase-0 PoC validates | +| LLM | Anthropic API, Haiku-class model | behind `LLMProvider` interface; model per-tenant configurable | +| TTS | Azure Neural TTS **or** ElevenLabs Flash API | behind `TTSProvider` interface; Phase-0 PoC decides default voice | +| Backend | FastAPI + SQLAlchemy + Alembic | REST + WebSocket (chat) | +| DB | PostgreSQL 16 | | +| Queue/scheduling | Built-in (arq or APScheduler) | proposal expiry, SMS dispatch retries | +| Email | SMTP (configurable provider) + generated `.ics` attachments | one-click action links | +| Dashboard | Server-rendered (FastAPI + Jinja2 + htmx) or lightweight React — implementer's choice, favor simplicity | Bosnian UI, Latin script for MVP | +| Chat widget | Vanilla JS embeddable snippet + WebSocket | no framework requirements on salon's site | +| Auth | Session-based; single account per salon + super-admin role | **no public registration** | + +--- + +## 5. Telephony Layer + +### 5.1 Inbound call path +1. Salon activates **conditional call forwarding** on its own number: forward on no-answer (~15s), on busy, and (if operator supports schedules — usually not, so handled by us) out of hours. Dashboard shows per-operator activation codes (m:tel, BH Telecom, HT Eronet) generated for the salon's assigned Gogo number. +2. Calls arrive at a **SIM in the GSM gateway** mapped to exactly one salon (MVP: 1 SIM = 1 salon; pooling with DID-style mapping is Phase 2). +3. GSM gateway converts to SIP → Asterisk. + +### 5.2 Dial-plan logic (per salon, driven by tenant config) +``` +IF within salon working hours AND ring_group not empty AND ring_first_enabled: + ring staff softphones (strategy: ring-all or sequential; default ring-all) + timeout: configurable, default 12s + IF answered by human → bridge call, record, tag outcome = "human_answered" + ELSE → route to AI agent +ELSE (out of hours, or ring group empty/disabled): + route to AI agent immediately +``` + +### 5.3 Staff softphones +- Staff use **off-the-shelf SIP clients** (Linphone / Zoiper / Groundwire). We build nothing mobile for MVP. +- Dashboard: owner adds a worker (name) → system generates SIP credentials + QR code / config link for easy setup. +- Workers have **no accounts** in the product — they exist only as SIP endpoints in the ring group. + +### 5.4 Call recording & disclosure +- All agent calls are recorded (audio) and transcribed. +- The agent's greeting **must include a recording disclosure**, e.g.: + > "Dobar dan, dobili ste salon {naziv}. Ja sam Gogo, virtuelni asistent. Razgovor se snima. Kako vam mogu pomoći?" +- Recordings retention: default 90 days (configurable), then delete audio, keep transcript. + +### 5.5 SMS (outbound, via GSM gateway) +- The same GSM gateway sends SMS through the salon's assigned SIM (effectively free within SIM plan). +- MVP SMS use cases: + 1. **Missed-call SMS**: caller hung up before human or agent answered → "Poštovani, dobili ste {salon}. Možete zakazati i putem poruke ili chata: {link}. Nazvaćemo vas ili nas pozovite ponovo." + 2. **Request received** (optional, config): "Primili smo vaš zahtjev za termin. Javićemo vam potvrdu u najkraćem roku." + 3. **Confirmation**: "Potvrđen termin: {usluga}, {dan} {datum} u {vrijeme}h — {salon}." + 4. **Rejection / new proposal**: "{salon}: nažalost traženi termin nije moguć. {alternativa ili molba da pozovu}." +- SMS templates are per-tenant editable (with safe defaults), always in Bosnian. + +--- + +## 6. Voice Agent + +### 6.1 Language & voice +- Understands and speaks **Bosnian/Serbian/Croatian** (treated as one spoken language; STT language hint `sr`). +- TTS voice: 2–3 curated voices to choose from in dashboard (final list decided by Phase-0 PoC). +- Agent persona name: "Gogo". Tone: warm, brief, professional. No small talk beyond politeness. + +### 6.2 Conversation goals (in order) +1. Greet + recording disclosure (template with salon name). +2. Identify caller's need: **(a)** book an appointment, **(b)** ask a question (prices, hours, location, services), **(c)** cancel/reschedule → MVP: agent takes a message for the owner (full flow is Phase 2), **(d)** other → take a message. +3. For bookings, collect: **service** (match against tenant service list), **client name**, **callback phone number** (confirm digit by digit if unclear; default to caller ID with verbal confirmation), **home visit?** (only if the service is flagged as available at home — then also collect address/area), and **time preferences**. +4. Call the availability tool (§8.2) and offer **up to 3 concrete free slots** consistent with the caller's preference. The caller may also state their own preferred time — the agent checks it, and if busy, offers nearest alternatives. +5. Close explicitly: "Vaš zahtjev prosljeđujem salonu — kontaktiraće vas u najkraćem roku radi potvrde termina. Hvala i prijatno!" + +### 6.3 Prompt composition (owners are never prompt engineers) +- The agent's system prompt is built from a **global prompt template owned and maintained by the super-admin** (versioned; improving the template upgrades all salons at once). +- The template contains placeholders filled from structured tenant data only: `{salon_profile}`, `{working_hours}`, `{services_table}`, `{notes}`, `{price_mode}`, `{greeting}`. +- The greeting is auto-generated from the template + salon name (includes the recording disclosure). Owners do not edit prompt text anywhere. +- **Per-tenant prompt overrides exist but are super-admin-only** (advanced escape hatch for unusual salons). +- Owner-entered "notes" are plain informational fields injected as data, not free-form instructions to the model; the template instructs the model to treat them as facts about the salon. + +### 6.4 Hard rules (system prompt requirements) +- **Never state a booking as confirmed.** Always "zahtjev" / "prijedlog" until the owner approves. +- Never invent services, prices, or free slots — only from tenant data and availability tool results. +- Price answering mode per tenant config: exact prices / ranges only / "cijene na upit". +- Include per-tenant custom notes in context (e.g., "ne primamo djecu ispod 7 godina", "parking iza zgrade"). +- Target call duration 60–120s; the agent steers back to the goal politely if the caller drifts. +- If the caller is abusive or the agent cannot understand after 2 clarification attempts → apologize, promise a callback, end call, log a message for the owner. +- Escalation to human mid-call: **Phase 2** (see §14). + +### 6.5 Tools exposed to the agent (LLM function-calling) +Identical for voice and chat: + +| Tool | Purpose | +|---|---| +| `get_salon_info()` | working hours, address, services with duration/price/notes, custom notes | +| `check_availability(service_id, date_range, preference)` | returns free slots via the tenant's `SchedulingProvider` (§8): Google Calendar free/busy computation, or the partner's `GET /availability`; buffers applied for GCal tenants, partner tenants return ready slots | +| `submit_booking_request(...)` | creates the proposal: service, client name, phone, 1–3 slots (ordered by client preference), home-visit flag + address, free-text summary of the request/problem | +| `take_message(text, client_name, phone)` | for non-booking intents; emailed to owner | + +--- + +## 7. Chat Agent (web widget) + +- Embeddable JS snippet (one `