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:
- Extraktion der Zählerinformationen aus der Kundenanfrage oder durch OCR-Analyse von Anhängen
- Abgleich mit Stammdaten zur Zuordnung zum richtigen Vertrag und Zähler
- Durchführung von Plausibilitätsprüfungen zur Sicherstellung realistischer Verbrauchswerte
- Übermittlung der validierten Daten an die Abrechnungssysteme
- 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)