# Thinkera Enterprise

The site + CMS, the learning platform and the WhatsApp gateway as **one Docker Compose project**: one network,
one PostgreSQL, one Redis, one command (`tk`) to build, run, back up, restore and deploy.

```
Thinkera-Enterprise/
├── docker-compose.yml      the whole stack
├── tk  (tk.cmd)            the control script (bash; tk.cmd runs it from cmd/PowerShell)
├── .env                    shared: ports, database roles and passwords, Redis, Evolution key
├── env/
│   ├── platform.env        the platform's own settings (site name, SMTP, BBB, WhatsApp channel, Google …)
│   ├── cms.env             the site's own settings (Payload secret, admin, platform URL, SMTP …)
│   ├── gateway.env         the gateway's own settings (API keys, sending limits, provider …)
│   └── deploy.env          where ./tk deploy sends the code (key authentication only)
├── infra/postgres/init.sh  creates the roles and databases on the first start of an empty volume
├── apps/
│   ├── platform/           image (Dockerfile, php/, nginx/) and the Thinkera plugins
│   ├── cms/                Next.js + Payload source
│   └── wa-gateway/gateway/ FastAPI gateway + worker
└── backups/                ./tk backup writes here (BACKUP_DIR in .env to change it)
```

`.env` and `env/*.env` hold secrets and never leave the machine; each has a `.example` beside it.

## Services and addresses

| Service | What it is | Host address | Inside the network |
|---|---|---|---|
| `cms` | site + CMS | http://localhost:8890 (`/admin`) | `http://cms:3000` |
| `platform-web` | platform (nginx) | http://localhost:8891 | `http://platform-web` |
| `moodle`, `cron` | platform PHP and its cron | – | `moodle:9000` |
| `gateway` | WhatsApp notification API | http://localhost:8892 | `http://gateway:8000` |
| `worker` | sends the queued WhatsApp messages | – | – |
| `evolution` | WhatsApp connection (manager UI) | http://localhost:8893/manager | `http://evolution:8080` |
| `db` | PostgreSQL 17: databases `moodle`, `thinkera_site`, `evolution`, `gateway` | – | `db:5432` |
| `redis` | Redis 7: platform on db 0, Evolution on db 6 | – | `redis:6379` |

Host ports are all bound to `127.0.0.1` and set in `.env` (`SITE_PORT`, `PLATFORM_PORT`, `GATEWAY_PORT`,
`EVOLUTION_PORT`). The apps reach each other by service name: the site reads the catalogue from
`http://platform-web`, the platform sends WhatsApp through `http://gateway:8000`.

Each application has its own database role, which can only use its own database. `PG_ADMIN_USER` is used only by
`tk` for backups and restores.

## Everyday use

Git Bash (or any bash) from this folder; from cmd/PowerShell use `tk` instead of `./tk`.

```bash
./tk up
```
builds whatever changed and starts everything. Other commands:

| Command | Does |
|---|---|
| `./tk status` | containers, and an HTTP check of the site, platform and gateway |
| `./tk rebuild [service…]` | rebuild images (fresh base images) and recreate the containers; runs the platform upgrade |
| `./tk restart [service…]` | restart without rebuilding (after changing an `env/*.env` file use `./tk up`) |
| `./tk logs [service…]` | follow the logs |
| `./tk upgrade` | platform: copy the plugins in, run the upgrade, purge caches. **Run it after changing a plugin.** |
| `./tk configure` | platform: apply `env/platform.env` (mail, live sessions, WhatsApp, analytics) |
| `./tk rebrand` · `./tk harden` | platform: re-apply branding / security settings |
| `./tk moodle <script>` | any platform CLI script, e.g. `./tk moodle admin/cli/purge_caches.php` |
| `./tk cms-tools <cmd>` | Payload CLI, e.g. `./tk cms-tools npx payload migrate:create name` |
| `./tk psql [database]` | PostgreSQL shell |
| `./tk sh <service>` | a shell inside a container |
| `./tk llm` | where the models went, and how to point the platform at one |
| `./tk down` | remove the containers (the data volumes stay) |

What needs what after a change:

| You changed | Run |
|---|---|
| a platform plugin or the theme (`apps/platform/plugins`) | `./tk upgrade` |
| the platform image (`apps/platform/Dockerfile`, `php/`) | `./tk rebuild moodle cron` |
| the site (`apps/cms/src`, `seed/`, `public/`) | `./tk up cms` |
| a public guide or one of its pictures (`apps/cms/public/guides`) | `./tk up cms` — and `python infra/guides/make_pdf.py` if the PDFs should follow |
| the gateway (`apps/wa-gateway/gateway`) | `./tk up gateway worker` |
| an `env/*.env` file | `./tk up` (and `./tk configure` for platform settings) |
| which model grades | nothing to deploy: the admin page, or `./tk moodle local/tkai/cli/set_gate.php --help` |

