Skip to content
Fondazione ITS INCOM

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:

  1. at most 120 characters, so it fits on a lock screen;
  2. the room code, exactly as in the alarm;
  3. what is wrong in plain words: the alarm code TEMP_HIGH must not appear;
  4. the value with its unit: 31.5 °C in English, 31,5 °C in Italian;
  5. 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:

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:

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:

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

Exercise

All examples