An in-app AI support chatbot for astrologers on AstroLokal. Astrologers get instant, tool-backed answers for payout/KYC/salary questions and a real ticket tracker for everything else; admins triage and resolve tickets from a dashboard.
backend/ FastAPI + SQLAlchemy + Alembic + Postgres, Gemini tool-calling agent
chat-app/ Astrologer-facing chat webview (React + Vite), opened from a WebView
admin-app/ KAM/admin dashboard (React + Vite)
packages/shared/ Shared TypeScript types, status/color map, Tailwind design-token preset
docker-compose.yml Postgres only — backend and both frontends run natively via their own tools
Backend layering (see backend/app/):
agent/— the Gemini tool-calling orchestrator. Only importsagent/tool_schemas.py(pure data); never importsintegrations/orservices/directly.agent/executor.py— the one place that resolves a tool name to a handler and dispatches it. This is also the security boundary: it unconditionally strips whateverastrologer_idthe model supplied in a tool call and replaces it with the id from the verified JWT, before any handler runs. Handlers never trust anastrologer_idfrom tool input.integrations/— mocked external systems (see below). Isolated, one file each.services/— cross-integration business logic (e.g.ticket_service.create_ticketdoes create → auto-assign → Slack-notify in one transaction). Routes and the agent never call integrations directly for anything that spans more than one system.api/routes/— thin FastAPI route handlers that call intoservices/.
- Python 3.12 (the repo's venv is built against this; earlier versions choke on
X | Noneunion syntax used throughout the SQLAlchemy 2.0 / Pydantic v2 code) - Node.js 20+ and npm 10+ (npm workspaces — not pnpm/yarn)
- Docker (for Postgres via Docker Compose)
docker compose up -dBrings up Postgres on localhost:5434 (not 5432, to avoid clashing with other local Postgres
instances — see docker-compose.yml). Then create the two databases it expects:
docker exec astrohelp-postgres psql -U astrohelp -d astrohelp -c "CREATE DATABASE astrohelp_test;"(astrohelp itself is created automatically by the Postgres image via POSTGRES_DB.)
cd backend
python3.12 -m venv .venv
source .venv/bin/activate
pip install -r requirements-dev.txt
cp .env.example .env # edit if you changed ports/secrets
alembic upgrade head
python -m scripts.seed # seeds 3 admins + 6 astrologers
uvicorn app.main:app --reload --port 8000Env vars (backend/.env, see .env.example for the full list with defaults):
| Var | Purpose |
|---|---|
DATABASE_URL / TEST_DATABASE_URL |
Postgres connection strings |
JWT_SECRET |
Admin-login tokens only — signed and verified entirely by us. The astrologer side has no signing at all: the main AstroLokal app hands off a plain user_id in the webview URL, resolved directly against astrologers.user_id. |
GEMINI_API_KEY |
Required for /api/chat to actually reach Gemini. Without it (or with a placeholder), the endpoint returns a graceful 500 ({"detail": "Something went wrong..."}) — everything else in the app works fine without it. |
GEMINI_MODEL |
Defaults to gemini-flash-latest (Google's rolling alias for its current fast/cheap model — the dated gemini-2.5-flash snapshot has since been retired for new API keys) |
MOCK_MODE |
Gates every file in integrations/ — see "Mocked integrations" below |
N8N_BEAUTIFY_WEBHOOK_URL, SLACK_WEBHOOK_URL |
Real endpoints to call once MOCK_MODE=false |
CORS_ORIGINS |
Comma-separated list of frontend origins allowed to call the API |
Opening the chat webview locally: there's no token to mint — the astrologer
side isn't signed at all. Just open
http://localhost:5173/?user_id=<astrologer's user_id>. scripts/seed.py
gives each seeded astrologer a placeholder user_id (90001–90006, printed when
it runs) for exactly this purpose; a real, linked astrologer's actual
user_id (from the priority-ranking sync) works the same way.
Seeded admin login: ananya@astrolokal.example / astrohelp123 (also vikram@... and
meera@..., same password — see backend/scripts/seed.py).
Backend tests:
pytest # 14 tests: agent tool-selection incl. astrologer_id-override proof,
# ticket_service status/history invariants, chat + tickets routes
ruff check app scripts testsFrom the repo root (npm workspaces):
npm install
cp chat-app/.env.example chat-app/.env
cp admin-app/.env.example admin-app/.env
npm run dev:chat # http://localhost:5173
npm run dev:admin # http://localhost:5174Both point at VITE_API_BASE_URL=http://localhost:8000 by default.
- Open
http://localhost:5173/?user_id=90001(seeded astrologer Priya Sharma). - Ask "when is my payout coming?" — the agent calls
get_payout_statusand answers from real (seeded) data, with a subtle "Checked your payout status" line above the reply. (RequiresGEMINI_API_KEYto be set to a real key — see above.) - Say you want to change your profile photo, upload one — the agent calls
trigger_photo_beautifythencreate_support_ticketwith the beautified image attached. - Check the "My Tickets" tab — the new ticket shows
Submitted → Assignedalready filled in on the tracker. - Log into the admin dashboard, find the ticket in the queue, open it, move its status to "In Progress" with a note.
- Back in the chat app, the tracker updates within ~15s (it polls until a ticket is resolved).
- Check the admin dashboard's Slack Log page — a mocked notification for the ticket's creation is there.
Every mocked integration lives in its own file under backend/app/integrations/, starts with a
# MOCKED — replace with real API call comment, and is gated by the MOCK_MODE env var. Nothing
outside the file needs to change to swap it — callers only ever see the function signature.
| File | Mocked behavior | To go live |
|---|---|---|
payout_client.py |
Deterministic, seeded-per-astrologer payout status/amount/dates | Replace the body with a real call to AstroLokal's payments service |
kyc_client.py |
Deterministic KYC status + rejection reason | Replace with a real KYC/compliance service call |
salary_client.py |
Deterministic monthly salary + revision dates | Replace with a real payroll service call |
admin_mapping_client.py |
Round-robins over whichever admins currently exist in our admins table (not hardcoded ids — see the file's docstring for why) |
Replace with a real roster/ops API call; drop the db argument once it's a real HTTP call that doesn't need our own database |
n8n_client.py |
Already structured as a real httpx.post(N8N_BEAUTIFY_WEBHOOK_URL, ...) call — short-circuits before the network call under MOCK_MODE and returns a fabricated processed_image_url |
Set MOCK_MODE=false and N8N_BEAUTIFY_WEBHOOK_URL to the real n8n workflow URL |
slack_client.py |
Already structured as a real Slack webhook call — under MOCK_MODE it skips the network call and writes a row to slack_log instead (what the admin dashboard's Slack Log page reads) |
Set MOCK_MODE=false and SLACK_WEBHOOK_URL to a real incoming webhook |
Also mocked, not as an "integration" file but worth knowing about: the astrologer's kyc_status,
payout_status, and monthly_salary_inr columns on the Astrologer model exist only to make
scripts/seed.py produce plausible data — a real deployment would drop them and rely entirely on
the clients above.
Two core tables, related by ticket_id:
tickets— one row per support ticket.statusalways mirrors the latestticket_status_historyrow; this is enforced entirely inapp/services/ticket_service.py(the only code path allowed to write either), not via a DB trigger — see the module docstring for why.ticket_status_history— append-only log of every status change; powers the tracker's timeline.
Plus astrologers, admins, and slack_log (every mocked Slack notification, real or not).
Migration: backend/alembic/versions/0001_initial_schema.py (well, whatever the autogenerated
filename is — check alembic history).
Palette and type scale are defined once in packages/shared/src/tailwind-preset.js and consumed
by both frontends via Tailwind's presets field, so the two apps can't visually drift apart.
The palette (night, terracotta, cream, moss, ochre, clay, ink) matches the actual
AstroLokal brand — warm cream, terracotta accent, near-black chrome, serif display headings
(Playfair Display) over Inter body text — since the chat webview is opened directly from the
AstroLokal app and should read as a continuation of it, not a visually disconnected tool. moss
/ochre/clay stay as restrained status colors (success/attention/error), distinct enough from
terracotta to read as status rather than brand. See the preset file's header comment for more.
GEMINI_API_KEY is not committed anywhere (as it shouldn't be) — backend/.env currently holds
a placeholder value. Until a real key is dropped in, /api/chat returns a graceful error and the
astrologer-facing chat UI shows a calm "couldn't send" state on the message — everything else
(tickets, tracker, admin dashboard, Slack log) works without it.