## The AI models, and the grading suggestions

**The models do not run in this stack.** They are their own project — **LLM-Gate** — so that they can be
copied to another machine, with the models they have already downloaded, and run there without any of this. It
is two containers and a folder: Ollama, a door in front of it that wants a key, and `models/`. Its own README
explains it; `./tk llm` here says the same in one screen.

The platform reaches it over HTTP like any other provider, with a key:

```bash
# in LLM-Gate: mint a key for this platform
./gate keys add thinkera-platform

# here: point the platform at the gate (the same thing the admin page does)
./tk moodle local/tkai/cli/set_gate.php --url=http://host.docker.internal:11444/v1/ --key=<key>
./tk moodle local/tkai/cli/active.php            # which connection and model each task uses now
```

`host.docker.internal` is how a container on this machine reaches this host; from another machine use its
address. `set_gate.php` creates the connection, asks the gate which models it has, points the tasks at it, and
switches off any connection still aimed at a model server inside this stack — there is none any more, and a
connection to something that is gone is a «why is grading failing» waiting to happen.

On the platform this is the plugin **`local_tkai` («الذكاء الاصطناعي في Thinkera»)**: *Site administration →
Plugins → Local plugins → Thinkera AI*. It keeps the connections — the models on this server, OpenAI, Google
Gemini through its OpenAI-compatible address, or anything else with the same API — each with its key (which is
written, never shown again), its models, timeout and temperature. One menu at the top of that page picks **the
connection and model that grade**, so moving between `qwen3`, `llama4`, OpenAI and Gemini is a single choice,
with «اختبر الاتصال» beside it to prove it answers. Its usage log keeps what each request cost, never a key and
never anything a student wrote.

`local_thinkera` uses it to mark written answers, and it is asked in two different situations.

**The instructor asks** — «اقتراح درجة» in «تصحيح الإجابات». The model marks an answer against the question's own
rubric, criterion by criterion, quoting the student's own words as evidence, and the instructor adopts, edits or
ignores the suggestion. Where an administrator (or an instructor, for one course) switched it on, a confident
suggestion may also grade a **practice** answer by itself the moment it is submitted — never an exam answer. Such
a mark is provisional: the student has it at once, and it waits in «في انتظار تأكيدك» for the instructor to stand
behind it.

**The student asks** — «تحقَّق» under an essay, while they are still looking at what they wrote. Until this
existed an essay was the one question type with no button at all: it is marked by a person, so the platform said
nothing about it until somebody read it, and the student moved on without knowing. Pressing it hands the answer
in (it can no longer be edited, as with «تحقَّق» everywhere else) and the model reads it in front of them, in
about fifteen seconds on the server's hardware. Then one of two things:

* the model is **95% sure or more** of its own marking → the mark is given there and then and **stands**. It does
  not enter the instructor's queue, because the point of it is that neither of them waits.
* it is less sure → **no mark at all.** A number the model doubts is worse than no number, so the student is told
  their instructor will read this one, and the answer arrives in «في انتظار التصحيح» with the model's reading of
  it beside it as a suggestion.

| Setting | Where | Default |
|---|---|---|
| `aistudentthreshold` | *Plugins → Local plugins → Thinkera Academy* | `0.95` — how sure the model must be before a student is shown a mark |
| `aiautothreshold` | the same page | `0.85` — below this a suggestion is called doubtful and sorted to the top of the instructor's queue |
| «صحّح الأسئلة المقالية آلياً…» | the AI card on «تصحيح الإجابات», per course | **on** — the instructor's own switch for the paragraph above |
| `aiautopractice`, `aiautoexam`, `aiautocourse` | *Plugins → Local plugins → Thinkera Academy* | marking a practice (or exam) answer the moment it is submitted |

The two thresholds are not the same question. `aiautothreshold` only orders a queue — every suggestion is read
either way. `aistudentthreshold` decides whether a student is shown a mark **at all**, which is why it is higher.

«دقة اقتراحات الذكاء الاصطناعي» compares the suggestions with the instructors' own marks, model by model; a mark
no human ever looked at is left out of that comparison rather than counted as agreement.

## The public guides

`apps/cms/public/guides/` holds the student guide and the instructor guide — `learner.html` and `trainer.html`,
served at `/guides/learner.html` and `/guides/trainer.html`. Each is **one file carrying both languages**: the
Arabic and the English are both in the markup, `guide.css` shows one of them by the `data-lang` attribute on the
root, and `guide.js` switches it. Arabic is what the file says with no scripting at all, because that is the
language the guides are written in. Every picture is in the file once and carries `data-alt-en` beside its Arabic
`alt`, so its accessible name changes with the language instead of being duplicated.

