Examples · Internet of Things
Alarms that people read
Raw alarms from the building system become short phone notifications in Italian and English. Your code checks five rules on every text, asks the model again once when a rule is broken, and falls back to a template, so the alarm always goes out.
Beginner · 25 min
The idea
A building system sends alarms like this one:
{"id": 1041, "time": "2026-10-09 07:42", "room": "B1.04", "room_name": "Server room", "code": "TEMP_HIGH", "value": 31.5, "unit": "°C", "threshold": 27.0}
The caretaker reads it on a phone, maybe while carrying a ladder, and needs one short line: “B1.04: temperature too high, 31.5 °C. Check the air conditioning”. At the school's two sites, some people read Italian and some read English, so every alarm becomes two notifications.
A model writes them. Your code checks every text against five rules:
- at most 120 characters, so it fits on a lock screen;
- the room code, exactly as in the alarm;
- what is wrong in plain words: the alarm code
TEMP_HIGHmust not appear; - the value with its unit:
31.5 °Cin English,31,5 °Cin Italian; - what to do, word for word from the building's procedures.
When a rule is broken, the program says which one and asks again, once. If the second answer breaks a rule too, a template writes the notification. An alarm cannot wait for a model to get it right.
What you need
A key and the openai library, as in Your first API call in Python.
The alarms
Eight alarms from one morning at the two sites, as alarms.json:
[
{"id": 1041, "time": "2026-10-09 07:42", "room": "B1.04", "room_name": "Server room", "code": "TEMP_HIGH", "value": 31.5, "unit": "°C", "threshold": 27.0},
{"id": 1042, "time": "2026-10-09 08:15", "room": "B0.12", "room_name": "Lab 2", "code": "CO2_HIGH", "value": 1850, "unit": "ppm", "threshold": 1500},
{"id": 1043, "time": "2026-10-09 08:31", "room": "BS.01", "room_name": "Boiler room", "code": "PRESSURE_LOW", "value": 0.8, "unit": "bar", "threshold": 1.0},
{"id": 1044, "time": "2026-10-09 09:02", "room": "L2.03", "room_name": "Library", "code": "HUMIDITY_HIGH", "value": 78, "unit": "%", "threshold": 65},
{"id": 1045, "time": "2026-10-09 09:10", "room": "L0.20", "room_name": "Gym", "code": "TEMP_LOW", "value": 12.5, "unit": "°C", "threshold": 16.0},
{"id": 1046, "time": "2026-10-09 10:47", "room": "L1.07", "room_name": "Toilets, 1st floor", "code": "WATER_LEAK", "value": 4, "unit": "mm", "threshold": 1},
{"id": 1047, "time": "2026-10-09 11:20", "room": "L0.05", "room_name": "Canteen kitchen", "code": "FREEZER_TEMP_HIGH", "value": -9.5, "unit": "°C", "threshold": -15.0},
{"id": 1048, "time": "2026-10-09 12:05", "room": "BS.03", "room_name": "Electrical room", "code": "POWER_HIGH", "value": 48.2, "unit": "kW", "threshold": 45.0}
]
The program
Save it as notify.py, next to the alarms:
import json
import os
import re
import sys
from openai import OpenAI
client = OpenAI(
base_url="https://api.ai.itsincom.org/v1",
api_key=os.environ["API_KEY"],
)
MODEL = sys.argv[1] if len(sys.argv) > 1 else "qwen3.8-27b"
# What to do, from the building's procedures: the same words every time
ACTIONS = {
"TEMP_HIGH": {"en": "Check the air conditioning", "it": "Controlla il condizionatore"},
"CO2_HIGH": {"en": "Open the windows", "it": "Apri le finestre"},
"PRESSURE_LOW": {"en": "Call the heating technician", "it": "Chiama il tecnico della caldaia"},
"HUMIDITY_HIGH": {"en": "Switch on the dehumidifier", "it": "Accendi il deumidificatore"},
"TEMP_LOW": {"en": "Check the heating", "it": "Controlla il riscaldamento"},
"WATER_LEAK": {"en": "Close the water valve", "it": "Chiudi la valvola dell'acqua"},
"FREEZER_TEMP_HIGH": {"en": "Move the food to the cold room", "it": "Sposta il cibo nella cella frigo"},
"POWER_HIGH": {"en": "Switch off the electric heaters", "it": "Spegni le stufette elettriche"},
}
SYSTEM = ("You write phone notifications for the caretakers of a school, from the alarms of the building "
"system. For each alarm, write one notification in Italian and one in English. Each one must have: "
"at most 120 characters; the room code; what is wrong, in plain words, not the alarm code; the value "
"with its unit, exactly as given (31.5 in English, 31,5 in Italian, 1850 in both); the action, "
"word for word. No emoji.")
schema = {
"type": "object",
"properties": {"it": {"type": "string"}, "en": {"type": "string"}},
"required": ["it", "en"],
}
def number(x, lang):
return str(x).replace(".", ",") if lang == "it" else str(x)
def broken_rules(alarm, lang, text):
"""The rules, checked in code: the list of the rules that the text breaks."""
value = number(alarm["value"], lang)
action = ACTIONS[alarm["code"]][lang]
problems = []
if len(text) > 120:
problems.append(f"{len(text)} characters, the limit is 120")
if alarm["room"] not in text:
problems.append(f"the room code {alarm['room']} is missing")
if alarm["code"] in text:
problems.append(f"{alarm['code']} is not plain words")
if not re.search(r"(?<!\d)" + re.escape(value) + r" ?" + re.escape(alarm["unit"]), text): # not in 14 mm
problems.append(f"the value «{value} {alarm['unit']}» is missing")
if action.lower() not in text.lower():
problems.append(f"the action «{action}» is missing")
return problems
def template(alarm, lang):
"""When the model fails twice: less friendly, but it keeps every rule, and the alarm goes out."""
unit, limit = alarm["unit"], "soglia" if lang == "it" else "limit"
return (f"{alarm['room']}: {number(alarm['value'], lang)} {unit}, {limit} "
f"{number(alarm['threshold'], lang)} {unit}. {ACTIONS[alarm['code']][lang]}")
def ask(alarm, messages):
"""One try: the two texts, and the rules they break."""
answer = client.chat.completions.create(
model=MODEL,
temperature=0,
max_tokens=2000, # leave room for the silent reasoning of qwen3.8-27b
messages=messages,
response_format={"type": "json_schema", "json_schema": {"name": "notification", "schema": schema}},
)
if answer.choices[0].finish_reason != "stop": # cut off: the JSON is not complete
return None, ["the answer was cut off"]
texts = json.loads(answer.choices[0].message.content)
return texts, [f"{lang}: {p}" for lang in ("it", "en") for p in broken_rules(alarm, lang, texts[lang])]
with open("alarms.json") as f:
alarms = json.load(f)
first_try = 0
for alarm in alarms:
request = {**alarm, "action_it": ACTIONS[alarm["code"]]["it"], "action_en": ACTIONS[alarm["code"]]["en"]}
messages = [{"role": "system", "content": SYSTEM},
{"role": "user", "content": json.dumps(request, ensure_ascii=False)}]
texts, problems = ask(alarm, messages)
status = "ok at the first try"
if not problems:
first_try += 1
else:
# Ask again, once, and say exactly which rules were broken
print(f"#{alarm['id']} first try: {texts}\n broken: {'; '.join(problems)}")
if texts:
messages.append({"role": "assistant", "content": json.dumps(texts, ensure_ascii=False)})
feedback = f"Some rules are broken: {'; '.join(problems)}. Write both again."
messages.append({"role": "user", "content": feedback})
texts, problems = ask(alarm, messages)
status = "ok at the second try"
if problems:
# The alarm must go out anyway
print(f"#{alarm['id']} second try: {texts}\n broken: {'; '.join(problems)}")
texts = {lang: template(alarm, lang) for lang in ("it", "en")}
status = "the model failed twice: template"
print(f"#{alarm['id']} {status}")
for lang in ("it", "en"):
print(f" {lang} ({len(texts[lang]):3}) {texts[lang]}")
print(f"\n{MODEL}: {first_try} of {len(alarms)} alarms passed every rule at the first try")
Run it with python3 notify.py, or with another model: python3 notify.py ministral-3-14b.
What you should see
#1041 ok at the first try
it ( 68) B1.04: temperatura troppo alta, 31,5 °C. Controlla il condizionatore
en ( 64) B1.04: temperature too high, 31.5 °C. Check the air conditioning
#1042 ok at the first try
it ( 56) B0.12 Lab 2: CO2 troppo alta, 1850 ppm. Apri le finestre
en ( 53) B0.12 Lab 2: CO2 too high, 1850 ppm. Open the windows
#1043 ok at the first try
it ( 64) BS.01: pressione bassa, 0,8 bar. Chiama il tecnico della caldaia
en ( 57) BS.01: low pressure, 0.8 bar. Call the heating technician
…
#1047 ok at the first try
it ( 79) L0.05: Il freezer è a -9,5°C, sopra la soglia. Sposta il cibo nella cella frigo
en ( 76) L0.05: Freezer is at -9.5°C, above threshold. Move the food to the cold room
#1048 ok at the first try
it ( 67) BS.03 - Potenza troppo alta: 48,2 kW. Spegni le stufette elettriche
en ( 64) BS.03 - Power too high: 48.2 kW. Switch off the electric heaters
qwen3.8-27b: 8 of 8 alarms passed every rule at the first try
With ministral-3-14b, two alarms take another path:
#1046 first try: {'it': "L1.07 - Perdita d'acqua. Livello: 4,5 mm. Chiudi la valvola dell'acqua", 'en': 'L1.07 - Water leak. Level: 4.5 mm. Close the water valve'}
broken: it: the value «4 mm» is missing; en: the value «4 mm» is missing
#1046 second try: {'it': "L1.07 - Perdita d'acqua. Livello: 4,5 mm. Chiudi la valvola dell'acqua", 'en': 'L1.07 - Water leak. Level: 4.5 mm. Close the water valve'}
broken: it: the value «4 mm» is missing; en: the value «4 mm» is missing
#1046 the model failed twice: template
it ( 54) L1.07: 4 mm, soglia 1 mm. Chiudi la valvola dell'acqua
en ( 46) L1.07: 4 mm, limit 1 mm. Close the water valve
#1047 first try: {'it': 'Aula L0.05: **Cucina mensa** - Temperatura congelatore troppo alta (-9,5°C). **Sposta il cibo nella cella frigo**', 'en': 'Room L0.05: **Canteen kitchen** - Freezer temperature too high (31.5°F). **Move the food to the cold room**'}
broken: en: the value «-9.5 °C» is missing
#1047 ok at the second try
it (102) Aula L0.05: **Cucina mensa** - Congelatore troppo caldo (-9,5°C). **Sposta il cibo nella cella frigo**
en ( 95) Room L0.05: **Canteen kitchen** - Freezer too warm (-9.5°C). **Move the food to the cold room**
How often the first try passes
We ran the program five times with each model:
| Model | Passed at the first try | After asking again | Template | Time for 8 alarms |
|---|---|---|---|---|
qwen3.8-27b |
40 of 40 | – | – | 62 to 77 s |
gemma-4-26b |
40 of 40 | – | – | about 6 s |
ministral-3-14b |
30 of 40 | 35 of 40 | 5 | about 18 s |
Ministral made the same two mistakes in all five runs:
- It invented a number. The leak sensor reads 4 mm; Ministral wrote 4.5 mm, in both languages, and again after the program told it “the value 4 mm is missing”. The template sent the alarm.
- It changed a unit. For a freezer at −9.5 °C, the English text said 31.5°F. That is not the conversion (it would be 14.9 °F), and 31.5 is the example in the system message. Asked again, it wrote −9.5°C.
A number in an alarm is the one thing that must be right. Nobody can compare every notification with its alarm by hand; rule 4 does it for every message.
A model knows only what you tell it
Our first system message said: “the value with its unit, exactly as given, but in Italian with a decimal comma (31,5)”. It does not say what to do in English. With it:
- Gemma used the comma in English too (“31,5 °C”), and added decimals that were not there (“1850,0 ppm”, “78,0 %”). First try: 5 alarms of 24, in three runs.
- Ministral wrote “1.850 ppm” and “0,8 bar” in English. First try: 0 of 16, in two runs, and asking again did not help: all 16 alarms went to the template.
- Qwen3.8 passed 16 of 16.
Adding “31.5 in English, 31,5 in Italian, 1850 in both” took Gemma from 5 of 24 to 40 of 40. The check did not change; what changed was what the model was told.
What the rules cannot see
Every text that passed the rules had the right number. It did not always have the right words:
- Ministral starts many Italian texts with «Aula», a classroom: the server room and the canteen kitchen are not classrooms.
- Ministral put Markdown stars (
**) around words. On a phone they show up as asterisks. - In one run with the first system message, Gemma called the power alarm «tensione troppo alta»: tensione is voltage, not power.
Some of these can become rules: "*" not in text is one line. Others need a person who reads a sample of the notifications every week.
Good to know
- The template is part of the design. A water leak cannot wait for a third try. When the model fails, a less friendly message that is right goes out.
- An alarm is about time, too. In our runs Qwen3.8 needed 8 to 10 seconds per alarm, Gemma less than one. For an alarm that matters, send the template at once and the friendly text after, or choose the fast model.
len()counts characters, not bytes.°is one character in Python, two bytes in UTF-8. If your SMS gateway counts bytes or uses an old character set, measure what it measures.- The rules are your specification. The 120-character rule never fired in our runs: the longest text was 102 characters. Keep it anyway: one long room name, and it will.
- The value check has a hole.
(?<!\d)stops “14 mm” from passing for “4 mm”, but not “0.4 mm”: the character before the 4 is a dot. A minus sign typed as “−” (U+2212) instead of “-” fails the check too. Make the look-behind(?<![\d.,]), and turn “−” into “-” before you search.
Exercise
- Add a rule: no
*and no#in the text. How many first tries does Ministral pass now? - Change the leak value to
4.0inalarms.json. What does the value check expect now, and what do the models write? - Add a third language: German for a visiting technician. You need the actions in German, and
number()needs a rule for it. - Use the
timeof the alarm: add “at 07:42” to every notification, and a rule that checks it. Does it still fit in 120 characters?
