> ## Documentation Index
> Fetch the complete documentation index at: https://docs.enneo.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# 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.
</Tip>

### 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.

<CodeGroup>
  ```php PHP ReadingAgent.php icon="php" lines expandable theme={null}
  <?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'
                      ]
                  ]
              ]
          ]
      ];
  }
  ```

  ```python Python ReadingAgent.py icon="python" lines expandable theme={null}
  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)
  ```
</CodeGroup>
