The Meter Reading Agent specializes in the efficient processing of transmitted meter readings. It recognizes, validates, and processes meter reading reports fully automatically and ensures a smooth integration into billing systems. Especially innovative: The agent analyzes email attachments with OCR technology and independently extracts meter reading data from submitted images or documents.

Key Functions

The Meter Reading Agent handles the entire process of meter reading collection and offers the following key functions:

  • Intelligent Data Recognition: Automatic identification of meter readings and associated meter numbers in customer inquiries
  • 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 transmission of validated meter readings into the billing systems
  • Customer Communication: Automated confirmation of captured data or queries in case of ambiguities

Process Flow

The agent follows a structured procedure, combining accuracy and efficiency:

  1. Extraction of meter information from customer inquiry or through OCR analysis of attachments
  2. Comparison with master data for assignment to the correct contract and meter
  3. Performing plausibility checks to ensure realistic consumption values
  4. Submission of validated data to the billing systems
  5. Feedback to the customer with confirmation of successful processing

The combination of intelligent text analysis and modern OCR technology makes the Meter Reading Agent particularly powerful. It processes meter readings not only from structured entries but also recognizes these in images of meters that customers submit as email attachments - without having to be manually recorded.

<?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'
                    ]
                ]
            ]
        ]
    ];
}