Business Logic Samples
Meter Reading Agent
Automated collection and processing of meter readings using OCR technology
Tip
The Meter Reading Agent specializes in the efficient processing of submitted meter readings. It identifies, validates, and processes meter reading notifications fully automatically and ensures a smooth integration into billing systems. Particularly innovative: The agent analyzes email attachments with OCR technology and independently extracts meter reading data from transmitted images or documents.
Core Functions
The Meter Reading Agent handles the entire process of meter reading collection, providing the following essential functions:
- Intelligent Data Recognition: Automatic identification of meter readings and associated meter numbers in customer requests.
- OCR Image Analysis: Recognition and extraction of meter reading data from email attachments such as photos or scanned documents.
- Plausibility Check: Validation of reported readings compared to historical values and typical consumption patterns.
- System Integration: Seamless transfer of validated meter readings into billing systems.
- Customer Communication: Automated confirmation of captured data or follow-up questions if there are any uncertainties.
Process Flow
The agent operates according to a structured procedure that combines accuracy and efficiency:
- Extraction of meter information from the customer request or by OCR analysis of attachments.
- Comparison with master data for assignment to the correct contract and meter.
- Carrying out of plausibility checks to ensure realistic consumption values.
- Transmission of validated data to the billing systems.
- Feedback to the customer confirming successful processing.
The combination of intelligent text analysis and modern OCR technology makes the Meter Reading Agent particularly powerful. It not only processes meter readings from structured inputs but also recognizes these in images of meters that customers submit as email attachments - without the need for manual entry.
<?php
declare(strict_types=1);
use EnneoSDK\Api;
use EnneoSDK\ApiEnneo;
use EnneoSDK\Helpers;
use EnneoSDK\IntentInfo;
use EnneoSDK\IntentOption;
use EnneoSDK\Interaction;
require(getenv()['SDK'] ?? 'sdk.php');
/** @var stdClass $in */
######### Expected Input:
// contractId -> ID of the contract (required)
// readingDate -> date of the reading. Optional. If no date is provided, the ticket creation date will be used
// ticketDate -> date of ticket creation (required)
// value -> reading value (optional). If no value is provided, the function will check attachments and try OCR there
// ticketId -> ID of the ticket (required)
// channel -> ticket channel. Voice and chat can be handled synchronously.
// counter -> consumption-related counter. Value is empty while first execution.
// plausibilityReason -> reason to save invalid readings. Used only if value can't be saved without a reason
######### Possible Actions:
// For better readability, actions can be prefixed with "base_", if they are handled in main agent business logic
// save reading without reason
const BASE_SAVE_READING = 'base_save_reading';
// save invalid reading using plausibility reason
const BASE_SAVE_INVALID_READING = 'base_save_invalid_reading';
// reading date is not valid
const READING_DATE_INVALID = 'reading_date_invalid';
// no reading value provided
const READING_VALUE_NOT_PROVIDED = 'reading_value_not_provided';
// reading value is not valid
const READING_VALUE_INVALID = 'reading_value_invalid';
// success
const SAVE_READING_COMPLETED = 'save_reading_completed';
// Create an Interaction object for output and fetch contract data from ERP
$interaction = new Interaction($in);
$contract = ApiEnneo::getContract($in->contractId);
######### STEP: if "Save" or "Save unplausibel" action is already selected, try to save reading
if ($in->_action === BASE_SAVE_READING || $in->_action === BASE_SAVE_INVALID_READING) {
try {
$parameters = [
'tenant' => 'tenant',
'mockedStatus' => 'OK', // use PLAUSIBILITY_REASON_NEEDED to test unplausible reading
'contractId' => $in->contractId,
'readingDate' => $in->readingDate,
'newConsumptions' => [
[
'consumption' => $in->value,
'consumptionUnit' => 'KILOWATT_HOURS',
'consumptionRelatedCounter' => $in->counter
]
]
];
if ($in->_acttion === BASE_SAVE_INVALID_READING) {
if (!$in->_plausibilityReason) {
$interaction->infos[] = new IntentInfo(
type: 'warning',
message: 'Bitte den Plausibilitätsgrund angeben'
);
$interaction->options[] = new IntentOption(
type: BASE_SAVE_INVALID_READING,
name: 'Erneut versuchen',
recommended: true
);
stopProcessing($interaction);
}
$parameters['params']['newConsumptions'][0]['plausibilityReason'] = $in->_plausibilityReason;
}
$response = Api::call(
method: 'POST',
url: 'https://echo.enneo.ai',
params: $parameters
);
} catch (Throwable $exception) {
$interaction->infos[] = new IntentInfo(
type: 'danger',
message: 'Zählerstand konnte nicht gespeichert werden'
);
$interaction->options[] = new IntentOption(
type: BASE_SAVE_READING,
name: 'Erneut versuchen',
recommended: true
);
stopProcessing($interaction);
}
// On invalid readings: save using a plausibility reason or inform the customer
if ($response->mockedStatus === 'PLAUSIBILITY_REASON_NEEDED') {
$interaction->infos[] = new IntentInfo(
type: 'warning',
message: 'Zählerstand ist nicht plausibel. Bitte den Grund wür die Abweichung angeben.'
);
$interaction->options[] = new IntentOption(
type: BASE_SAVE_INVALID_READING,
name: 'Plausibilitätsgrund angeben und speichern',
recommended: true
);
$interaction->options[] = new IntentOption(
type: READING_VALUE_INVALID,
name: 'Kunden über inplausiblen ZS informieren',
recommended: false
);
stopProcessing($interaction);
}
// Success
$interaction->infos[] = new IntentInfo(
type: 'success',
message: 'Zählerstand gespeichert'
);
$in->_action = SAVE_READING_COMPLETED;
stopProcessing($interaction);
}
######### STEP: fetch existing readings
$meterReadingsDataResponse = Api::call(
method: 'POST',
url: 'https://echo.enneo.ai',
params: [
'contractId' => $in->contractId,
'mockedResult' => getMockedMeterDataResponse()
]
);
$meterReadingsData = $meterReadingsDataResponse->mockedResult ?? getMockedMeterDataResponse();
// set consumption-related counter
$in->counter = $meterReadingsData->meters[0]->counterType;
######### STEP: get reading date and validate it
$in->readingDate = empty($in->readingDate) ? $in->ticketDate : $in->readingDate;
$isReadingDateValid = true;
// TODO:
// reading date can be validated here.
// On synchronous channel, user will be requested to change the date. On async channels, text template can be sent
$latestExistingReadingDate = max(array_map(fn($item) => $item->readingDate, $meterReadingsData->meters[0]->meterReadings));
if ($in->readingDate <= $latestExistingReadingDate) {
$interaction->infos[] = new IntentInfo(
type: 'warning',
message: isSyncChannel($in)
? sprintf('Bitte geben Sie ein Datum nach dem %s ein', Helpers::formatDate($latestExistingReadingDate))
: sprintf('Es liget bereits eine Ablseung vom %s vor', Helpers::formatDate($latestExistingReadingDate))
);
$interaction->options[] = new IntentOption(
type: READING_DATE_INVALID,
name: isSyncChannel($in) ? 'Datum korrigieren' : 'Kunden informieren',
recommended: true
);
stopProcessing($interaction);
}
######### STEP: get reading value and validate it
if (!$in->value && !isSyncChannel($in)) {
// on async channels (email), value could be provided as image in email attachment
$in->value = getValueUsingOcrOnAttachments($in, $meterReadingsData);
}
if (!$in->value) {
$interaction->infos[] = new IntentInfo(
type: 'warning',
message: 'Es wurde kein Zählerstand übermittelt'
);
$interaction->options[] = new IntentOption(
type: READING_VALUE_NOT_PROVIDED,
name: isSyncChannel($in) ? 'Zählerstand eingeben' : 'Kunden informieren',
recommended: true
);
stopProcessing($interaction);
}
// TODO:
// reading value validation can be adjusted and improved here
$latestReadingValue = max(array_map(fn($item) => $item->consumption, $meterReadingsData->meters[0]->meterReadings));
if ($in->value <= $latestReadingValue) {
$interaction->infos[] = new IntentInfo(
type: 'warning',
message: sprintf(
'Zählerstand ist kleiner als zuletzt übermittelter Zählerstand (%s, %s)',
Helpers::formatDate($latestExistingReadingDate),
$latestReadingValue
)
);
$interaction->options[] = new IntentOption(
type: READING_VALUE_INVALID,
name: isSyncChannel($in) ? 'Anderen Zählerstand eingeben' : 'Kunden informieren',
recommended: true
);
stopProcessing($interaction);
}
######### STEP: data validation passed. Add "save" action
$interaction->options[] = new IntentOption(
type: BASE_SAVE_READING,
name: 'Zählerstand speichern',
recommended: true
);
stopProcessing($interaction);
######### HELPER FUNCTIONS #########
function stopProcessing(Interaction $interaction): void
{
echo json_encode($interaction);
exit();
}
function isSyncChannel($in): bool
{
return in_array($in->channel, ['chat', 'phone']);
}
function getValueUsingOcrOnAttachments(stdClass $in, stdClass $meterReadingsData): ?float
{
// Last reading date & value can be provided to improve the OCR
$latestReadingValue = max(array_map(fn($item) => $item->consumption, $meterReadingsData->meters[0]->meterReadings));
$latestReadingDate = max(array_map(fn($item) => $item->readingDate, $meterReadingsData->meters[0]->meterReadings));
$ticket = ApiEnneo::getTicket($in->ticketId);
foreach ($ticket->attachments as $attachment) {
if ($attachment->size < 10000) {
// attachment to small (logo etc)
continue;
}
if (!in_array($attachment->fileEnding, ['jpg', 'jpeg', 'png', 'gif'])) {
// unsupported file extension
continue;
}
$response = ApiEnneo::post(
endpoint: '/api/cortex/ocrMeter',
body: [
'ticketId' => $in->ticketId,
'fileUrl' => $_ENV['ENNEO_API_URL'] . $attachment->url,
'readingDate' => $in->readingDate,
'lastReading' => $latestReadingValue,
'lastReadingDate' => $latestReadingDate,
]
);
if ($response->responses ?? $response->response) {
$body = $response->responses ?? $response->response;
// TODO: value2 can be used for HT/NT implementation
$value2 = (float)($body->meter_reading_2 ?? $body->meterReading2);
return (float)($body->meter_reading_1 ?? $body->meterReading1);
}
}
return null;
}
/**
* Generates a mocked response for meter data including meter details,
* reading details, and associated properties.
*/
function getMockedMeterDataResponse(): array
{
return [
'meters' => [
[
'meterNumber' => '000000000000123456',
'counterType' => 'ET',
'numberOfPreDecimalDigitsForMeter' => 6,
'numberOfPostDecimalDigitsForMeter' => 1,
'addMeterReadingPossible' => true,
'meterReadings' => [
[
'consumptionRelatedCounter' => 'ET',
'readingDate' => '2024-05-22',
'consumption' => 80048,
'consumptionUnit' => 'KILOWATT_HOURS',
'reason' => 'INITIAL_METER_COUNT'
],
[
'consumptionRelatedCounter' => 'ET',
'readingDate' => '2024-12-21',
'consumption' => 81246,
'consumptionUnit' => 'KILOWATT_HOURS',
'reason' => 'INTERMEDIATE_READING'
]
]
]
]
];
}import importlib.util
import os
import json
import requests
file_path = os.getenv('SDK', 'sdk.py')
spec = importlib.util.spec_from_file_location('sdk', file_path)
sdk = importlib.util.module_from_spec(spec)
spec.loader.exec_module(sdk)
######### Expected Input:
# contractId -> ID of the contract (required)
# readingDate -> date of the reading. Optional. If no date is provided, the ticket creation date will be used
# ticketDate -> date of ticket creation (required)
# value -> reading value (optional). If no value is provided, the function will check attachments and try OCR there
# ticketId -> ID of the ticket (required)
# channel -> ticket channel. Voice and chat can be handled synchronously.
# counter -> consumption-related counter. Value is empty while first execution.
# plausibilityReason -> reason to save invalid readings. Used only if value can't be saved without a reason
######### Possible Actions:
# For better readability, actions can be prefixed with "base_", if they are handled in main agent business logic
# save reading without reason
BASE_SAVE_READING = 'base_save_reading'
# save invalid reading using plausibility reason
BASE_SAVE_INVALID_READING = 'base_save_invalid_reading'
# reading date is not valid
READING_DATE_INVALID = 'reading_date_invalid'
# no reading value provided
READING_VALUE_NOT_PROVIDED = 'reading_value_not_provided'
# reading value is not valid
READING_VALUE_INVALID = 'reading_value_invalid'
# success
SAVE_READING_COMPLETED = 'save_reading_completed'
######### HELPER FUNCTIONS #########
def is_sync_channel(input_data):
"""
Check if the channel is synchronous (chat or phone)
"""
return input_data['channel'] in ['chat', 'phone']
def get_value_using_ocr_on_attachments(input_data, meter_readings_data):
"""
Extract reading value from ticket attachments using OCR
"""
# Last reading date & value can be provided to improve the OCR
latest_reading_value = max(item['consumption'] for item in meter_readings_data['meters'][0]['meterReadings'])
latest_reading_date = max(item['readingDate'] for item in meter_readings_data['meters'][0]['meterReadings'])
ticket = sdk.ApiEnneo.get_ticket(input_data['ticketId'])
for attachment in ticket['attachments']:
if attachment['size'] < 10000:
# attachment too small (logo etc)
continue
if attachment['fileEnding'] not in ['jpg', 'jpeg', 'png', 'gif']:
# unsupported file extension
continue
response = sdk.ApiEnneo.post(
endpoint='/api/cortex/ocrMeter',
body={
'ticketId': input_data['ticketId'],
'fileUrl': os.getenv('ENNEO_API_URL') + attachment['url'],
'readingDate': input_data['readingDate'],
'lastReading': latest_reading_value,
'lastReadingDate': latest_reading_date,
}
)
if response.get('responses') or response.get('response'):
body = response.get('responses') or response.get('response')
# TODO: value2 can be used for HT/NT implementation
value2 = float(body.get('meter_reading_2', body.get('meterReading2', 0)))
return float(body.get('meter_reading_1', body.get('meterReading1', 0)))
return None
def get_mocked_meter_data_response():
"""
Generates a mocked response for meter data including meter details,
reading details, and associated properties.
"""
return {
'meters': [
{
'meterNumber': '000000000000123456',
'counterType': 'ET',
'numberOfPreDecimalDigitsForMeter': 6,
'numberOfPostDecimalDigitsForMeter': 1,
'addMeterReadingPossible': True,
'meterReadings': [
{
'consumptionRelatedCounter': 'ET',
'readingDate': '2024-05-22',
'consumption': 80048,
'consumptionUnit': 'KILOWATT_HOURS',
'reason': 'INITIAL_METER_COUNT'
},
{
'consumptionRelatedCounter': 'ET',
'readingDate': '2024-12-21',
'consumption': 81246,
'consumptionUnit': 'KILOWATT_HOURS',
'reason': 'INTERMEDIATE_READING'
}
]
}
]
}
def stop_processing(interaction):
"""
Business logic execution should always return an Interaction object in JSON format
"""
print(json.dumps(interaction.model_dump()))
exit()
input_data = sdk.load_input_data()
interaction = sdk.Interaction(data=input_data)
# Create an Interaction object for output and fetch contract data from ERP
contract = sdk.ApiEnneo.get_contract(input_data['contractId'])
######### STEP: if "Save" or "Save unplausibel" action is already selected, try to save reading
if input_data.get('_action') == BASE_SAVE_READING or input_data.get('_action') == BASE_SAVE_INVALID_READING:
try:
parameters = {
'tenant': 'tenant',
'mockedStatus': 'OK', # use PLAUSIBILITY_REASON_NEEDED to test unplausible reading
'contractId': input_data['contractId'],
'readingDate': input_data['readingDate'],
'newConsumptions': [
{
'consumption': input_data['value'],
'consumptionUnit': 'KILOWATT_HOURS',
'consumptionRelatedCounter': input_data['counter']
}
]
}
if input_data.get('_action') == BASE_SAVE_INVALID_READING:
if not input_data.get('_plausibilityReason'):
interaction.infos.append(
sdk.IntentInfo(
type='warning',
message='Bitte den Plausibilitätsgrund angeben'
)
)
interaction.options.append(
sdk.IntentOption(
type=BASE_SAVE_INVALID_READING,
name='Erneut versuchen',
recommended=True
)
)
stop_processing(interaction)
parameters['newConsumptions'][0]['plausibilityReason'] = input_data['_plausibilityReason']
try:
# Make the API call directly using requests to handle JSON decode errors properly
headers = {'Content-Type': 'application/json', 'User-Agent': 'enneo/1.0.0'}
response = requests.post(
'https://echo.enneo.ai',
headers=headers,
json=parameters,
timeout=60
)
# Handle the response similar to PHP version
if response.status_code < 200 or response.status_code >= 400:
raise Exception(f"Api call to https://echo.enneo.ai failed with code {response.status_code} and response: {response.text}")
# Check if the response is valid JSON
if response.text and (response.text.strip().startswith('{') or response.text.strip().startswith('[')):
try:
response_data = response.json()
except json.JSONDecodeError:
# If JSON decode fails, raise exception
raise Exception("Invalid JSON response from API")
else:
# Special case when a REST API does not respond with a json-encoded response
raise Exception("API did not return valid JSON")
except Exception as e:
raise Exception(f"API call failed: {str(e)}")
except Exception as exception:
interaction.infos.append(
sdk.IntentInfo(
type='danger',
message='Zählerstand konnte nicht gespeichert werden'
)
)
interaction.options.append(
sdk.IntentOption(
type=BASE_SAVE_READING,
name='Erneut versuchen',
recommended=True
)
)
stop_processing(interaction)
# On invalid readings: save using a plausibility reason or inform the customer
if response_data.get('mockedStatus') == 'PLAUSIBILITY_REASON_NEEDED':
interaction.infos.append(
sdk.IntentInfo(
type='warning',
message='Zählerstand ist nicht plausibel. Bitte den Grund für die Abweichung angeben.'
)
)
interaction.options.append(
sdk.IntentOption(
type=BASE_SAVE_INVALID_READING,
name='Plausibilitätsgrund angeben und speichern',
recommended=True
)
)
interaction.options.append(
sdk.IntentOption(
type=READING_VALUE_INVALID,
name='Kunden über inplausiblen ZS informieren',
recommended=False
)
)
stop_processing(interaction)
# Success
interaction.infos.append(
sdk.IntentInfo(
type='success',
message='Zählerstand gespeichert'
)
)
input_data['_action'] = SAVE_READING_COMPLETED
stop_processing(interaction)
######### STEP: fetch existing readings
try:
# Make the API call directly using requests to handle JSON decode errors properly
headers = {'Content-Type': 'application/json', 'User-Agent': 'enneo/1.0.0'}
response = requests.post(
'https://echo.enneo.ai',
headers=headers,
json={
'contractId': input_data['contractId'],
'mockedResult': get_mocked_meter_data_response()
},
timeout=60
)
# Handle the response similar to PHP version
if response.status_code < 200 or response.status_code >= 400:
raise Exception(f"Api call to https://echo.enneo.ai failed with code {response.status_code} and response: {response.text}")
# Check if the response is valid JSON
if response.text and (response.text.strip().startswith('{') or response.text.strip().startswith('[')):
try:
meter_readings_data_response = response.json()
except json.JSONDecodeError:
# If JSON decode fails, use mocked data
meter_readings_data_response = {'mockedResult': get_mocked_meter_data_response()}
else:
# Special case when a REST API does not respond with a json-encoded response
meter_readings_data_response = {'mockedResult': get_mocked_meter_data_response()}
except Exception as e:
# Handle any other exceptions by using mocked data
meter_readings_data_response = {'mockedResult': get_mocked_meter_data_response()}
meter_readings_data = meter_readings_data_response.get('mockedResult', get_mocked_meter_data_response())
# set consumption-related counter
input_data['counter'] = meter_readings_data['meters'][0]['counterType']
######### STEP: get reading date and validate it
input_data['readingDate'] = input_data.get('readingDate') or input_data['ticketDate']
is_reading_date_valid = True
# TODO:
# reading date can be validated here.
# On synchronous channel, user will be requested to change the date. On async channels, text template can be sent
latest_existing_reading_date = max(item['readingDate'] for item in meter_readings_data['meters'][0]['meterReadings'])
if input_data['readingDate'] <= latest_existing_reading_date:
message = (f"Bitte geben Sie ein Datum nach dem {sdk.Helpers.format_date(latest_existing_reading_date)} ein"
if is_sync_channel(input_data)
else f"Es liegt bereits eine Ablesung vom {sdk.Helpers.format_date(latest_existing_reading_date)} vor")
interaction.infos.append(
sdk.IntentInfo(
type='warning',
message=message
)
)
interaction.options.append(
sdk.IntentOption(
type=READING_DATE_INVALID,
name='Datum korrigieren' if is_sync_channel(input_data) else 'Kunden informieren',
recommended=True
)
)
stop_processing(interaction)
######### STEP: get reading value and validate it
if not input_data.get('value') and not is_sync_channel(input_data):
# on async channels (email), value could be provided as image in email attachment
input_data['value'] = get_value_using_ocr_on_attachments(input_data, meter_readings_data)
if not input_data.get('value'):
interaction.infos.append(
sdk.IntentInfo(
type='warning',
message='Es wurde kein Zählerstand übermittelt'
)
)
interaction.options.append(
sdk.IntentOption(
type=READING_VALUE_NOT_PROVIDED,
name='Zählerstand eingeben' if is_sync_channel(input_data) else 'Kunden informieren',
recommended=True
)
)
stop_processing(interaction)
# TODO:
# reading value validation can be adjusted and improved here
latest_reading_value = max(item['consumption'] for item in meter_readings_data['meters'][0]['meterReadings'])
if input_data['value'] <= latest_reading_value:
interaction.infos.append(
sdk.IntentInfo(
type='warning',
message=f"Zählerstand ist kleiner als zuletzt übermittelter Zählerstand ({sdk.Helpers.format_date(latest_existing_reading_date)}, {latest_reading_value})"
)
)
interaction.options.append(
sdk.IntentOption(
type=READING_VALUE_INVALID,
name='Anderen Zählerstand eingeben' if is_sync_channel(input_data) else 'Kunden informieren',
recommended=True
)
)
stop_processing(interaction)
######### STEP: data validation passed. Add "save" action
interaction.options.append(
sdk.IntentOption(
type=BASE_SAVE_READING,
name='Zählerstand speichern',
recommended=True
)
)
stop_processing(interaction)