IoT_Master

AI integration

The AI service explains a single sensor reading in language a Year 7 student understands. It is a separate service from the main API so that the Anthropic API key, retries, and prompt logic live in one place.

1. Scope

If any of those four lines change, this document changes first.

2. Configuration

# .env (never committed)
ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
ANTHROPIC_MAX_OUTPUT_TOKENS=256
ANTHROPIC_TIMEOUT_S=15

3. Request flow

Vue: tap "Explain" on a sensor card
  → POST /api/ai/explain    { sensor_id }                 [main API]
  → POST /ai/explain        { sensor_id, reading }        [AI service]
  → render template (system + user)
  → anthropic.messages.create(...)
  → persist ai_prompts + ai_responses rows
  → 200 { text, latency_ms, tokens }
  → Vue renders the text under the card

The main API is a thin proxy: it looks up the sensor’s latest reading from SQLite (one indexed query), forwards it to the AI service, returns the text. That keeps the AI service unaware of the database schema.

4. The prompt template

One template under services/ai/app/prompts/:

prompts/
  explain-sensor-v1.md

explain-sensor-v1.md:

---
id: explain-sensor-v1
audience: year-7
required: [metric, value, unit, source]
---

## system
You are a friendly science teacher explaining a single sensor reading to
Year 7 students. Use British English. Be concise: 2–3 sentences. Be concrete.
Avoid jargon. Never invent values. If a reading looks unusual for normal
classroom or outdoor conditions, say so plainly.

## user
A sensor called "" has just measured:
- :  

Explain what this reading means and whether it looks normal.

The template is loaded and validated at AI service boot. A malformed template fails boot loudly — better than failing on the first user request.

5. Calling Claude

# services/ai/app/claude.py

from anthropic import AsyncAnthropic

client = AsyncAnthropic()  # picks up ANTHROPIC_API_KEY from env

async def explain(rendered_system: str, rendered_user: str) -> Completion:
    msg = await client.messages.create(
        model=settings.anthropic_model,
        max_tokens=settings.anthropic_max_output_tokens,
        system=[
            {
                "type": "text",
                "text": rendered_system,
                "cache_control": {"type": "ephemeral"},   # cache the system block
            }
        ],
        messages=[{"role": "user", "content": rendered_user}],
        timeout=settings.anthropic_timeout_s,
    )
    return Completion(
        text=msg.content[0].text,
        input_tokens=msg.usage.input_tokens,
        output_tokens=msg.usage.output_tokens,
        model=msg.model,
    )

Notes:

6. Offline / no-key fallback

If ANTHROPIC_API_KEY is unset at boot, the service replaces the Claude call with a deterministic stub that returns:

“Sample reading: {metric} is {value} {unit} from {source}. (AI explanations are disabled — no API key configured.)”

The frontend shows a one-line banner (“AI explanations disabled”) so the teacher knows. The platform never crashes for this reason.

7. Persistence

Every successful and every failed call writes:

  1. One ai_prompts row — template id, context JSON, rendered text.
  2. One ai_responses row — model, text (or error), input/output tokens, latency, status.

Schema lives in database-design.md §2.4. Keeping this is non-negotiable: it powers the cost view, the “show what we asked the model” debug surface, and any future audit needs.

8. Failure handling

Failure Behaviour
ANTHROPIC_API_KEY unset at boot Fallback stub takes over. UI banner.
Anthropic returns 429 / 5xx Exponential backoff, max 3 attempts, then status=error. UI shows “Couldn’t reach Claude — try again”.
Timeout (ANTHROPIC_TIMEOUT_S) One retry, then status=timeout. Same UI message.
Template render error Don’t call Claude. Log to system_logs. Return 500.
Sensor has no readings yet Main API returns 409 before calling the AI service. UI button is disabled until a reading exists.

9. Cost control

10. Security

11. Worked example

User taps “Explain” on the temperature card for microbit-01.

API forwards to the AI service:

{
  "sensor_id": "microbit-01.temperature",
  "reading": {
    "source": "microbit-01",
    "metric": "temperature",
    "value": 24.3,
    "unit": "celsius",
    "ts": "2026-05-10T13:45:12.123Z"
  }
}

AI service responds:

{
  "text": "The temperature from microbit-01 is 24.3 °C, which is a comfortable, mildly warm classroom temperature. Nothing unusual — typical of a heated room on a spring day.",
  "model": "claude-haiku-4-5-20251001",
  "tokens": {"input": 178, "output": 41},
  "latency_ms": 612,
  "status": "ok"
}

The Vue card shows the text. The same prompt and response sit in ai_prompts / ai_responses for the cost and history views.