Enneo

Business Logik Beispiele

Zählerstand-Agent

Automatisierte Erfassung und Verarbeitung von Zählerständen mit OCR-Technologie

Tip

Der Zählerstand-Agent ist auf die effiziente Verarbeitung von übermittelten Zählerständen spezialisiert. Er erkennt, validiert und verarbeitet Zählerstandsmeldungen vollautomatisch und sorgt für eine reibungslose Integration in Abrechnungssysteme. Besonders innovativ: Der Agent analysiert E-Mail-Anhänge mit OCR-Technologie und extrahiert selbstständig Zählerstandsdaten aus übermittelten Bildern oder Dokumenten.

Kernfunktionen

Der Zählerstand-Agent übernimmt den gesamten Prozess der Zählerstandserfassung und bietet dabei folgende wesentliche Funktionen:

  • Intelligente Datenerkennung: Automatische Identifikation von Zählerständen und zugehörigen Zählernummern in Kundenanfragen
  • OCR-Bildanalyse: Erkennung und Extraktion von Zählerstandsdaten aus E-Mail-Anhängen wie Fotos oder gescannten Dokumenten
  • Plausibilitätsprüfung: Validierung der gemeldeten Stände im Vergleich zu historischen Werten und typischen Verbrauchsmustern
  • Systemintegration: Nahtlose Übertragung validierter Zählerstände in die Abrechnungssysteme
  • Kundenkommunikation: Automatisierte Bestätigung der erfassten Daten oder Rückfragen bei Unklarheiten

Prozessablauf

Der Agent arbeitet nach einem strukturierten Verfahren, das Genauigkeit und Effizienz kombiniert:

  1. Extraktion der Zählerinformationen aus der Kundenanfrage oder durch OCR-Analyse von Anhängen
  2. Abgleich mit Stammdaten zur Zuordnung zum richtigen Vertrag und Zähler
  3. Durchführung von Plausibilitätsprüfungen zur Sicherstellung realistischer Verbrauchswerte
  4. Übermittlung der validierten Daten an die Abrechnungssysteme
  5. Rückmeldung an den Kunden mit Bestätigung der erfolgreichen Verarbeitung

Die Kombination aus intelligenter Textanalyse und moderner OCR-Technologie macht den Zählerstand-Agenten besonders leistungsfähig. Er verarbeitet Zählerstände nicht nur aus strukturierten Eingaben, sondern erkennt diese auch in Bildern von Zählern, die Kunden als E-Mail-Anhänge übermitteln - ohne dass diese manuell erfasst werden müssen.

<?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)