The four PDFs beside them are built from those same files:

```bash
python infra/guides/make_pdf.py             # learner-ar, learner-en, trainer-ar, trainer-en
python infra/guides/make_pdf.py trainer-ar  # one of them
```

It prints each page twice with Chromium (Playwright, the `chrome` channel), once per language, at A4 with a page
number in the footer. The `@media print` rules in `guide.css` do the rest: no site header, no language switcher,
no call to action, no picture split across two pages, and a picture taller than it is wide (`figure.shot.is-tall`)
held to 62% of the column so it does not take a page to itself.

The pictures in `img/` are 1200-wide screenshots of the real pages on a development stack, each cut to end just
below the thing its caption talks about. Two things to know before retaking one:

* **The lesson video needs real Chrome.** It is an H.264 file and Playwright's *bundled* Chromium has no decoder
  for it, so the player photographs as a white rectangle. Use the `chrome` channel, and seek the `<video>` a few
  seconds in and wait for `seeked` — a video that has never played paints nothing either.
* **The instructor's screens need an instructor.** `local/thinkera/devlogin.php` signs in without a password, but
  only as the three demo students, so an instructor's page needs a real account on the development stack.

**Publishing.** The site serves `public/` from a manifest made when its image is built, so:

| Change | What it takes |
|---|---|
| an existing file (a guide, a picture, a PDF, the stylesheet) | replace it and `docker cp` it into the running container — it is served at once, with no restart |
| a **new** path (a picture or a PDF that was not there before) | `./tk up cms` — until the image is rebuilt the new path answers 404 however correctly it sits on disk |

## Backup and restore

```bash
./tk backup
```
writes `backups/<date_time>/` with one dump per database (`db-moodle.dump`, `db-thinkera_site.dump`,
`db-evolution.dump`, `db-gateway.dump`), the files (`moodledata.tgz`, `cms-media.tgz`, `evolution.tgz` = the
WhatsApp session) and `SHA256SUMS`, verified before the folder is named. The newest `BACKUP_KEEP` (14) are kept.
On a server, set `BACKUP_DIR=/var/www/private/thinkera.academy/backups` in `.env` and add a nightly cron line:

```bash
15 3 * * * cd /var/www/private/thinkera.academy/enterprise && ./tk backup >> backups/backup.log 2>&1
```

Restore everything, or only some parts:

```bash
./tk restore backups/2026-09-21_1700
./tk restore backups/2026-09-21_1700 cms
./tk restore backups/2026-09-21_1700 platform --old-wwwroot=http://localhost:8891
```
`--old-wwwroot` rewrites the platform's links when the backup came from another address (a laptop's backup restored
on the server, for example).

## Moving from the three separate stacks (once)

`./tk migrate` exports from the old folders (`../Thinkera/platform/moodle`, `../Thinkera/thinkera.academy`,
`../Thinkera/wa-gateway` by default), stops the old stacks, and restores everything here. The old volumes are not
touched: `docker compose start` in an old folder brings it back. This machine and the server were both moved on
21 September 2026 (server: `/var/www/private/thinkera.academy/enterprise`, config written by `infra/server-env.sh`).

On the server, where the old folders are `platform/` and `cms/` and there is no gateway yet:

```bash
./tk migrate --platform=../thinkera.academy/platform --cms=../thinkera.academy/cms --gateway=none
```

### Server move, step by step

1. On the server: `mkdir /var/www/private/thinkera.academy/enterprise` and put `env/deploy.env`'s `DEPLOY_PATH` to it.
2. Copy `.env` and `env/*.env` there, with the server values: `MOODLE_WWWROOT=https://platform.thinkera.academy`,
   `SITE_URL`/`PLATFORM_URL` for the site, `DEV_LOGIN_KEY=` empty, `DEV_VIDEO_DIR=./infra/empty`,
   `BACKUP_DIR=/var/www/private/thinkera.academy/backups`, and the database passwords of the current server stacks.
3. `./tk deploy` from this machine (ships the code, builds on the server).
4. On the server: `./tk migrate --platform=… --cms=… --gateway=none -y` (it stops the old stacks first-hand).
5. Apache keeps proxying to `127.0.0.1:8890` and `127.0.0.1:8891`; nothing changes there.
6. To send WhatsApp from the server: scan the QR in the Evolution manager (tunnel `ssh -L 8893:127.0.0.1:8893`),
   then set `WHATSAPP_ENABLED=1` and the key in `env/platform.env` and run `./tk up && ./tk configure`.

## Deploy

```bash
./tk deploy
```
packages the code (never `.env`, `env/*.env` or backups), uploads it over SSH with the key in `env/deploy.env`,
takes a backup on the server, then runs `./tk up`, `./tk upgrade` and `./tk status` there.
