From a2989cd09af284f895b2e7f9579f7abb98e3e801 Mon Sep 17 00:00:00 2001 From: Arun Venkataswamy Date: Wed, 9 Oct 2024 16:44:54 +0530 Subject: [PATCH 01/15] V2 features added --- src/unstract/llmwhisperer/client_temp.py | 77 ++++ src/unstract/llmwhisperer/client_v2.py | 437 +++++++++++++++++++++++ tests/client_test_v2.py | 125 +++++++ tests/sample.env | 1 + 4 files changed, 640 insertions(+) create mode 100644 src/unstract/llmwhisperer/client_temp.py create mode 100644 src/unstract/llmwhisperer/client_v2.py create mode 100644 tests/client_test_v2.py diff --git a/src/unstract/llmwhisperer/client_temp.py b/src/unstract/llmwhisperer/client_temp.py new file mode 100644 index 0000000..b07b01a --- /dev/null +++ b/src/unstract/llmwhisperer/client_temp.py @@ -0,0 +1,77 @@ +import json +import logging +import os +import time +from client_v2 import LLMWhispererClientV2, LLMWhispererClientException + + +if __name__ == "__main__": + client = LLMWhispererClientV2() + + try: + # result = client.whisper( + # mode="native_text", + # output_mode="layout_preserving", + # file_path="../../../tests/test_data/credit_card.pdf", + # ) + # result = client.whisper( + # mode="high_quality", + # output_mode="layout_preserving", + # file_path="../../../tests/test_data/credit_card.pdf", + # ) + # result = client.whisper( + # mode="low_cost", + # output_mode="layout_preserving", + # file_path="../../../tests/test_data/credit_card.pdf", + # ) + + # result = client.register_webhook( + # url="https://webhook.site/15422328-2a5e-4a1d-9a20-f78313ca5007", + # auth_token="my_auth_token", + # webhook_name="wb3", + # ) + # print(result) + + # result = client.get_webhook_details(webhook_name="wb3") + # print(result) + + result = client.whisper( + mode="high_quality", + output_mode="layout_preserving", + file_path="../../../tests/test_data/credit_card.pdf", + use_webhook="wb3", + webhook_metadata="Dummy metadata for webhook", + ) + + # result = client.whisper( + # mode="form", + # output_mode="layout_preserving", + # file_path="../../../tests/test_data/credit_card.pdf", + # ) + + # if result["status_code"] == 202: + # print("Whisper request accepted.") + # print(f"Whisper hash: {result['whisper_hash']}") + # while True: + # print("Polling for whisper status...") + # status = client.whisper_status(whisper_hash=result["whisper_hash"]) + # print(status) + # if status["status"] == "processing": + # print("STATUS: processing...") + # elif status["status"] == "delivered": + # print("STATUS: Already delivered!") + # break + # elif status["status"] == "unknown": + # print("STATUS: unknown...") + # break + # elif status["status"] == "processed": + # print("STATUS: processed!") + # print("Let's retrieve the result of the extraction...") + # resultx = client.whisper_retrieve( + # whisper_hash=result["whisper_hash"] + # ) + # print(resultx) + # break + # time.sleep(2) + except LLMWhispererClientException as e: + print(e) diff --git a/src/unstract/llmwhisperer/client_v2.py b/src/unstract/llmwhisperer/client_v2.py new file mode 100644 index 0000000..05bf565 --- /dev/null +++ b/src/unstract/llmwhisperer/client_v2.py @@ -0,0 +1,437 @@ +"""This module provides a Python client for interacting with the LLMWhisperer +API. + +Note: This is for the LLMWhisperer API v2.x + +Prepare documents for LLM consumption +LLMs are powerful, but their output is as good as the input you provide. +LLMWhisperer is a technology that presents data from complex documents +(different designs and formats) to LLMs in a way that they can best understand. + +LLMWhisperer is available as an API that can be integrated into your existing +systems to preprocess your documents before they are fed into LLMs. It can handle +a variety of document types, including PDFs, images, and scanned documents. + +This client simplifies the process of making requests to the API and handling the responses. + +Classes: + LLMWhispererClientException: Exception raised for errors in the LLMWhispererClient. +""" + +import json +import logging +import os +from typing import IO +import copy + +import requests + + +BASE_URL = "https://llmwhisperer-api.unstract.com/api/v2" + + +class LLMWhispererClientException(Exception): + """Exception raised for errors in the LLMWhispererClient. + + Attributes: + message (str): Explanation of the error. + status_code (int): HTTP status code returned by the LLMWhisperer API. + + Args: + message (str): Explanation of the error. + status_code (int, optional): HTTP status code returned by the LLMWhisperer API. Defaults to None. + """ + + def __init__(self, value): + self.value = value + + def __str__(self): + return repr(self.value) + + def error_message(self): + return self.value + + +class LLMWhispererClientV2: + """A client for interacting with the LLMWhisperer API. + + Note: This is for the LLMWhisperer API v2.x + + This client uses the requests library to make HTTP requests to the + LLMWhisperer API. It also includes a logger for tracking the + client's activities and errors. + """ + + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + logger = logging.getLogger(__name__) + log_stream_handler = logging.StreamHandler() + log_stream_handler.setFormatter(formatter) + logger.addHandler(log_stream_handler) + + api_key = "" + base_url = "" + + def __init__( + self, + base_url: str = "", + api_key: str = "", + logging_level: str = "", + ): + """Initializes the LLMWhispererClient with the given parameters. + + Args: + base_url (str, optional): The base URL for the LLMWhisperer API. Defaults to "". + If the base_url is not provided, the client will use + the value of the LLMWHISPERER_BASE_URL_V2 environment + variable,or the default value. + api_key (str, optional): The API key for the LLMWhisperer API. Defaults to "". + If the api_key is not provided, the client will use the + value of the LLMWHISPERER_API_KEY environment variable. + logging_level (str, optional): The logging level for the client. Can be "DEBUG", + "INFO", "WARNING" or "ERROR". Defaults to the + value of the LLMWHISPERER_LOGGING_LEVEL + environment variable, or "DEBUG" if the + environment variable is not set. + """ + if logging_level == "": + logging_level = os.getenv("LLMWHISPERER_LOGGING_LEVEL", "DEBUG") + if logging_level == "DEBUG": + self.logger.setLevel(logging.DEBUG) + elif logging_level == "INFO": + self.logger.setLevel(logging.INFO) + elif logging_level == "WARNING": + self.logger.setLevel(logging.WARNING) + elif logging_level == "ERROR": + self.logger.setLevel(logging.ERROR) + self.logger.setLevel(logging_level) + self.logger.debug("logging_level set to %s", logging_level) + + if base_url == "": + self.base_url = os.getenv("LLMWHISPERER_BASE_URL_V2", BASE_URL) + else: + self.base_url = base_url + self.logger.debug("base_url set to %s", self.base_url) + + if api_key == "": + self.api_key = os.getenv("LLMWHISPERER_API_KEY", "") + else: + self.api_key = api_key + + self.headers = {"unstract-key": self.api_key} + + ### TODO: REMOVE THIS!!!!!!!!!! + ### This is for testing in local machine + self.headers = { + "unstract-key": self.api_key, + "Subscription-Id": "test", + "Start-Date": "9-07-2024", + } + ### ------------------------------------ + + def get_usage_info(self) -> dict: + """Retrieves the usage information of the LLMWhisperer API. + + This method sends a GET request to the '/get-usage-info' endpoint of the LLMWhisperer API. + The response is a JSON object containing the usage information. + Refer to https://docs.unstract.com/llm_whisperer/apis/llm_whisperer_usage_api + + Returns: + dict: A dictionary containing the usage information. + + Raises: + LLMWhispererClientException: If the API request fails, it raises an exception with + the error message and status code returned by the API. + """ + self.logger.debug("get_usage_info called") + url = f"{self.base_url}/get-usage-info" + self.logger.debug("url: %s", url) + req = requests.Request("GET", url, headers=self.headers) + prepared = req.prepare() + s = requests.Session() + response = s.send(prepared, timeout=120) + if response.status_code != 200: + err = json.loads(response.text) + err["status_code"] = response.status_code + raise LLMWhispererClientException(err) + return json.loads(response.text) + + def whisper( + self, + file_path: str = "", + stream: IO[bytes] = None, + url: str = "", + mode: str = "high_quality", + output_mode: str = "layout_preserving", + page_seperator: str = "<<<", + pages_to_extract: str = "", + median_filter_size: int = 0, + gaussian_blur_radius: int = 0, + line_splitter_tolerance: float = 0.75, + horizontal_stretch_factor: float = 1.0, + mark_vertical_lines: bool = False, + mark_horizontal_lines: bool = False, + line_spitter_strategy: str = "left-priority", + lang="eng", + tag="default", + filename="", + webhook_metadata="", + use_webhook="", + ) -> dict: + """ + Sends a request to the LLMWhisperer API to process a document. + Refer to https://docs.unstract.com/llm_whisperer/apis/llm_whisperer_text_extraction_api + + Args: + file_path (str, optional): The path to the file to be processed. Defaults to "". + stream (IO[bytes], optional): A stream of bytes to be processed. Defaults to None. + url (str, optional): The URL of the file to be processed. Defaults to "". + mode (str, optional): The processing mode. Can be "high_quality", "form", "low_cost" or "native_text". Defaults to "high_quality". + output_mode (str, optional): The output mode. Can be "layout_preserving" or "text". Defaults to "layout_preserving". + page_seperator (str, optional): The page separator. Defaults to "<<<". + pages_to_extract (str, optional): The pages to extract. Defaults to "". + median_filter_size (int, optional): The size of the median filter. Defaults to 0. + gaussian_blur_radius (int, optional): The radius of the Gaussian blur. Defaults to 0. + line_splitter_tolerance (float, optional): The line splitter tolerance. Defaults to 0.4. + horizontal_stretch_factor (float, optional): The horizontal stretch factor. Defaults to 1.0. + mark_vertical_lines (bool, optional): Whether to mark vertical lines. Defaults to False. + mark_horizontal_lines (bool, optional): Whether to mark horizontal lines. Defaults to False. + line_spitter_strategy (str, optional): The line splitter strategy. Defaults to "left-priority". + lang (str, optional): The language of the document. Defaults to "eng". + tag (str, optional): The tag for the document. Defaults to "default". + filename (str, optional): The name of the file to store in reports. Defaults to "". + webhook_metadata (str, optional): The webhook metadata. This data will be passed to the webhook if webhooks are used Defaults to "". + use_webhook (str, optional): Webhook name to call. Defaults to "". If not provided, the no webhook will be called. + + Returns: + dict: The response from the API as a dictionary. + + Raises: + LLMWhispererClientException: If the API request fails, it raises an exception with + the error message and status code returned by the API. + """ + self.logger.debug("whisper called") + api_url = f"{self.base_url}/whisper" + params = { + "url": url, + "mode": mode, + "output_mode": output_mode, + "page_seperator": page_seperator, + "pages_to_extract": pages_to_extract, + "median_filter_size": median_filter_size, + "gaussian_blur_radius": gaussian_blur_radius, + "line_splitter_tolerance": line_splitter_tolerance, + "horizontal_stretch_factor": horizontal_stretch_factor, + "mark_vertical_lines": mark_vertical_lines, + "mark_horizontal_lines": mark_horizontal_lines, + "line_spitter_strategy": line_spitter_strategy, + "lang": lang, + "tag": tag, + "filename": filename, + "webhook_metadata": webhook_metadata, + "use_webhook": use_webhook, + } + + self.logger.debug("api_url: %s", api_url) + self.logger.debug("params: %s", params) + + if url == "" and file_path == "" and stream is None: + raise LLMWhispererClientException( + { + "status_code": -1, + "message": "Either url, stream or file_path must be provided", + } + ) + + should_stream = False + if url == "": + if stream is not None: + + should_stream = True + + def generate(): + for chunk in stream: + yield chunk + + req = requests.Request( + "POST", + api_url, + params=params, + headers=self.headers, + data=generate(), + ) + + else: + with open(file_path, "rb") as f: + data = f.read() + req = requests.Request( + "POST", + api_url, + params=params, + headers=self.headers, + data=data, + ) + else: + req = requests.Request("POST", api_url, params=params, headers=self.headers) + prepared = req.prepare() + s = requests.Session() + response = s.send(prepared, timeout=120, stream=should_stream) + if response.status_code != 200 and response.status_code != 202: + message = json.loads(response.text) + message["status_code"] = response.status_code + raise LLMWhispererClientException(message) + if response.status_code == 202: + message = json.loads(response.text) + message["status_code"] = response.status_code + return message + + # Will not reach here if status code is 202 + message = response.text + message["status_code"] = response.status_code + return message + + def whisper_status(self, whisper_hash: str) -> dict: + """Retrieves the status of the whisper operation from the LLMWhisperer + API. + + This method sends a GET request to the '/whisper-status' endpoint of the LLMWhisperer API. + The response is a JSON object containing the status of the whisper operation. + + Refer https://docs.unstract.com/llm_whisperer/apis/llm_whisperer_text_extraction_status_api + + Args: + whisper_hash (str): The hash of the whisper (returned by whisper method) + + Returns: + dict: A dictionary containing the status of the whisper operation. The keys in the + dictionary include 'status_code' and the status details. + + Raises: + LLMWhispererClientException: If the API request fails, it raises an exception with + the error message and status code returned by the API. + """ + self.logger.debug("whisper_status called") + url = f"{self.base_url}/whisper-status" + params = {"whisper_hash": whisper_hash} + self.logger.debug("url: %s", url) + req = requests.Request("GET", url, headers=self.headers, params=params) + prepared = req.prepare() + s = requests.Session() + response = s.send(prepared, timeout=120) + if response.status_code != 200: + err = json.loads(response.text) + err["status_code"] = response.status_code + raise LLMWhispererClientException(err) + message = json.loads(response.text) + message["status_code"] = response.status_code + return message + + def whisper_retrieve(self, whisper_hash: str) -> dict: + """Retrieves the result of the whisper operation from the LLMWhisperer + API. + + This method sends a GET request to the '/whisper-retrieve' endpoint of the LLMWhisperer API. + The response is a JSON object containing the result of the whisper operation. + + Refer to https://docs.unstract.com/llm_whisperer/apis/llm_whisperer_text_extraction_retrieve_api + + Args: + whisper_hash (str): The hash of the whisper operation. + + Returns: + dict: A dictionary containing the status code and the extracted text from the whisper operation. + + Raises: + LLMWhispererClientException: If the API request fails, it raises an exception with + the error message and status code returned by the API. + """ + self.logger.debug("whisper_retrieve called") + url = f"{self.base_url}/whisper-retrieve" + params = {"whisper_hash": whisper_hash} + self.logger.debug("url: %s", url) + req = requests.Request("GET", url, headers=self.headers, params=params) + prepared = req.prepare() + s = requests.Session() + response = s.send(prepared, timeout=120) + if response.status_code != 200: + err = json.loads(response.text) + err["status_code"] = response.status_code + raise LLMWhispererClientException(err) + + return { + "status_code": response.status_code, + "extraction": json.loads(response.text), + } + + def register_webhook(self, url: str, auth_token: str, webhook_name: str) -> dict: + """Registers a webhook with the LLMWhisperer API. + + This method sends a POST request to the '/whisper-manage-callback' endpoint of the LLMWhisperer API. + The response is a JSON object containing the status of the webhook registration. + + Refer to https://docs.unstract.com/llm_whisperer/apis/ + + Args: + url (str): The URL of the webhook. + auth_token (str): The authentication token for the webhook. + webhook_name (str): The name of the webhook. + + Returns: + dict: A dictionary containing the status code and the response from the API. + + Raises: + LLMWhispererClientException: If the API request fails, it raises an exception with + the error message and status code returned by the API. + + """ + + data = { + "url": url, + "auth_token": auth_token, + "webhook_name": webhook_name, + } + url = f"{self.base_url}/whisper-manage-callback" + headersx = copy.deepcopy(self.headers) + headersx["Content-Type"] = "application/json" + req = requests.Request("POST", url, headers=headersx, json=data) + prepared = req.prepare() + s = requests.Session() + response = s.send(prepared, timeout=120) + if response.status_code != 200: + err = json.loads(response.text) + err["status_code"] = response.status_code + raise LLMWhispererClientException(err) + return json.loads(response.text) + + def get_webhook_details(self, webhook_name: str) -> dict: + """Retrieves the details of a webhook from the LLMWhisperer API. + + This method sends a GET request to the '/whisper-manage-callback' endpoint of the LLMWhisperer API. + The response is a JSON object containing the details of the webhook. + + Refer to https://docs.unstract.com/llm_whisperer/apis/ + + Args: + webhook_name (str): The name of the webhook. + + Returns: + dict: A dictionary containing the status code and the response from the API. + + Raises: + LLMWhispererClientException: If the API request fails, it raises an exception with + the error message and status code returned by the API. + + """ + + url = f"{self.base_url}/whisper-manage-callback" + params = {"webhook_name": webhook_name} + req = requests.Request("GET", url, headers=self.headers, params=params) + prepared = req.prepare() + s = requests.Session() + response = s.send(prepared, timeout=120) + if response.status_code != 200: + err = json.loads(response.text) + err["status_code"] = response.status_code + raise LLMWhispererClientException(err) + return json.loads(response.text) diff --git a/tests/client_test_v2.py b/tests/client_test_v2.py new file mode 100644 index 0000000..3c36d5e --- /dev/null +++ b/tests/client_test_v2.py @@ -0,0 +1,125 @@ +import logging +import os +import unittest +from pathlib import Path + +import pytest +import requests + +from unstract.llmwhisperer import LLMWhispererClient + +logger = logging.getLogger(__name__) + + +def test_get_usage_info(client): + usage_info = client.get_usage_info() + logger.info(usage_info) + assert isinstance(usage_info, dict), "usage_info should be a dictionary" + expected_keys = [ + "current_page_count", + "daily_quota", + "monthly_quota", + "overage_page_count", + "subscription_plan", + "today_page_count", + ] + assert set(usage_info.keys()) == set( + expected_keys + ), f"usage_info {usage_info} does not contain the expected keys" + + +@pytest.mark.parametrize( + "processing_mode, output_mode, input_file", + [ + ("ocr", "line-printer", "restaurant_invoice_photo.pdf"), + ("ocr", "line-printer", "credit_card.pdf"), + ("ocr", "line-printer", "handwritten-form.pdf"), + ("ocr", "text", "restaurant_invoice_photo.pdf"), + ("text", "line-printer", "restaurant_invoice_photo.pdf"), + ("text", "text", "handwritten-form.pdf"), + ], +) +def test_whisper(client, data_dir, processing_mode, output_mode, input_file): + file_path = os.path.join(data_dir, input_file) + response = client.whisper( + processing_mode=processing_mode, + output_mode=output_mode, + file_path=file_path, + timeout=200, + ) + logger.debug(response) + + exp_basename = f"{Path(input_file).stem}.{processing_mode}.{output_mode}.txt" + exp_file = os.path.join(data_dir, "expected", exp_basename) + with open(exp_file, encoding="utf-8") as f: + exp = f.read() + + assert isinstance(response, dict) + assert response["status_code"] == 200 + assert response["extracted_text"] == exp + + +# TODO: Review and port to pytest based tests +class TestLLMWhispererClient(unittest.TestCase): + @unittest.skip("Skipping test_whisper") + def test_whisper(self): + client = LLMWhispererClient() + # response = client.whisper( + # url="https://storage.googleapis.com/pandora-static/samples/bill.jpg.pdf" + # ) + response = client.whisper( + file_path="test_data/restaurant_invoice_photo.pdf", + timeout=200, + store_metadata_for_highlighting=True, + ) + print(response) + # self.assertIsInstance(response, dict) + + # @unittest.skip("Skipping test_whisper") + def test_whisper_stream(self): + client = LLMWhispererClient() + download_url = ( + "https://storage.googleapis.com/pandora-static/samples/bill.jpg.pdf" + ) + # Create a stream of download_url and pass it to whisper + response_download = requests.get(download_url, stream=True) + response_download.raise_for_status() + response = client.whisper( + stream=response_download.iter_content(chunk_size=1024), + timeout=200, + store_metadata_for_highlighting=True, + ) + print(response) + # self.assertIsInstance(response, dict) + + @unittest.skip("Skipping test_whisper_status") + def test_whisper_status(self): + client = LLMWhispererClient() + response = client.whisper_status( + whisper_hash="7cfa5cbb|5f1d285a7cf18d203de7af1a1abb0a3a" + ) + logger.info(response) + self.assertIsInstance(response, dict) + + @unittest.skip("Skipping test_whisper_retrieve") + def test_whisper_retrieve(self): + client = LLMWhispererClient() + response = client.whisper_retrieve( + whisper_hash="7cfa5cbb|5f1d285a7cf18d203de7af1a1abb0a3a" + ) + logger.info(response) + self.assertIsInstance(response, dict) + + @unittest.skip("Skipping test_whisper_highlight_data") + def test_whisper_highlight_data(self): + client = LLMWhispererClient() + response = client.highlight_data( + whisper_hash="9924d865|5f1d285a7cf18d203de7af1a1abb0a3a", + search_text="Indiranagar", + ) + logger.info(response) + self.assertIsInstance(response, dict) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/sample.env b/tests/sample.env index c69cc3d..a39817b 100644 --- a/tests/sample.env +++ b/tests/sample.env @@ -1,3 +1,4 @@ LLMWHISPERER_BASE_URL=https://llmwhisperer-api.unstract.com/v1 +LLMWHISPERER_BASE_URL_V2=https://llmwhisperer-api.unstract.com/api/v2 LLMWHISPERER_LOG_LEVEL=DEBUG LLMWHISPERER_API_KEY= From 82f88e2576cd99cad1c3036367cd437590e44e6d Mon Sep 17 00:00:00 2001 From: Arun Venkataswamy Date: Fri, 11 Oct 2024 12:23:40 +0530 Subject: [PATCH 02/15] Added sync mode --- src/unstract/llmwhisperer/client_temp.py | 13 ++++- src/unstract/llmwhisperer/client_v2.py | 60 ++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/unstract/llmwhisperer/client_temp.py b/src/unstract/llmwhisperer/client_temp.py index b07b01a..7b310a9 100644 --- a/src/unstract/llmwhisperer/client_temp.py +++ b/src/unstract/llmwhisperer/client_temp.py @@ -35,13 +35,22 @@ # result = client.get_webhook_details(webhook_name="wb3") # print(result) + # result = client.whisper( + # mode="high_quality", + # output_mode="layout_preserving", + # file_path="../../../tests/test_data/credit_card.pdf", + # use_webhook="wb3", + # webhook_metadata="Dummy metadata for webhook", + # ) + result = client.whisper( mode="high_quality", output_mode="layout_preserving", file_path="../../../tests/test_data/credit_card.pdf", - use_webhook="wb3", - webhook_metadata="Dummy metadata for webhook", + # wait_for_completion=True, + # wait_timeout=200, ) + print(json.dumps(result)) # result = client.whisper( # mode="form", diff --git a/src/unstract/llmwhisperer/client_v2.py b/src/unstract/llmwhisperer/client_v2.py index 05bf565..a225afc 100644 --- a/src/unstract/llmwhisperer/client_v2.py +++ b/src/unstract/llmwhisperer/client_v2.py @@ -23,6 +23,7 @@ import os from typing import IO import copy +import time import requests @@ -178,6 +179,8 @@ def whisper( filename="", webhook_metadata="", use_webhook="", + wait_for_completion=False, + wait_timeout=180, ) -> dict: """ Sends a request to the LLMWhisperer API to process a document. @@ -203,6 +206,8 @@ def whisper( filename (str, optional): The name of the file to store in reports. Defaults to "". webhook_metadata (str, optional): The webhook metadata. This data will be passed to the webhook if webhooks are used Defaults to "". use_webhook (str, optional): Webhook name to call. Defaults to "". If not provided, the no webhook will be called. + wait_for_completion (bool, optional): Whether to wait for the whisper operation to complete. Defaults to False. + wait_timeout (int, optional): The number of seconds to wait for the whisper operation to complete. Defaults to 180. Returns: dict: The response from the API as a dictionary. @@ -236,6 +241,14 @@ def whisper( self.logger.debug("api_url: %s", api_url) self.logger.debug("params: %s", params) + if use_webhook != "" and wait_for_completion: + raise LLMWhispererClientException( + { + "status_code": -1, + "message": "Cannot wait for completion when using webhook", + } + ) + if url == "" and file_path == "" and stream is None: raise LLMWhispererClientException( { @@ -280,10 +293,57 @@ def generate(): if response.status_code != 200 and response.status_code != 202: message = json.loads(response.text) message["status_code"] = response.status_code + message["extraction"] = {} raise LLMWhispererClientException(message) if response.status_code == 202: message = json.loads(response.text) message["status_code"] = response.status_code + message["extraction"] = {} + if not wait_for_completion: + return message + whisper_hash = message["whisper_hash"] + start_time = time.time() + while time.time() - start_time < wait_timeout: + status = self.whisper_status(whisper_hash=whisper_hash) + if status["status"] == "processing": + self.logger.debug( + f"Whisper-hash:{whisper_hash} | STATUS: processing..." + ) + elif status["status"] == "delivered": + self.logger.debug( + f"Whisper-hash:{whisper_hash} | STATUS: Already delivered!" + ) + raise LLMWhispererClientException( + { + "status_code": -1, + "message": "Whisper operation already delivered", + } + ) + elif status["status"] == "unknown": + self.logger.debug( + f"Whisper-hash:{whisper_hash} | STATUS: unknown..." + ) + raise LLMWhispererClientException( + { + "status_code": -1, + "message": "Whisper operation status unknown", + } + ) + elif status["status"] == "processed": + self.logger.debug( + f"Whisper-hash:{whisper_hash} | STATUS: processed!" + ) + resultx = self.whisper_retrieve(whisper_hash=whisper_hash) + if resultx["status_code"] == 200: + message["status_code"] = 200 + message["message"] = "Whisper operation completed" + message["status"] = "processed" + message["extraction"] = resultx["extraction"] + return message + time.sleep(5) + message["status_code"] = -1 + message["message"] = "Whisper client operation timed out" + message["extraction"] = {} return message # Will not reach here if status code is 202 From 3a86aece3bf18758bd7f8ecf93369b578521b1a6 Mon Sep 17 00:00:00 2001 From: Arun Venkataswamy Date: Fri, 11 Oct 2024 13:44:41 +0530 Subject: [PATCH 03/15] Removed local testing stubs --- src/unstract/llmwhisperer/client_v2.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/unstract/llmwhisperer/client_v2.py b/src/unstract/llmwhisperer/client_v2.py index a225afc..a231afe 100644 --- a/src/unstract/llmwhisperer/client_v2.py +++ b/src/unstract/llmwhisperer/client_v2.py @@ -122,15 +122,6 @@ def __init__( self.headers = {"unstract-key": self.api_key} - ### TODO: REMOVE THIS!!!!!!!!!! - ### This is for testing in local machine - self.headers = { - "unstract-key": self.api_key, - "Subscription-Id": "test", - "Start-Date": "9-07-2024", - } - ### ------------------------------------ - def get_usage_info(self) -> dict: """Retrieves the usage information of the LLMWhisperer API. From 8307195deaacb80c14ce4f2c60fa4151d4b349ed Mon Sep 17 00:00:00 2001 From: Arun Venkataswamy Date: Fri, 11 Oct 2024 16:32:02 +0530 Subject: [PATCH 04/15] Added failure case for sync --- src/unstract/llmwhisperer/client_v2.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/unstract/llmwhisperer/client_v2.py b/src/unstract/llmwhisperer/client_v2.py index a231afe..5360923 100644 --- a/src/unstract/llmwhisperer/client_v2.py +++ b/src/unstract/llmwhisperer/client_v2.py @@ -296,6 +296,11 @@ def generate(): start_time = time.time() while time.time() - start_time < wait_timeout: status = self.whisper_status(whisper_hash=whisper_hash) + if status["status_code"] != 200: + message["status_code"] = -1 + message["message"] = "Whisper client operation failed" + message["extraction"] = {} + return message if status["status"] == "processing": self.logger.debug( f"Whisper-hash:{whisper_hash} | STATUS: processing..." @@ -320,6 +325,14 @@ def generate(): "message": "Whisper operation status unknown", } ) + elif status["status"] == "failed": + self.logger.debug( + f"Whisper-hash:{whisper_hash} | STATUS: failed..." + ) + message["status_code"] = -1 + message["message"] = "Whisper operation failed" + message["extraction"] = {} + return message elif status["status"] == "processed": self.logger.debug( f"Whisper-hash:{whisper_hash} | STATUS: processed!" From b13b3ba7a1525ea4cffd62ac6706b0eb1c2ecedb Mon Sep 17 00:00:00 2001 From: Arun Venkataswamy Date: Fri, 11 Oct 2024 18:11:29 +0530 Subject: [PATCH 05/15] Fixed errors --- src/unstract/llmwhisperer/client_v2.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/unstract/llmwhisperer/client_v2.py b/src/unstract/llmwhisperer/client_v2.py index 5360923..e81e141 100644 --- a/src/unstract/llmwhisperer/client_v2.py +++ b/src/unstract/llmwhisperer/client_v2.py @@ -343,6 +343,10 @@ def generate(): message["message"] = "Whisper operation completed" message["status"] = "processed" message["extraction"] = resultx["extraction"] + else: + message["status_code"] = -1 + message["message"] = "Whisper client operation failed" + message["extraction"] = {} return message time.sleep(5) message["status_code"] = -1 From b80da265bbb85cfa498d94bd1ad45245ecae7ec2 Mon Sep 17 00:00:00 2001 From: Arun Venkataswamy Date: Thu, 17 Oct 2024 19:28:40 +0530 Subject: [PATCH 06/15] Added helper function for highlight data --- src/unstract/llmwhisperer/client_v2.py | 32 ++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/unstract/llmwhisperer/client_v2.py b/src/unstract/llmwhisperer/client_v2.py index e81e141..a3e0257 100644 --- a/src/unstract/llmwhisperer/client_v2.py +++ b/src/unstract/llmwhisperer/client_v2.py @@ -503,3 +503,35 @@ def get_webhook_details(self, webhook_name: str) -> dict: err["status_code"] = response.status_code raise LLMWhispererClientException(err) return json.loads(response.text) + + def get_highlight_rect( + self, + line_metadata: list[int], + target_width: int, + target_height: int, + ) -> tuple[int, int, int, int, int]: + """ + Given the line metadata and the line number, this function returns the bounding box of the line + in the format (page,x1,y1,x2,y2) + + Args: + line_metadata (list[int]): The line metadata returned by the LLMWhisperer API. + line_no (int): The line number for which the bounding box is required. + target_width (int): The width of your target image/page in UI. + target_height (int): The height of your target image/page in UI. + + Returns: + tuple: The bounding box of the line in the format (page,x1,y1,x2,y2) + """ + + page = line_metadata[0] + x1 = 0 + y1 = line_metadata[1] - line_metadata[2] + x2 = target_width + y2 = line_metadata[1] + original_height = line_metadata[3] + + y1 = int((float(y1) / float(original_height)) * float(target_height)) + y2 = int((float(y2) / float(original_height)) * float(target_height)) + + return (page, x1, y1, x2, y2) From a6af309caf5a6bff381f342139426c22b153de10 Mon Sep 17 00:00:00 2001 From: Arun Venkataswamy Date: Thu, 17 Oct 2024 20:02:36 +0530 Subject: [PATCH 07/15] Added helper function for highlight data --- src/unstract/llmwhisperer/client_v2.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/unstract/llmwhisperer/client_v2.py b/src/unstract/llmwhisperer/client_v2.py index a3e0257..77a3be4 100644 --- a/src/unstract/llmwhisperer/client_v2.py +++ b/src/unstract/llmwhisperer/client_v2.py @@ -516,7 +516,6 @@ def get_highlight_rect( Args: line_metadata (list[int]): The line metadata returned by the LLMWhisperer API. - line_no (int): The line number for which the bounding box is required. target_width (int): The width of your target image/page in UI. target_height (int): The height of your target image/page in UI. From 124c5629178922587620dadad2598e97c5bf81e9 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M <117059509+chandrasekharan-zipstack@users.noreply.github.com> Date: Mon, 28 Oct 2024 14:12:09 +0530 Subject: [PATCH 08/15] feat: v2 client tests (#12) * V2 client test for whisper API * Fixed test outputs, refactored tests into integration folder * Added minor unit tests for callback related functions * Fixed typo in filename * Minor pre-commit config fix --- .pre-commit-config.yaml | 5 +- src/unstract/llmwhisperer/client_v2.py | 35 +- tests/client_test_v2.py | 125 -- tests/conftest.py | 8 +- tests/integration/__init__.py | 0 tests/{ => integration}/client_test.py | 16 +- tests/integration/client_v2_test.py | 71 + ...credit_card.low_cost.layout_preserving.txt | 355 ++++ .../expected/credit_card.low_cost.text.txt | 1577 +++++++++++++++++ ...dit_card.native_text.layout_preserving.txt | 329 ++++ .../expected/credit_card.native_text.text.txt | 318 ++++ ...andwritten-form.form.layout_preserving.txt | 25 + .../expected/handwritten-form.form.text.txt | 27 + ...e_photo.high_quality.layout_preserving.txt | 43 + ...aurant_invoice_photo.high_quality.text.txt | 39 + tests/unit/client_v2_test.py | 34 + 16 files changed, 2840 insertions(+), 167 deletions(-) delete mode 100644 tests/client_test_v2.py create mode 100644 tests/integration/__init__.py rename tests/{ => integration}/client_test.py (88%) create mode 100644 tests/integration/client_v2_test.py create mode 100644 tests/test_data/expected/credit_card.low_cost.layout_preserving.txt create mode 100644 tests/test_data/expected/credit_card.low_cost.text.txt create mode 100644 tests/test_data/expected/credit_card.native_text.layout_preserving.txt create mode 100644 tests/test_data/expected/credit_card.native_text.text.txt create mode 100644 tests/test_data/expected/handwritten-form.form.layout_preserving.txt create mode 100644 tests/test_data/expected/handwritten-form.form.text.txt create mode 100644 tests/test_data/expected/restaurant_invoice_photo.high_quality.layout_preserving.txt create mode 100644 tests/test_data/expected/restaurant_invoice_photo.high_quality.text.txt create mode 100644 tests/unit/client_v2_test.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 26bf2f5..f298e61 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,6 +17,7 @@ repos: exclude_types: - "markdown" - id: end-of-file-fixer + exclude: "tests/test_data/.*" - id: check-yaml args: [--unsafe] - id: check-added-large-files @@ -65,9 +66,7 @@ repos: args: [--max-line-length=120] exclude: | (?x)^( - .*migrations/.*\.py| - unstract-core/tests/.*| - pkgs/unstract-flags/src/unstract/flags/evaluation_.*\.py| + tests/test_data/.*| )$ - repo: https://github.com/pycqa/isort rev: 5.13.2 diff --git a/src/unstract/llmwhisperer/client_v2.py b/src/unstract/llmwhisperer/client_v2.py index 77a3be4..101e74e 100644 --- a/src/unstract/llmwhisperer/client_v2.py +++ b/src/unstract/llmwhisperer/client_v2.py @@ -18,16 +18,15 @@ LLMWhispererClientException: Exception raised for errors in the LLMWhispererClient. """ +import copy import json import logging import os -from typing import IO -import copy import time +from typing import IO import requests - BASE_URL = "https://llmwhisperer-api.unstract.com/api/v2" @@ -63,9 +62,7 @@ class LLMWhispererClientV2: client's activities and errors. """ - formatter = logging.Formatter( - "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - ) + formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") logger = logging.getLogger(__name__) log_stream_handler = logging.StreamHandler() log_stream_handler.setFormatter(formatter) @@ -251,12 +248,10 @@ def whisper( should_stream = False if url == "": if stream is not None: - should_stream = True def generate(): - for chunk in stream: - yield chunk + yield from stream req = requests.Request( "POST", @@ -302,13 +297,9 @@ def generate(): message["extraction"] = {} return message if status["status"] == "processing": - self.logger.debug( - f"Whisper-hash:{whisper_hash} | STATUS: processing..." - ) + self.logger.debug(f"Whisper-hash:{whisper_hash} | STATUS: processing...") elif status["status"] == "delivered": - self.logger.debug( - f"Whisper-hash:{whisper_hash} | STATUS: Already delivered!" - ) + self.logger.debug(f"Whisper-hash:{whisper_hash} | STATUS: Already delivered!") raise LLMWhispererClientException( { "status_code": -1, @@ -316,9 +307,7 @@ def generate(): } ) elif status["status"] == "unknown": - self.logger.debug( - f"Whisper-hash:{whisper_hash} | STATUS: unknown..." - ) + self.logger.debug(f"Whisper-hash:{whisper_hash} | STATUS: unknown...") raise LLMWhispererClientException( { "status_code": -1, @@ -326,17 +315,13 @@ def generate(): } ) elif status["status"] == "failed": - self.logger.debug( - f"Whisper-hash:{whisper_hash} | STATUS: failed..." - ) + self.logger.debug(f"Whisper-hash:{whisper_hash} | STATUS: failed...") message["status_code"] = -1 message["message"] = "Whisper operation failed" message["extraction"] = {} return message elif status["status"] == "processed": - self.logger.debug( - f"Whisper-hash:{whisper_hash} | STATUS: processed!" - ) + self.logger.debug(f"Whisper-hash:{whisper_hash} | STATUS: processed!") resultx = self.whisper_retrieve(whisper_hash=whisper_hash) if resultx["status_code"] == 200: message["status_code"] = 200 @@ -451,7 +436,6 @@ def register_webhook(self, url: str, auth_token: str, webhook_name: str) -> dict Raises: LLMWhispererClientException: If the API request fails, it raises an exception with the error message and status code returned by the API. - """ data = { @@ -489,7 +473,6 @@ def get_webhook_details(self, webhook_name: str) -> dict: Raises: LLMWhispererClientException: If the API request fails, it raises an exception with the error message and status code returned by the API. - """ url = f"{self.base_url}/whisper-manage-callback" diff --git a/tests/client_test_v2.py b/tests/client_test_v2.py deleted file mode 100644 index 3c36d5e..0000000 --- a/tests/client_test_v2.py +++ /dev/null @@ -1,125 +0,0 @@ -import logging -import os -import unittest -from pathlib import Path - -import pytest -import requests - -from unstract.llmwhisperer import LLMWhispererClient - -logger = logging.getLogger(__name__) - - -def test_get_usage_info(client): - usage_info = client.get_usage_info() - logger.info(usage_info) - assert isinstance(usage_info, dict), "usage_info should be a dictionary" - expected_keys = [ - "current_page_count", - "daily_quota", - "monthly_quota", - "overage_page_count", - "subscription_plan", - "today_page_count", - ] - assert set(usage_info.keys()) == set( - expected_keys - ), f"usage_info {usage_info} does not contain the expected keys" - - -@pytest.mark.parametrize( - "processing_mode, output_mode, input_file", - [ - ("ocr", "line-printer", "restaurant_invoice_photo.pdf"), - ("ocr", "line-printer", "credit_card.pdf"), - ("ocr", "line-printer", "handwritten-form.pdf"), - ("ocr", "text", "restaurant_invoice_photo.pdf"), - ("text", "line-printer", "restaurant_invoice_photo.pdf"), - ("text", "text", "handwritten-form.pdf"), - ], -) -def test_whisper(client, data_dir, processing_mode, output_mode, input_file): - file_path = os.path.join(data_dir, input_file) - response = client.whisper( - processing_mode=processing_mode, - output_mode=output_mode, - file_path=file_path, - timeout=200, - ) - logger.debug(response) - - exp_basename = f"{Path(input_file).stem}.{processing_mode}.{output_mode}.txt" - exp_file = os.path.join(data_dir, "expected", exp_basename) - with open(exp_file, encoding="utf-8") as f: - exp = f.read() - - assert isinstance(response, dict) - assert response["status_code"] == 200 - assert response["extracted_text"] == exp - - -# TODO: Review and port to pytest based tests -class TestLLMWhispererClient(unittest.TestCase): - @unittest.skip("Skipping test_whisper") - def test_whisper(self): - client = LLMWhispererClient() - # response = client.whisper( - # url="https://storage.googleapis.com/pandora-static/samples/bill.jpg.pdf" - # ) - response = client.whisper( - file_path="test_data/restaurant_invoice_photo.pdf", - timeout=200, - store_metadata_for_highlighting=True, - ) - print(response) - # self.assertIsInstance(response, dict) - - # @unittest.skip("Skipping test_whisper") - def test_whisper_stream(self): - client = LLMWhispererClient() - download_url = ( - "https://storage.googleapis.com/pandora-static/samples/bill.jpg.pdf" - ) - # Create a stream of download_url and pass it to whisper - response_download = requests.get(download_url, stream=True) - response_download.raise_for_status() - response = client.whisper( - stream=response_download.iter_content(chunk_size=1024), - timeout=200, - store_metadata_for_highlighting=True, - ) - print(response) - # self.assertIsInstance(response, dict) - - @unittest.skip("Skipping test_whisper_status") - def test_whisper_status(self): - client = LLMWhispererClient() - response = client.whisper_status( - whisper_hash="7cfa5cbb|5f1d285a7cf18d203de7af1a1abb0a3a" - ) - logger.info(response) - self.assertIsInstance(response, dict) - - @unittest.skip("Skipping test_whisper_retrieve") - def test_whisper_retrieve(self): - client = LLMWhispererClient() - response = client.whisper_retrieve( - whisper_hash="7cfa5cbb|5f1d285a7cf18d203de7af1a1abb0a3a" - ) - logger.info(response) - self.assertIsInstance(response, dict) - - @unittest.skip("Skipping test_whisper_highlight_data") - def test_whisper_highlight_data(self): - client = LLMWhispererClient() - response = client.highlight_data( - whisper_hash="9924d865|5f1d285a7cf18d203de7af1a1abb0a3a", - search_text="Indiranagar", - ) - logger.info(response) - self.assertIsInstance(response, dict) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/conftest.py b/tests/conftest.py index e5e6b03..49eab9a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,15 +3,21 @@ import pytest from unstract.llmwhisperer.client import LLMWhispererClient +from unstract.llmwhisperer.client_v2 import LLMWhispererClientV2 @pytest.fixture(name="client") def llm_whisperer_client(): - # Create an instance of the client client = LLMWhispererClient() return client +@pytest.fixture(name="client_v2") +def llm_whisperer_client_v2(): + client = LLMWhispererClientV2() + return client + + @pytest.fixture(name="data_dir", scope="session") def test_data_dir(): return os.path.join(os.path.dirname(__file__), "test_data") diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/client_test.py b/tests/integration/client_test.py similarity index 88% rename from tests/client_test.py rename to tests/integration/client_test.py index 3c36d5e..764fd72 100644 --- a/tests/client_test.py +++ b/tests/integration/client_test.py @@ -23,9 +23,7 @@ def test_get_usage_info(client): "subscription_plan", "today_page_count", ] - assert set(usage_info.keys()) == set( - expected_keys - ), f"usage_info {usage_info} does not contain the expected keys" + assert set(usage_info.keys()) == set(expected_keys), f"usage_info {usage_info} does not contain the expected keys" @pytest.mark.parametrize( @@ -78,9 +76,7 @@ def test_whisper(self): # @unittest.skip("Skipping test_whisper") def test_whisper_stream(self): client = LLMWhispererClient() - download_url = ( - "https://storage.googleapis.com/pandora-static/samples/bill.jpg.pdf" - ) + download_url = "https://storage.googleapis.com/pandora-static/samples/bill.jpg.pdf" # Create a stream of download_url and pass it to whisper response_download = requests.get(download_url, stream=True) response_download.raise_for_status() @@ -95,18 +91,14 @@ def test_whisper_stream(self): @unittest.skip("Skipping test_whisper_status") def test_whisper_status(self): client = LLMWhispererClient() - response = client.whisper_status( - whisper_hash="7cfa5cbb|5f1d285a7cf18d203de7af1a1abb0a3a" - ) + response = client.whisper_status(whisper_hash="7cfa5cbb|5f1d285a7cf18d203de7af1a1abb0a3a") logger.info(response) self.assertIsInstance(response, dict) @unittest.skip("Skipping test_whisper_retrieve") def test_whisper_retrieve(self): client = LLMWhispererClient() - response = client.whisper_retrieve( - whisper_hash="7cfa5cbb|5f1d285a7cf18d203de7af1a1abb0a3a" - ) + response = client.whisper_retrieve(whisper_hash="7cfa5cbb|5f1d285a7cf18d203de7af1a1abb0a3a") logger.info(response) self.assertIsInstance(response, dict) diff --git a/tests/integration/client_v2_test.py b/tests/integration/client_v2_test.py new file mode 100644 index 0000000..a5ef4b6 --- /dev/null +++ b/tests/integration/client_v2_test.py @@ -0,0 +1,71 @@ +import logging +import os +from difflib import SequenceMatcher, unified_diff +from pathlib import Path + +import pytest + +logger = logging.getLogger(__name__) + + +def test_get_usage_info(client_v2): + usage_info = client_v2.get_usage_info() + logger.info(usage_info) + assert isinstance(usage_info, dict), "usage_info should be a dictionary" + expected_keys = [ + "current_page_count", + "current_page_count_low_cost", + "current_page_count_form", + "current_page_count_high_quality", + "current_page_count_native_text", + "daily_quota", + "monthly_quota", + "overage_page_count", + "subscription_plan", + "today_page_count", + ] + assert set(usage_info.keys()) == set(expected_keys), f"usage_info {usage_info} does not contain the expected keys" + + +@pytest.mark.parametrize( + "output_mode, mode, input_file", + [ + ("layout_preserving", "native_text", "credit_card.pdf"), + ("layout_preserving", "low_cost", "credit_card.pdf"), + ("layout_preserving", "high_quality", "restaurant_invoice_photo.pdf"), + ("layout_preserving", "form", "handwritten-form.pdf"), + ("text", "native_text", "credit_card.pdf"), + ("text", "low_cost", "credit_card.pdf"), + ("text", "high_quality", "restaurant_invoice_photo.pdf"), + ("text", "form", "handwritten-form.pdf"), + ], +) +def test_whisper_v2(client_v2, data_dir, output_mode, mode, input_file): + file_path = os.path.join(data_dir, input_file) + whisper_result = client_v2.whisper( + mode=mode, output_mode=output_mode, file_path=file_path, wait_for_completion=True + ) + logger.debug(f"Result for '{output_mode}', '{mode}', " f"'{input_file}: {whisper_result}") + + exp_basename = f"{Path(input_file).stem}.{mode}.{output_mode}.txt" + exp_file = os.path.join(data_dir, "expected", exp_basename) + with open(exp_file, encoding="utf-8") as f: + exp = f.read() + + assert isinstance(whisper_result, dict) + assert whisper_result["status_code"] == 200 + + # For text based processing, perform a strict match + if mode == "native_text" and output_mode == "text": + assert whisper_result["extraction"]["result_text"] == exp + # For OCR based processing, perform a fuzzy match + else: + extracted_text = whisper_result["extraction"]["result_text"] + similarity = SequenceMatcher(None, extracted_text, exp).ratio() + threshold = 0.97 + + if similarity < threshold: + diff = "\n".join( + unified_diff(exp.splitlines(), extracted_text.splitlines(), fromfile="Expected", tofile="Extracted") + ) + pytest.fail(f"Texts are not similar enough: {similarity * 100:.2f}% similarity. Diff:\n{diff}") diff --git a/tests/test_data/expected/credit_card.low_cost.layout_preserving.txt b/tests/test_data/expected/credit_card.low_cost.layout_preserving.txt new file mode 100644 index 0000000..974d682 --- /dev/null +++ b/tests/test_data/expected/credit_card.low_cost.layout_preserving.txt @@ -0,0 +1,355 @@ + + +AMERICAN Blue Cash® from American Express p. 1/7 + EXPRESS + JOSEPH PAULSON Customer Care: 1-888-258-3741 + Closing Date 09/27/23 TTY: Use Relay 711 + Website: americanexpress.com + Account Ending 7-73045 ~ ~ + + Reward Dollars + New Balance $10,269.65 as of 08/29/2023 + + Minimum Payment Due $205.39 1,087.93 + For more details about Rewards, visit + americanexpress.com/cashbackrewards + + Payment Due Date 10/22/23 Account Summary + + Late Payment Warning: If we do not receive your Minimum Payment Due by Previous Balance $6,583.67 + + the Payment Due Date of 10/22/23, you may have to pay a late fee of up to Payments/Credits -$6,583.67 + $40.00 and your APRs may be increased to the Penalty APR of 29.99%. New Charges +$10,269.65 + Fees +$0.00 + + Interest Charged +$0.00 + + Minimum Payment Warning: If you have a Non-Plan Balance and make only the New Balance $10,269.65 + + minimum payment each period, you will pay more in interest and it will take you longer Minimum Payment Due $205.39 + to pay off your Non-Plan Balance. For example: + Credit Limit $26,400.00 + If you make no additional You will pay off the balance And you will pay an Available Credit $16,130.35 + charges and each month shown on this statement in estimated total of... + you pay... about... Cash Available Advance Cash Limit $4,600.00 $4,600.00 + + Only the 22 years $29,830 + Minimum Payment Due + + $14,640 + $407 3 years (Savings = $15,190) + + If you would like information about credit counseling services, call 1-888-733-4139. + + See page 2 for important information about your account. + [+] + + > Please refer to the IMPORTANT NOTICES section on + page 7. + + Continued on page 3 + + \ Please fold on the perforation below, detach and return with your payment V + + ps Payment Coupon Pay by Computer Pay by Phone Account Ending 7-73045 + I Do not staple or use paper clips americanexpress.com/pbc C 1-800-472-9297 + Enter 15 digit account # on all payments. + Make check payable to American Express. + + JOSEPH PAULSON Payment Due Date + 3742 CLOUD SPGS RD 10/22/23 + #403-1045 + DALLAS TX 75219-4136 New Balance + $10,269.65 + + Minimum Payment Due + 205.39 + + See reverse side for instructions AMERICAN EXPRESS e + + on how to update your address, PO BOX 6031 Amount Enclosed + phone number, or email. CAROL STREAM IL 60197-6031 + + Wall dbollllllllatloodladll + + 00003499916e2708152 0010269650000280539 a4 d +<<< + + JOSEPH PAULSON Account Ending 7-73045 p. 2/7 + + Payments: Your payment must be sent to the payment address shown on represents money owed to you. If within the six-month period following +your statement and must be received by 5 p.m. local time at that address to the date of the first statement indicating the credit balance you do not + be credited as of the day it is received. Payments we receive after 5 p.m. will request a refund or charge enough to use up the credit balance, we will + not be credited to your Account until the next day. Payments must also: (1) send you a check for the credit balance within 30 days if the amount is + include the remittance coupon from your statement; (2) be made with a $1.00 or more. + single check drawn on a US bank and payable in US dollars, or with a Credit Reporting: We may report information about your Account to credit + negotiable instrument payable in US dollars and clearable through the US bureaus. Late payments, missed payments, or other defaults on your + banking system; and (3) include your Account number. If your payment Account may be reflected in your credit report. + does not meet all of the above requirements, crediting may be delayed and What To Do If You Think You Find A Mistake On Your Statement +you may incur late payment fees and additional interest charges. Electronic If you think there is an error on your statement, write to us at: + payments must be made through an electronic payment method payable American Express, PO Box 981535, El Paso TX 79998-1535 + in US dollars and clearable through the US banking system. Please do not You may also contact us on the Web: www.americanexpress.com + send post-dated checks as they will be deposited upon receipt. Any In your letter, give us the following information: + restrictive language on a payment we accept will have no effect on us - Account information: Your name and account number. +without our express prior written approval. We will re-present to your - Dollar amount: The dollar amount of the suspected error. +financial institution any payment that is returned unpaid. - Description of Problem: If you think there is an error on your bill, + Permission for Electronic Withdrawal: (1) When you send a check for describe what you believe is wrong and why you believe it is a mistake. + payment, you give us permission to electronically withdraw your payment You must contact us within 60 days after the error appeared on your +from your deposit or other asset account. We will process checks statement. + electronically by transmitting the amount of the check, routing number, You must notify us of any potential errors in writing [or electronically]. You + account number and check serial number to your financial institution, may call us, but if you do we are not required to investigate any potential + unless the check is not processable electronically or a less costly process is errors and you may have to pay the amount in question. + available. When we process your check electronically, your payment may While we investigate whether or not there has been an error, the following + be withdrawn from your deposit or other asset account as soon as the same are true: + day we receive your check, and you will not receive that cancelled check - We cannot try to collect the amount in question, or report you as +with your deposit or other asset account statement. If we cannot collect the delinquent on that amount. +funds electronically we may issue a draft against your deposit or other asset - The charge in question may remain on your statement, and we may + account for the amount of the check. (2) By using Pay By Computer, Pay By continue to charge you interest on that amount. But, if we determine that + Phone or any other electronic payment service of ours, you give us we made a mistake, you will not have to pay the amount in question or any + permission to electronically withdraw funds from the deposit or other asset interest or other fees related to that amount. + account you specify in the amount you request. Payments using such - While you do not have to pay the amount in question, you are responsible + services of ours received after 8:00 p.m. MST may not be credited until the for the remainder of your balance. + next day. - We can apply any unpaid amount against your credit limit. + How We Calculate Your Balance: We use the Average Daily Balance (ADB) Your Rights If You Are Dissatisfied With Your Credit Card Purchases + method (including new transactions) to calculate the balance on which we If you are dissatisfied with the goods or services that you have purchased + charge interest on your Account. Call the Customer Care number on page 3 with your credit card, and you have tried in good faith to correct the +for more information about this balance computation method and how problem with the merchant, you may have the right not to pay the + resulting interest charges are determined. The method we use to figure the remaining amount due on the purchase. +ADB and interest results in daily compounding of interest. To use this right, all of the following must be true: + Paying Interest: Your due date is at least 25 days after the close of each 1. The purchase must have been made in your home state or within 100 + billing period. We will not charge you interest on your purchases if you pay miles of your current mailing address, and the purchase price must have + each month your entire balance (or Adjusted Balance if applicable) by the been more than $50. (Note: Neither of these is necessary if your purchase + due date each month. We will charge you interest on cash advances and was based on an advertisement we mailed to you, or if we own the + (unless otherwise disclosed) balance transfers beginning on the transaction company that sold you the goods or services.) + date. 2. You must have used your credit card for the purchase. Purchases made + Foreign Currency Charges: If you make a Charge in a foreign currency, we with cash advances from an ATM or with a check that accesses your credit +will convert it into US dollars on the date we or our agents process it. We card account do not qualify. +will charge a fee of 2.70% of the converted US dollar amount. We will 3. You must not yet have fully paid for the purchase. + choose a conversion rate that is acceptable to us for that date, unless a If all of the criteria above are met and you are still dissatisfied with the + particular rate is required by law. The conversion rate we use is no more purchase, contact us in writing or electronically at: +than the highest official rate published by a government agency or the American Express, PO Box 981535, El Paso TX 79998-1535 + highest interbank rate we identify from customary banking sources on the www.americanexpress.com + conversion date or the prior business day. This rate may differ from rates in While we investigate, the same rules apply to the disputed amount as + effect on the date of your charge. Charges converted by establishments discussed above. After we finish our investigation, we will tell you our + (such as airlines) will be billed at the rates such establishments use. decision. At that point, if we think you owe an amount and you do not pay + Credit Balance: A credit balance (designated CR) shown on this statement we may report you as delinquent. + + Pay Your Bill with AutoPay + + Deduct your payment from your bank + account automatically each month. + + - Avoid late fees + + - Save time + + Change of Address, phone number, email + + Visit americanexpress.com/autopay + - Online at www.americanexpress.com/updatecontactinfo today to enroll. + - Via mobile device + + - Voice automated: call the number on the back of your card + - For name, company name, and foreign address or phone changes, please call Customer Care + + Please do not add any written communication or address change on this stub + For information on how we protect your + privacy and to set your communication + and privacy choices, please visit + www.americanexpress.com/privacy. +<<< + +AMERICAN Blue Cash® from American Express p. 3/7 + EXPRESS + JOSEPH PAULSON + + Closing Date 09/27/23 Account Ending 7-73045 + + Customer Care & Billing Inquiries 1-888-258-3741 + C International Collect 1-336-393-1111 =] Website: americanexpress.com + Cash Advance at ATMs Inquiries 1-800-CASH-NOW + Large Print & Braille Statements 1-888-258-3741 Customer Care Payments + & Billing Inquiries PO BOX 6031 + P.O. BOX 981535 CAROL STREAM IL + EL PASO, TX 60197-6031 + 79998-1535 + Hearing Impaired + Online chat at americanexpress.com or use Relay dial 711 and 1-888-258-3741 + + American Express® High Yield Savings Account + No monthly fees. No minimum opening monthly deposit. 24/7 customer + + support. FDIC insured. Meet your savings goals faster with an American + + Express High Yield Savings Account. Terms apply. Learn more by visiting + + americanexpress.com/savenow. + + Total + + Payments -$6,583.67 + + Credits $0.00 + + Total Payments and Credits -$6,583.67 + + Payments Amount + + 09/22/23* MOBILE PAYMENT - THANK YOU -$6,583.67 + + Total + + Total New Charges $10,269.65 + + JOSEPH PAULSON + an Card Ending 7-73045 + + Amount + + 08/30/23 SAFEWAY CUPERTINO CA $23.11 + 800-898-4027 + + 09/01/23 BANANA LEAF 650000012619980 MILPITAS CA $144.16 + 4087199811 + + 09/01/23 BT*LINODE*AKAMAI CAMBRIDGE MA $6,107.06 + 6093807100 + + 09/01/23 GOOGLE*GSUITE_SOCIALANIMAL.IO MOUNTAIN VIEW CA $20.44 + ADVERTISING SERVICE + + 09/02/23 Amazon Web Services AWS.Amazon.com WA $333.88 + WEB SERVICES + + 09/03/23 SAFEWAY CUPERTINO CA $11.18 + 800-898-4027 + + 09/09/23 TST* BIKANER SWEET 00053687 SUNNYVALE CA $21.81 + RESTAURANT + + Continued on reverse +<<< + + JOSEPH PAULSON Account Ending 7-73045 p.4/7 + + Amount + +09/10/23 CVS PHARMACY CUPERTINO CA $2.34 + 8007467287 + +09/13/23 APPLE.COM/BILL INTERNET CHARGE CA $2.99 + RECORD STORE + +09/13/23 SAFEWAY CUPERTINO CA $26.73 + 800-898-4027 + +09/14/23 MCDONALD'S CUPERTINO CA $3.26 + 6509404200 + +09/14/23 PANERA BREAD #204476 CAMPBELL CA $23.38 + + 975313007 95008 + +09/14/23 MANLEY DONUTS 00-08040662747 CUPERTINO CA $21.15 + BAKERY + +09/15/23 Ap|Pay 6631309 - PEETS B TMP 53033 OKALAND CA $4.27 + RESTAURANT + +09/16/23 VEGAS.COM LAS VEGAS NV $761.58 + 18669983427 + +09/16/23 Ap|Pay PANDA EXPRESS LAS VEGAS NV $12.08 + FAST FOOD RESTAURANT + +09/17/23 Ap|IPay LUX_STARBUCKS_ATRIUM LAS VEGAS NV $23.68 + 11980066 89109 + RESTAURANT + +09/18/23 SPK*SPOKEO ENTPRS 888-858-0803 CA $119.95 + + 888-858-0803 + +09/24/23 SIXT USA POS FORT LAUDERDALE FL $2,537.90 + AUTOMOBILE RENTAL + Sixt9497938611 + 30826E5JF4ZIIBIHSB + +09/24/23 LUCKY #773.SANTA CLARACA 0000000009925 SANTA CLARA CA $35.17 + 4082475200 + +09/24/23 MILAN SWEET CENTER 0000 MILPITAS CA $27.03 + 408-946-2525 + +09/25/23 ApIPay MANLEY DONUTS 00-08040662747 CUPERTINO CA $6.50 + + BAKERY + + Amount + +Total Fees for this Period $0.00 + + Amount + +Total Interest Charged for this Period $0.00 + +About Trailing Interest +You may see interest on your next statement even if you pay the new balance in full and on time and make no new charges. This is called +"trailing interest". Trailing interest is the interest charged when, for example, you didn't pay your previous balance in full. When that +happens, we charge interest from the first day of the billing period until we receive your payment in full. You can avoid paying interest +on purchases by paying your balance in full (or if you have a Plan balance, by paying your Adjusted Balance on your billing statement) by +the due date each month. Please see the "When we charge interest" sub-section in your Cardmember Agreement for details. + + Continued on next page +<<< + +AMERICAN Blue Cash® from American Express p.5/7 + EXPRESS + JOSEPH PAULSON + + Closing Date 09/27/23 Account Ending 7-73045 + + Amount + + Total Fees in 2023 $0.00 + + Total Interest in 2023 $0.00 + + Your Annual Percentage Rate (APR) is the annual interest rate on your account. + Variable APRs will not exceed 29.99%. + Transactions Dated Annual Balance Interest + Percentage Subject to Charge + From To Rate Interest Rate + + Purchases 02/26/2011 24.49% (v) $0.00 $0.00 + + Cash Advances 02/26/2011 29.99% (v) $0.00 $0.00 + + Total $0.00 + + (v) Variable Rate +<<< + +JOSEPH PAULSON Account Ending 7-73045 p. 6/7 +<<< + +AMERICAN 7/7 + EXPRESS JOSEPH PAULSON Closing Date 09/27/23 Account Ending 7-73045 + + EFT Error Resolution Notice + In Case of Errors or Questions About Your Electronic Transfers Telephone us at 1-800-IPAY-AXP for Pay By + Phone questions, at 1-800-528-2122 for Pay By Computer questions, and at 1-800-528-4800 for AutoPay. You + may also write us at American Express, Electronic Funds Services, P.O. Box 981531, El Paso TX 79998-1531, or + contact online at www.americanexpress.com/inquirycenter as soon as you can, if you think your statement or + receipt is wrong or if you need more information about a transfer on the statement or receipt. We must hear from + you no later than 60 days after we sent you the FIRST statement on which the error or problem appeared. + 1. Tell us your name and account number (if any). + 2. Describe the error or the transfer you are unsure about, and explain as clearly as you can why you + believe it is an error or why you need more information. + 3. Tell us the dollar amount of the suspected error. + We will investigate your complaint and will correct any error promptly. If we take more than 10 business days to + do this, we will credit your account for the amount you think is in error, so that you will have the use of the money + during the time it takes us to complete our investigation. + + End of Important Notices. +<<< \ No newline at end of file diff --git a/tests/test_data/expected/credit_card.low_cost.text.txt b/tests/test_data/expected/credit_card.low_cost.text.txt new file mode 100644 index 0000000..6a428a8 --- /dev/null +++ b/tests/test_data/expected/credit_card.low_cost.text.txt @@ -0,0 +1,1577 @@ +AMERICAN + + +Blue Cash® from American Express + + +p. 1/7 + + +EXPRESS + + +JOSEPH PAULSON + + +Customer Care: + + +1-888-258-3741 + + +TTY: + + +Closing Date 09/27/23 + + +Use Relay 711 + + +Account Ending 7-73045 + + +~ + + +~ + + +Website: + + +americanexpress.com + + +Reward Dollars + + +New Balance + + +$10,269.65 + + +as of 08/29/2023 + + +Minimum Payment Due + + +$205.39 + + +1,087.93 + + +For more details about Rewards, visit + + +americanexpress.com/cashbackrewards + + +Payment Due Date + + +10/22/23 + + +Account Summary + + +Previous Balance + + +$6,583.67 + + +Late Payment Warning: If we do not receive your Minimum Payment Due by + + +Payments/Credits + + +-$6,583.67 + + +the Payment Due Date of 10/22/23, you may have to pay a late fee of up to + + +$40.00 and your APRs may be increased to the Penalty APR of 29.99%. + + +New Charges + + ++$10,269.65 + + +Fees + + ++$0.00 + + +Interest Charged + + ++$0.00 + + +New Balance + + +Minimum Payment Warning: If you have a Non-Plan Balance and make only the + + +$10,269.65 + + +minimum payment each period, you will pay more in interest and it will take you longer + + +Minimum Payment Due + + +$205.39 + + +to pay off your Non-Plan Balance. For example: + + +Credit Limit + + +$26,400.00 + + +If you make no additional + + +You will pay off the balance + + +And you will pay an + + +Available Credit + + +$16,130.35 + + +charges and each month + + +shown on this statement in + + +estimated total of... + + +you pay... + + +about... + + +Cash Advance Limit + + +$4,600.00 + + +Available Cash + + +$4,600.00 + + +Only the + + +22 years + + +$29,830 + + +Minimum Payment Due + + +$14,640 + + +$407 + + +3 years + + +(Savings = $15,190) + + +If you would like information about credit counseling services, call 1-888-733-4139. + + +[+] See page 2 for important information about your account. + + +> + + +Please refer to the IMPORTANT NOTICES section on + + +page 7. + + +Continued on page 3 + + +\ Please fold on the perforation below, detach and return with your payment V + + +ps Payment Coupon + + +Pay by Computer + + +Pay by Phone + + +Account Ending 7-73045 + + +I Do not staple or use paper clips + + +americanexpress.com/pbc + + +C + + +1-800-472-9297 + + +Enter 15 digit account # on all payments. + + +Make check payable to American Express. + + +JOSEPH PAULSON + + +Payment Due Date + + +3742 CLOUD SPGS RD + + +10/22/23 + + +#403-1045 + + +DALLAS TX 75219-4136 + + +New Balance + + +$10,269.65 + + +Minimum Payment Due + + +205.39 + + +AMERICAN EXPRESS + + +See reverse side for instructions + + +e + + +on how to update your address, + + +PO BOX 6031 + + +Amount Enclosed + + +phone number, or email. + + +CAROL STREAM IL 60197-6031 + + +Wall dbollllllllatloodladll + + +00003499916e2708152 0010269650000280539 a4 d + + +<<< + + + + + + + +JOSEPH PAULSON + + +Account Ending 7-73045 + + +p. 2/7 + + +Payments: Your payment must be sent to the payment address shown on + + +represents money owed to you. If within the six-month period following + + +your statement and must be received by 5 p.m. local time at that address to + + +the date of the first statement indicating the credit balance you do not + + +be credited as of the day it is received. Payments we receive after 5 p.m. will + + +request a refund or charge enough to use up the credit balance, we will + + +not be credited to your Account until the next day. Payments must also: (1) + + +send you a check for the credit balance within 30 days if the amount is + + +include the remittance coupon from your statement; (2) be made with a + + +$1.00 or more. + + +single check drawn on a US bank and payable in US dollars, or with a + + +Credit Reporting: We may report information about your Account to credit + + +negotiable instrument payable in US dollars and clearable through the US + + +bureaus. Late payments, missed payments, or other defaults on your + + +banking system; and (3) include your Account number. If your payment + + +Account may be reflected in your credit report. + + +does not meet all of the above requirements, crediting may be delayed and + + +What To Do If You Think You Find A Mistake On Your Statement + + +you may incur late payment fees and additional interest charges. Electronic + + +If you think there is an error on your statement, write to us at: + + +payments must be made through an electronic payment method payable + + +American Express, PO Box 981535, El Paso TX 79998-1535 + + +in US dollars and clearable through the US banking system. Please do not + + +You may also contact us on the Web: www.americanexpress.com + + +send post-dated checks as they will be deposited upon receipt. Any + + +In your letter, give us the following information: + + +restrictive language on a payment we accept will have no effect on us + + +- Account information: Your name and account number. + + +without our express prior written approval. We will re-present to your + + +- Dollar amount: The dollar amount of the suspected error. + + +financial institution any payment that is returned unpaid. + + +- Description of Problem: If you think there is an error on your bill, + + +Permission for Electronic Withdrawal: (1) When you send a check for + + +describe what you believe is wrong and why you believe it is a mistake. + + +payment, you give us permission to electronically withdraw your payment + + +You must contact us within 60 days after the error appeared on your + + +from your deposit or other asset account. We will process checks + + +statement. + + +electronically by transmitting the amount of the check, routing number, + + +You must notify us of any potential errors in writing [or electronically]. You + + +account number and check serial number to your financial institution, + + +may call us, but if you do we are not required to investigate any potential + + +unless the check is not processable electronically or a less costly process is + + +errors and you may have to pay the amount in question. + + +available. When we process your check electronically, your payment may + + +While we investigate whether or not there has been an error, the following + + +be withdrawn from your deposit or other asset account as soon as the same + + +are true: + + +day we receive your check, and you will not receive that cancelled check + + +- We cannot try to collect the amount in question, or report you as + + +with your deposit or other asset account statement. If we cannot collect the + + +delinquent on that amount. + + +funds electronically we may issue a draft against your deposit or other asset + + +- The charge in question may remain on your statement, and we may + + +account for the amount of the check. (2) By using Pay By Computer, Pay By + + +continue to charge you interest on that amount. But, if we determine that + + +Phone or any other electronic payment service of ours, you give us + + +we made a mistake, you will not have to pay the amount in question or any + + +permission to electronically withdraw funds from the deposit or other asset + + +interest or other fees related to that amount. + + +account you specify in the amount you request. Payments using such + + +- While you do not have to pay the amount in question, you are responsible + + +services of ours received after 8:00 p.m. MST may not be credited until the + + +for the remainder of your balance. + + +next day. + + +- We can apply any unpaid amount against your credit limit. + + +How We Calculate Your Balance: We use the Average Daily Balance (ADB) + + +Your Rights If You Are Dissatisfied With Your Credit Card Purchases + + +method (including new transactions) to calculate the balance on which we + + +If you are dissatisfied with the goods or services that you have purchased + + +charge interest on your Account. Call the Customer Care number on page 3 + + +with your credit card, and you have tried in good faith to correct the + + +for more information about this balance computation method and how + + +problem with the merchant, you may have the right not to pay the + + +resulting interest charges are determined. The method we use to figure the + + +remaining amount due on the purchase. + + +ADB and interest results in daily compounding of interest. + + +To use this right, all of the following must be true: + + +Paying Interest: Your due date is at least 25 days after the close of each + + +1. The purchase must have been made in your home state or within 100 + + +billing period. We will not charge you interest on your purchases if you pay + + +miles of your current mailing address, and the purchase price must have + + +each month your entire balance (or Adjusted Balance if applicable) by the + + +been more than $50. (Note: Neither of these is necessary if your purchase + + +due date each month. We will charge you interest on cash advances and + + +was based on an advertisement we mailed to you, or if we own the + + +(unless otherwise disclosed) balance transfers beginning on the transaction + + +company that sold you the goods or services.) + + +date. + + +2. You must have used your credit card for the purchase. Purchases made + + +Foreign Currency Charges: If you make a Charge in a foreign currency, we + + +with cash advances from an ATM or with a check that accesses your credit + + +will convert it into US dollars on the date we or our agents process it. We + + +card account do not qualify. + + +will charge a fee of 2.70% of the converted US dollar amount. We will + + +3. You must not yet have fully paid for the purchase. + + +choose a conversion rate that is acceptable to us for that date, unless a + + +If all of the criteria above are met and you are still dissatisfied with the + + +particular rate is required by law. The conversion rate we use is no more + + +purchase, contact us in writing or electronically at: + + +than the highest official rate published by a government agency or the + + +American Express, PO Box 981535, El Paso TX 79998-1535 + + +highest interbank rate we identify from customary banking sources on the + + +www.americanexpress.com + + +conversion date or the prior business day. This rate may differ from rates in + + +While we investigate, the same rules apply to the disputed amount as + + +effect on the date of your charge. Charges converted by establishments + + +discussed above. After we finish our investigation, we will tell you our + + +(such as airlines) will be billed at the rates such establishments use. + + +decision. At that point, if we think you owe an amount and you do not pay + + +Credit Balance: A credit balance (designated CR) shown on this statement + + +we may report you as delinquent. + + +Pay Your Bill with AutoPay + + +Deduct your payment from your bank + + +account automatically each month. + + +- Avoid late fees + + +- Save time + + +Change of Address, phone number, email + + +Visit americanexpress.com/autopay + + +- Online at www.americanexpress.com/updatecontactinfo + + +today to enroll. + + +- Via mobile device + + +- Voice automated: call the number on the back of your card + + +- For name, company name, and foreign address or phone changes, please call Customer Care + + +Please do not add any written communication or address change on this stub + + +For information on how we protect your + + +privacy and to set your communication + + +and privacy choices, please visit + + +www.americanexpress.com/privacy. + + +<<< + + + + + + + +AMERICAN + + +Blue Cash® from American Express + + +p. 3/7 + + +EXPRESS + + +JOSEPH PAULSON + + +Closing Date 09/27/23 + + +Account Ending 7-73045 + + +1-888-258-3741 + + +Customer Care & Billing Inquiries + + +C + + +International Collect + + +1-336-393-1111 + + +=] Website: americanexpress.com + + +Cash Advance at ATMs Inquiries + + +1-800-CASH-NOW + + +Large Print & Braille Statements + + +1-888-258-3741 + + +Customer Care + + +Payments + + +& Billing Inquiries + + +PO BOX 6031 + + +P.O. BOX 981535 + + +CAROL STREAM IL + + +EL PASO, TX + + +60197-6031 + + +79998-1535 + + +Hearing Impaired + + +Online chat at americanexpress.com or use Relay dial 711 and 1-888-258-3741 + + +American Express® High Yield Savings Account + + +No monthly fees. No minimum opening monthly deposit. 24/7 customer + + +support. FDIC insured. Meet your savings goals faster with an American + + +Express High Yield Savings Account. Terms apply. Learn more by visiting + + +americanexpress.com/savenow. + + +Total + + +Payments + + +-$6,583.67 + + +Credits + + +$0.00 + + +Total Payments and Credits + + +-$6,583.67 + + +Payments + + +Amount + + +09/22/23* + + +MOBILE PAYMENT - THANK YOU + + +-$6,583.67 + + +Total + + +Total New Charges + + +$10,269.65 + + +JOSEPH PAULSON + + +an + + +Card Ending 7-73045 + + +Amount + + +08/30/23 + + +SAFEWAY + + +CUPERTINO + + +CA + + +$23.11 + + +800-898-4027 + + +09/01/23 + + +BANANA LEAF 650000012619980 + + +MILPITAS + + +CA + + +$144.16 + + +4087199811 + + +09/01/23 + + +BT*LINODE*AKAMAI + + +CAMBRIDGE + + +MA + + +$6,107.06 + + +6093807100 + + +09/01/23 + + +GOOGLE*GSUITE_SOCIALANIMAL.IO + + +MOUNTAIN VIEW + + +CA + + +$20.44 + + +ADVERTISING SERVICE + + +09/02/23 + + +Amazon Web Services + + +AWS.Amazon.com + + +WA + + +$333.88 + + +WEB SERVICES + + +09/03/23 + + +SAFEWAY + + +CUPERTINO + + +CA + + +$11.18 + + +800-898-4027 + + +09/09/23 + + +TST* BIKANER SWEET 00053687 + + +SUNNYVALE + + +CA + + +$21.81 + + +RESTAURANT + + +Continued on reverse + + +<<< + + + + + + + +JOSEPH PAULSON + + +Account Ending 7-73045 + + +p.4/7 + + +Amount + + +09/10/23 + + +CVS PHARMACY + + +CUPERTINO + + +CA + + +$2.34 + + +8007467287 + + +09/13/23 + + +APPLE.COM/BILL + + +INTERNET CHARGE + + +CA + + +$2.99 + + +RECORD STORE + + +09/13/23 + + +SAFEWAY + + +CUPERTINO + + +CA + + +$26.73 + + +800-898-4027 + + +09/14/23 + + +MCDONALD'S + + +CUPERTINO + + +CA + + +$3.26 + + +6509404200 + + +PANERA BREAD #204476 + + +CAMPBELL + + +CA + + +09/14/23 + + +$23.38 + + +975313007 95008 + + +09/14/23 + + +MANLEY DONUTS 00-08040662747 + + +CUPERTINO + + +CA + + +$21.15 + + +BAKERY + + +09/15/23 + + +Ap|Pay 6631309 - PEETS B TMP 53033 + + +OKALAND + + +CA + + +$4.27 + + +RESTAURANT + + +09/16/23 + + +VEGAS.COM + + +LAS VEGAS + + +NV + + +$761.58 + + +18669983427 + + +09/16/23 + + +Ap|Pay PANDA EXPRESS + + +LAS VEGAS + + +NV + + +$12.08 + + +FAST FOOD RESTAURANT + + +09/17/23 + + +Ap|IPay LUX_STARBUCKS_ATRIUM + + +LAS VEGAS + + +NV + + +$23.68 + + +11980066 89109 + + +RESTAURANT + + +SPK*SPOKEO ENTPRS + + +888-858-0803 + + +CA + + +09/18/23 + + +$119.95 + + +888-858-0803 + + +09/24/23 + + +SIXT USA POS + + +FORT LAUDERDALE + + +FL + + +$2,537.90 + + +AUTOMOBILE RENTAL + + +Sixt9497938611 + + +30826E5JF4ZIIBIHSB + + +09/24/23 + + +LUCKY #773.SANTA CLARACA 0000000009925 + + +SANTA CLARA + + +CA + + +$35.17 + + +4082475200 + + +09/24/23 + + +MILAN SWEET CENTER 0000 + + +MILPITAS + + +CA + + +$27.03 + + +408-946-2525 + + +CUPERTINO + + +CA + + +09/25/23 + + +ApIPay MANLEY DONUTS 00-08040662747 + + +$6.50 + + +BAKERY + + +Amount + + +Total Fees for this Period + + +$0.00 + + +Amount + + +Total Interest Charged for this Period + + +$0.00 + + +About Trailing Interest + + +You may see interest on your next statement even if you pay the new balance in full and on time and make no new charges. This is called + + +"trailing interest". Trailing interest is the interest charged when, for example, you didn't pay your previous balance in full. When that + + +happens, we charge interest from the first day of the billing period until we receive your payment in full. You can avoid paying interest + + +on purchases by paying your balance in full (or if you have a Plan balance, by paying your Adjusted Balance on your billing statement) by + + +the due date each month. Please see the "When we charge interest" sub-section in your Cardmember Agreement for details. + + +Continued on next page + + +<<< + + + + + + + +AMERICAN + + +Blue Cash® from American Express + + +p.5/7 + + +EXPRESS + + +JOSEPH PAULSON + + +Closing Date 09/27/23 + + +Account Ending 7-73045 + + +Amount + + +Total Fees in 2023 + + +$0.00 + + +Total Interest in 2023 + + +$0.00 + + +Your Annual Percentage Rate (APR) is the annual interest rate on your account. + + +Variable APRs will not exceed 29.99%. + + +Transactions Dated + + +Annual + + +Balance + + +Interest + + +Percentage + + +Subject to + + +Charge + + +From + + +To + + +Rate + + +Interest Rate + + +Purchases + + +02/26/2011 + + +24.49% (v) + + +$0.00 + + +$0.00 + + +Cash Advances + + +02/26/2011 + + +29.99% (v) + + +$0.00 + + +$0.00 + + +Total + + +$0.00 + + +(v) Variable Rate + + +<<< + + + + + + + +JOSEPH PAULSON + + +Account Ending 7-73045 + + +p. 6/7 + + +<<< + + + + + + + +AMERICAN + + +7/7 + + +EXPRESS + + +JOSEPH PAULSON + + +Closing Date 09/27/23 + + +Account Ending 7-73045 + + +EFT Error Resolution Notice + + +In Case of Errors or Questions About Your Electronic Transfers Telephone us at 1-800-IPAY-AXP for Pay By + + +Phone questions, at 1-800-528-2122 for Pay By Computer questions, and at 1-800-528-4800 for AutoPay. You + + +may also write us at American Express, Electronic Funds Services, P.O. Box 981531, El Paso TX 79998-1531, or + + +contact online at www.americanexpress.com/inquirycenter as soon as you can, if you think your statement or + + +receipt is wrong or if you need more information about a transfer on the statement or receipt. We must hear from + + +you no later than 60 days after we sent you the FIRST statement on which the error or problem appeared. + + +1. Tell us your name and account number (if any). + + +2. Describe the error or the transfer you are unsure about, and explain as clearly as you can why you + + +believe it is an error or why you need more information. + + +3. Tell us the dollar amount of the suspected error. + + +We will investigate your complaint and will correct any error promptly. If we take more than 10 business days to + + +do this, we will credit your account for the amount you think is in error, so that you will have the use of the money + + +during the time it takes us to complete our investigation. + + +End of Important Notices. + + +<<< + + + + + + + diff --git a/tests/test_data/expected/credit_card.native_text.layout_preserving.txt b/tests/test_data/expected/credit_card.native_text.layout_preserving.txt new file mode 100644 index 0000000..0a15a48 --- /dev/null +++ b/tests/test_data/expected/credit_card.native_text.layout_preserving.txt @@ -0,0 +1,329 @@ + + + Blue Cash® from American Express p. 1/7 + + JOSEPH PAULSON Customer Care: 1-888-258-3741 + Closing Date 09/27/23 TTY: Use Relay 711 + Account Ending 7-73045 Website: americanexpress.com + + Reward Dollars + New Balance $10,269.65 as of 08/29/2023 + + Minimum Payment Due $205.39 1,087.93 + For more details about Rewards, visit + americanexpress.com/cashbackrewards + + Payment Due Date 10/22/23 Account Summary + + Late Payment Warning: If we do not receive your Minimum Payment Due by Previous Balance $6,583.67 + the Payment Due Date of 10/22/23, you may have to pay a late fee of up to Payments/Credits -$6,583.67 + $40.00 and your APRs may be increased to the Penalty APR of 29.99%. New Charges +$10,269.65 + Fees +$0.00 + Interest Charged +$0.00 + +Minimum Payment Warning: If you have a Non-Plan Balance and make only the New Balance $10,269.65 +minimum payment each period, you will pay more in interest and it will take you longer Minimum Payment Due $205.39 +to pay off your Non-Plan Balance. For example: + Credit Limit $26,400.00 +If you make no additional You will pay off the balance And you will pay an Available Credit $16,130.35 +charges and each month shown on this statement in estimated total of... +you pay... about... Cash Available Advance Cash Limit $4,600.00 $4,600.00 + + Only the + Minimum Payment Due 22 years $29,830 + + $14,640 + $407 3 years (Savings = $15,190) + +If you would like information about credit counseling services, call 1-888-733-4139. + + See page 2 for important information about your account. + + Please refer to the IMPORTANT NOTICES section on + page 7. + + Continued on page 3 + + Please fold on the perforation below, detach and return with your payment + + Payment Coupon Pay by Computer Pay by Phone A c c o u n t E n d i n g 7 - 7 3 0 4 5 + Do not staple or use paper clips americanexpress.com/pbc 1-800-472-9297 + Enter 15 digit account # on all payments. + Make check payable to American Express. + + J O S E P H PA U L S O N Payment Due Date + 3742 CLOUD SPGS RD 10/22/23 + #403-1045 New Balance + DALLAS TX 75219-4136 $10,269.65 + + Minimum Payment Due + $205.39 + + See reverse side for instructions AMERICAN EXPRESS $ + on how to update your address, PO BOX 6031 Amount Enclosed . + phone number, or email. CAROL STREAM IL 60197-6031 +<<< + + J O S E P H P A U L S O N Account Ending 7-73045 p. 2/7 + +Payments: Your payment must be sent to the payment address shown on represents money owed to you. If within the six-month period following +your statement and must be received by 5 p.m. local time at that address to the date of the first statement indicating the credit balance you do not +be credited as of the day it is received. Payments we receive after 5 p.m. will request a refund or charge enough to use up the credit balance, we will +not be credited to your Account until the next day. Payments must also: (1) send you a check for the credit balance within 30 days if the amount is +include the remittance coupon from your statement; (2) be made with a $1.00 or more. +single check drawn on a US bank and payable in US dollars, or with a Credit Reporting: We may report information about your Account to credit +negotiable instrument payable in US dollars and clearable through the US bureaus. Late payments, missed payments, or other defaults on your +banking system; and (3) include your Account number. If your payment Account may be reflected in your credit report. +does not meet all of the above requirements, crediting may be delayed and What To Do If You Think You Find A Mistake On Your Statement +you may incur late payment fees and additional interest charges. Electronic If you think there is an error on your statement, write to us at: +payments must be made through an electronic payment method payable American Express, PO Box 981535, El Paso TX 79998-1535 +in US dollars and clearable through the US banking system. Please do not You may also contact us on the Web: www.americanexpress.com +send post-dated checks as they will be deposited upon receipt. Any In your letter, give us the following information: +restrictive language on a payment we accept will have no effect on us - Account information: Your name and account number. +without our express prior written approval. We will re-present to your - Dollar amount: The dollar amount of the suspected error. +financial institution any payment that is returned unpaid. - Description of Problem: If you think there is an error on your bill, +Permission for Electronic Withdrawal: (1) When you send a check for describe what you believe is wrong and why you believe it is a mistake. +payment, you give us permission to electronically withdraw your payment You must contact us within 60 days after the error appeared on your +from your deposit or other asset account. We will process checks statement. +electronically by transmitting the amount of the check, routing number, You must notify us of any potential errors in writing [or electronically]. You +account number and check serial number to your financial institution, may call us, but if you do we are not required to investigate any potential +unless the check is not processable electronically or a less costly process is errors and you may have to pay the amount in question. +available. When we process your check electronically, your payment may While we investigate whether or not there has been an error, the following +be withdrawn from your deposit or other asset account as soon as the same are true: +day we receive your check, and you will not receive that cancelled check - We cannot try to collect the amount in question, or report you as +with your deposit or other asset account statement. If we cannot collect the delinquent on that amount. +funds electronically we may issue a draft against your deposit or other asset - The charge in question may remain on your statement, and we may +account for the amount of the check. (2) By using Pay By Computer, Pay By continue to charge you interest on that amount. But, if we determine that +Phone or any other electronic payment service of ours, you give us we made a mistake, you will not have to pay the amount in question or any +permission to electronically withdraw funds from the deposit or other asset interest or other fees related to that amount. +account you specify in the amount you request. Payments using such - While you do not have to pay the amount in question, you are responsible +services of ours received after 8:00 p.m. MST may not be credited until the for the remainder of your balance. +next day. - We can apply any unpaid amount against your credit limit. +How We Calculate Your Balance: We use the Average Daily Balance (ADB) Your Rights If You Are Dissatisfied With Your Credit Card Purchases +method (including new transactions) to calculate the balance on which we If you are dissatisfied with the goods or services that you have purchased +charge interest on your Account. Call the Customer Care number on page 3 with your credit card, and you have tried in good faith to correct the +for more information about this balance computation method and how problem with the merchant, you may have the right not to pay the +resulting interest charges are determined. The method we use to figure the remaining amount due on the purchase. +ADB and interest results in daily compounding of interest. To use this right, all of the following must be true: +Paying Interest: Your due date is at least 25 days after the close of each 1. The purchase must have been made in your home state or within 100 +billing period. We will not charge you interest on your purchases if you pay miles of your current mailing address, and the purchase price must have +each month your entire balance (or Adjusted Balance if applicable) by the been more than $50. (Note: Neither of these is necessary if your purchase +due date each month. We will charge you interest on cash advances and was based on an advertisement we mailed to you, or if we own the +(unless otherwise disclosed) balance transfers beginning on the transaction company that sold you the goods or services.) +date. 2. You must have used your credit card for the purchase. Purchases made +Foreign Currency Charges: If you make a Charge in a foreign currency, we with cash advances from an ATM or with a check that accesses your credit +will convert it into US dollars on the date we or our agents process it. We card account do not qualify. +will charge a fee of 2.70% of the converted US dollar amount. We will 3. You must not yet have fully paid for the purchase. +choose a conversion rate that is acceptable to us for that date, unless a If all of the criteria above are met and you are still dissatisfied with the +particular rate is required by law. The conversion rate we use is no more purchase, contact us in writing or electronically at: +than the highest official rate published by a government agency or the American Express, PO Box 981535, El Paso TX 79998-1535 +highest interbank rate we identify from customary banking sources on the www.americanexpress.com +conversion date or the prior business day. This rate may differ from rates in While we investigate, the same rules apply to the disputed amount as +effect on the date of your charge. Charges converted by establishments discussed above. After we finish our investigation, we will tell you our +(such as airlines) will be billed at the rates such establishments use. decision. At that point, if we think you owe an amount and you do not pay +Credit Balance: A credit balance (designated CR) shown on this statement we may report you as delinquent. + + Pay Your Bill with AutoPay + Deduct your payment from your bank + account automatically each month. + + - Avoid late fees + - Save time +Change of Address, phone number, email + Visit americanexpress.com/autopay + - Online at www.americanexpress.com/updatecontactinfo today to enroll. + - Via mobile device + - Voice automated: call the number on the back of your card + - For name, company name, and foreign address or phone changes, please call Customer Care + +Please do not add any written communication or address change on this stub For information on how we protect your + privacy and to set your communication + and privacy choices, please visit + www.americanexpress.com/privacy. +<<< + + Blue Cash® from American Express p. 3/7 + + JOSEPH PAULSON + Closing Date 09/27/23 Account Ending 7-73045 + + Customer Care & Billing Inquiries 1-888-258-3741 + International Collect 1-336-393-1111 Website: americanexpress.com + Cash Advance at ATMs Inquiries 1-800-CASH-NOW + Large Print & Braille Statements 1-888-258-3741 Customer Care Payments + & Billing Inquiries PO BOX 6031 + P.O. BOX 981535 CAROL STREAM IL + EL PASO, TX 60197-6031 + 79998-1535 + Hearing Impaired + Online chat at americanexpress.com or use Relay dial 711 and 1-888-258-3741 + + American Express® High Yield Savings Account + No monthly fees. No minimum opening monthly deposit. 24/7 customer + support. FDIC insured. Meet your savings goals faster with an American + Express High Yield Savings Account. Terms apply. Learn more by visiting + americanexpress.com/savenow . + + Payments and Credits + + Summary + + Total + +Payments -$6,583.67 +Credits $0.00 +Total Payments and Credits -$6,583.67 + + Detail *Indicates posting date + +Payments Amount + +09/22/23* MOBILE PAYMENT - THANK YOU -$6,583.67 + + New Charges + + Summary + + Total +Total New Charges $10,269.65 + + Detail + + J O S E P H P A U L S O N + C a r d E n d i n g 7 - 7 3 0 4 5 + + Amount + +08/30/23 SAFEWAY CUPERTINO CA $23.11 + 800-898-4027 +09/01/23 BANANA LEAF 650000012619980 MILPITAS CA $144.16 + 4087199811 +09/01/23 BT*LINODE*AKAMAI CAMBRIDGE MA $6,107.06 + 6093807100 +09/01/23 GOOGLE*GSUITE_SOCIALANIMAL.IO MOUNTAIN VIEW CA $20.44 + ADVERTISING SERVICE +09/02/23 Amazon Web Services AWS.Amazon.com WA $333.88 + WEB SERVICES +09/03/23 SAFEWAY CUPERTINO CA $11.18 + 800-898-4027 +09/09/23 TST* BIKANER SWEET 00053687 SUNNYVALE CA $21.81 + RESTAURANT + + Continued on reverse +<<< + + JOSEPH PAULSON A c c o u n t E n d i n g 7 - 7 3 0 4 5 p. 4/7 + + Detail Continued + + Amount + +09/10/23 CVS PHARMACY CUPERTINO CA $2.34 + 8007467287 +09/13/23 APPLE.COM/BILL INTERNET CHARGE CA $2.99 + RECORD STORE +09/13/23 SAFEWAY CUPERTINO CA $26.73 + 800-898-4027 +09/14/23 MCDONALD'S CUPERTINO CA $3.26 + 6509404200 +09/14/23 PANERA BREAD #204476 CAMPBELL CA $23.38 + 975313007 95008 +09/14/23 MANLEY DONUTS 00-08040662747 CUPERTINO CA $21.15 + BAKERY +09/15/23 AplPay 6631309 - PEETS B TMP 53033 OKALAND CA $4.27 + RESTAURANT +09/16/23 VEGAS.COM LAS VEGAS NV $761.58 + 18669983427 +09/16/23 AplPay PANDA EXPRESS LAS VEGAS NV $12.08 + FAST FOOD RESTAURANT +09/17/23 AplPay LUX_STARBUCKS_ATRIUM LAS VEGAS NV $23.68 + 11980066 89109 + RESTAURANT +09/18/23 SPK*SPOKEO ENTPRS 888-858-0803 CA $119.95 + 888-858-0803 +09/24/23 SIXT USA POS FORT LAUDERDALE FL $2,537.90 + AUTOMOBILE RENTAL + Sixt9497938611 + 30826E5JF4ZIIBIHSB +09/24/23 LUCKY #773.SANTA CLARACA 0000000009925 SANTA CLARA CA $35.17 + 4082475200 +09/24/23 MILAN SWEET CENTER 0000 MILPITAS CA $27.03 + 408-946-2525 +09/25/23 AplPay MANLEY DONUTS 00-08040662747 CUPERTINO CA $6.50 + BAKERY + + Fees + + Amount + +Total Fees for this Period $0.00 + + Interest Charged + + Amount + +Total Interest Charged for this Period $0.00 + +About Trailing Interest +You may see interest on your next statement even if you pay the new balance in full and on time and make no new charges. This is called +"trailing interest". Trailing interest is the interest charged when, for example, you didn't pay your previous balance in full. When that +happens, we charge interest from the first day of the billing period until we receive your payment in full. You can avoid paying interest +on purchases by paying your balance in full (or if you have a Plan balance, by paying your Adjusted Balance on your billing statement) by +the due date each month. Please see the "When we charge interest" sub-section in your Cardmember Agreement for details. + + Continued on next page +<<< + + Blue Cash® from American Express p. 5/7 + + JOSEPH PAULSON + Closing Date 09/27/23 A c c o u n t E n d i n g 7 - 7 3 0 4 5 + + 2023 Fees and Interest Totals Year-to-Date + + Amount + Total Fees in 2023 $0.00 + + Total Interest in 2023 $0.00 + + Interest Charge Calculation Days in Billing Period: 30 + + Your Annual Percentage Rate (APR) is the annual interest rate on your account. + Variable APRs will not exceed 29.99%. + Transactions Dated Annual Balance Interest + Percentage Subject to Charge + From To Rate Interest Rate + +Purchases 02/26/2011 24.49% (v) $0.00 $0.00 + +Cash Advances 02/26/2011 29.99% (v) $0.00 $0.00 + +Total $0.00 +(v) Variable Rate +<<< + +JOSEPH PAULSON A c c o u n t E n d i n g 7 - 7 3 0 4 5 p. 6/7 +<<< + + p. 7/7 + JOSEPH PAULSON Closing Date 09/27/23 A c c o u n t E n d i n g 7 - 7 3 0 4 5 + + IMPORTANT NOTICES + +EFT Error Resolution Notice +In Case of Errors or Questions About Your Electronic Transfers Telephone us at 1-800-IPAY-AXP for Pay By +Phone questions, at 1-800-528-2122 for Pay By Computer questions, and at 1-800-528-4800 for AutoPay. You +may also write us at American Express, Electronic Funds Services, P.O. Box 981531, El Paso TX 79998-1531, or +contact online at www.americanexpress.com/inquirycenter as soon as you can, if you think your statement or +receipt is wrong or if you need more information about a transfer on the statement or receipt. We must hear from +you no later than 60 days after we sent you the FIRST statement on which the error or problem appeared. + 1. Tell us your name and account number (if any). + 2. Describe the error or the transfer you are unsure about, and explain as clearly as you can why you + believe it is an error or why you need more information. + 3. Tell us the dollar amount of the suspected error. +We will investigate your complaint and will correct any error promptly. If we take more than 10 business days to +do this, we will credit your account for the amount you think is in error, so that you will have the use of the money +during the time it takes us to complete our investigation. + + End of Important Notices. +<<< \ No newline at end of file diff --git a/tests/test_data/expected/credit_card.native_text.text.txt b/tests/test_data/expected/credit_card.native_text.text.txt new file mode 100644 index 0000000..9259fe5 --- /dev/null +++ b/tests/test_data/expected/credit_card.native_text.text.txt @@ -0,0 +1,318 @@ +Blue Cash® from American Express p.1/7 +JOSEPH PAULSON CustomerCare: 1-888-258-3741 +Closing Date 09/27/23 TTY: UseRelay711 +Account Ending 7-73045 Website: americanexpress.com +RewardDollars +New Balance $10,269.65 +asof 08/29/2023 +Minimum Payment Due $205.39 1,087.93 +FormoredetailsaboutRewards,visit +americanexpress.com/cashbackrewards +Payment Due Date 10/22/23 +AccountSummary +LatePaymentWarning:IfwedonotreceiveyourMinimumPaymentDueby PreviousBalance $6,583.67 +the Payment Due Date of 10/22/23, you may have to pay a late fee of up to Payments/Credits -$6,583.67 +$40.00andyourAPRsmaybeincreasedtothePenaltyAPRof29.99%. NewCharges +$10,269.65 +Fees +$0.00 +InterestCharged +$0.00 +MinimumPaymentWarning:IfyouhaveaNon-PlanBalanceandmakeonlythe NewBalance $10,269.65 +minimumpaymenteachperiod,youwillpaymoreininterestanditwilltakeyoulonger MinimumPaymentDue $205.39 +topayoffyourNon-PlanBalance.Forexample: +CreditLimit $26,400.00 +Ifyoumakenoadditional Youwillpayoffthebalance Andyouwillpayan AvailableCredit $16,130.35 +chargesandeachmonth shownonthisstatementin estimatedtotalof... +CashAdvanceLimit $4,600.00 +youpay... about... +AvailableCash $4,600.00 +Onlythe +22years $29,830 +MinimumPaymentDue +$14,640 +$407 3years (Savings=$15,190) +Ifyouwouldlikeinformationaboutcreditcounselingservices,call1-888-733-4139. +Seepage2forimportantinformationaboutyouraccount. +PleaserefertotheIMPORTANTNOTICESsectionon +page7. +Continuedonpage3 +Pleasefoldontheperforationbelow,detachandreturnwithyourpayment +PaymentCoupon PaybyComputer PaybyPhone A c c o u n t E n d in g 7 -7 3 0 4 5 +Donotstapleorusepaperclips americanexpress.com/pbc 1-800-472-9297 +Enter15digitaccount#onallpayments. +MakecheckpayabletoAmericanExpress. +J O S E P H PAU L S O N PaymentDueDate +3742 CLOUD SPGS RD 10/22/23 +#403-1045 +DALLAS TX 75219-4136 NewBalance +$10,269.65 +MinimumPaymentDue +$205.39 +. +Seereversesideforinstructions AMERICANEXPRESS $ +onhowtoupdateyouraddress, POBOX6031 Amount Enclosed +phonenumber,oremail. CAROLSTREAMIL60197-6031 +<<< + + + + + + + +J O S E P H P A U L S O N +Account Ending 7-73045 p.2/7 +Payments:Yourpaymentmustbesenttothepaymentaddressshownon representsmoneyowedtoyou.Ifwithinthesix-monthperiodfollowing +yourstatementandmustbereceivedby5p.m.localtimeatthataddressto the date of the first statement indicating the credit balance you do not +becreditedasofthedayitisreceived.Paymentswereceiveafter5p.m.will requestarefundorchargeenoughtouseupthecreditbalance,wewill +notbecreditedtoyourAccountuntilthenextday.Paymentsmustalso:(1) sendyou a check for the credit balance within 30days ifthe amountis +includethe remittancecouponfromyour statement;(2)bemadewitha $1.00ormore. +single check drawn on a US bank and payable in US dollars, or with a CreditReporting: WemayreportinformationaboutyourAccounttocredit +negotiableinstrumentpayableinUSdollarsandclearablethroughtheUS bureaus. Late payments, missed payments, or other defaults on your +banking system; and (3) include your Account number. If your payment Accountmaybereflectedinyourcreditreport. +doesnotmeetalloftheaboverequirements,creditingmaybedelayedand WhatToDoIfYouThinkYouFindAMistakeOnYourStatement +youmayincurlatepaymentfeesandadditionalinterestcharges.Electronic Ifyouthinkthereisanerroronyourstatement,writetousat: +paymentsmustbemadethroughanelectronicpaymentmethodpayable AmericanExpress,POBox981535,ElPasoTX79998-1535 +inUSdollarsandclearablethroughtheUSbankingsystem.Pleasedonot YoumayalsocontactusontheWeb:www.americanexpress.com +send post-dated checks as they will be deposited upon receipt. Any Inyourletter,giveusthefollowinginformation: +restrictive language on a payment we accept will have no effect on us -Accountinformation:Yournameandaccountnumber. +without our express prior written approval. We will re-present to your -Dollaramount:Thedollaramountofthesuspectederror. +financialinstitutionanypaymentthatisreturnedunpaid. -DescriptionofProblem:Ifyouthinkthereisanerroronyourbill, +Permission for Electronic Withdrawal: (1) When you send a check for describewhatyoubelieveiswrongandwhyyoubelieveitisamistake. +payment,yougiveuspermissiontoelectronicallywithdrawyourpayment Youmustcontactuswithin60daysaftertheerrorappearedonyour +from your deposit or other asset account. We will process checks statement. +electronically by transmitting the amount of the check, routing number, Youmustnotifyusofanypotentialerrorsinwriting[orelectronically].You +account number and check serial number to your financial institution, maycallus,butifyoudowearenotrequiredtoinvestigateanypotential +unlessthecheckisnotprocessableelectronicallyoralesscostlyprocessis errorsandyoumayhavetopaytheamountinquestion. +available.Whenweprocessyourcheckelectronically,yourpaymentmay Whileweinvestigatewhetherornottherehasbeenanerror,thefollowing +bewithdrawnfromyourdepositorotherassetaccountassoonasthesame aretrue: +daywereceiveyourcheck,andyouwillnotreceivethatcancelledcheck -Wecannottrytocollecttheamountinquestion,orreportyouas +withyourdepositorotherassetaccountstatement.Ifwecannotcollectthe delinquentonthatamount. +fundselectronicallywemayissueadraftagainstyourdepositorotherasset -Thechargeinquestionmayremainonyourstatement,andwemay +accountfortheamountofthecheck.(2)ByusingPayByComputer,PayBy continuetochargeyouinterestonthatamount.But,ifwedeterminethat +Phone or any other electronic payment service of ours, you give us wemadeamistake,youwillnothavetopaytheamountinquestionorany +permissiontoelectronicallywithdrawfundsfromthedepositorotherasset interestorotherfeesrelatedtothatamount. +account you specify in the amount you request. Payments using such -Whileyoudonothavetopaytheamountinquestion,youareresponsible +servicesofoursreceivedafter8:00p.m.MSTmaynotbecrediteduntilthe fortheremainderofyourbalance. +nextday. -Wecanapplyanyunpaidamountagainstyourcreditlimit. +HowWeCalculateYourBalance: WeusetheAverageDailyBalance(ADB) YourRightsIfYouAreDissatisfiedWithYourCreditCardPurchases +method(includingnewtransactions)tocalculatethebalanceonwhichwe Ifyouaredissatisfiedwiththegoodsorservicesthatyouhavepurchased +chargeinterestonyourAccount.CalltheCustomerCarenumberonpage3 withyourcreditcard,andyouhavetriedingoodfaithtocorrectthe +for more information about this balance computation method and how problemwiththemerchant,youmayhavetherightnottopaythe +resultinginterestchargesaredetermined. Themethodweusetofigurethe remainingamountdueonthepurchase. +ADBandinterestresultsindailycompoundingofinterest. Tousethisright,allofthefollowingmustbetrue: +PayingInterest: Yourduedateisatleast25daysafterthecloseofeach 1.Thepurchasemusthavebeenmadeinyourhomestateorwithin100 +billingperiod.Wewillnotchargeyouinterestonyourpurchasesifyoupay milesofyourcurrentmailingaddress,andthepurchasepricemusthave +eachmonthyourentirebalance(orAdjustedBalanceifapplicable)bythe beenmorethan$50.(Note:Neitheroftheseisnecessaryifyourpurchase +duedateeachmonth.Wewillchargeyouinterestoncashadvancesand wasbasedonanadvertisementwemailedtoyou,orifweownthe +(unlessotherwisedisclosed)balancetransfersbeginningonthetransaction companythatsoldyouthegoodsorservices.) +date. 2.Youmusthaveusedyourcreditcardforthepurchase.Purchasesmade +ForeignCurrencyCharges: IfyoumakeaChargeinaforeigncurrency,we withcashadvancesfromanATMorwithacheckthataccessesyourcredit +willconvertitintoUSdollarsonthedateweorouragentsprocessit.We cardaccountdonotqualify. +willchargeafeeof2.70%oftheconvertedUSdollaramount. Wewill 3.Youmustnotyethavefullypaidforthepurchase. +choosea conversion rate that is acceptable tous for that date, unless a Ifallofthecriteriaabovearemetandyouarestilldissatisfiedwiththe +particularrateisrequiredbylaw.Theconversionrateweuseisnomore purchase,contactusinwritingorelectronicallyat: +than the highest official rate published by a government agency or the AmericanExpress,POBox981535,ElPasoTX79998-1535 +highestinterbankrateweidentifyfromcustomarybankingsourcesonthe www.americanexpress.com +conversiondateorthepriorbusinessday.Thisratemaydifferfromratesin Whileweinvestigate,thesamerulesapplytothedisputedamountas +effect on the date of your charge. Charges converted by establishments discussedabove.Afterwefinishourinvestigation,wewilltellyouour +(suchasairlines)willbebilledattheratessuchestablishmentsuse. decision.Atthatpoint,ifwethinkyouoweanamountandyoudonotpay +CreditBalance: Acreditbalance(designatedCR)shownonthisstatement wemayreportyouasdelinquent. +PayYourBillwithAutoPay +Deductyourpaymentfromyourbank +accountautomaticallyeachmonth. +-Avoidlatefees +-Savetime +ChangeofAddress,phonenumber,email +Visitamericanexpress.com/autopay +-Onlineatwww.americanexpress.com/updatecontactinfo +todaytoenroll. +-Viamobiledevice +-Voiceautomated:callthenumberonthebackofyourcard +-Forname,companyname,andforeignaddressorphonechanges,pleasecallCustomerCare +Pleasedonotaddanywrittencommunicationoraddresschangeonthisstub +Forinformationonhowweprotectyour +privacyandtosetyourcommunication +andprivacychoices,pleasevisit +www.americanexpress.com/privacy. +<<< + + + + + + + +Blue Cash® from American Express p.3/7 +JOSEPH PAULSON +Closing Date 09/27/23 Account Ending 7-73045 +CustomerCare&BillingInquiries 1-888-258-3741 +InternationalCollect 1-336-393-1111 Website:americanexpress.com +CashAdvanceatATMsInquiries 1-800-CASH-NOW +LargePrint&BrailleStatements 1-888-258-3741 CustomerCare Payments +&BillingInquiries POBOX6031 +P.O.BOX981535 CAROLSTREAMIL +ELPASO,TX 60197-6031 +79998-1535 +HearingImpaired +Onlinechatatamericanexpress.comoruseRelaydial711and1-888-258-3741 +AmericanExpress®HighYieldSavingsAccount +No monthly fees. No minimum opening monthly deposit. 24/7 customer +support. FDIC insured. Meet your savings goals faster with an American +Express High Yield Savings Account. Terms apply. Learn more by visiting +americanexpress.com/savenow. +Payments and Credits +Summary +Total +Payments -$6,583.67 +Credits $0.00 +TotalPaymentsandCredits -$6,583.67 +Detail *Indicatespostingdate +Payments Amount +09/22/23* MOBILEPAYMENT-THANKYOU -$6,583.67 +New Charges +Summary +Total +Total NewCharges $10,269.65 +Detail +J O S E P H P A U L S O N +C a rd E n d in g 7 -7 3 0 4 5 +Amount +08/30/23 SAFEWAY CUPERTINO CA $23.11 +800-898-4027 +09/01/23 BANANALEAF650000012619980 MILPITAS CA $144.16 +4087199811 +09/01/23 BT*LINODE*AKAMAI CAMBRIDGE MA $6,107.06 +6093807100 +09/01/23 GOOGLE*GSUITE_SOCIALANIMAL.IO MOUNTAINVIEW CA $20.44 +ADVERTISINGSERVICE +09/02/23 AmazonWebServices AWS.Amazon.com WA $333.88 +WEBSERVICES +09/03/23 SAFEWAY CUPERTINO CA $11.18 +800-898-4027 +09/09/23 TST*BIKANERSWEET00053687 SUNNYVALE CA $21.81 +RESTAURANT +Continuedonreverse +<<< + + + + + + + +JOSEPH PAULSON A c c o u n t E n d in g 7 -7 3 0 4 5 p.4/7 +DetailContinued +Amount +09/10/23 CVSPHARMACY CUPERTINO CA $2.34 +8007467287 +09/13/23 APPLE.COM/BILL INTERNETCHARGE CA $2.99 +RECORDSTORE +09/13/23 SAFEWAY CUPERTINO CA $26.73 +800-898-4027 +09/14/23 MCDONALD'S CUPERTINO CA $3.26 +6509404200 +09/14/23 PANERABREAD#204476 CAMPBELL CA $23.38 +97531300795008 +09/14/23 MANLEYDONUTS00-08040662747 CUPERTINO CA $21.15 +BAKERY +09/15/23 AplPay6631309-PEETSBTMP53033 OKALAND CA $4.27 +RESTAURANT +09/16/23 VEGAS.COM LASVEGAS NV $761.58 +18669983427 +09/16/23 AplPayPANDAEXPRESS LASVEGAS NV $12.08 +FASTFOODRESTAURANT +09/17/23 AplPayLUX_STARBUCKS_ATRIUM LASVEGAS NV $23.68 +1198006689109 +RESTAURANT +09/18/23 SPK*SPOKEOENTPRS 888-858-0803 CA $119.95 +888-858-0803 +09/24/23 SIXTUSAPOS FORTLAUDERDALE FL $2,537.90 +AUTOMOBILERENTAL +Sixt9497938611 +30826E5JF4ZIIBIHSB +09/24/23 LUCKY#773.SANTACLARACA0000000009925 SANTACLARA CA $35.17 +4082475200 +09/24/23 MILANSWEETCENTER0000 MILPITAS CA $27.03 +408-946-2525 +09/25/23 AplPayMANLEYDONUTS00-08040662747 CUPERTINO CA $6.50 +BAKERY +Fees +Amount +TotalFeesforthisPeriod $0.00 +Interest Charged +Amount +TotalInterestChargedforthisPeriod $0.00 +AboutTrailingInterest +Youmayseeinterestonyournextstatementevenifyoupaythenewbalanceinfullandontimeandmakenonewcharges.Thisiscalled +"trailinginterest". Trailinginterestis theinterestchargedwhen,forexample,youdidn'tpayyour previousbalanceinfull.Whenthat +happens,wechargeinterestfromthefirstdayofthebillingperioduntilwereceiveyourpaymentinfull.Youcanavoidpayinginterest +onpurchasesbypayingyourbalanceinfull(orifyouhaveaPlanbalance,bypayingyourAdjustedBalanceonyourbillingstatement)by +theduedateeachmonth.Pleaseseethe"Whenwechargeinterest"sub-sectioninyourCardmemberAgreementfordetails. +Continuedonnextpage +<<< + + + + + + + +Blue Cash® from American Express p.5/7 +JOSEPH PAULSON +Closing Date 09/27/23 A c c o u n t E n d in g 7 -7 3 0 4 5 +2023 Fees and Interest Totals Year-to-Date +Amount +TotalFeesin2023 $0.00 +TotalInterestin2023 $0.00 +Interest Charge Calculation DaysinBillingPeriod:30 +YourAnnualPercentageRate(APR)istheannualinterestrateonyouraccount. +VariableAPRswillnotexceed29.99%. +TransactionsDated Annual Balance Interest +Percentage Subjectto Charge +From To Rate InterestRate +Purchases 02/26/2011 24.49%(v) $0.00 $0.00 +CashAdvances 02/26/2011 29.99%(v) $0.00 $0.00 +Total $0.00 +(v)VariableRate +<<< + + + + + + + +JOSEPH PAULSON A c c o u n t E n d in g 7 -7 3 0 4 5 p.6/7 +<<< + + + + + + + +p.7/7 +JOSEPH PAULSON ClosingDate09/27/23 A c c o u n t E n d in g 7 -7 3 0 4 5 +IMPORTANT NOTICES +EFT Error ResolutionNotice +In Case of Errors or Questions About Your Electronic Transfers Telephone us at 1-800-IPAY-AXP for Pay By +Phone questions, at 1-800-528-2122 for Pay By Computer questions, and at 1-800-528-4800 for AutoPay. You +mayalsowriteusatAmericanExpress,ElectronicFunds Services,P.O.Box981531,ElPasoTX79998-1531,or +contact online at www.americanexpress.com/inquirycenter as soon as you can, if you think your statement or +receipt is wrong or if you need more information about a transfer on the statement or receipt. We must hear from +younolaterthan60daysafterwesentyoutheFIRSTstatementonwhichtheerrororproblemappeared. +1. Tellusyournameandaccountnumber(ifany). +2. Describe the error or the transfer you are unsure about, and explain as clearly as you can why you +believeitisanerrororwhyyouneedmoreinformation. +3. Tellusthedollaramountofthesuspectederror. +We will investigate your complaint and will correct any error promptly. If we take more than 10 business days to +do this, we will credit your accountfor theamount youthinkis in error, so that you will have theuse ofthe money +duringthetimeittakesustocompleteourinvestigation. +EndofImportantNotices. +<<< + + + + + + + diff --git a/tests/test_data/expected/handwritten-form.form.layout_preserving.txt b/tests/test_data/expected/handwritten-form.form.layout_preserving.txt new file mode 100644 index 0000000..0f85851 --- /dev/null +++ b/tests/test_data/expected/handwritten-form.form.layout_preserving.txt @@ -0,0 +1,25 @@ + + +Name STEPHEN YOUNG + + Address 123 MAIN ST. + + Are you: [ ] Married [X] Single + + How did you hear about us? + + [ ] Search Ad + [ ] Facebook + [ ] X (formerly Twitter) + [ ] This mailer + [X] Other (Explain) SAW THE SIGN WHEN THE LOCATION + + WAS BEING BUILT + + By signing, I agree to receive all communications from acme, inc. + + Signature + + 10/15/23 + Date +<<< \ No newline at end of file diff --git a/tests/test_data/expected/handwritten-form.form.text.txt b/tests/test_data/expected/handwritten-form.form.text.txt new file mode 100644 index 0000000..8440f06 --- /dev/null +++ b/tests/test_data/expected/handwritten-form.form.text.txt @@ -0,0 +1,27 @@ +Name +STEPHEN YOUNG +Address +123 MAIN ST. +Are you: +[ ] Married +[X] Single +How did you hear about us? +[ ] Search Ad +[ ] Facebook +[ ] X (formerly Twitter) +[ ] This mailer +[X] Other (Explain) i SAW THE SIGN WHEN THE LOCATION WAS BEING BUILT +By signing, I agree to receive all communications from acme, inc. +Signature +Date +10/15/23 + + +<<< + + + + + + + diff --git a/tests/test_data/expected/restaurant_invoice_photo.high_quality.layout_preserving.txt b/tests/test_data/expected/restaurant_invoice_photo.high_quality.layout_preserving.txt new file mode 100644 index 0000000..3a74955 --- /dev/null +++ b/tests/test_data/expected/restaurant_invoice_photo.high_quality.layout_preserving.txt @@ -0,0 +1,43 @@ + + + BURGER SEIGNEUR + + No.35, 80 feet road, + HAL 3rd Stage, + Indiranagar, Bangalore + GST: 29AAHFL9534H1ZV + + Order Number : T2- 57 + + Type : Table + + Table Number: 2 + + Bill No .: T2 -- 126653 + Date:2023-05-31 23:16:50 + Kots:63 + + Item Qty Amt + + Jack The + + Ripper 1 400.00 + Plain Fries + + Coke 300 ML 1 130.00 + + Total Qty: 2 + SubTotal: 530.00 + + GST@5% 26.50 + CGST @2.5% 13.25 + SGST @2.5% 13.25 + + Round Off: 0.50 + Total Invoice Value: 557 + + PAY: 557 + + Thank you, visit again! + +Powered by - POSIST +<<< \ No newline at end of file diff --git a/tests/test_data/expected/restaurant_invoice_photo.high_quality.text.txt b/tests/test_data/expected/restaurant_invoice_photo.high_quality.text.txt new file mode 100644 index 0000000..59d47f8 --- /dev/null +++ b/tests/test_data/expected/restaurant_invoice_photo.high_quality.text.txt @@ -0,0 +1,39 @@ +BURGER SEIGNEUR No.35, 80 feet road, HAL 3rd Stage, Indiranagar, Bangalore GST: 29AAHFL9534H1ZV +Order Number : T2- 57 Type : Table Table Number: 2 +Bill No .: T2 -- 126653 Date:2023-05-31 23:16:50 Kots:63 +Item +Qty +Amt +Jack The +Ripper +1 +400.00 +Plain Fries ++ +Coke 300 ML +1 +130.00 +Total Qty: 2 +SubTotal: 530.00 +GST@5% +26.50 +CGST @2.5% +13.25 +SGST @2.5% +13.25 +Round Off: +0.50 +Total Invoice Value: +557 +PAY: 557 Thank you, visit again! +Powered by - POSIST + + +<<< + + + + + + + diff --git a/tests/unit/client_v2_test.py b/tests/unit/client_v2_test.py new file mode 100644 index 0000000..70191b5 --- /dev/null +++ b/tests/unit/client_v2_test.py @@ -0,0 +1,34 @@ +WEBHOOK_URL = "http://test-webhook.com/callback" +AUTH_TOKEN = "dummy-auth-token" +WEBHOOK_NAME = "test_webhook" +WEBHOOK_RESPONSE = {"status": "success", "message": "Webhook registered successfully"} +WHISPER_RESPONSE = {"status_code": 200, "extraction": {"result_text": "Test result"}} + + +def test_register_webhook(mocker, client_v2): + mock_send = mocker.patch("requests.Session.send") + mock_response = mocker.MagicMock() + mock_response.status_code = 200 + mock_response.text = '{"status": "success", "message": "Webhook registered successfully"}' # noqa: E501 + mock_send.return_value = mock_response + + response = client_v2.register_webhook(WEBHOOK_URL, AUTH_TOKEN, WEBHOOK_NAME) + + assert response == WEBHOOK_RESPONSE + mock_send.assert_called_once() + + +def test_get_webhook_details(mocker, client_v2): + mock_send = mocker.patch("requests.Session.send") + mock_response = mocker.MagicMock() + mock_response.status_code = 200 + mock_response.text = ( + '{"status": "success", "webhook_details": {"url": "http://test-webhook.com/callback"}}' # noqa: E501 + ) + mock_send.return_value = mock_response + + response = client_v2.get_webhook_details(WEBHOOK_NAME) + + assert response["status"] == "success" + assert response["webhook_details"]["url"] == WEBHOOK_URL + mock_send.assert_called_once() From 224b3bfde6bbc52ed4cd3c1adf68bb1bb02c48fb Mon Sep 17 00:00:00 2001 From: Chandrasekharan M <117059509+chandrasekharan-zipstack@users.noreply.github.com> Date: Wed, 30 Oct 2024 12:00:05 +0530 Subject: [PATCH 09/15] feat: Encoding added for response (#14) * Encoding param added for response and tests added, defaults to utf-8 * Timeout related fixes on V1 and V2 APIs --- src/unstract/llmwhisperer/__init__.py | 2 +- src/unstract/llmwhisperer/client.py | 21 +- src/unstract/llmwhisperer/client_v2.py | 43 ++-- tests/integration/client_test.py | 1 + tests/integration/client_v2_test.py | 1 + ...8_chars.high_quality.layout_preserving.txt | 188 ++++++++++++++++++ .../expected/utf_8_chars.ocr.line-printer.txt | 188 ++++++++++++++++++ tests/test_data/utf_8_chars.pdf | Bin 0 -> 118619 bytes 8 files changed, 416 insertions(+), 28 deletions(-) create mode 100644 tests/test_data/expected/utf_8_chars.high_quality.layout_preserving.txt create mode 100644 tests/test_data/expected/utf_8_chars.ocr.line-printer.txt create mode 100644 tests/test_data/utf_8_chars.pdf diff --git a/src/unstract/llmwhisperer/__init__.py b/src/unstract/llmwhisperer/__init__.py index 8b7ae62..c50eaef 100644 --- a/src/unstract/llmwhisperer/__init__.py +++ b/src/unstract/llmwhisperer/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.21.0" +__version__ = "0.22.0" from .client import LLMWhispererClient # noqa: F401 diff --git a/src/unstract/llmwhisperer/client.py b/src/unstract/llmwhisperer/client.py index 39e34cb..6bbbb1e 100644 --- a/src/unstract/llmwhisperer/client.py +++ b/src/unstract/llmwhisperer/client.py @@ -58,9 +58,7 @@ class LLMWhispererClient: client's activities and errors. """ - formatter = logging.Formatter( - "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - ) + formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") logger = logging.getLogger(__name__) log_stream_handler = logging.StreamHandler() log_stream_handler.setFormatter(formatter) @@ -117,9 +115,7 @@ def __init__( self.api_key = os.getenv("LLMWHISPERER_API_KEY", "") else: self.api_key = api_key - self.logger.debug( - "api_key set to %s", LLMWhispererUtils.redact_key(self.api_key) - ) + self.logger.debug("api_key set to %s", LLMWhispererUtils.redact_key(self.api_key)) self.api_timeout = api_timeout @@ -169,6 +165,7 @@ def whisper( ocr_provider: str = "advanced", line_splitter_tolerance: float = 0.4, horizontal_stretch_factor: float = 1.0, + encoding: str = "utf-8", ) -> dict: """ Sends a request to the LLMWhisperer API to process a document. @@ -190,6 +187,7 @@ def whisper( ocr_provider (str, optional): The OCR provider. Can be "advanced" or "basic". Defaults to "advanced". line_splitter_tolerance (float, optional): The line splitter tolerance. Defaults to 0.4. horizontal_stretch_factor (float, optional): The horizontal stretch factor. Defaults to 1.0. + encoding (str): The character encoding to use for processing the text. Defaults to "utf-8". Returns: dict: The response from the API as a dictionary. @@ -238,12 +236,10 @@ def whisper( should_stream = False if url == "": if stream is not None: - should_stream = True def generate(): - for chunk in stream: - yield chunk + yield from stream req = requests.Request( "POST", @@ -267,7 +263,8 @@ def generate(): req = requests.Request("POST", api_url, params=params, headers=self.headers) prepared = req.prepare() s = requests.Session() - response = s.send(prepared, timeout=self.api_timeout, stream=should_stream) + response = s.send(prepared, timeout=timeout, stream=should_stream) + response.encoding = encoding if response.status_code != 200 and response.status_code != 202: message = json.loads(response.text) message["status_code"] = response.status_code @@ -318,7 +315,7 @@ def whisper_status(self, whisper_hash: str) -> dict: message["status_code"] = response.status_code return message - def whisper_retrieve(self, whisper_hash: str) -> dict: + def whisper_retrieve(self, whisper_hash: str, encoding: str = "utf-8") -> dict: """Retrieves the result of the whisper operation from the LLMWhisperer API. @@ -329,6 +326,7 @@ def whisper_retrieve(self, whisper_hash: str) -> dict: Args: whisper_hash (str): The hash of the whisper operation. + encoding (str): The character encoding to use for processing the text. Defaults to "utf-8". Returns: dict: A dictionary containing the status code and the extracted text from the whisper operation. @@ -345,6 +343,7 @@ def whisper_retrieve(self, whisper_hash: str) -> dict: prepared = req.prepare() s = requests.Session() response = s.send(prepared, timeout=self.api_timeout) + response.encoding = encoding if response.status_code != 200: err = json.loads(response.text) err["status_code"] = response.status_code diff --git a/src/unstract/llmwhisperer/client_v2.py b/src/unstract/llmwhisperer/client_v2.py index 101e74e..110a9a6 100644 --- a/src/unstract/llmwhisperer/client_v2.py +++ b/src/unstract/llmwhisperer/client_v2.py @@ -70,6 +70,7 @@ class LLMWhispererClientV2: api_key = "" base_url = "" + api_timeout = 120 def __init__( self, @@ -139,7 +140,7 @@ def get_usage_info(self) -> dict: req = requests.Request("GET", url, headers=self.headers) prepared = req.prepare() s = requests.Session() - response = s.send(prepared, timeout=120) + response = s.send(prepared, timeout=self.api_timeout) if response.status_code != 200: err = json.loads(response.text) err["status_code"] = response.status_code @@ -169,6 +170,7 @@ def whisper( use_webhook="", wait_for_completion=False, wait_timeout=180, + encoding: str = "utf-8", ) -> dict: """ Sends a request to the LLMWhisperer API to process a document. @@ -178,8 +180,10 @@ def whisper( file_path (str, optional): The path to the file to be processed. Defaults to "". stream (IO[bytes], optional): A stream of bytes to be processed. Defaults to None. url (str, optional): The URL of the file to be processed. Defaults to "". - mode (str, optional): The processing mode. Can be "high_quality", "form", "low_cost" or "native_text". Defaults to "high_quality". - output_mode (str, optional): The output mode. Can be "layout_preserving" or "text". Defaults to "layout_preserving". + mode (str, optional): The processing mode. Can be "high_quality", "form", "low_cost" or "native_text". + Defaults to "high_quality". + output_mode (str, optional): The output mode. Can be "layout_preserving" or "text". + Defaults to "layout_preserving". page_seperator (str, optional): The page separator. Defaults to "<<<". pages_to_extract (str, optional): The pages to extract. Defaults to "". median_filter_size (int, optional): The size of the median filter. Defaults to 0. @@ -192,10 +196,15 @@ def whisper( lang (str, optional): The language of the document. Defaults to "eng". tag (str, optional): The tag for the document. Defaults to "default". filename (str, optional): The name of the file to store in reports. Defaults to "". - webhook_metadata (str, optional): The webhook metadata. This data will be passed to the webhook if webhooks are used Defaults to "". - use_webhook (str, optional): Webhook name to call. Defaults to "". If not provided, the no webhook will be called. - wait_for_completion (bool, optional): Whether to wait for the whisper operation to complete. Defaults to False. - wait_timeout (int, optional): The number of seconds to wait for the whisper operation to complete. Defaults to 180. + webhook_metadata (str, optional): The webhook metadata. This data will be passed to the webhook if + webhooks are used Defaults to "". + use_webhook (str, optional): Webhook name to call. Defaults to "". If not provided, then + no webhook will be called. + wait_for_completion (bool, optional): Whether to wait for the whisper operation to complete. + Defaults to False. + wait_timeout (int, optional): The number of seconds to wait for the whisper operation to complete. + Defaults to 180. + encoding (str): The character encoding to use for processing the text. Defaults to "utf-8". Returns: dict: The response from the API as a dictionary. @@ -275,7 +284,8 @@ def generate(): req = requests.Request("POST", api_url, params=params, headers=self.headers) prepared = req.prepare() s = requests.Session() - response = s.send(prepared, timeout=120, stream=should_stream) + response = s.send(prepared, timeout=wait_timeout, stream=should_stream) + response.encoding = encoding if response.status_code != 200 and response.status_code != 202: message = json.loads(response.text) message["status_code"] = response.status_code @@ -371,7 +381,7 @@ def whisper_status(self, whisper_hash: str) -> dict: req = requests.Request("GET", url, headers=self.headers, params=params) prepared = req.prepare() s = requests.Session() - response = s.send(prepared, timeout=120) + response = s.send(prepared, timeout=self.api_timeout) if response.status_code != 200: err = json.loads(response.text) err["status_code"] = response.status_code @@ -380,7 +390,7 @@ def whisper_status(self, whisper_hash: str) -> dict: message["status_code"] = response.status_code return message - def whisper_retrieve(self, whisper_hash: str) -> dict: + def whisper_retrieve(self, whisper_hash: str, encoding: str = "utf-8") -> dict: """Retrieves the result of the whisper operation from the LLMWhisperer API. @@ -391,6 +401,7 @@ def whisper_retrieve(self, whisper_hash: str) -> dict: Args: whisper_hash (str): The hash of the whisper operation. + encoding (str): The character encoding to use for processing the text. Defaults to "utf-8". Returns: dict: A dictionary containing the status code and the extracted text from the whisper operation. @@ -406,7 +417,8 @@ def whisper_retrieve(self, whisper_hash: str) -> dict: req = requests.Request("GET", url, headers=self.headers, params=params) prepared = req.prepare() s = requests.Session() - response = s.send(prepared, timeout=120) + response = s.send(prepared, timeout=self.api_timeout) + response.encoding = encoding if response.status_code != 200: err = json.loads(response.text) err["status_code"] = response.status_code @@ -449,7 +461,7 @@ def register_webhook(self, url: str, auth_token: str, webhook_name: str) -> dict req = requests.Request("POST", url, headers=headersx, json=data) prepared = req.prepare() s = requests.Session() - response = s.send(prepared, timeout=120) + response = s.send(prepared, timeout=self.api_timeout) if response.status_code != 200: err = json.loads(response.text) err["status_code"] = response.status_code @@ -480,7 +492,7 @@ def get_webhook_details(self, webhook_name: str) -> dict: req = requests.Request("GET", url, headers=self.headers, params=params) prepared = req.prepare() s = requests.Session() - response = s.send(prepared, timeout=120) + response = s.send(prepared, timeout=self.api_timeout) if response.status_code != 200: err = json.loads(response.text) err["status_code"] = response.status_code @@ -493,9 +505,8 @@ def get_highlight_rect( target_width: int, target_height: int, ) -> tuple[int, int, int, int, int]: - """ - Given the line metadata and the line number, this function returns the bounding box of the line - in the format (page,x1,y1,x2,y2) + """Given the line metadata and the line number, this function returns + the bounding box of the line in the format (page,x1,y1,x2,y2) Args: line_metadata (list[int]): The line metadata returned by the LLMWhisperer API. diff --git a/tests/integration/client_test.py b/tests/integration/client_test.py index 764fd72..60d7c88 100644 --- a/tests/integration/client_test.py +++ b/tests/integration/client_test.py @@ -35,6 +35,7 @@ def test_get_usage_info(client): ("ocr", "text", "restaurant_invoice_photo.pdf"), ("text", "line-printer", "restaurant_invoice_photo.pdf"), ("text", "text", "handwritten-form.pdf"), + ("ocr", "line-printer", "utf_8_chars.pdf"), ], ) def test_whisper(client, data_dir, processing_mode, output_mode, input_file): diff --git a/tests/integration/client_v2_test.py b/tests/integration/client_v2_test.py index a5ef4b6..80e7544 100644 --- a/tests/integration/client_v2_test.py +++ b/tests/integration/client_v2_test.py @@ -38,6 +38,7 @@ def test_get_usage_info(client_v2): ("text", "low_cost", "credit_card.pdf"), ("text", "high_quality", "restaurant_invoice_photo.pdf"), ("text", "form", "handwritten-form.pdf"), + ("layout_preserving", "high_quality", "utf_8_chars.pdf"), ], ) def test_whisper_v2(client_v2, data_dir, output_mode, mode, input_file): diff --git a/tests/test_data/expected/utf_8_chars.high_quality.layout_preserving.txt b/tests/test_data/expected/utf_8_chars.high_quality.layout_preserving.txt new file mode 100644 index 0000000..5f9776d --- /dev/null +++ b/tests/test_data/expected/utf_8_chars.high_quality.layout_preserving.txt @@ -0,0 +1,188 @@ + + + + TCPDF Example 008 + TCPDF by Nicola Asuni - Tecnick.com + www.tcpdf.org + + +Sentences that contain all letters commonly used in a language + + +This file is UTF-8 encoded. + + +Czech (cz) + + + Příšerně žluťoučký kůň úpěl ďábelské ódy. + Hleď, toť přízračný kůň v mátožné póze šíleně úpí. + Zvlášť zákeřný učeň s ďolíčky běží podél zóny úlů. + Loď čeří kýlem tůň obzvlášť v Grónské úžině. + Ó, náhlý déšť již zvířil prach a čilá laň teď běží s houfcem gazel k úkrytům. + + +Danish (da) + + + Quizdeltagerne spiste jordbær med fløde, mens cirkusklovnen + Wolther spillede på xylofon. + (= Quiz contestants were eating strawbery with cream while Wolther + the circus clown played on xylophone.) + + +German (de) + + + Falsches Üben von Xylophonmusik quält jeden größeren Zwerg + (= Wrongful practicing of xylophone music tortures every larger dwarf) + + + Zwölf Boxkämpfer jagten Eva quer über den Sylter Deich + (= Twelve boxing fighters hunted Eva across the dike of Sylt) + + + Heizölrückstoßabdämpfung + (= fuel oil recoil absorber) + (jqvwxy missing, but all non-ASCII letters in one word) + + +English (en) + + + The quick brown fox jumps over the lazy dog + + +Spanish (es) + + + El pingüino Wenceslao hizo kilómetros bajo exhaustiva lluvia y + frío, añoraba a su querido cachorro. + (Contains every letter and every accent, but not every combination + + + page 1 / 3 +<<< + + + + TCPDF Example 008 + TCPDF by Nicola Asuni - Tecnick.com + www.tcpdf.org + + + of vowel + acute.) + + +French (fr) + + + Portez ce vieux whisky au juge blond qui fume sur son île intérieure, à + côté de l'alcôve ovoïde, où les bûches se consument dans l'âtre, ce + qui lui permet de penser à la cænogenèse de l'être dont il est question + dans la cause ambiguë entendue à Moÿ, dans un capharnaüm qui, + pense-t-il, diminue çà et là la qualité de son œuvre. + + + l'île exiguë + Où l'obèse jury mûr + Fête l'haï volapük, + Âne ex aéquo au whist, + Ôtez ce vœu déçu. + + + Le cœur déçu mais l'âme plutôt naïve, Louys rêva de crapaüter en + canoë au delà des îles, près du mälström où brûlent les novæ. + + +Irish Gaelic (ga) + + + D'fhuascail Íosa, Úrmhac na hÓighe Beannaithe, pór Éava agus Ádhaimh + + +Hungarian (hu) + + + Árvíztűrő tükörfúrógép + (= flood-proof mirror-drilling machine, only all non-ASCII letters) + + +Icelandic (is) + + + Kæmi ný öxi hér ykist bjófum nú bæỗi víl og ádrepa + + + Sævör grét áðan því úlpan var ónýt + (some ASCII letters missing) + + +Greek (el) + + + Γαζέες και μυρτιές δέν θά βρώ στό χρυσαφί ξέφωτο + (= No more shall I see acacias or myrtles in the golden clearing) + + + Ξεσκεπάζω την ψυχοφθόρα βδελυγμία + + + page 2 / 3 +<<< + + + + TCPDF Example 008 + TCPDF by Nicola Asuni - Tecnick.com + www.tcpdf.org + + + (= I uncover the soul-destroying abhorrence) + + +Hebrew (iw) + + +הקליטה איך חברה לו מצא ולפתע מאוכזב בים שט סקרן דג ? + + +Polish (pl) + + + Pchnąć w tę łódź jeża lub osiem skrzyń fig + (= To push a hedgehog or eight bins of figs in this boat) + + + Zażółć gęślą jaźń + + +Russian (ru) + + + В чащах юга жил бы цитрус? Да, но фальшивый экземпляр! + (= Would a citrus live in the bushes of south? Yes, but only a fake one!) + + +Thai (th) + + + [- -] + เป็น มนุษย์ สุดประเสริฐ เลิศ คุณค่า กว่า บรรดา ฝูง สัตว์ เดรัจฉาน + จง ฝ่าฟัน พัฒนา วิชาการ อย่า ล้าง ผลาญ ฤา เข่น ฆ่า บีฑา ใคร + ไม่ ถือ โทษ โกรธ แช่ง ซัด ฮึดฮัด ด่า หัด อภัย เหมือน กีฬา อัชฌาสัย + ปฏิบัติ ประพฤติ กฎ กำหนด ใจ พูดจา ให้ จ๊ะๆ จำๆ น่า ฟัง เอย ฯ + + + [The copyright for the Thai example is owned by The Computer + Association of Thailand under the Royal Patronage of His Majesty the + King.] + + +Please let me know if you find others! Special thanks to the people +from all over the world who contributed these sentences. + + + page 3 / 3 +<<< + diff --git a/tests/test_data/expected/utf_8_chars.ocr.line-printer.txt b/tests/test_data/expected/utf_8_chars.ocr.line-printer.txt new file mode 100644 index 0000000..5f9776d --- /dev/null +++ b/tests/test_data/expected/utf_8_chars.ocr.line-printer.txt @@ -0,0 +1,188 @@ + + + + TCPDF Example 008 + TCPDF by Nicola Asuni - Tecnick.com + www.tcpdf.org + + +Sentences that contain all letters commonly used in a language + + +This file is UTF-8 encoded. + + +Czech (cz) + + + Příšerně žluťoučký kůň úpěl ďábelské ódy. + Hleď, toť přízračný kůň v mátožné póze šíleně úpí. + Zvlášť zákeřný učeň s ďolíčky běží podél zóny úlů. + Loď čeří kýlem tůň obzvlášť v Grónské úžině. + Ó, náhlý déšť již zvířil prach a čilá laň teď běží s houfcem gazel k úkrytům. + + +Danish (da) + + + Quizdeltagerne spiste jordbær med fløde, mens cirkusklovnen + Wolther spillede på xylofon. + (= Quiz contestants were eating strawbery with cream while Wolther + the circus clown played on xylophone.) + + +German (de) + + + Falsches Üben von Xylophonmusik quält jeden größeren Zwerg + (= Wrongful practicing of xylophone music tortures every larger dwarf) + + + Zwölf Boxkämpfer jagten Eva quer über den Sylter Deich + (= Twelve boxing fighters hunted Eva across the dike of Sylt) + + + Heizölrückstoßabdämpfung + (= fuel oil recoil absorber) + (jqvwxy missing, but all non-ASCII letters in one word) + + +English (en) + + + The quick brown fox jumps over the lazy dog + + +Spanish (es) + + + El pingüino Wenceslao hizo kilómetros bajo exhaustiva lluvia y + frío, añoraba a su querido cachorro. + (Contains every letter and every accent, but not every combination + + + page 1 / 3 +<<< + + + + TCPDF Example 008 + TCPDF by Nicola Asuni - Tecnick.com + www.tcpdf.org + + + of vowel + acute.) + + +French (fr) + + + Portez ce vieux whisky au juge blond qui fume sur son île intérieure, à + côté de l'alcôve ovoïde, où les bûches se consument dans l'âtre, ce + qui lui permet de penser à la cænogenèse de l'être dont il est question + dans la cause ambiguë entendue à Moÿ, dans un capharnaüm qui, + pense-t-il, diminue çà et là la qualité de son œuvre. + + + l'île exiguë + Où l'obèse jury mûr + Fête l'haï volapük, + Âne ex aéquo au whist, + Ôtez ce vœu déçu. + + + Le cœur déçu mais l'âme plutôt naïve, Louys rêva de crapaüter en + canoë au delà des îles, près du mälström où brûlent les novæ. + + +Irish Gaelic (ga) + + + D'fhuascail Íosa, Úrmhac na hÓighe Beannaithe, pór Éava agus Ádhaimh + + +Hungarian (hu) + + + Árvíztűrő tükörfúrógép + (= flood-proof mirror-drilling machine, only all non-ASCII letters) + + +Icelandic (is) + + + Kæmi ný öxi hér ykist bjófum nú bæỗi víl og ádrepa + + + Sævör grét áðan því úlpan var ónýt + (some ASCII letters missing) + + +Greek (el) + + + Γαζέες και μυρτιές δέν θά βρώ στό χρυσαφί ξέφωτο + (= No more shall I see acacias or myrtles in the golden clearing) + + + Ξεσκεπάζω την ψυχοφθόρα βδελυγμία + + + page 2 / 3 +<<< + + + + TCPDF Example 008 + TCPDF by Nicola Asuni - Tecnick.com + www.tcpdf.org + + + (= I uncover the soul-destroying abhorrence) + + +Hebrew (iw) + + +הקליטה איך חברה לו מצא ולפתע מאוכזב בים שט סקרן דג ? + + +Polish (pl) + + + Pchnąć w tę łódź jeża lub osiem skrzyń fig + (= To push a hedgehog or eight bins of figs in this boat) + + + Zażółć gęślą jaźń + + +Russian (ru) + + + В чащах юга жил бы цитрус? Да, но фальшивый экземпляр! + (= Would a citrus live in the bushes of south? Yes, but only a fake one!) + + +Thai (th) + + + [- -] + เป็น มนุษย์ สุดประเสริฐ เลิศ คุณค่า กว่า บรรดา ฝูง สัตว์ เดรัจฉาน + จง ฝ่าฟัน พัฒนา วิชาการ อย่า ล้าง ผลาญ ฤา เข่น ฆ่า บีฑา ใคร + ไม่ ถือ โทษ โกรธ แช่ง ซัด ฮึดฮัด ด่า หัด อภัย เหมือน กีฬา อัชฌาสัย + ปฏิบัติ ประพฤติ กฎ กำหนด ใจ พูดจา ให้ จ๊ะๆ จำๆ น่า ฟัง เอย ฯ + + + [The copyright for the Thai example is owned by The Computer + Association of Thailand under the Royal Patronage of His Majesty the + King.] + + +Please let me know if you find others! Special thanks to the people +from all over the world who contributed these sentences. + + + page 3 / 3 +<<< + diff --git a/tests/test_data/utf_8_chars.pdf b/tests/test_data/utf_8_chars.pdf new file mode 100644 index 0000000000000000000000000000000000000000..ba2c90608e7f09a8aed5658eae8bff84a723dce5 GIT binary patch literal 118619 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

