Product · medium — un restaurant qui sert aussi des entreprises
Le cas. Les clients commandent comme avant. En plus, l'agent ChatGPT d'un cabinet commande le déjeuner de l'équipe, sous une sous-identité créée par le cabinet : 15 000 F CFA sans validation, de 11 h à 14 h, à l'adresse du bureau, sans pouvoir annuler.
Ce que l'exemple montre. Un agent sur deux portails (le web et WhatsApp, où le client prouve
son numéro), des profils, des kinds reconnus par condition, un choix de méthode
dans un playbook, l'activation par intention, un filtre de sortie, un budget journalier, une
politique smart qui ne peut que demander un humain, qui soumet, et une phrase de refus réécrite par le
restaurant. Et la commande elle-même : un seul appel placeOrder, déclaré sur le playbook, qui
envoie les plats avec leur accompagnement (un champ de ligne), la boisson et les couverts (des lignes
ajoutées),
sous la forme que la cuisine attend (line:).
# Product · medium — CIBLE. The restaurant serves people AND companies' agents.
# Profiles (who), kinds (what), a playbook as tasks with a choice, activation by intent,
# who submits, a daily ceiling, and one smart policy that can only ask for a human.
# The order is ONE call on the playbook (dishes + drink + cutlery in one placeOrder), a line field per dish.
station:
id: teranga
kind: product
name: Teranga Kitchen
guidance: {enforce: 0.85, suggest: 0.5, margin: 0.05, max_mode: enforce}
delegation: # what a client may grant its agents (ADR-029 §11)
restrictions: [max_amount, hours, addresses, deny_skills, confirm_above]
identity: # the identity providers this station accepts (ADR-031)
providers:
phone: {mechanism: otp, verifies: phone, via: convoy, channel: sms} # a code by SMS, sent by Convoy's service
global: # applies to every field of its type, every skill
language: fr
currency: XOF # every `type: price` field
locale: fr-SN
timezone: Africa/Dakar
texts: # Convoy's refusal sentences, in the restaurant's words
fr:
step.waits: "{task} vient après {before} : choisissons d'abord {before}."
apis:
- name: cuisine
openapi_url: https://api.teranga.example.sn/openapi.json
base_url: https://api.teranga.example.sn
auth: {type: api_key, key_name: X-API-Key, key_location: header, key_env: TERANGA_KEY}
operations:
- {id: getMenu, access: read}
- {id: getDrinks, access: read}
- {id: getDeliveryOptions, access: read}
- {id: checkDeliverable, access: read}
- {id: getDeliveryPrice, access: read}
- {id: placeOrder, access: write, risk: medium, reversible: cancelOrder}
- {id: createDelivery, access: write, risk: medium, reversible: false}
- {id: listOrders, access: read}
- {id: cancelOrder, access: write, risk: medium}
profiles:
anonyme:
when: "identity.authenticated == false"
grants: [cuisine.getMenu, cuisine.getDrinks, cuisine.placeOrder, cuisine.getDeliveryOptions, cuisine.checkDeliverable,
cuisine.getDeliveryPrice, cuisine.createDelivery]
client:
when: "identity.authenticated and identity.type == 'person'"
grants: [cuisine.getMenu, cuisine.getDrinks, cuisine.placeOrder, cuisine.getDeliveryOptions, cuisine.checkDeliverable,
cuisine.getDeliveryPrice, cuisine.createDelivery,
cuisine.listOrders, cuisine.cancelOrder]
agent-entreprise: # no cancelOrder: cancelling stays a person's act
when: "identity.type == 'agent' and identity.org != null"
grants: [cuisine.getMenu, cuisine.getDrinks, cuisine.placeOrder, cuisine.getDeliveryOptions, cuisine.checkDeliverable,
cuisine.getDeliveryPrice, cuisine.createDelivery]
policies:
commande-inhabituelle:
type: smart
question: "ce volume ou ce contenu est-il inhabituel pour ce compte ?"
effect: needs-human
threshold: 0.85
on_unavailable: allow
depense-du-jour: # a budget is a rule policy: a counter over several actions
type: rule
budget: {sum: "$selection.total", per: day, scope: identity, max: 60000}
skills:
- name: takeMealSelection
cognitive: {objective: "montrer la carte pour choisir les plats", diagnose: [l'envie, les restrictions]}
invoke: {method: getMenu}
kinds:
dish:
when: "$fields.category != 'nems'"
fields: # filled by the person, line by line; sent by the order's `line:`
variante: {type: id, required: true, label: "Accompagnement"}
nems:
when: "$fields.category == 'nems'"
select: {quantity: {min: 5}}
present:
fields:
name: {from: name, type: text, role: title}
price: {from: price, type: price, role: billed}
image: {from: image_url, type: image}
category: {from: category, type: text} # what each kind's `when` reads
render: {component: carousel} # no submit: the order is the playbook's call
- name: takeDrinkSelection
cognitive: {objective: "proposer une boisson pour accompagner les plats"}
invoke: {method: getDrinks}
kinds: {drink: {}} # its rows are items (CFG-73 leaves skills without kinds open)
present:
fields:
name: {from: name, type: text, role: title}
price: {from: price, type: price, role: billed}
select: {max_items: 1}
render: {component: carousel} # no submit either: same order
- name: takeDeliveryOption
cognitive: {objective: "proposer le retrait ou la livraison"}
params:
order_id: {type: id, set_by: [result]}
invoke: {method: getDeliveryOptions}
kinds:
pickup:
when: "$fields.type == 'pickup'"
submit: [{name: collect, mode: self}]
delivery:
when: "$fields.type == 'delivery'"
select:
guards:
- form:
address: {type: text, required: true, label: "Adresse de livraison"}
phone: {type: phone, required: true, label: "Téléphone", identifies: true} # declared; `phone` above verifies it
- check: checkDeliverable
- update: {price: getDeliveryPrice}
submit:
- name: dispatch
tool: createDelivery
args:
body.address: "$item.fields.address"
body.order_id: "$params.order_id"
body.fee: "$item.price"
per_profile:
agent-entreprise: # an agent delivers only to an address on file
select: {guards: [{name: adresse-enregistree, require: "$item.fields.address in identity.addresses"}]}
present:
fields:
name: {from: name, type: text, role: title}
type: {from: type, type: text} # what each kind's `when` reads
output_filters: {reduce: {address: district}} # the model sees "Plateau, Dakar"
render: {component: cards}
- name: annuler
cognitive: {objective: "annuler une commande pas encore préparée"}
params: {customer: {type: id, set_by: [identity]}} # never the model: only the person's own orders
invoke: {method: listOrders, args: {customer: "$params.customer"}}
present: {fields: {name: {from: label, type: text, role: title}}}
select: {max_items: 1}
submit: [{name: cancel, tool: cancelOrder, args: {body.order_id: "$item.ref"}}]
render: {component: cards}
playbooks:
- name: take-meal-order
goal: "le repas est commandé, retiré ou livré"
activation:
intent: "commander un repas"
tasks:
plats: {skill: takeMealSelection, label: "Vos plats"}
boisson: {skill: takeDrinkSelection, label: "Votre boisson", after: [plats], required: false}
remise:
label: "Retrait ou livraison"
after: [plats]
depends_on: {plats: {on_loss: clear, on_change: revalidate}} # the fee was quoted on these dishes
one_of:
choose: customer
methods:
retrait: {skill: takeDeliveryOption, kind: pickup, label: "Retrait sur place"}
livraison:
skill: takeDeliveryOption
kind: delivery
label: "Livraison"
params: {order_id: "$submitted[order].order_id"} # a call of this playbook
submit: # ONE order for the lines of several tasks, declared once
- &order
name: order
tool: placeOrder
args:
body.items: "$selection[task=plats,boisson]"
body.idempotency_key: "$action.key"
line: # what ONE line looks like to the kitchen
product_id: "$line.ref"
quantity: "$line.quantity"
variant_id: "$line.fields.variante" # left out of a nems or drink line: no `variante` there
extra_lines: # sent with the order, never chosen: cutlery, a catalogue row
- {ref: couverts-avec, quantity: "$submit.couverts", when: "$submit.couverts > 0"}
- {ref: couverts-sans, when: "$submit.couverts == 0"}
output: {order_id: {from: "$output.id", type: id}}
submit_guard:
form: {couverts: {type: quantity, required: true, label: "Combien de couverts ?"}}
confirm: true
- name: group-lunch
goal: "le repas du groupe est commandé et confirmé"
activation:
when: "'agent-entreprise' in profiles"
intent: "commander pour plusieurs personnes"
tasks:
plats: {skill: takeMealSelection, label: "Vos plats", guards: [{policy: depense-du-jour}]}
boisson: {skill: takeDrinkSelection, label: "Votre boisson", after: [plats], required: false}
remise:
label: "Retrait ou livraison"
after: [plats]
one_of:
methods: # choose: customer, the default
retrait: {skill: takeDeliveryOption, kind: pickup, label: "Retrait sur place"}
livraison:
skill: takeDeliveryOption
kind: delivery
label: "Livraison au bureau"
params: {order_id: "$submitted[order].order_id"}
submit: [*order] # another journey, another order: the same call, by anchor
submit_guard:
form: {couverts: {type: quantity, required: true, label: "Combien de couverts ?"}}
guards: [{policy: commande-inhabituelle}]
submitted_by:
- {who: user.approver, when: "total > identity.limits.max_amount"} # set by the company on the sub-identity
- {who: owner}
agent:
name: Teranga
persona: "un hôte chaleureux qui aide à composer le repas"
tone: friendly
portals:
web:
widget: {origins: [https://teranga.example.sn], theme: {primary: "#1B7A4B"}}
pwa: {name: "Teranga & Moi"}
whatsapp:
number: "+221 77 000 00 00"
identity: {auth: phone} # who the person is, as this portal proves it
Ce qui se passe
| ce qui arrive | qui décide | résultat |
|---|---|---|
| « On est 8 au bureau ce midi » | seek_guidance : admissibles group-lunch et take-meal-order ; modèle 0,91 / 0,07 | ENFORCE : group-lunch s'ouvre, journalisé |
| l'agent ajoute la livraison d'abord | l'ordre du playbook, dit avec la phrase du restaurant (texts) | refusé : « Retrait ou livraison vient après Vos plats : choisissons d'abord Vos plats. » |
| 8 plats, 25 500 F CFA | permission, budget du jour | réservés |
| 3 nems | quantity: {min: 5} du kind nems | refusé : au moins cinq nems |
| un plat sans accompagnement | le champ de ligne variante, obligatoire | réservé ; le submit attend la réponse pour cette ligne |
| submit, « 8 couverts » | le formulaire du submit, extra_lines | un seul placeOrder : les plats, chacun avec son variant_id, la boisson, et la ligne couverts-avec × 8 |
| « livre aux Almadies » | per_profile : adresse non enregistrée | refusé pour la livraison ; le retrait reste possible |
| annuler la commande d'hier | agent-entreprise n'a pas cuisine.cancelOrder | refusé : réservé aux personnes |
| submit à 25 500 > 15 000 | submitted_by : user.approver | un humain : l'approbateur que le cabinet a fixé sur la sous-identité |
| le modèle juge la commande inhabituelle | commande-inhabituelle | un humain (déjà le cas ; c'est noté au journal) |
| le modèle est indisponible | on_unavailable: allow | la politique ne joue pas ; toutes les règles déterministes jouent |
À retenir
- Une commande est déclarée une fois, sur le playbook, à côté de son
submit_guard: elle prend ses lignes par tâche ($selection[task=plats,boisson]). La livraison, une action d'une seule skill, garde son appel sur son kind. - Le kind dit quoi (retrait ou livraison), le profil dit qui (client ou agent d'entreprise). Les deux spécialisent les mêmes points.
- L'agent voit « Plateau, Dakar », jamais l'adresse complète (
output_filters). - Les limites de la sous-identité sont posées par le cabinet sur la page du tenant ; la station
déclare seulement ce qu'il est permis de limiter (
station.delegation.restrictions).