# Tale AI agents — React integration guide

How the dashboard (React) uses the two AI agents deployed at **agent.talecrafter.net**.

The browser connects **directly** to the agents over WebSocket. No backend change is needed.

| Agent | What it does | WebSocket URL |
|---|---|---|
| Questionnaire | Chat that builds a survey with the user | `wss://agent.talecrafter.net/ws/questionnaire` |
| Échantillonnage | 3 steps that size and target the sample | `wss://agent.talecrafter.net/ws/echantillonnage` |

A study always goes **Questionnaire → (get `study_id`) → Échantillonnage**.

---

## Step 0 — Check the agents are up

```bash
curl https://agent.talecrafter.net/sante/questionnaire
# {"statut":"ok","sessions_actives":0,"max_sessions":50}

curl https://agent.talecrafter.net/sante/echantillonnage
# {"statut":"ok"}
```

The agents call Google Gemini. If every session stalls at the first AI step, check the Gemini key
has quota (see [Troubleshooting](#troubleshooting)).

---

## Step 1 — Get the token

The agents accept one shared token: `TALE_WS_TOKEN`. It lives on the agent server only.

```bash
ssh root@91.98.40.136
grep '^TALE_WS_TOKEN=' /opt/tale-agents/.env | cut -d= -f2- | tr -d '"'
this the token : a2c7c0f48fe0445ff3dde1c4afa8725c7267759557e51c914611977ca5b4f0f4*
```

- 65 characters, **ends with `*`** — copy the whole value, the `*` included.
- Never paste it in chat, tickets, or this file.

---

## Step 2 — Configure the dashboard

Add to `dashboard/.env.development` **and** `dashboard/.env.production`:

```bash
REACT_APP_AGENT_WS_URL=wss://agent.talecrafter.net
REACT_APP_AGENT_WS_TOKEN=<value from step 1>
```

Restart `npm start` after editing — React reads `.env` files only at startup.

> **Option: keep the token out of git.** Store it in Jenkins as a *Secret text* credential
> (id `agent-ws-token`), remove the line from `.env.production`, and change the build stage of
> `dashboard/Jenkinsfile`:
>
> ```groovy
> withCredentials([string(credentialsId: 'agent-ws-token', variable: 'REACT_APP_AGENT_WS_TOKEN')]) {
>     sh "REACT_APP_AGENT_WS_URL=wss://agent.talecrafter.net npm run build"
> }
> ```

**Know this:** whatever the option, the token ends up inside the built JavaScript and can be read in
DevTools. If it leaks, rotate it (see [Rotating the token](#rotating-the-token)).

---

## Step 3 — Add the API module

`dashboard/src/api/agent.js`

```js
const URL = process.env.REACT_APP_AGENT_WS_URL;
const TOKEN = process.env.REACT_APP_AGENT_WS_TOKEN;
const FINAL = ["termine", "interrompu", "erreur"];

/**
 * Questionnaire agent — a chat.
 * onLine(text)    a line to display
 * onAsk(prompt)   the agent now waits for an answer (enable the input)
 * onEnd(message)  termine | interrompu | erreur | { type: "coupure" } (connection dropped)
 */
export function openQuestionnaire({ studyId, onLine, onAsk, onEnd }) {
  const ws = new WebSocket(`${URL}/ws/questionnaire`);
  let ended = false;

  ws.onopen = () =>
    ws.send(JSON.stringify({ action: "demarrer", token: TOKEN, ...(studyId && { study_id: studyId }) }));

  ws.onmessage = (e) => {
    const m = JSON.parse(e.data);
    if (m.type === "message") {
      if (m.texte.trim()) onLine(m.texte);
    } else if (m.type === "question") {
      onAsk(m.texte.trim());
    } else if (FINAL.includes(m.type)) {
      ended = true;
      onEnd(m);
    }
  };

  ws.onclose = () => {
    if (!ended) onEnd({ type: "coupure" });
  };

  return {
    answer: (texte) => ws.send(JSON.stringify({ texte })),
    close: () => ws.close(),
  };
}

/**
 * Échantillonnage agent — 3 fixed steps.
 * onStep(message)  message.etape = choix_n | choix_categories | resultat | personnalisee
 * onError(message) message.code
 */
export function openSampling({ studyId, onStep, onError }) {
  const ws = new WebSocket(`${URL}/ws/echantillonnage`);

  ws.onopen = () =>
    ws.send(JSON.stringify({ action: "demarrer", token: TOKEN, study_id: studyId }));

  ws.onmessage = (e) => {
    const m = JSON.parse(e.data);
    m.type === "erreur" ? onError(m) : onStep(m);
  };

  return {
    chooseN: (n) => ws.send(JSON.stringify({ action: "choix_n", n })),
    chooseCriteria: (criteres) => ws.send(JSON.stringify({ action: "choix_categories", criteres })),
    close: () => ws.close(),
  };
}

/** Menu options printed just before a question: [{ key: "A", label: "…" }, …] */
export function parseOptions(lines) {
  const opts = [];
  for (const line of [...lines].reverse()) {
    const m = line.match(/^\s*([A-H]|\d{1,2})[.)]\s+(.*)$/);
    if (m) opts.unshift({ key: m[1], label: m[2] });
    else if (opts.length) break;
  }
  return opts;
}

/** Internal model-fallback notices — hide them, show a "thinking…" indicator instead. */
export const isTechnicalLine = (line) => /^⚠️\s+gemini/i.test(line.trim());
```

---

## Step 4 — Build the questionnaire chat

### How the conversation really looks

Observed on production (full session: phases 0 → 9, about 4 minutes, 100+ messages):

1. The agent sends several `message` lines (the actual question, a menu, a summary…).
2. Then **one `question` frame whose text is only a short prompt** — `"👤 Vous:"`, `"Votre choix :"`,
   `"Choix :"`, `"(o/n) :"`. **The real question is in the lines before it.**
3. The user answers, the agent continues.

Phases shown to the user, in order: `PHASE 0 — OBJECTIF & CADRAGE`, `PHASE 1 — CONSTRUIRE LE MODÈLE`,
`PHASE 2 — GÉNÉRER LES ITEMS`, `PHASE 3 — CHOISIR LE FORMAT`, `PHASE 4 — CONTRÔLE DE LONGUEUR`,
`PHASE 6 — CONTRÔLE DE CLARTÉ`, `PHASE 7 — ORDRE DES QUESTIONS`, `PHASE 9 — RÉCAPITULATIF ET VALIDATION FINALE`.

### What to send back

| The prompt / preceding lines look like | Send | UI |
|---|---|---|
| `👤 Vous:` | free text — a real sentence | text area |
| `Votre choix :` after `A. …` `B. …` lines | the letter, e.g. `"A"` | buttons from `parseOptions` |
| `Choix :` after `1. …` `2. …` lines | the number, e.g. `"4"` | buttons from `parseOptions` |
| contains `(o/n)` | `"o"` (yes) or `"n"` (no) | Yes / No buttons |
| `Cochez les numéros … (virgules)` | `"1,3,5"`, or `""` to keep all | checkboxes |
| contains `Entrée pour …` | `""` keeps the default | "Keep" button + text field |

**Empty `""` is only valid when the prompt says "Entrée".** Anywhere else the agent answers
`⚠️ Choix invalide.` and asks again.

**To finish**, in phase 9 the user picks `4. Valider ce questionnaire`. The agent then sends `termine`.

### Example component

`dashboard/src/components/agent/QuestionnaireChat.jsx`

```jsx
import { useEffect, useRef, useState } from "react";
import { openQuestionnaire, parseOptions, isTechnicalLine } from "../../api/agent";

export default function QuestionnaireChat({ studyId, onFinished }) {
  const [lines, setLines] = useState([]);        // everything shown in the chat
  const [pending, setPending] = useState([]);    // lines since the last question
  const [prompt, setPrompt] = useState(null);    // null = agent is thinking
  const [text, setText] = useState("");
  const [end, setEnd] = useState(null);
  const session = useRef(null);

  useEffect(() => {
    session.current = openQuestionnaire({
      studyId,
      onLine: (line) => {
        if (isTechnicalLine(line)) return;
        setLines((l) => [...l, { from: "agent", text: line }]);
        setPending((p) => [...p, line]);
      },
      onAsk: (p) => setPrompt(p),
      onEnd: (m) => {
        setEnd(m);
        setPrompt(null);
        if (m.type === "termine") onFinished?.(m);
      },
    });
    return () => session.current?.close();
  }, [studyId]);

  const send = (value) => {
    session.current.answer(value);
    setLines((l) => [...l, { from: "user", text: value || "(par défaut)" }]);
    setPending([]);
    setPrompt(null);
    setText("");
  };

  const options = prompt ? parseOptions(pending) : [];
  const yesNo = prompt && /\(o\/n\)/.test(prompt + pending.join(" "));
  const allowsEmpty = prompt && /Entrée/.test(prompt);

  return (
    <div>
      {lines.map((l, i) => (
        <p key={i} className={l.from}>{l.text}</p>
      ))}

      {!prompt && !end && <p className="thinking">L'assistant réfléchit…</p>}

      {prompt && yesNo && (
        <>
          <button onClick={() => send("o")}>Oui</button>
          <button onClick={() => send("n")}>Non</button>
        </>
      )}

      {prompt && !yesNo && options.length > 0 &&
        options.map((o) => (
          <button key={o.key} onClick={() => send(o.key)}>{o.key}. {o.label}</button>
        ))}

      {prompt && !yesNo && (
        <form onSubmit={(e) => { e.preventDefault(); if (text.trim() || allowsEmpty) send(text.trim()); }}>
          <input value={text} onChange={(e) => setText(e.target.value)} placeholder={prompt} />
          <button type="submit">Envoyer</button>
          {allowsEmpty && <button type="button" onClick={() => send("")}>Conserver</button>}
        </form>
      )}

      {end?.type === "interrompu" && <p>Session interrompue — vous pourrez la reprendre.</p>}
      {end?.type === "coupure" && <p>Connexion perdue.</p>}
      {end?.type === "erreur" && <p>Erreur : {end.texte} (réf. {end.trace_id})</p>}
    </div>
  );
}
```

### Final messages

**`termine`** — survey built:

```json
{
  "type": "termine",
  "study_id": "abc-123",
  "nb_questions": 18,
  "nb_questions_souhaite": 12,
  "duree_estimee_minutes": 4,
  "langue_questionnaire": "fr",
  "items": [
    { "id": "…", "categorie": "Satisfaction générale", "texte": "Dans l'ensemble, …", "format": "echelle_5",
      "options": ["Pas du tout d'accord", "Plutôt pas d'accord", "Neutre", "Plutôt d'accord", "Tout à fait d'accord"],
      "statut": "…" }
  ]
}
```

**Save `study_id`** — the sampling agent needs it.

**`interrompu`** — `{ "type": "interrompu", "study_id": "abc-123", "phase_courante": "modele" }`.
Work up to the last finished phase is saved: reopen with `openQuestionnaire({ studyId: "abc-123" })`.

**Connection dropped without a final message** (`coupure`) — the user was idle 15 min or the network
dropped. A brand-new study gets its `study_id` only at the end, so it cannot be resumed; start over.

---

## Step 5 — Build the sampling screens

Open with the `study_id` from `termine`:

```js
const sampling = openSampling({ studyId, onStep: setStep, onError: setError });
```

| `step.etape` | Show | User action |
|---|---|---|
| `choix_n` | `step.message` + `step.options[]` → `{ valeur, label, marge, prix_dt }` (e.g. `500`, `"500 répondants"`, `"±4.4%"`, `"200 DT"`) | `sampling.chooseN(option.valeur)` |
| `choix_categories` | `step.message`, `step.marge_erreur`, `step.criteres_recommandes` (keys), `step.options[]` → `{ key, label, icone, type, cout_dt }` | `sampling.chooseCriteria(["region", "age"])` — at most `step.max_criteres` |
| `resultat` | `step.message` + `step.resultat` (below) | done — connection closes |
| `personnalisee` | `step.message` — a Tale advisor will take over; `resultat` is `null` | done — **not an error** |

Allowed sample sizes: `50, 100, 150, 250, 500, 1000, 2000, 5000`.

`step.message` contains Markdown (`**bold**`) — render it with a Markdown component.

`resultat`:

```json
{
  "n": 500,
  "marge_erreur": 4.4,
  "marge_reference": 5.0,
  "niveau_confiance": 95,
  "offre_type": "…",
  "disponibilite": "…",
  "cellules": [
    { "critere": "region", "valeur": "Tunis", "proportion_ins": 0.24, "n_quota": 120,
      "disponibilite_brute": 300, "disponibilite_effective": 250 }
  ],
  "suggestions": ["…"],
  "nb_cellules_insuffisantes": 0
}
```

---

## Step 6 — Handle errors

Every error closes the connection. Show `texte`, log `code` and `trace_id`.

| `code` | Meaning | UI |
|---|---|---|
| `non_autorise` | wrong/missing token | configuration bug — check `REACT_APP_AGENT_WS_TOKEN` |
| `parametre_invalide` | bad message (e.g. no `study_id` for sampling) | code bug |
| `questionnaire_absent` | no validated questionnaire for this `study_id` | "Validez d'abord le questionnaire" |
| `service_sature` | 50 sessions already running | "Service occupé, réessayez" |
| `llm_indisponible` / `service_indisponible` | Gemini down or too slow | "Réessayer" button |
| `phase_0_incomplete` | framing phase did not finish | "Réessayer" button |
| `erreur_interne` | anything else | "Réessayer" + give `trace_id` to support |

Support finds the incident with:

```bash
docker logs tale-agents-questionnaire 2>&1 | grep <trace_id>
```

---

## Step 7 — Test before shipping

**Token check** — browser console on the dashboard (paste the token in place of `TOKEN`):

```js
const ws = new WebSocket("wss://agent.talecrafter.net/ws/echantillonnage");
ws.onopen = () => ws.send(JSON.stringify({ action: "demarrer", token: "TOKEN", study_id: "test" }));
ws.onmessage = (e) => console.log(e.data);
```

- `questionnaire_absent` → token OK
- `non_autorise` → wrong token (usually the final `*` is missing)

**Full flow checklist**

- [ ] Questionnaire starts, first agent lines appear within ~30 s
- [ ] Letter / number menus render as buttons and are accepted
- [ ] `(o/n)` renders Yes / No
- [ ] "Conserver" sends `""` only where the prompt says "Entrée"
- [ ] Phase 9 → `4. Valider ce questionnaire` → `termine` with `study_id` and `items`
- [ ] Sampling opens with that `study_id` → `choix_n` → `choix_categories` → `resultat` or `personnalisee`
- [ ] Closing the tab mid-chat does not break the next session
- [ ] `⚠️ gemini… surchargé` lines are hidden

---

## Timings to design for

| Event | Expect |
|---|---|
| Connection opens | < 1 s |
| First agent line after `demarrer` | 2–30 s |
| Silence between messages | up to 60 s — show "thinking…", do **not** time out |
| Full questionnaire | ~4–20 min |
| Agent waits for the user | 15 min, then closes silently |

---

## Troubleshooting

| Symptom | Cause | Fix |
|---|---|---|
| `non_autorise` | token mismatch | re-copy from `/opt/tale-agents/.env`, keep the final `*`, restart `npm start` / rebuild |
| Stalls after the first agent message, lines `⚠️ gemini-… quota épuisé` | Gemini API key out of quota (HTTP 429 on every model) | enable billing / new key in Google AI Studio, update `GEMINI_API_KEY` in `/opt/tale-agents/.env`, restart containers |
| `{"statut":"ok"}` missing / 404 on `/sante/...` | agent containers down | `docker ps \| grep tale-agents`, then the Jenkins job `tale-agents-prod` |
| Env variable is `undefined` | `.env` edited without restart | restart `npm start`; production needs a rebuild |
| Agent keeps saying `Choix invalide` | sending `""` or text where a letter/number is expected | use `parseOptions` buttons |

Restart the agents after changing `/opt/tale-agents/.env`:

```bash
cd /opt/tale-agents && docker compose -f docker-compose.prod.yml -p tale-agents up -d
```

---

## Rotating the token

1. On the server: `python3 -c "import secrets; print(secrets.token_hex(32))"`
2. Replace `TALE_WS_TOKEN=` in `/opt/tale-agents/.env`, restart the agents (command above).
3. Put the new value in the dashboard (`.env.*` or the Jenkins credential) and rebuild/redeploy.

Old sessions stop working the moment the agents restart — do steps 2 and 3 together.