LIcRU7ra^}=4e@;8!>bL22L8rrAVwqMQs;zQV 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�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 QAQ;HL2`DO>ZZs44A8h9UrvLx| literal 0 HcmV?d00001 From f031453b655f8c1a3c231e0aed64101c19d6e42a Mon Sep 17 00:00:00 2001 From: Nagesh <150021592+nagesh-zip@users.noreply.github.com> Date: Wed, 30 Oct 2024 17:30:06 +0530 Subject: [PATCH 10/15] URL in post support and test case (#13) * added url in post logic and test using url * added common function for extracted text assertion * renamed assert function --------- Signed-off-by: Chandrasekharan M <117059509+chandrasekharan-zipstack@users.noreply.github.com> Co-authored-by: Chandrasekharan M <117059509+chandrasekharan-zipstack@users.noreply.github.com> --- src/unstract/llmwhisperer/client_v2.py | 10 +- tests/conftest.py | 3 + tests/integration/client_v2_test.py | 80 ++- ...credit_card.low_cost.layout_preserving.txt | 586 +++++++++--------- 4 files changed, 378 insertions(+), 301 deletions(-) diff --git a/src/unstract/llmwhisperer/client_v2.py b/src/unstract/llmwhisperer/client_v2.py index 110a9a6..97210cf 100644 --- a/src/unstract/llmwhisperer/client_v2.py +++ b/src/unstract/llmwhisperer/client_v2.py @@ -152,13 +152,13 @@ def whisper( file_path: str = "", stream: IO[bytes] = None, url: str = "", - mode: str = "high_quality", + mode: str = "form", output_mode: str = "layout_preserving", page_seperator: str = "<<<", pages_to_extract: str = "", median_filter_size: int = 0, gaussian_blur_radius: int = 0, - line_splitter_tolerance: float = 0.75, + line_splitter_tolerance: float = 0.4, horizontal_stretch_factor: float = 1.0, mark_vertical_lines: bool = False, mark_horizontal_lines: bool = False, @@ -216,7 +216,6 @@ def whisper( self.logger.debug("whisper called") api_url = f"{self.base_url}/whisper" params = { - "url": url, "mode": mode, "output_mode": output_mode, "page_seperator": page_seperator, @@ -281,7 +280,8 @@ def generate(): data=data, ) else: - req = requests.Request("POST", api_url, params=params, headers=self.headers) + params["url_in_post"] = True + req = requests.Request("POST", api_url, params=params, headers=self.headers, data=url) prepared = req.prepare() s = requests.Session() response = s.send(prepared, timeout=wait_timeout, stream=should_stream) @@ -350,7 +350,7 @@ def generate(): return message # Will not reach here if status code is 202 - message = response.text + message = json.loads(response.text) message["status_code"] = response.status_code return message diff --git a/tests/conftest.py b/tests/conftest.py index 49eab9a..3c342c1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,13 @@ import os import pytest +from dotenv import load_dotenv from unstract.llmwhisperer.client import LLMWhispererClient from unstract.llmwhisperer.client_v2 import LLMWhispererClientV2 +load_dotenv() + @pytest.fixture(name="client") def llm_whisperer_client(): diff --git a/tests/integration/client_v2_test.py b/tests/integration/client_v2_test.py index 80e7544..a097765 100644 --- a/tests/integration/client_v2_test.py +++ b/tests/integration/client_v2_test.py @@ -50,23 +50,75 @@ def test_whisper_v2(client_v2, data_dir, output_mode, mode, input_file): exp_basename = f"{Path(input_file).stem}.{mode}.{output_mode}.txt" exp_file = os.path.join(data_dir, "expected", exp_basename) - with open(exp_file, encoding="utf-8") as f: + # verify extracted text + assert_extracted_text(exp_file, whisper_result, mode, output_mode) + + +@pytest.mark.parametrize( + "output_mode, mode, url, input_file, page_count", + [ + ("layout_preserving", "native_text", "https://unstractpocstorage.blob.core.windows.net/public/Amex.pdf", + "credit_card.pdf", 7), + ("layout_preserving", "low_cost", "https://unstractpocstorage.blob.core.windows.net/public/Amex.pdf", + "credit_card.pdf", 7), + ("layout_preserving", "high_quality", "https://unstractpocstorage.blob.core.windows.net/public/scanned_bill.pdf", + "restaurant_invoice_photo.pdf", 1), + ("layout_preserving", "form", "https://unstractpocstorage.blob.core.windows.net/public/scanned_form.pdf", + "handwritten-form.pdf", 1), + ] +) +def test_whisper_v2_url_in_post(client_v2, data_dir, output_mode, mode, url, input_file, page_count): + usage_before = client_v2.get_usage_info() + whisper_result = client_v2.whisper( + mode=mode, output_mode=output_mode, url=url, wait_for_completion=True + ) + logger.debug(f"Result for '{output_mode}', '{mode}', " f"'{input_file}: {whisper_result}") + + exp_basename = f"{Path(input_file).stem}.{mode}.{output_mode}.txt" + exp_file = os.path.join(data_dir, "expected", exp_basename) + # verify extracted text + assert_extracted_text(exp_file, whisper_result, mode, output_mode) + usage_after = client_v2.get_usage_info() + # Verify usage after extraction + verify_usage(usage_before, usage_after, page_count, mode) + + +def assert_extracted_text(file_path, whisper_result, mode, output_mode): + with open(file_path, encoding="utf-8") as f: exp = f.read() assert isinstance(whisper_result, dict) assert whisper_result["status_code"] == 200 - # For text based processing, perform a strict match + # For OCR based processing + threshold = 0.97 + + # For text based processing if mode == "native_text" and output_mode == "text": - assert whisper_result["extraction"]["result_text"] == exp - # For OCR based processing, perform a fuzzy match - else: - extracted_text = whisper_result["extraction"]["result_text"] - similarity = SequenceMatcher(None, extracted_text, exp).ratio() - threshold = 0.97 - - if similarity < threshold: - diff = "\n".join( - unified_diff(exp.splitlines(), extracted_text.splitlines(), fromfile="Expected", tofile="Extracted") - ) - pytest.fail(f"Texts are not similar enough: {similarity * 100:.2f}% similarity. Diff:\n{diff}") + threshold = 0.99 + extracted_text = whisper_result["extraction"]["result_text"] + similarity = SequenceMatcher(None, extracted_text, exp).ratio() + + if similarity < threshold: + diff = "\n".join( + unified_diff(exp.splitlines(), extracted_text.splitlines(), fromfile="Expected", tofile="Extracted") + ) + pytest.fail(f"Texts are not similar enough: {similarity * 100:.2f}% similarity. Diff:\n{diff}") + + +def verify_usage(before_extract, after_extract, page_count, mode='form'): + all_modes = ['form', 'high_quality', 'low_cost', 'native_text'] + all_modes.remove(mode) + assert (after_extract['today_page_count'] == before_extract['today_page_count'] + page_count), \ + "today_page_count calculation is wrong" + assert (after_extract['current_page_count'] == before_extract['current_page_count'] + page_count), \ + "current_page_count calculation is wrong" + if after_extract['overage_page_count'] > 0: + assert (after_extract['overage_page_count'] == before_extract['overage_page_count'] + page_count), \ + "overage_page_count calculation is wrong" + assert (after_extract[f'current_page_count_{mode}'] == before_extract[f'current_page_count_{mode}'] + page_count), \ + f"{mode} mode calculation is wrong" + for i in range(len(all_modes)): + assert (after_extract[f'current_page_count_{all_modes[i]}'] == + before_extract[f'current_page_count_{all_modes[i]}']), \ + f"{all_modes[i]} mode calculation is wrong" diff --git a/tests/test_data/expected/credit_card.low_cost.layout_preserving.txt b/tests/test_data/expected/credit_card.low_cost.layout_preserving.txt index 974d682..8964a8a 100644 --- a/tests/test_data/expected/credit_card.low_cost.layout_preserving.txt +++ b/tests/test_data/expected/credit_card.low_cost.layout_preserving.txt @@ -1,355 +1,377 @@ -AMERICAN Blue Cash® from American Express p. 1/7 - EXPRESS - JOSEPH PAULSON Customer Care: 1-888-258-3741 - Closing Date 09/27/23 TTY: Use Relay 711 - Website: americanexpress.com - Account Ending 7-73045 ~ ~ - - Reward Dollars - New Balance $10,269.65 as of 08/29/2023 - - Minimum Payment Due $205.39 1,087.93 - For more details about Rewards, visit - americanexpress.com/cashbackrewards - - Payment Due Date 10/22/23 Account Summary - - Late Payment Warning: If we do not receive your Minimum Payment Due by Previous Balance $6,583.67 - - the Payment Due Date of 10/22/23, you may have to pay a late fee of up to Payments/Credits -$6,583.67 - $40.00 and your APRs may be increased to the Penalty APR of 29.99%. New Charges +$10,269.65 - Fees +$0.00 - - Interest Charged +$0.00 - - Minimum Payment Warning: If you have a Non-Plan Balance and make only the New Balance $10,269.65 - - minimum payment each period, you will pay more in interest and it will take you longer Minimum Payment Due $205.39 - to pay off your Non-Plan Balance. For example: - Credit Limit $26,400.00 - If you make no additional You will pay off the balance And you will pay an Available Credit $16,130.35 - charges and each month shown on this statement in estimated total of... - you pay... about... Cash Available Advance Cash Limit $4,600.00 $4,600.00 - - Only the 22 years $29,830 - Minimum Payment Due - - $14,640 - $407 3 years (Savings = $15,190) - - If you would like information about credit counseling services, call 1-888-733-4139. - - See page 2 for important information about your account. - [+] - - > Please refer to the IMPORTANT NOTICES section on - page 7. - - Continued on page 3 - - \ Please fold on the perforation below, detach and return with your payment V - - ps Payment Coupon Pay by Computer Pay by Phone Account Ending 7-73045 - I Do not staple or use paper clips americanexpress.com/pbc C 1-800-472-9297 - Enter 15 digit account # on all payments. - Make check payable to American Express. - - JOSEPH PAULSON Payment Due Date - 3742 CLOUD SPGS RD 10/22/23 - #403-1045 - DALLAS TX 75219-4136 New Balance - $10,269.65 - - Minimum Payment Due - 205.39 - - See reverse side for instructions AMERICAN EXPRESS e - - on how to update your address, PO BOX 6031 Amount Enclosed - phone number, or email. CAROL STREAM IL 60197-6031 - - Wall dbollllllllatloodladll - - 00003499916e2708152 0010269650000280539 a4 d +AMERICAN Blue Cash® from American Express p. 1/7 + EXPRESS + JOSEPH PAULSON Customer Care: 1-888-258-3741 + TTY: Use 711 + Closing Date 09/27/23 Relay + Account 7-73045 Website: americanexpress.com + Ending ~ ~ + + Reward Dollars + New Balance $10,269.65 + as of 08/29/2023 + + Minimum Payment Due $205.39 1,087.93 + For more details about Rewards, visit + americanexpress.com/cashbackrewards + + Payment Due Date 10/22/23 + Account Summary + + Previous Balance $6,583.67 + Late Payment Warning: If we do not receive your Minimum Payment Due by + the Payment Due Date of 10/22/23, you may have to pay a late fee of up to Payments/Credits -$6,583.67 + $40.00 and your APRs may be increased to the Penalty APR of 29.99%. New Charges +$10,269.65 + Fees +$0.00 + + Interest Charged +$0.00 + + New Balance + Minimum Payment Warning: If you have a Non-Plan Balance and make only the $10,269.65 + Minimum Due $205.39 + minimum payment each period, you will pay more in interest and it will take you longer Payment + to pay off your Non-Plan Balance. For example: + Credit Limit $26,400.00 + If you make no additional You will pay off the balance And you will pay an Available Credit $16,130.35 + charges and each month shown on this statement in estimated total of... + Cash Advance Limit $4,600.00 + you pay... about... + Available Cash $4,600.00 + + Only the + 22 years $29,830 + Minimum Payment Due + + $14,640 + $407 3 years = + (Savings $15,190) + + If you would like information about credit counseling services, call 1-888-733-4139. + + See page 2 for important information about your account. + [+] + + > Please refer to the IMPORTANT NOTICES section on + page 7. + + Continued on page 3 + + \ Please fold on the perforation below, detach and return with your payment V + + ps Payment Coupon Pay by Computer Pay by Phone Account Ending 7-73045 + I Do not or use C 1-800-472-9297 + staple paper clips americanexpress.com/pbc + Enter 15 digit account # on all payments. + Make check payable to American Express. + + JOSEPH PAULSON Payment Due Date + 3742 CLOUD SPGS RD 10/22/23 + #403-1045 + New Balance + DALLAS TX 75219-4136 + $10,269.65 + + Minimum Payment Due + 205.39 + + See reverse side for instructions AMERICAN EXPRESS e + PO BOX 6031 Amount Enclosed + on how to update your address, + CAROL STREAM IL 60197-6031 + phone number, or email. + + Wall dbollllllllatloodladll + + 00003499916e2708152 0010269650000280539 a4 d <<< - JOSEPH PAULSON Account Ending 7-73045 p. 2/7 - - Payments: Your payment must be sent to the payment address shown on represents money owed to you. If within the six-month period following -your statement and must be received by 5 p.m. local time at that address to the date of the first statement indicating the credit balance you do not - be credited as of the day it is received. Payments we receive after 5 p.m. will request a refund or charge enough to use up the credit balance, we will - not be credited to your Account until the next day. Payments must also: (1) send you a check for the credit balance within 30 days if the amount is - include the remittance coupon from your statement; (2) be made with a $1.00 or more. - single check drawn on a US bank and payable in US dollars, or with a Credit Reporting: We may report information about your Account to credit - negotiable instrument payable in US dollars and clearable through the US bureaus. Late payments, missed payments, or other defaults on your - banking system; and (3) include your Account number. If your payment Account may be reflected in your credit report. - does not meet all of the above requirements, crediting may be delayed and What To Do If You Think You Find A Mistake On Your Statement -you may incur late payment fees and additional interest charges. Electronic If you think there is an error on your statement, write to us at: - payments must be made through an electronic payment method payable American Express, PO Box 981535, El Paso TX 79998-1535 - in US dollars and clearable through the US banking system. Please do not You may also contact us on the Web: www.americanexpress.com - send post-dated checks as they will be deposited upon receipt. Any In your letter, give us the following information: - restrictive language on a payment we accept will have no effect on us - Account information: Your name and account number. -without our express prior written approval. We will re-present to your - Dollar amount: The dollar amount of the suspected error. -financial institution any payment that is returned unpaid. - Description of Problem: If you think there is an error on your bill, - Permission for Electronic Withdrawal: (1) When you send a check for describe what you believe is wrong and why you believe it is a mistake. - payment, you give us permission to electronically withdraw your payment You must contact us within 60 days after the error appeared on your -from your deposit or other asset account. We will process checks statement. - electronically by transmitting the amount of the check, routing number, You must notify us of any potential errors in writing [or electronically]. You - account number and check serial number to your financial institution, may call us, but if you do we are not required to investigate any potential - unless the check is not processable electronically or a less costly process is errors and you may have to pay the amount in question. - available. When we process your check electronically, your payment may While we investigate whether or not there has been an error, the following - be withdrawn from your deposit or other asset account as soon as the same are true: - day we receive your check, and you will not receive that cancelled check - We cannot try to collect the amount in question, or report you as -with your deposit or other asset account statement. If we cannot collect the delinquent on that amount. -funds electronically we may issue a draft against your deposit or other asset - The charge in question may remain on your statement, and we may - account for the amount of the check. (2) By using Pay By Computer, Pay By continue to charge you interest on that amount. But, if we determine that - Phone or any other electronic payment service of ours, you give us we made a mistake, you will not have to pay the amount in question or any - permission to electronically withdraw funds from the deposit or other asset interest or other fees related to that amount. - account you specify in the amount you request. Payments using such - While you do not have to pay the amount in question, you are responsible - services of ours received after 8:00 p.m. MST may not be credited until the for the remainder of your balance. - next day. - We can apply any unpaid amount against your credit limit. - How We Calculate Your Balance: We use the Average Daily Balance (ADB) Your Rights If You Are Dissatisfied With Your Credit Card Purchases - method (including new transactions) to calculate the balance on which we If you are dissatisfied with the goods or services that you have purchased - charge interest on your Account. Call the Customer Care number on page 3 with your credit card, and you have tried in good faith to correct the -for more information about this balance computation method and how problem with the merchant, you may have the right not to pay the - resulting interest charges are determined. The method we use to figure the remaining amount due on the purchase. -ADB and interest results in daily compounding of interest. To use this right, all of the following must be true: - Paying Interest: Your due date is at least 25 days after the close of each 1. The purchase must have been made in your home state or within 100 - billing period. We will not charge you interest on your purchases if you pay miles of your current mailing address, and the purchase price must have - each month your entire balance (or Adjusted Balance if applicable) by the been more than $50. (Note: Neither of these is necessary if your purchase - due date each month. We will charge you interest on cash advances and was based on an advertisement we mailed to you, or if we own the - (unless otherwise disclosed) balance transfers beginning on the transaction company that sold you the goods or services.) - date. 2. You must have used your credit card for the purchase. Purchases made - Foreign Currency Charges: If you make a Charge in a foreign currency, we with cash advances from an ATM or with a check that accesses your credit -will convert it into US dollars on the date we or our agents process it. We card account do not qualify. -will charge a fee of 2.70% of the converted US dollar amount. We will 3. You must not yet have fully paid for the purchase. - choose a conversion rate that is acceptable to us for that date, unless a If all of the criteria above are met and you are still dissatisfied with the - particular rate is required by law. The conversion rate we use is no more purchase, contact us in writing or electronically at: -than the highest official rate published by a government agency or the American Express, PO Box 981535, El Paso TX 79998-1535 - highest interbank rate we identify from customary banking sources on the www.americanexpress.com - conversion date or the prior business day. This rate may differ from rates in While we investigate, the same rules apply to the disputed amount as - effect on the date of your charge. Charges converted by establishments discussed above. After we finish our investigation, we will tell you our - (such as airlines) will be billed at the rates such establishments use. decision. At that point, if we think you owe an amount and you do not pay - Credit Balance: A credit balance (designated CR) shown on this statement we may report you as delinquent. - - Pay Your Bill with AutoPay - - Deduct your payment from your bank - account automatically each month. - - - Avoid late fees - - - Save time - - Change of Address, phone number, email - - Visit americanexpress.com/autopay - - Online at www.americanexpress.com/updatecontactinfo today to enroll. - - Via mobile device - - - Voice automated: call the number on the back of your card - - For name, company name, and foreign address or phone changes, please call Customer Care - - Please do not add any written communication or address change on this stub - For information on how we protect your - privacy and to set your communication - and privacy choices, please visit - www.americanexpress.com/privacy. + JOSEPH PAULSON + Account Ending 7-73045 p. 2/7 + + Payments: Your payment must be sent to the payment address shown on represents money owed to you. If within the six-month period following +your statement and must be received by 5 p.m. local time at that address to the date of the first statement indicating the credit balance you do not + be credited as of the day it is received. Payments we receive after 5 p.m. will request a refund or charge enough to use up the credit balance, we will + not be credited to your Account until the next day. Payments must also: (1) send you a check for the credit balance within 30 days if the amount is + include the remittance coupon from your statement; (2) be made with a $1.00 or more. + single check drawn on a US bank and payable in US dollars, or with a Credit Reporting: We may report information about your Account to credit + negotiable instrument payable in US dollars and clearable through the US bureaus. Late payments, missed payments, or other defaults on your + banking system; and (3) include your Account number. If your payment Account may be reflected in your credit report. + does not meet all of the above requirements, crediting may be delayed and What To Do If You Think You Find A Mistake On Your Statement +you may incur late payment fees and additional interest charges. Electronic If you think there is an error on your statement, write to us at: + payments must be made through an electronic payment method payable American Express, PO Box 981535, El Paso TX 79998-1535 + in US dollars and clearable through the US banking system. Please do not You may also contact us on the Web: www.americanexpress.com + send post-dated checks as they will be deposited upon receipt. Any In your letter, give us the following information: + restrictive language on a payment we accept will have no effect on us - Account information: Your name and account number. +without our express prior written approval. We will re-present to your - Dollar amount: The dollar amount of the suspected error. + - If +financial institution any payment that is returned unpaid. Description of Problem: you think there is an error on your bill, + Permission for Electronic Withdrawal: (1) When you send a check for describe what you believe is wrong and why you believe it is a mistake. + payment, you give us permission to electronically withdraw your payment You must contact us within 60 days after the error appeared on your +from your deposit or other asset account. We will process checks statement. + electronically by transmitting the amount of the check, routing number, You must notify us of any potential errors in writing [or electronically]. You + account number and check serial number to your financial institution, may call us, but if you do we are not required to investigate any potential + unless the check is not processable electronically or a less costly process is errors and you may have to pay the amount in question. + available. When we process your check electronically, your payment may While we investigate whether or not there has been an error, the following + be withdrawn from your deposit or other asset account as soon as the same are true: + day we receive your check, and you will not receive that cancelled check - We cannot try to collect the amount in question, or report you as +with your deposit or other asset account statement. If we cannot collect the delinquent on that amount. +funds electronically we may issue a draft against your deposit or other asset - The charge in question may remain on your statement, and we may + account for the amount of the check. (2) By using Pay By Computer, Pay By continue to charge you interest on that amount. But, if we determine that + Phone or any other electronic payment service of ours, you give us we made a mistake, you will not have to pay the amount in question or any + permission to electronically withdraw funds from the deposit or other asset interest or other fees related to that amount. + account you specify in the amount you request. Payments using such - While you do not have to pay the amount in question, you are responsible + services of ours received after 8:00 p.m. MST may not be credited until the for the remainder of your balance. + next day. - We can apply any unpaid amount against your credit limit. + How We Calculate Your Balance: We use the Average Daily Balance (ADB) Your Rights If You Are Dissatisfied With Your Credit Card Purchases + method (including new transactions) to calculate the balance on which we If you are dissatisfied with the goods or services that you have purchased + charge interest on your Account. Call the Customer Care number on page 3 with your credit card, and you have tried in good faith to correct the +for more information about this balance computation method and how problem with the merchant, you may have the right not to pay the + resulting interest charges are determined. The method we use to figure the remaining amount due on the purchase. +ADB and interest results in daily compounding of interest. To use this right, all of the following must be true: + Paying Interest: Your due date is at least 25 days after the close of each 1. The purchase must have been made in your home state or within 100 + billing period. We will not charge you interest on your purchases if you pay miles of your current mailing address, and the purchase price must have + each month your entire balance (or Adjusted Balance if applicable) by the been more than $50. (Note: Neither of these is necessary if your purchase + due date each month. We will charge you interest on cash advances and was based on an advertisement we mailed to you, or if we own the + (unless otherwise disclosed) balance transfers beginning on the transaction company that sold you the goods or services.) + date. 2. You must have used your credit card for the purchase. Purchases made + Foreign Currency Charges: If you make a Charge in a foreign currency, we with cash advances from an ATM or with a check that accesses your credit +will convert it into US dollars on the date we or our agents process it. We card account do not qualify. +will charge a fee of 2.70% of the converted US dollar amount. We will 3. You must not yet have fully paid for the purchase. + choose a conversion rate that is acceptable to us for that date, unless a If all of the criteria above are met and you are still dissatisfied with the + particular rate is required by law. The conversion rate we use is no more purchase, contact us in writing or electronically at: +than the highest official rate published by a government agency or the American Express, PO Box 981535, El Paso TX 79998-1535 + highest interbank rate we identify from customary banking sources on the www.americanexpress.com + conversion date or the prior business day. This rate may differ from rates in While we investigate, the same rules apply to the disputed amount as + effect on the date of your charge. Charges converted by establishments discussed above. After we finish our investigation, we will tell you our + (such as airlines) will be billed at the rates such establishments use. decision. At that point, if we think you owe an amount and you do not pay + Credit Balance: A credit balance (designated CR) shown on this statement we may report you as delinquent. + + Your Bill with + Pay AutoPay + + Deduct your payment from your bank + account automatically each month. + + - + Avoid late fees + - + Save time + + Change of Address, phone number, email + + - Visit americanexpress.com/autopay + Online at www.americanexpress.com/updatecontactinfo + today to enroll. + - + Via mobile device + - + Voice automated: call the number on the back of your card + - + For name, company name, and foreign address or phone changes, please call Customer Care + + Please do not add any written communication or address change on this stub + For information on how we protect your + privacy and to set your communication + and privacy choices, please visit + www.americanexpress.com/privacy. <<< -AMERICAN Blue Cash® from American Express p. 3/7 - EXPRESS - JOSEPH PAULSON +AMERICAN Blue Cash® from American Express p. 3/7 + EXPRESS + JOSEPH PAULSON - Closing Date 09/27/23 Account Ending 7-73045 + Closing Date 09/27/23 Account Ending 7-73045 - Customer Care & Billing Inquiries 1-888-258-3741 - C International Collect 1-336-393-1111 =] Website: americanexpress.com - Cash Advance at ATMs Inquiries 1-800-CASH-NOW - Large Print & Braille Statements 1-888-258-3741 Customer Care Payments - & Billing Inquiries PO BOX 6031 - P.O. BOX 981535 CAROL STREAM IL - EL PASO, TX 60197-6031 - 79998-1535 - Hearing Impaired - Online chat at americanexpress.com or use Relay dial 711 and 1-888-258-3741 + Customer Care & Billing Inquiries 1-888-258-3741 + C International Collect 1-336-393-1111 + =] Website: americanexpress.com + Cash Advance at ATMs Inquiries 1-800-CASH-NOW + Customer Care Payments + Large Print & Braille Statements 1-888-258-3741 + & Billing Inquiries PO BOX 6031 + P.O. BOX 981535 CAROL STREAM IL + EL PASO, TX 60197-6031 + 79998-1535 + Hearing Impaired + Online chat at americanexpress.com or use Relay dial 711 and 1-888-258-3741 - American Express® High Yield Savings Account - No monthly fees. No minimum opening monthly deposit. 24/7 customer + American Express® High Yield Savings Account + No monthly fees. No minimum opening monthly deposit. 24/7 customer - support. FDIC insured. Meet your savings goals faster with an American + support. FDIC insured. Meet your savings goals faster with an American - Express High Yield Savings Account. Terms apply. Learn more by visiting + Express High Yield Savings Account. Terms apply. Learn more by visiting - americanexpress.com/savenow. + americanexpress.com/savenow. - Total + Total - Payments -$6,583.67 + Payments -$6,583.67 - Credits $0.00 + Credits $0.00 - Total Payments and Credits -$6,583.67 + Total Payments and Credits -$6,583.67 - Payments Amount + Payments Amount - 09/22/23* MOBILE PAYMENT - THANK YOU -$6,583.67 + 09/22/23* MOBILE PAYMENT - THANK YOU -$6,583.67 - Total + Total - Total New Charges $10,269.65 + Total New Charges $10,269.65 - JOSEPH PAULSON - an Card Ending 7-73045 + JOSEPH PAULSON + an Card Ending 7-73045 - Amount + Amount - 08/30/23 SAFEWAY CUPERTINO CA $23.11 - 800-898-4027 + 08/30/23 SAFEWAY CUPERTINO CA $23.11 + 800-898-4027 - 09/01/23 BANANA LEAF 650000012619980 MILPITAS CA $144.16 - 4087199811 + 09/01/23 BANANA LEAF 650000012619980 MILPITAS CA $144.16 + 4087199811 - 09/01/23 BT*LINODE*AKAMAI CAMBRIDGE MA $6,107.06 - 6093807100 + 09/01/23 BT*LINODE*AKAMAI CAMBRIDGE MA $6,107.06 + 6093807100 - 09/01/23 GOOGLE*GSUITE_SOCIALANIMAL.IO MOUNTAIN VIEW CA $20.44 - ADVERTISING SERVICE + 09/01/23 GOOGLE*GSUITE_SOCIALANIMAL.IO MOUNTAIN VIEW CA $20.44 + ADVERTISING SERVICE - 09/02/23 Amazon Web Services AWS.Amazon.com WA $333.88 - WEB SERVICES + 09/02/23 Amazon Web Services AWS.Amazon.com WA $333.88 + WEB SERVICES - 09/03/23 SAFEWAY CUPERTINO CA $11.18 - 800-898-4027 + 09/03/23 SAFEWAY CUPERTINO CA $11.18 + 800-898-4027 - 09/09/23 TST* BIKANER SWEET 00053687 SUNNYVALE CA $21.81 - RESTAURANT + 09/09/23 TST* BIKANER SWEET 00053687 SUNNYVALE CA $21.81 + RESTAURANT - Continued on reverse + Continued on reverse <<< - JOSEPH PAULSON Account Ending 7-73045 p.4/7 + JOSEPH PAULSON + Account Ending 7-73045 p.4/7 - Amount + Amount -09/10/23 CVS PHARMACY CUPERTINO CA $2.34 - 8007467287 +09/10/23 CVS PHARMACY CUPERTINO CA $2.34 + 8007467287 -09/13/23 APPLE.COM/BILL INTERNET CHARGE CA $2.99 - RECORD STORE +09/13/23 APPLE.COM/BILL INTERNET CHARGE CA $2.99 + RECORD STORE -09/13/23 SAFEWAY CUPERTINO CA $26.73 - 800-898-4027 +09/13/23 SAFEWAY CUPERTINO CA $26.73 + 800-898-4027 -09/14/23 MCDONALD'S CUPERTINO CA $3.26 - 6509404200 +09/14/23 MCDONALD'S CUPERTINO CA $3.26 + 6509404200 -09/14/23 PANERA BREAD #204476 CAMPBELL CA $23.38 +09/14/23 PANERA BREAD #204476 CAMPBELL CA $23.38 - 975313007 95008 + 975313007 95008 -09/14/23 MANLEY DONUTS 00-08040662747 CUPERTINO CA $21.15 - BAKERY +09/14/23 MANLEY DONUTS 00-08040662747 CUPERTINO CA $21.15 + BAKERY -09/15/23 Ap|Pay 6631309 - PEETS B TMP 53033 OKALAND CA $4.27 - RESTAURANT +09/15/23 Ap|Pay 6631309 - PEETS B TMP 53033 OKALAND CA $4.27 + RESTAURANT -09/16/23 VEGAS.COM LAS VEGAS NV $761.58 - 18669983427 +09/16/23 VEGAS.COM LAS VEGAS NV $761.58 + 18669983427 -09/16/23 Ap|Pay PANDA EXPRESS LAS VEGAS NV $12.08 - FAST FOOD RESTAURANT +09/16/23 Ap|Pay PANDA EXPRESS LAS VEGAS NV $12.08 + FAST FOOD RESTAURANT -09/17/23 Ap|IPay LUX_STARBUCKS_ATRIUM LAS VEGAS NV $23.68 - 11980066 89109 - RESTAURANT +09/17/23 Ap|IPay LUX_STARBUCKS_ATRIUM LAS VEGAS NV $23.68 + 11980066 89109 + RESTAURANT -09/18/23 SPK*SPOKEO ENTPRS 888-858-0803 CA $119.95 +09/18/23 SPK*SPOKEO ENTPRS 888-858-0803 CA $119.95 - 888-858-0803 + 888-858-0803 -09/24/23 SIXT USA POS FORT LAUDERDALE FL $2,537.90 - AUTOMOBILE RENTAL - Sixt9497938611 - 30826E5JF4ZIIBIHSB +09/24/23 SIXT USA POS FORT LAUDERDALE FL $2,537.90 + AUTOMOBILE RENTAL + Sixt9497938611 + 30826E5JF4ZIIBIHSB -09/24/23 LUCKY #773.SANTA CLARACA 0000000009925 SANTA CLARA CA $35.17 - 4082475200 +09/24/23 LUCKY #773.SANTA CLARACA 0000000009925 SANTA CLARA CA $35.17 + 4082475200 -09/24/23 MILAN SWEET CENTER 0000 MILPITAS CA $27.03 - 408-946-2525 +09/24/23 MILAN SWEET CENTER 0000 MILPITAS CA $27.03 + 408-946-2525 -09/25/23 ApIPay MANLEY DONUTS 00-08040662747 CUPERTINO CA $6.50 +09/25/23 ApIPay MANLEY DONUTS 00-08040662747 CUPERTINO CA $6.50 - BAKERY + BAKERY - Amount + Amount -Total Fees for this Period $0.00 +Total Fees for this Period $0.00 - Amount + Amount -Total Interest Charged for this Period $0.00 +Total Interest Charged for this Period $0.00 -About Trailing Interest -You may see interest on your next statement even if you pay the new balance in full and on time and make no new charges. This is called -"trailing interest". Trailing interest is the interest charged when, for example, you didn't pay your previous balance in full. When that -happens, we charge interest from the first day of the billing period until we receive your payment in full. You can avoid paying interest -on purchases by paying your balance in full (or if you have a Plan balance, by paying your Adjusted Balance on your billing statement) by -the due date each month. Please see the "When we charge interest" sub-section in your Cardmember Agreement for details. +About Trailing Interest +You may see interest on your next statement even if you pay the new balance in full and on time and make no new charges. This is called +"trailing interest". Trailing interest is the interest charged when, for example, you didn't pay your previous balance in full. When that +happens, we charge interest from the first day of the billing period until we receive your payment in full. You can avoid paying interest +on purchases by paying your balance in full (or if you have a Plan balance, by paying your Adjusted Balance on your billing statement) by +the due date each month. Please see the "When we charge interest" sub-section in your Cardmember Agreement for details. - Continued on next page + Continued on next page <<< -AMERICAN Blue Cash® from American Express p.5/7 - EXPRESS - JOSEPH PAULSON +AMERICAN Blue Cash® from American Express p.5/7 + EXPRESS + JOSEPH PAULSON - Closing Date 09/27/23 Account Ending 7-73045 + Closing Date 09/27/23 Account Ending 7-73045 - Amount + Amount - Total Fees in 2023 $0.00 + Total Fees in 2023 $0.00 - Total Interest in 2023 $0.00 + Total Interest in 2023 $0.00 - Your Annual Percentage Rate (APR) is the annual interest rate on your account. - Variable APRs will not exceed 29.99%. - Transactions Dated Annual Balance Interest - Percentage Subject to Charge - From To Rate Interest Rate + Your Annual Percentage Rate (APR) is the annual interest rate on your account. + Variable APRs will not exceed 29.99%. + Transactions Dated Annual Balance Interest + Percentage Subject to Charge + From To Rate Interest Rate - Purchases 02/26/2011 24.49% (v) $0.00 $0.00 + Purchases 02/26/2011 24.49% (v) $0.00 $0.00 - Cash Advances 02/26/2011 29.99% (v) $0.00 $0.00 + Cash Advances 02/26/2011 29.99% (v) $0.00 $0.00 - Total $0.00 + Total $0.00 - (v) Variable Rate + (v) Variable Rate <<< -JOSEPH PAULSON Account Ending 7-73045 p. 6/7 +JOSEPH PAULSON + Account Ending 7-73045 p. 6/7 <<< -AMERICAN 7/7 - EXPRESS JOSEPH PAULSON Closing Date 09/27/23 Account Ending 7-73045 - - EFT Error Resolution Notice - In Case of Errors or Questions About Your Electronic Transfers Telephone us at 1-800-IPAY-AXP for Pay By - Phone questions, at 1-800-528-2122 for Pay By Computer questions, and at 1-800-528-4800 for AutoPay. You - may also write us at American Express, Electronic Funds Services, P.O. Box 981531, El Paso TX 79998-1531, or - contact online at www.americanexpress.com/inquirycenter as soon as you can, if you think your statement or - receipt is wrong or if you need more information about a transfer on the statement or receipt. We must hear from - you no later than 60 days after we sent you the FIRST statement on which the error or problem appeared. - 1. Tell us your name and account number (if any). - 2. Describe the error or the transfer you are unsure about, and explain as clearly as you can why you - believe it is an error or why you need more information. - 3. Tell us the dollar amount of the suspected error. - We will investigate your complaint and will correct any error promptly. If we take more than 10 business days to - do this, we will credit your account for the amount you think is in error, so that you will have the use of the money - during the time it takes us to complete our investigation. - - End of Important Notices. +AMERICAN 7/7 + EXPRESS JOSEPH PAULSON Closing Date 09/27/23 Account Ending 7-73045 + + EFT Error Resolution Notice + In Case of Errors or Questions About Your Electronic Transfers Telephone us at 1-800-IPAY-AXP for Pay By + Phone questions, at 1-800-528-2122 for Pay By Computer questions, and at 1-800-528-4800 for AutoPay. You + may also write us at American Express, Electronic Funds Services, P.O. Box 981531, El Paso TX 79998-1531, or + contact online at www.americanexpress.com/inquirycenter as soon as you can, if you think your statement or + receipt is wrong or if you need more information about a transfer on the statement or receipt. We must hear from + you no later than 60 days after we sent you the FIRST statement on which the error or problem appeared. + 1. Tell us your name and account number (if any). + 2. Describe the error or the transfer you are unsure about, and explain as clearly as you can why you + believe it is an error or why you need more information. + 3. Tell us the dollar amount of the suspected error. + We will investigate your complaint and will correct any error promptly. If we take more than 10 business days to + do this, we will credit your account for the amount you think is in error, so that you will have the use of the money + during the time it takes us to complete our investigation. + + End of Important Notices. <<< \ No newline at end of file From bac2286c5784591f3fa54134b45139cbb7f97e82 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 30 Oct 2024 17:50:37 +0530 Subject: [PATCH 11/15] Removed dead code and moved sample.env to root out of tests --- .github/workflows/ci_test.yaml | 4 +- pyproject.toml | 2 +- tests/sample.env => sample.env | 0 src/unstract/llmwhisperer/__init__.py | 3 +- src/unstract/llmwhisperer/client_temp.py | 86 ------------------------ tests/conftest.py | 3 - 6 files changed, 5 insertions(+), 93 deletions(-) rename tests/sample.env => sample.env (100%) delete mode 100644 src/unstract/llmwhisperer/client_temp.py diff --git a/.github/workflows/ci_test.yaml b/.github/workflows/ci_test.yaml index 22abd96..6e8ac56 100644 --- a/.github/workflows/ci_test.yaml +++ b/.github/workflows/ci_test.yaml @@ -35,8 +35,8 @@ jobs: - name: Create test env shell: bash run: | - cp tests/sample.env tests/.env - sed -i "s|LLMWHISPERER_API_KEY=|LLMWHISPERER_API_KEY=${{ secrets.LLMWHISPERER_API_KEY }}|" tests/.env + cp sample.env .env + sed -i "s|LLMWHISPERER_API_KEY=|LLMWHISPERER_API_KEY=${{ secrets.LLMWHISPERER_API_KEY }}|" .env - name: Run tox id: tox diff --git a/pyproject.toml b/pyproject.toml index b893a52..501c267 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ includes = ["src"] package-dir = "src" [tool.pytest.ini_options] -env_files = ["tests/.env"] +env_files = [".env"] addopts = "-s" log_level = "INFO" log_cli = true diff --git a/tests/sample.env b/sample.env similarity index 100% rename from tests/sample.env rename to sample.env diff --git a/src/unstract/llmwhisperer/__init__.py b/src/unstract/llmwhisperer/__init__.py index c50eaef..abce009 100644 --- a/src/unstract/llmwhisperer/__init__.py +++ b/src/unstract/llmwhisperer/__init__.py @@ -1,8 +1,9 @@ __version__ = "0.22.0" from .client import LLMWhispererClient # noqa: F401 +from .client_v2 import LLMWhispererClientV2 # noqa: F401 -def get_sdk_version(): +def get_llmw_py_client_version(): """Returns the SDK version.""" return __version__ diff --git a/src/unstract/llmwhisperer/client_temp.py b/src/unstract/llmwhisperer/client_temp.py deleted file mode 100644 index 7b310a9..0000000 --- a/src/unstract/llmwhisperer/client_temp.py +++ /dev/null @@ -1,86 +0,0 @@ -import json -import logging -import os -import time -from client_v2 import LLMWhispererClientV2, LLMWhispererClientException - - -if __name__ == "__main__": - client = LLMWhispererClientV2() - - try: - # result = client.whisper( - # mode="native_text", - # output_mode="layout_preserving", - # file_path="../../../tests/test_data/credit_card.pdf", - # ) - # result = client.whisper( - # mode="high_quality", - # output_mode="layout_preserving", - # file_path="../../../tests/test_data/credit_card.pdf", - # ) - # result = client.whisper( - # mode="low_cost", - # output_mode="layout_preserving", - # file_path="../../../tests/test_data/credit_card.pdf", - # ) - - # result = client.register_webhook( - # url="https://webhook.site/15422328-2a5e-4a1d-9a20-f78313ca5007", - # auth_token="my_auth_token", - # webhook_name="wb3", - # ) - # print(result) - - # result = client.get_webhook_details(webhook_name="wb3") - # print(result) - - # result = client.whisper( - # mode="high_quality", - # output_mode="layout_preserving", - # file_path="../../../tests/test_data/credit_card.pdf", - # use_webhook="wb3", - # webhook_metadata="Dummy metadata for webhook", - # ) - - result = client.whisper( - mode="high_quality", - output_mode="layout_preserving", - file_path="../../../tests/test_data/credit_card.pdf", - # wait_for_completion=True, - # wait_timeout=200, - ) - print(json.dumps(result)) - - # result = client.whisper( - # mode="form", - # output_mode="layout_preserving", - # file_path="../../../tests/test_data/credit_card.pdf", - # ) - - # if result["status_code"] == 202: - # print("Whisper request accepted.") - # print(f"Whisper hash: {result['whisper_hash']}") - # while True: - # print("Polling for whisper status...") - # status = client.whisper_status(whisper_hash=result["whisper_hash"]) - # print(status) - # if status["status"] == "processing": - # print("STATUS: processing...") - # elif status["status"] == "delivered": - # print("STATUS: Already delivered!") - # break - # elif status["status"] == "unknown": - # print("STATUS: unknown...") - # break - # elif status["status"] == "processed": - # print("STATUS: processed!") - # print("Let's retrieve the result of the extraction...") - # resultx = client.whisper_retrieve( - # whisper_hash=result["whisper_hash"] - # ) - # print(resultx) - # break - # time.sleep(2) - except LLMWhispererClientException as e: - print(e) diff --git a/tests/conftest.py b/tests/conftest.py index 3c342c1..49eab9a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,10 @@ import os import pytest -from dotenv import load_dotenv from unstract.llmwhisperer.client import LLMWhispererClient from unstract.llmwhisperer.client_v2 import LLMWhispererClientV2 -load_dotenv() - @pytest.fixture(name="client") def llm_whisperer_client(): From 850f5e036905ac8d29455fa78b80c574c632434c Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Tue, 5 Nov 2024 17:47:14 +0530 Subject: [PATCH 12/15] Updated v2 BASE URL in sample.env --- sample.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample.env b/sample.env index a39817b..4b9d712 100644 --- a/sample.env +++ b/sample.env @@ -1,4 +1,4 @@ LLMWHISPERER_BASE_URL=https://llmwhisperer-api.unstract.com/v1 -LLMWHISPERER_BASE_URL_V2=https://llmwhisperer-api.unstract.com/api/v2 +LLMWHISPERER_BASE_URL_V2=https://llmwhisperer-api.us-central.unstract.com/api/v2 LLMWHISPERER_LOG_LEVEL=DEBUG LLMWHISPERER_API_KEY= From 1d8a5541f4f5231fa93bcb55830fd60f6a2b321d Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Tue, 5 Nov 2024 18:09:51 +0530 Subject: [PATCH 13/15] Bumped SDK version to 0.23.0 --- src/unstract/llmwhisperer/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/unstract/llmwhisperer/__init__.py b/src/unstract/llmwhisperer/__init__.py index abce009..54049f0 100644 --- a/src/unstract/llmwhisperer/__init__.py +++ b/src/unstract/llmwhisperer/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.22.0" +__version__ = "0.23.0" from .client import LLMWhispererClient # noqa: F401 from .client_v2 import LLMWhispererClientV2 # noqa: F401 From b95cf27e1744aacae54ae75646569b75067d72f4 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Tue, 5 Nov 2024 18:11:38 +0530 Subject: [PATCH 14/15] Updated URL in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 501c267..9d5cb77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ dependencies = [ "requests>=2", ] readme = "README.md" -urls = { Homepage = "https://llmwhisperer.unstract.com", Source = "https://github.com/Zipstack/llm-whisperer-python-client" } +urls = { Homepage = "https://unstract.com/llmwhisperer/", Source = "https://github.com/Zipstack/llm-whisperer-python-client" } license = {text = "AGPL v3"} authors = [ {name = "Zipstack Inc", email = "devsupport@zipstack.com"}, From 3e44930db435bdd7b28fbd9e081f8427a9c8e95e Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Tue, 5 Nov 2024 18:14:12 +0530 Subject: [PATCH 15/15] Updated docs URL in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b279246..cbdf6d1 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ LLMs are powerful, but their output is as good as the input you provide. LLMWhisperer is a technology that presents data from complex documents (different designs and formats) to LLMs in a way that they can best understand. LLMWhisperer features include Layout Preserving Mode, Auto-switching between native text and OCR modes, proper representation of radio buttons and checkboxes in PDF forms as raw text, among other features. You can now extract raw text from complex PDF documents or images without having to worry about whether the document is a native text document, a scanned image or just a picture clicked on a smartphone. Extraction of raw text from invoices, purchase orders, bank statements, etc works easily for structured data extraction with LLMs powered by LLMWhisperer's Layout Preserving mode. -Refer to the client documentation for more information: [LLMWhisperer Client Documentation](https://docs.unstract.com/llm_whisperer/python_client/llm_whisperer_python_client_intro) +Refer to the client documentation for more information: [LLMWhisperer Client Documentation](https://docs.unstract.com/llmwhisperer/index.html) ## Features