# Copyright 2023-2026 llmware

# Licensed under the Apache License, Version 2.0 (the "License"); you
# may not use this file except in compliance with the License.  You
# may obtain a copy of the License at

# http://www.apache.org/licenses/LICENSE-2.0

# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.  See the License for the specific language governing
# permissions and limitations under the License.

"""The retrieval module implements the Query class.  The Query class provides a high-level interface for executing
a variety of queries on a Library collection, whether instantiated on Mongo, Postgres, or SQLite.

The Query class includes both text retrieval strategies, which operate directly as queries on the text collection
database, as well as vector embedding semantic retrieval strategies, which require the use of o vector DB and that the
embeddings were previously created for the Library.  There are also a number of convenience methods that provide
'hybrid' strategies combining elements of semantic and text querying."""


import logging
import os
from collections import Counter
from datetime import datetime

try:
    from bson.objectid import ObjectId
except:
    pass

from llmware.configs import LLMWareConfig, LLMWareException, ModelNotFoundException
from llmware.embeddings import EmbeddingHandler
from llmware.resources import CollectionRetrieval, QueryState
from llmware.util import Utilities, CorpTokenizer
from llmware.models import ModelCatalog

logger = logging.getLogger(__name__)


class Query:

    """Implements the query capabilities against a ``Library` object`.

    Query is responsible for executing queries against an indexed library. The library can be semantic, text, custom,
    or hybrid. A query object requires a library object as input, which will be the source of the query.

    Parameters
    ----------
    library : Library object
        A ``library`` object.

    embedding_model : object, default=None
        An ``embedding_model`` object.

    tokenizer : object, default=None

    vector_db_api_key : str, default=None
        The API key for the vector store.

    query_id : int, default=None
        The identifier for a query. This is used when a query state has to be loaded.

    from_hf : bool, default=False
        Sets whether the embedding model should be loaded from hugging face.

    from_sentence_transformer: bool, default=False
        Sets whether the embedding model should be loaded from ``LLMWareSemanticModel``.

    embedding_model_name : str, default=None
        The name of the embedding model. This has to be set if ``from_sentence_transformer=True``.

    save_history : bool, default=True
        Sets whether the history of queries should be saved.

    query_mode : str, default=None
        Sets the query mode that should be used. It has to be either 'text', 'semantic', or 'hybrid'.

    vector_db : str, default=None
        The name of the vector store to be queried against. If it is not set, then this is determined by the
        given ``embedding_model``.


    Examples
    ----------
    >>> from llmware.library import Library
    >>> from llmware.retrieval import Query
    >>> library = Library().create_new_library('lib_semantic_query')
    >>> library.add_website(url='https://en.wikipedia.org/wiki/Austria', get_links=False)
    >>> library.install_new_embedding(embedding_model_name="industry-bert-sec", vector_db="milvus", batch_size=500)
    >>> query = Query(library=library)
    >>> results = query.semantic_query(query='the capital of austria is', result_count=3)
    >>> len(results)
    3
    >>> results[0].keys()
    dict_keys(['query', '_id', 'text', 'doc_ID', 'block_ID', 'page_num', 'content_type',
               'author_or_speaker', 'special_field1', 'file_source', 'added_to_collection',
               'table', 'coords_x', 'coords_y', 'coords_cx', 'coords_cy', 'external_files',
               'score', 'similarity', 'distance', 'matches', 'account_name', 'library_name'])
    >>> results[0]['query']
    'the capital of austria is'
    >>> results[0]['text']
    'Austria is a parliamentary representative democracy with a popularly elected president as head of '
    'state and a chancellor as head of government and chief executive. Major cities include Vienna , Graz, '
    'Linz , Salzburg , and Innsbruck . Austria has the 17th highest nominal GDP per capita with high '
    'standards of living; it was ranked 25th in the world for its Human Development Index in 2021. '
    >>> results[2]['text']
    "Austrian Parliament Building Vienna The Parliament of Austria is located in Vienna , the country's capital "
    "and most populous city. Austria became a federal , representative democratic republic through the "
    "Federal Constitutional Law of 1920. The political system of the Second Republic with its nine federal "
    "states is based on the constitution of 1920, amended in 1929, which was re-enacted on 1 May 1945. [108] "
    """

    def __init__(self, library, embedding_model=None, tokenizer=None, vector_db_api_key=None,
                 query_id=None, from_hf=False, from_sentence_transformer=False,embedding_model_name=None,
                 save_history=True, query_mode=None, vector_db=None, model_api_key=None):

        # load user profile & instantiate core library assets linked to profile

        self.library = library

        if library:
            self.library_name = library.library_name
            self.account_name = library.account_name
        else:
            # throw error if library object does not have library_name and account_name attributes
            raise LLMWareException(message= f"Query - init - library object not found - {library}")

        # explicitly pass name of embedding model, if multiple embeddings on library
        self.embedding_model_name = embedding_model_name

        # added option to pass embedding_model and tokenizer
        self.user_passed_model = embedding_model
        self.user_passed_tokenizer = tokenizer
        self.from_hf = from_hf
        self.from_sentence_transformer = from_sentence_transformer

        # edge case - if a user tries to load a sentence_transformer model but does not pass a model name
        if from_sentence_transformer and not embedding_model_name:
            raise LLMWareException(message=f"Query - init - to use sentence_transformers, please "
                                           f"provide the model name directly to load")

        # load default configs
        # embedding initialization parameters
        self.query_embedding = None
        self.embedding_model = None
        self.embedding_db = None
        self.embeddings = None
        self.model_api_key = model_api_key

        if self.library:
            self.embeddings = EmbeddingHandler(self.library)

        self.semantic_distance_threshold = 1000  # basic shut off at such a high level

        # keys that will be included in query results

        # full list
        self.query_result_standard_keys = ["_id", "text", "doc_ID", "block_ID","page_num","content_type",
                                           "author_or_speaker", "special_field1", "file_source","added_to_collection",
                                           "table", "coords_x", "coords_y", "coords_cx", "coords_cy", "external_files",
                                           "score", "similarity", "distance", "matches"]

        # short_list
        self.query_result_short_keys = ["text", "file_source", "page_num", "score", "distance","matches"]

        # minimum_list
        self.query_result_min_required_keys = ["text", "file_source", "page_num"]

        # default - set at 'full list'
        self.query_result_return_keys = self.query_result_standard_keys

        # default is semantic if embedding in place
        embedding_record = self.library.get_embedding_status()

        matched_lib_model = False

        if embedding_model_name:
            for emb in embedding_record:

                logger.info(f"update: Query - embedding record lookup - {embedding_model_name} - {emb}")

                if emb["embedding_model"] == embedding_model_name:

                    # if no vector_db name passed, then select based only on embedding_model
                    if not vector_db:
                        if emb["embedding_status"] == "yes":
                            self.embedding_db = emb["embedding_db"]
                            self.search_mode = "semantic"
                            matched_lib_model = True
                            break
                    else:
                        # confirm match of pair - embedding_model + vector_db
                        if emb["embedding_db"] == vector_db:
                            if emb["embedding_status"] == "yes":
                                self.embedding_db = emb["embedding_db"]
                                self.search_mode = "semantic"
                                matched_lib_model = True
                                break

        else:
            if len(embedding_record) > 0:

                if not vector_db:
                    last_emb_record = embedding_record[-1]
                    if last_emb_record["embedding_status"] == "yes":
                        self.embedding_db = last_emb_record["embedding_db"]
                        self.search_mode = "semantic"
                        self.embedding_model_name = last_emb_record["embedding_model"]
                        matched_lib_model = True
                else:
                    # look for match to passed vector_db and take most recent embedding
                    embedding_record.reverse()
                    for embs in embedding_record:
                        if embs["embedding_db"] == vector_db:
                            if embs["embedding_status"] == "yes":
                                self.embedding_db = vector_db
                                self.search_mode = "semantic"
                                self.embedding_model_name = embs["embedding_model"]
                                matched_lib_model = True
                                break

        if matched_lib_model:

            logger.info(f"update: Query - found matches in embedding record - "
                        f"{self.embedding_db} - {self.embedding_model_name}")

            if not self.embedding_model:
                self.load_embedding_model()

        else:
            self.search_mode = "text"

        # passed for accessing api_based vector db
        self.vector_db_api_key = vector_db_api_key

        # if query_id passed, then load that state
        if query_id:
            self.query_id = query_id
            self.load_query_state(query_id)
        else:
            self.query_id = QueryState().issue_new_query_id()

        self.result_text_chunk_size = self.library.block_size_target_characters

        # state variables
        self.results = []
        self.query_history = []
        self.doc_id_list = []
        self.doc_fn_list = []

        self.save_history = save_history

        if query_mode:
            self.search_mode = query_mode

        #   confirm that 'query_history' path exists
        query_history_path = LLMWareConfig().get_query_path()
        if not os.path.exists(query_history_path):
            os.mkdir(query_history_path)
            os.chmod(query_history_path, 0o777)

    def load_embedding_model(self):

        """ Loads the embedding model pulled from the embedding_record of the library. """

        # skip if already instantiated self.embedding_model

        if not self.embedding_model:

            if self.user_passed_model:

                if self.from_hf:
                    self.embedding_model = ModelCatalog().load_hf_embedding_model(self.user_passed_model,
                                                                                  self.user_passed_tokenizer)
                if self.from_sentence_transformer:
                    self.embedding_model = ModelCatalog().load_sentence_transformer_model(self.user_passed_model,
                                                                                          self.embedding_model_name)

            else:
                if ModelCatalog().lookup_model_card(self.embedding_model_name):
                    self.embedding_model = ModelCatalog().load_model(selected_model=self.embedding_model_name,
                                                                     api_key=self.model_api_key)
                else:
                    logger.info(f"update: Query - selected embedding model could not be found - "
                                f"{self.embedding_model_name}")

        return self

    def get_output_keys(self):

        """ Returns list of keys that will be provided in each query_result. """

        return self.query_result_return_keys

    def set_output_keys(self, result_key_list):

        """ Sets the list of keys that will be returned in each query_result. """

        # set the output keys
        validated_list = []
        for key in result_key_list:
            if key in self.library.default_keys:
                validated_list.append(key)

        # minimum required list
        for rk in self.query_result_min_required_keys:
            if rk not in validated_list:
                validated_list.append(rk)
                logger.info(f"warning: Query - adding required keys useful in downstream processing - {rk}")

        # setting updated query_return_keys that is used in packaging query results
        self.query_result_return_keys = validated_list

        return validated_list

    def start_query_session(self, query_id=None):

        """ Initiates a query session and will capture potentially multiple related queries in single state. """

        if query_id:
            self.query_id = query_id

        if self.query_id:
            QueryState(self).load_state(self.query_id)
        else:
            query_id = QueryState(self).initiate_new_state_session()

        return query_id

    def register_query (self, retrieval_dict):

        """ Registers a query to the query state. """

        # qr_dict = ["query", "results", "doc_ID", "file_source"]

        # add query results as new "column" in query state
        self.results += retrieval_dict["results"]

        if retrieval_dict["query"] not in self.query_history:
            self.query_history.append(retrieval_dict["query"])

        for doc_id in retrieval_dict["doc_ID"]:
            if doc_id not in self.doc_id_list:
                self.doc_id_list.append(doc_id)

        for doc_fn in retrieval_dict["file_source"]:
            if doc_fn not in self.doc_fn_list:
                self.doc_fn_list.append(doc_fn)

        # QueryState(self).save_state(self.query_id)

        return self

    def load_query_state(self, query_id):

        """ Loads a query state of a previous query by query_id """

        state = QueryState(self).load_state(query_id)
        return self

    def save_query_state(self):

        """ Saves the current query state. """

        QueryState(self).save_state()
        return self

    def clear_query_state(self):

        """ Resets the query state. """

        # need to reset state variables
        QueryState(self).initiate_new_state_session()
        return self

    def dump_current_query_state(self):

        """ Dumps the current query_state to a query_state_dict. """

        query_state_dict = {"query_id": self.query_id,
                            "query_history": self.query_history,
                            "results": self.results,
                            "doc_ID": self.doc_id_list,
                            "file_source": self.doc_fn_list
                            }

        return query_state_dict

    def query(self, query, query_type="text", result_count=20, results_only=True):

        """ Main method for executing a basic query - expects query as input, and optional parameters for
        query_type, result_count and whether results_only.  Output is a set of query results, which is a list of
        dictionaries, with each dictionary representing a single matching retrieval from the collection. """

        output_result = {"results": [], "doc_ID": [], "file_source": []}

        if query_type not in ["text", "semantic"]:
            logger.error("error: Query().query expects a query type of either 'text' or 'semantic'")
            return output_result

        if query_type == "text":
            output_result = self.text_query(query,result_count=result_count,results_only=results_only)

        if query_type == "semantic":

            #   check that embedding model is available, and if not, flip to text search

            if not self.embedding_model:
                self.load_embedding_model()

            if self.search_mode == "text" or not self.embedding_model:
                output_result = self.text_query(query, result_count=result_count,results_only=results_only)
            else:
                output_result = self.semantic_query(query, result_count=result_count,results_only=results_only)

        return output_result

    def text_query (self, query, exact_mode=False, result_count=20, exhaust_full_cursor=False, results_only=True):

        """ Execute a basic text query. """

        # prepare query if exact match required
        if exact_mode:
            query = self.exact_query_prep(query)

        # query the text collection
        cursor = CollectionRetrieval(self.library_name,account_name=self.account_name).basic_query(query)

        # package results, with correct sample counts and output keys requested
        results_dict = self._cursor_to_qr(query, cursor,result_count=result_count,exhaust_full_cursor=
                                          exhaust_full_cursor)

        if results_only:
            return results_dict["results"]

        return results_dict

    def text_query_with_document_filter(self, query, doc_filter, result_count=20, exhaust_full_cursor=False,
                                        results_only=True, exact_mode=False):

        """ Execute a text query with a document filter applied. """

        # prepare query if exact match required
        if exact_mode:
            query = self.exact_query_prep(query)

        key = None
        value_range = []

        if "doc_ID" in doc_filter:
            key = "doc_ID"
            value_range = doc_filter["doc_ID"]

        elif "file_source" in doc_filter:
            key = "file_source"
            value_range = doc_filter["file_source"]

        else:
            logger.warning("warning: Query - expected to receive document filter with keys of 'doc_ID' or "
                           "'file_source' - as a safe fall-back - will run the requested query without a filter.")

        if key:
            cursor = CollectionRetrieval(self.library_name, account_name=self.account_name). \
                    text_search_with_key_value_range(query, key, value_range)
        else:
            # as fallback, if no key found, then run query without filter
            cursor = CollectionRetrieval(self.library_name, account_name=self.account_name).basic_query(query)

        result_dict = self._cursor_to_qr(query, cursor, result_count=result_count,
                                         exhaust_full_cursor=exhaust_full_cursor)

        if results_only:
            return result_dict["results"]

        return result_dict

    def text_query_by_content_type (self, query, content_type,results_only=True):

        """ Execute a text query with additional constraint of content type, e.g., 'image', 'table', or 'text' """

        filter_dict = {"content_type": content_type}
        retrieval_dict = self.text_query_with_custom_filter(query,filter_dict,results_only=True)
        return retrieval_dict

    def image_query(self, query, results_only=True):

        """ Execute a query with content_type == 'image'. """

        filter_dict = {"content_type": "image"}
        retrieval_dict = self.text_query_with_custom_filter(query, filter_dict,results_only=True)
        return retrieval_dict

    def table_query(self, query, export_tables_to_csv=False, results_only=True):

        """ Execute a query with content_type == 'table'. """

        filter_dict = {"content_type": "table"}
        retrieval_dict = self.text_query_with_custom_filter(query, filter_dict,results_only=True)

        # output and write tables to csv files
        if export_tables_to_csv:
            for i, entry in enumerate(retrieval_dict["results"]):
                f = self.export_one_table_to_csv(entry,output_fp=LLMWareConfig.get_query_path(),
                                                 output_fn="table_{}.csv".format(i))

                logger.warning(f"update: csv created - {LLMWareConfig.get_query_path()}- {f}")

        return retrieval_dict

    def text_search_by_page (self, query, page_num=1, results_only=True):

        """ Execute a text search with page number constraint provided as page_num parameter. """

        key = "master_index"  # parsing uses "master_index" across multiple input sources, interpret as "page_num"

        if not isinstance(page_num, list):
            page_num = [page_num]

        cursor_results = CollectionRetrieval(self.library_name, account_name=self.account_name).\
            text_search_with_key_value_range(query, key, page_num)

        retrieval_dict = self._cursor_to_qr(query, cursor_results)

        if results_only:
            return retrieval_dict["results"]

        return retrieval_dict

    def text_query_by_author_or_speaker(self, query, author_or_speaker, results_only=True):

        """ Execute a text query with specific author_or_speaker constraint. """

        filter_dict = {"author_or_speaker": author_or_speaker}
        retrieval_dict = self.text_query_with_custom_filter(query,filter_dict,results_only=results_only)
        return retrieval_dict

    def text_query_with_custom_filter (self, query, filter_dict, result_count=20,
                                       exhaust_full_cursor=False, results_only=True):

        """ Execute a text query with additional custom filter dictionary. """

        # filter_dict is a dict with indefinite number of key:value pairs - each key will be interpreted
        #   as "$and" in the query, requiring a match against all of the key:values in the filter_dict

        # validate filter dict
        validated_filter_dict = {}
        for key, values in filter_dict.items():
            for valid_keys in self.library.default_keys:
                if key in valid_keys:
                    validated_filter_dict.update({key:values})

        if validated_filter_dict:
            cursor = CollectionRetrieval(self.library_name, account_name=self.account_name).\
                text_search_with_key_value_dict_filter(query,validated_filter_dict)

        else:
            logger.error("error: Query text_query_with_custom_filter - keys in filter_dict are not"
                         "recognized as part of the library.collection default_keys list.")

            return -1

        result_dict = self._cursor_to_qr_with_secondary_filter(query, cursor,filter_dict,
                                                               result_count=result_count,
                                                               exhaust_full_cursor=exhaust_full_cursor)

        if results_only:
            return result_dict["results"]

        return result_dict

    def _cursor_to_qr_with_secondary_filter(self, query, cursor_results, filter_dict,
                                            result_count=20, exhaust_full_cursor=False):

        """ Internal helper method to package query results from cursor. """

        qr = []
        counter = 0
        doc_id_list = []
        doc_fn_list = []

        for raw_qr in cursor_results:

            # update to locate match and add to result
            matches_found = self.locate_query_match(query, raw_qr["text"])
            raw_qr.update({"matches": matches_found})
            raw_qr.update({"page_num": raw_qr["master_index"]})

            raw_qr.update({"_id": str(raw_qr["_id"])})

            if "score" not in raw_qr:
                raw_qr.update({"score": 0.0})

            if "similarity" not in raw_qr:
                raw_qr.update({"similarity": 0.0})

            if "distance" not in raw_qr:
                raw_qr.update({"distance": 0.0})

            # apply secondary filter dict
            match = -1
            for key, value in filter_dict.items():
                if key in raw_qr:
                    # support case in which filter_dict is a list, e.g., doc_id = {5,9,13}
                    if raw_qr[key] == value or (isinstance(value,list) and raw_qr[key] in value):
                        match = 1
                    else:
                        match = -1
                        break

            if match == 1:

                # output target keys
                output_dict = {}
                output_dict.update({"query": query})

                for key in self.query_result_return_keys:
                    if key not in raw_qr:
                        logger.warning(f"warning: Query() - selected output key not found in result - {key}")
                    else:
                        output_dict.update({key: raw_qr[key]})

                output_dict.update({"account_name": self.account_name})
                output_dict.update({"library_name": self.library_name})

                qr.append(output_dict)

                if raw_qr["doc_ID"] not in doc_id_list:
                    doc_id_list.append(raw_qr["doc_ID"])
                    doc_fn_list.append(raw_qr["file_source"])

                counter += 1

                # will exhaust full cursor if .exhaust_full_cursor = True
                if counter >= result_count and not exhaust_full_cursor:
                    break

        qr_dict = {"query": query, "results": qr, "doc_ID": doc_id_list, "file_source": doc_fn_list}

        if self.save_history:
            self.register_query(qr_dict)

        return qr_dict

    def _cursor_to_qr (self, query, cursor_results, result_count=20, exhaust_full_cursor=False):

        """ Internal helper method to package query results from cursor. """

        qr = []
        counter = 0
        doc_id_list = []
        doc_fn_list = []

        for raw_qr in cursor_results:

            # update to locate match and add to result
            matches_found = self.locate_query_match(query, raw_qr["text"])
            raw_qr.update({"matches": matches_found})
            raw_qr.update({"page_num": raw_qr["master_index"]})

            raw_qr.update({"_id": str(raw_qr["_id"])})

            if "score" not in raw_qr:
                raw_qr.update({"score": 0.0})

            if "similarity" not in raw_qr:
                raw_qr.update({"similarity": 0.0})

            if "distance" not in raw_qr:
                raw_qr.update({"distance": 0.0})

            # output target keys
            output_dict = {}
            output_dict.update({"query": query})

            for key in self.query_result_return_keys:
                if key not in raw_qr:
                    logger.warning(f"warning: Query() - selected output key not found in result - {key}")
                else:
                    output_dict.update({key: raw_qr[key]})

            output_dict.update({"account_name": self.account_name})
            output_dict.update({"library_name": self.library_name})

            qr.append(output_dict)

            if raw_qr["doc_ID"] not in doc_id_list:
                doc_id_list.append(raw_qr["doc_ID"])
                doc_fn_list.append(raw_qr["file_source"])

            counter += 1

            # will exhaust full cursor if .exhaust_full_cursor = True
            if counter >= result_count and not exhaust_full_cursor:
                break

        qr_dict = {"query": query,"results": qr, "doc_ID": doc_id_list, "file_source": doc_fn_list}

        if self.save_history:
            self.register_query(qr_dict)

        return qr_dict

    def semantic_query(self, query, result_count=20, embedding_distance_threshold=None, custom_filter=None, results_only=True):

        """ Main method to execute a semantic query - only required parameter is the query. """

        if not embedding_distance_threshold:
            embedding_distance_threshold = self.semantic_distance_threshold

        self.load_embedding_model()

        # confirm that embedding model exists, or catch and raise error
        if self.embedding_model:
            self.query_embedding = self.embedding_model.embedding(query)
        else:
            raise ModelNotFoundException(self.library_name)

        if self.embedding_db and self.embedding_model:

            semantic_block_results = self.embeddings.search_index(self.query_embedding,
                                                                  embedding_db=self.embedding_db,
                                                                  model=self.embedding_model,
                                                                  sample_count=result_count)

        else:
            logger.error(f"error: Query - embedding record does not indicate embedding db - "
                         f"{self.embedding_db} and/or embedding model - {self.embedding_model}")

            raise LLMWareException(message=f"Query - semantic query - selected "
                                           f"embedding database is not supported - {self.embedding_db}")

        qr_raw = []

        # Collecting semantic results
        for i, blocks in enumerate(semantic_block_results):
            if blocks[1] < embedding_distance_threshold:
                block_data = blocks[0]
                block_data["distance"] = blocks[1]
                block_data["semantic"] = "semantic"
                block_data["score"] = 0.0
                qr_raw.append(block_data)

        # Applying custom filter if provided
        if custom_filter:
            qr_raw = self.apply_custom_filter(qr_raw, custom_filter)

        # Processing results
        results_dict = self._cursor_to_qr(query, qr_raw, result_count=result_count)

        return results_dict["results"] if results_only else results_dict

    def apply_custom_filter(self, results, custom_filter):

        """ Apply custom filter to a set of results. """

        filtered_results = []
        for result in results:
            if all(result.get(key) == value for key, value in custom_filter.items()):
                filtered_results.append(result)
        return filtered_results

    def semantic_query_with_document_filter(self, query, filter_dict, embedding_distance_threshold=None,
                                            result_count=100, results_only=True):

        """ Execute semantic query with secondary constraint of document filter. """

        #   checks for filter to offer option to do semantic query in specific doc, page or content range
        if not embedding_distance_threshold:
            embedding_distance_threshold = self.semantic_distance_threshold

        #   note:  by default, retrieves a much larger set of results to try to account for filter

        th = self.semantic_distance_threshold

        # confirm that embedding model exists, or catch and raise error
        if self.embedding_model:
            self.query_embedding = self.embedding_model.embedding(query)
        else:
            raise ModelNotFoundException(self.library_name)

        if self.embedding_db and self.embedding_model:
            semantic_block_results = self.embeddings.search_index(self.query_embedding,
                                                                  embedding_db=self.embedding_db,
                                                                  model=self.embedding_model,
                                                                  sample_count=result_count)

        else:
            logger.error(f"error: Query - embedding record does not indicate embedding db- {self.embedding_db} "
                         f"and/or an embedding_model - {self.embedding_model}")

            raise LLMWareException(message=f"Query - semantic query with document filter - selected "
                                           f"embedding database is not supported - {self.embedding_db}")

        qr_raw = []

        # may need to conform the output structure of semantic_block_results
        for i, blocks in enumerate(semantic_block_results):
            # assume that each block has at least two components:  [0] core mongo block, and [1] distance metric
            if blocks[1] < embedding_distance_threshold:

                blocks[0].update({"distance": blocks[1]})
                blocks[0].update({"semantic": "semantic"})
                blocks[0].update({"score": 0.0})

                qr_raw.append(blocks[0])

        result_output = self._cursor_to_qr_with_secondary_filter(query,qr_raw,filter_dict,result_count=result_count)

        if results_only:
            return result_output["results"]

        return result_output

    def similar_blocks_embedding(self, block, result_count=20, embedding_distance_threshold=10, results_only=True):

        """ Application of semantic embedding space - takes a block of text as input and finds most similar comparable
        text chunks. If you are comfortable with the normalization of the embedding vector space, then set a
        specific embedding_distance_threshold, e.g., embedding_distance_threshold=1.1 to limit the results to
        text blocks within a distance of 1.1 in the embedding space."""

        # will use embedding to find similar blocks from a given block
        # confirm that embedding model exists, or catch and raise error
        if self.embedding_model:
            self.query_embedding = self.embedding_model.embedding(block["text"])
        else:
            raise ModelNotFoundException(self.library_name)

        if self.embedding_model and self.embedding_db:
            semantic_block_results = self.embeddings.search_index(self.query_embedding,
                                                                  embedding_db=self.embedding_db,
                                                                  model=self.embedding_model,
                                                                  sample_count=result_count)

        else:
            logger.error(f"error: Query - embedding record does not indicate embedding db- "
                         f"{self.embedding_db} and/or embedding model - {self.embedding_model}")

            raise LLMWareException(message=f"Query - similar blocks embedding - selected "
                                           f"embedding database is not supported - {self.embedding_db}")

        qr_raw = []

        # may need to conform the output structure of semantic_block_results
        for i, blocks in enumerate(semantic_block_results):
            # assume that each block has at least two components:  [0] core mongo block, and [1] distance metric
            if blocks[1] < embedding_distance_threshold:

                blocks[0].update({"distance": blocks[1]})
                blocks[0].update({"semantic": "semantic"})
                blocks[0].update({"score": 0.0})

                qr_raw.append(blocks[0])

        # pick up with boilerplate
        results_dict = self._cursor_to_qr("", qr_raw, result_count=result_count)

        if results_only:
            return results_dict["results"]

        return results_dict

    def dual_pass_query(self, query, result_count=20, primary="text",
                        safety_check=True, custom_filter=None, results_only=True):

        """ Executes a combination of text and semantic queries and attempts to interweave and re-rank based on
        correspondence between the two query attempts. """

        # safety check
        if safety_check and result_count > 100:
            logger.info("warning: Query().dual_pass_query runs a comparison of output rankings using semantic "
                        "and text.  This particular implementation is not optimized for sample lists longer "
                        "than ~100 X 100.  To remove this warning, there are two options - (1) set the "
                        "safety_check to False in the method declaration, or (2) keep sample count below 100.")

            result_count = 100

        # following keys are required for dual pass query to work, add them if user has omitted them
        keys_to_check = ['_id', 'doc_ID']
        for key in keys_to_check:
            if key not in self.query_result_return_keys:
                self.query_result_return_keys.append(key)
        
        # run dual pass - text + semantic
        # Choose appropriate text query method based on custom_filter
        if custom_filter:
            retrieval_dict_text = self.text_query_with_custom_filter(query, custom_filter,
                                                                     result_count=result_count, results_only=True)
        else:
            retrieval_dict_text = self.text_query(query, result_count=result_count, results_only=True)

        # Semantic query with custom filter
        retrieval_dict_semantic = self.semantic_query(query, result_count=result_count,
                                                      custom_filter=custom_filter, results_only=True)

        if primary == "text":
            first_list = retrieval_dict_text
            second_list = retrieval_dict_semantic
        else:
            first_list = retrieval_dict_semantic
            second_list = retrieval_dict_text

        confirming_list = []
        primary_only = []
        secondary_only = []
        matched_second_list = []

        # this is the time intensive "n-squared" loop - probably OK up to 100+
        for i, entry in enumerate(first_list):
            match = -1
            for j, entry2 in enumerate(second_list):
                if entry["_id"] == entry2["_id"]:
                    entry["match_status"] = "matched"  # Tagging as matched
                    confirming_list.append(entry)
                    match = 1
                    matched_second_list.append(entry2["_id"])
                    break
            if match == -1:
                entry["match_status"] = "primary_only"  # Tagging as primary only
                primary_only.append(entry)

        for k, entry2 in enumerate(second_list):
            if entry2["_id"] not in matched_second_list:
                entry2["match_status"] = "secondary_only"  # Tagging as secondary only
                secondary_only.append(entry2)

        # assemble merged top results
        merged_results = []
        merged_results += confirming_list

        select_primary = min(len(primary_only), 5)
        select_secondary = min(len(secondary_only), 5)

        merged_results += primary_only[0:select_primary]
        merged_results += secondary_only[0:select_secondary]

        doc_id_list = []
        doc_fn_list = []

        for qr in merged_results:
            if qr["doc_ID"] not in doc_id_list:
                doc_id_list.append(qr["doc_ID"])
            if qr["file_source"] not in doc_fn_list:
                doc_fn_list.append(qr["file_source"])

        retrieval_dict = {"results": merged_results,
                          "text_results": retrieval_dict_text,
                          "semantic_results": retrieval_dict_semantic,
                          "doc_ID": doc_id_list,
                          "file_source": doc_fn_list}

        if results_only:
            return merged_results

        return retrieval_dict

    def augment_qr (self, query_result, query_topic, augment_query="semantic"):

        """ Augments the set of query results using alternative retrieval strategy. """

        if augment_query == "semantic":
            qr_aug = self.semantic_query(query_topic,result_count=20, results_only=True)
        else:
            qr_aug = self.text_query(query_topic,result_count=20, results_only=True)

        # consolidate the qr lists
        updated_qr = []
        for qr in query_result:
            updated_qr.append(qr)   # start with original qr list

        # add up to 10 entries from semantic list
        semantic_return_max = 10

        for j, sem_entries in enumerate(qr_aug):
            if sem_entries not in updated_qr:
                updated_qr.append(sem_entries)
                if j > semantic_return_max:
                    break

        return updated_qr

    def apply_semantic_ranking(self, qr, issue_semantic):

        """ Apply ranking of query results by semantic query ranking. """

        #   designed to take a set of query results, and re-rank the order of results by their semantic distance
        #   --note:  possible to use a different query term for issue_semantic than the original query result

        #   heuristic - look for result targets of at least 20, but up to the exact len of the qr
        result_target = max(len(qr),20)

        semantic_qr = self.semantic_query(issue_semantic,result_count=result_target)

        reranked_qr = []
        for i, s in enumerate(semantic_qr):

            for q in qr:
                if s["_id"] == q["_id"]:
                    reranked_qr.append(q)
                    break

        for q in qr:
            if q not in reranked_qr:
                reranked_qr.append(q)

        return reranked_qr

    def document_filter (self, filter_topic, query_mode="text", result_count=30,
                         exact_mode = False, exhaust_full_cursor=True):

        """ Takes a filter_topic as input, along with query_mode, and generates a document filter as output. """

        result_dict = None

        if query_mode not in ["text", "semantic", "hybrid"]:

            logger.error(f"error: Query document_filter supports query types - 'text', "
                         f"'semantic', and 'hybrid' - type selected not recognized - {query_mode}")

            return result_dict

        if query_mode == "text":
            result_dict = self.text_query(filter_topic,exact_mode=exact_mode,result_count=result_count,
                                          exhaust_full_cursor=exhaust_full_cursor,results_only=False)

        if query_mode == "semantic":
            result_dict = self.semantic_query(filter_topic,result_count=result_count, results_only=False)

        if query_mode == "hybrid":
            result_dict = self.dual_pass_query(filter_topic)

        if not result_dict:

            logger.error(f"error: Query file_selector_only could not find a result - unexpected error - "
                         f"{filter_topic}")

            return result_dict

        doc_filter_output = {"doc_ID": result_dict["doc_ID"], "file_source": result_dict["file_source"]}

        return doc_filter_output

    def page_lookup(self, page_list=None, doc_id_list=None, text_only=False):

        """ Look up by specific pages, e.g, useful to retrieve the entire first page of a template document. """

        if not doc_id_list:
            doc_id_list = self.list_doc_id()
        else:
            if isinstance(doc_id_list,dict):
                if "doc_ID" in doc_id_list:
                    doc_id_list = doc_id_list["doc_ID"]
                else:
                    logger.warning("warning: could not recognize doc id list requested.  by default, "
                                   "will set to all documents in the library collection.")

                    doc_id_list = self.list_doc_id()

        if not page_list:
            logger.warning("warning: page lookup requested, but no value range identified.  by default, will set "
                           "to retrieve the first page only.")
            page_list = [1]

        if text_only:
            page_dict = {"doc_ID": {"$in":doc_id_list}, "master_index": {"$in": page_list}, "content_type":"text"}
        else:
            page_dict = {"doc_ID": {"$in":doc_id_list}, "master_index": {"$in": page_list}}

        cursor_results = CollectionRetrieval(self.library_name,
                                             account_name=self.account_name).filter_by_key_dict(page_dict)

        output = []

        for x in cursor_results:

            x.update({"matches": []})
            x.update({"page_num": x["master_index"]})

            output.append(x)

        return output

    def get_whole_library(self, selected_keys=None):

        """ Gets the whole library - and will return as a list in-memory. """

        match_results_cursor = CollectionRetrieval(self.library_name,
                                                   account_name=self.account_name).get_whole_collection()

        match_results = match_results_cursor.pull_all()

        qr = []

        # option to retrieve only user selected keys
        if not selected_keys:
            selected_keys = self.library.default_keys

        for i, block in enumerate(match_results):

            new_row = {}
            new_row.update({"_id": str(block["_id"])})
            new_row.update({"matches": []})
            new_row.update({"page_num": block["master_index"]})
            new_row.update({"score": 0.0})
            new_row.update({"similarity": 0.0})
            new_row.update({"distance": 0.0})

            for keys in selected_keys:
                if keys in block:
                    if keys not in new_row:
                        new_row.update({keys:block[keys]})

            qr.append(new_row)

        return qr

    def export_all_tables(self, query="", output_fp=None):

        """ Exports all tables, with query option to limit the list from a library. """

        table_csv_files_created = []

        if not output_fp:
            output_fp = self.library.misc_path

        if not query:

            match_results = CollectionRetrieval(self.library_name,
                                                account_name=self.account_name).filter_by_key("content_type","table")

        else:
            kv_dict = {"content_type": "table"}
            match_results = CollectionRetrieval(self.library_name, account_name=self.account_name).\
                text_search_with_key_value_dict_filter(query,kv_dict)

        counter = 0

        for i, entries in enumerate(match_results):

            table = entries["table"]

            output = []

            table_raw = table
            rows = table_raw.split("<tr>")
            cols_tracker = []
            coords_master = []

            for row in rows:

                new_row = []
                if row.strip().endswith("</tr>"):
                    row = row.strip()[:-5]

                cells = row.lstrip().split("<th>")
                cols_count = 0
                coords = []

                for c in cells:

                    if c.strip().endswith("</th>"):
                        c = c.strip()[:-5]

                    clean_cell = ""
                    bracket_on = 0

                    fields = c.split("<")

                    if fields[0]:
                        index = fields[1].rstrip()[0:-1]

                        main_entry = fields[2].split(">")
                        value = main_entry[-1]

                        co = main_entry[0].split(" ")

                        if len(co) > 2:
                            x = co[1]
                            y = co[2]

                            coords.append((int(x), int(y)))

                    for c1 in c:
                        if bracket_on == 0 and c1 not in ("<", ">"):
                            clean_cell += c1
                        if c1 == "<":
                            bracket_on = 1
                        if c1 == ">":
                            bracket_on = 0

                    if c:
                        c_strip = c.split(">")[-1]
                        new_row.append(c_strip.strip())
                        cols_count += 1

                coords_master.append(coords)
                cols_tracker.append(cols_count)
                output.append(new_row)

            new_file = "table_{}.csv".format(counter)

            counter += 1
            f = Utilities().file_save(output, output_fp, new_file)
            output = []

            table_csv_files_created.append(new_file)

        output_dict = {"library": self.library_name, "query": query, "tables_created": counter,
                       "file_names": table_csv_files_created, "output_fp": output_fp}

        return output_dict

    def export_one_table_to_csv(self, query_result, output_fp=None, output_fn=None):

        """ Exports a single table query result into a csv file. """

        table = query_result["table"]

        output = []

        table_raw = table
        rows = table_raw.split("<tr>")
        cols_tracker = []
        coords_master = []

        for row in rows:

            new_row = []
            if row.strip().endswith("</tr>"):
                row = row.strip()[:-5]

            cells = row.lstrip().split("<th>")
            cols_count = 0
            coords = []

            for c in cells:

                if c.strip().endswith("</th>"):
                    c = c.strip()[:-5]

                clean_cell = ""
                bracket_on = 0

                fields = c.split("<")

                if fields[0]:
                    index = fields[1].rstrip()[0:-1]
                    main_entry = fields[2].split(">")
                    value = main_entry[-1]
                    co = main_entry[0].split(" ")
                    if len(co) > 2:
                        x = co[1]
                        y = co[2]
                        coords.append((int(x), int(y)))

                for c1 in c:
                    if bracket_on == 0 and c1 not in ("<", ">"):
                        clean_cell += c1
                    if c1 == "<":
                        bracket_on = 1
                    if c1 == ">":
                        bracket_on = 0

                if c:
                    c_strip = c.split(">")[-1]
                    new_row.append(c_strip.strip())
                    cols_count += 1
            coords_master.append(coords)
            cols_tracker.append(cols_count)
            output.append(new_row)

        if not output_fn:
            new_file = "table_0.csv"
        else:
            new_file = output_fn

        f = Utilities().file_save(output, output_fp, new_file)

        return new_file

    def list_doc_id(self):

        """ Utility function - returns list of all doc_ids in the library. """

        doc_id_list = CollectionRetrieval(self.library_name, account_name=self.account_name).get_distinct_list("doc_ID")

        return doc_id_list

    def list_doc_fn(self):

        """ Utility function - returns list of all document names in the library. """

        doc_fn_raw_list = CollectionRetrieval(self.library_name,
                                              account_name=self.account_name).get_distinct_list("file_source")

        doc_fn_out = []
        for i, file in enumerate(doc_fn_raw_list):
            doc_fn_out.append(file.split(os.sep)[-1])
        return doc_fn_out

    def aggregate_text(self, qr_list):

        """ Utility method that take a list of query result dictionaries as input (with all of the associated metadata
        attributes) and repackages into two useful outputs:

            -- text_agg, which is the aggregated text across all of the query results in a single unbroken string, and
            -- meta_agg, which is a list of dictionaries with all of the 'start/stop' information in the text
                string, which can be used to map back a snippet of text back to its original block entry in the DB.
        """

        text_agg = ""
        meta_agg = []

        for i, entry in enumerate(qr_list):
            t = entry["text"]
            meta = {"start_char": len(text_agg), "stop_char": len(text_agg) + len(t), "block_id": entry["block_ID"],
                    "doc_ID": entry["doc_ID"], "page_num": entry["page_num"]}
            meta_agg.append(meta)
            text_agg += t

        return text_agg, meta_agg

    def document_lookup(self, doc_id="", file_source="", include_images=False):
        """
        Takes as an input either a doc_id or file_source (e.g., filename) that is in a Library, and
        returns all of the text and table blocks in the document. Images can be optionally included.
        
        Parameters:
            doc_id (str): Document ID.
            file_source (str): Source file name.
            include_images (bool): Whether to include images in the result. Defaults to False.
            
        Returns:
            list: Filtered list of document blocks.
        """

        if doc_id:
            kv_dict = {"doc_ID": doc_id}
        elif file_source:
            kv_dict = {"file_source": file_source}
        else:
            raise RuntimeError(
                "Query document_lookup method requires as input either a document ID or "
                "the name of a file already parsed in the library"
            )

        output = CollectionRetrieval(self.library_name, account_name=self.account_name).filter_by_key_dict(kv_dict)

        if len(output) == 0:
            logger.warning(f"update: Query - document_lookup  - nothing found - {doc_id} - {file_source}")
            return []

        output_final = []

        for entries in output:
            # Filter out images if include_images is False
            if include_images or entries["content_type"] != "image":
                entries.update({"matches": []})
                entries.update({"page_num": entries["master_index"]})
                output_final.append(entries)

        output_final = sorted(output_final, key=lambda x: x["block_ID"], reverse=False)

        return output_final

    def block_lookup(self, block_id, doc_id):

        """ Look up by a specific pair of doc_id and block_id in a library. """

        result = None

        kv_dict = {"doc_ID": doc_id, "block_ID": block_id}

        output = CollectionRetrieval(self.library_name, account_name=self.account_name).filter_by_key_dict(kv_dict)

        if len(output) == 0:
            logger.info(f"update: Query - Library - block_lookup - block not found: {block_id}")
            result = None
            
            return result
            
        if len(output) > 1:
            result = output[0]

        if len(output) == 1:
            result = output[0]
        
        # if arrived this point, then positive result has been identified
        result.update({"matches": []})
        result.update({"page_num": result["master_index"]})


        return result

    def get_header_text_from_collection(self, text_field="header_text"):

        """ Pulls the header_text from the collection, captured during parsing, useful to identify headlines. """

        ds_folder = self.library.nlp_path

        results = CollectionRetrieval(self.library_name, account_name=self.account_name).get_whole_collection()

        f = open(ds_folder + "header_text.txt", "w", encoding='utf-8')
        counter = 0
        for elements in results:
            text_sample = elements[text_field]
            if text_sample:
                f.write(text_sample)
                f.write("\n")
                f.write(elements["text"])
                f.write("\n")
                counter += 1

        f.close()
        results.close()
        return counter

    def get_core_text_from_collection(self, text_field="text"):

        """ Returns all core text from a collection. """

        ds_folder = self.library.nlp_path

        results = CollectionRetrieval(self.library_name, account_name=self.account_name).get_whole_collection()

        f = open(os.path.join(ds_folder,"core_text.txt"), "w", encoding='utf-8')
        counter = 0
        for elements in results:
            text_sample = elements[text_field]
            if text_sample:
                f.write(text_sample)
                f.write("\n")
                counter += 1

        f.close()
        results.close()
        return counter

    def get_user_tags(self):

        """ Returns user tags, if any, identified - note: this is experimental method and may change."""

        # look for all non-empty user_tags
        output = CollectionRetrieval(self.library_name,
                                     account_name=self.account_name).filter_by_key_ne_value("user_tags", "")

        counter = 0
        user_tags_out = []
        for elements in output:
            counter += 1
            user_tags_out.append((elements["block_ID"], elements["user_tags"]))

        return user_tags_out

    def filter_by_time_stamp (self, qr, first_date="", last_date=""):

        """ Filters results by condition of time range. """

        # apply filter dict to the qr results found
        time_str = "%Y-%m-%d"
        if first_date:
            first_date = datetime.strptime(first_date,time_str)

        if last_date:
            last_date = datetime.strptime(last_date, time_str)

        filtered_qr = []

        for i, entry in enumerate(qr):

            if entry["added_to_collection"]:

                try:
                    # First try Linux format
                    time_str = "%a %b %d %H:%M:%S %Y"
                    doc_date = datetime.strptime(entry["added_to_collection"], time_str)

                except ValueError:
                    # If it fails, try Windows format
                    time_str = "%m/%d/%y %H:%M:%S"
                    doc_date = datetime.strptime(entry["added_to_collection"], time_str)

                time_accept = self._time_window_filter(first_date,last_date,doc_date)

                if time_accept:
                    filtered_qr.append(entry)

        return filtered_qr

    def _time_window_filter(self, start_time,end_time, test_time, time_str="%a %b %d %H:%M:%S %Y"):

        """ Internal helper function to evaluate and compare time stamps. """

        if start_time and end_time:
            if start_time <= test_time <= end_time:
                return True

        if start_time and not end_time:
            if start_time <= test_time:
                return True

        if not start_time and end_time:
            if test_time <= end_time:
                return True

        return False

    def locate_query_match (self, query, core_text):

        """ Utility function to locate the character-level match of a query inside a core_text. """

        matches_found = []
        
        # edge case - but return empty match if query is null
        if not query:
            return matches_found
            
        b = CorpTokenizer(one_letter_removal=False, remove_stop_words=False, remove_punctuation=False,
                          remove_numbers=False)

        query_tokens = b.tokenize(query)

        for x in range(0, len(core_text)):
            match = 0
            for key_term in query_tokens:
                if len(key_term) == 0:
                    continue
                
                if key_term.startswith('"'):
                    key_term = key_term[1:-1]

                if core_text[x].lower() == key_term[0].lower():
                    match += 1
                    if (x + len(key_term)) <= len(core_text):
                        for y in range(1, len(key_term)):
                            if key_term[y].lower() == core_text[x + y].lower():
                                match += 1
                            else:
                                match = -1
                                break

                        if match == len(key_term):
                            new_entry = [x, key_term]
                            matches_found.append(new_entry)

        return matches_found

    def exact_query_prep(self, query):

        """ Prepares an exact query prep. """

        if query.startswith('"') and query.endswith('"'):
            prepared_query = '\"' + query[1:-1] + '\"'

        else:
            # even if user did not wrap in quotes, treat as exact search
            prepared_query = '\"' + query + '\"'

        return prepared_query

    def bibliography_builder_from_qr(self, query_results):

        """ Builds a bibliography from a query result. """

        bibliography = []
        doc_id_reviewed = []
        doc_fn_reviewed = []

        # first  - assemble the list of docs in the query_results
        for y in range(0,len(query_results)):
            if "doc_ID" in query_results[y]:
                if query_results[y]["doc_ID"] not in doc_id_reviewed:
                    doc_id_reviewed.append(query_results[y]["doc_ID"])
                    doc_fn_reviewed.append(query_results[y]["file_source"])

        # second - identify and sort the key pages associated with the doc
        for x in range(0,len(doc_id_reviewed)):
            pages_reviewed = []
            for z in range(0,len(query_results)):
                if "doc_ID" in query_results[z]:
                    if query_results[z]["doc_ID"] == doc_id_reviewed[x]:
                        pages_reviewed.append(query_results[z]["page_num"])

            pr = Counter(pages_reviewed)
            mc = pr.most_common()
            page_output_list = []
            for m in mc:
                page_output_list.append(m[0])

            if len(doc_fn_reviewed) > x:
                doc_fn_tmp = doc_fn_reviewed[x]
            else:
                doc_fn_tmp = "Doc# " + str(doc_id_reviewed[x])

            bibliography.append({doc_fn_tmp:page_output_list})

        return bibliography

    def filter_cursor_list(self, cursor, filter_dict, sample_count=20, exhaust_full_cursor=None):

        """ Applies filter to a cursor list. """

        validated_filter_dict = self.prep_validated_filter_dict(filter_dict)
        result_output = []

        for i, entry in enumerate(cursor):

            for key, value in validated_filter_dict.items():
                if key not in entry:
                    logger.warning(f"warning: Query - retrieval cursor does not contain filter key - {key}")
                else:
                    if entry[key] == value:
                        result_output.append(entry)

                if len(result_output) > sample_count and not exhaust_full_cursor:
                    break

        return result_output

    def prep_validated_filter_dict(self, filter_dict):

        """ Internal utility to prepare a validated filter dict. """

        validated_filter_dict = {}

        for key, values in filter_dict.items():
            if key in self.library.default_keys:
                validated_filter_dict.update({key:values})
            else:
                logger.warning(f"warning: Query - filter key not in library collection - {key}")

        return validated_filter_dict

    def block_lookup_by_collection_id(self, _id):

        """ Block lookup using collection id key specifically. """

        # specific to Mongo lookup - uses mongo '_id' which needs to be wrapped in ObjectId
        return CollectionRetrieval(self.library_name,
                                   account_name=self.account_name).filter_by_key("_id", ObjectId(_id))

    def compare_text_blocks(self, t1, t2):

        """ Token-by-token comparison of two text blocks. """

        b = CorpTokenizer(one_letter_removal=True, remove_numbers=True, remove_stop_words=True)
        tokens1 = b.tokenize(t1)
        tokens2 = b.tokenize(t2)
        match_per = 0
        match = 0

        for x in range(0, len(tokens1)):
            for y in range(0, len(tokens2)):
                if tokens1[x].lower() == tokens2[y].lower():
                    match += 1
                    break

        if len(tokens1) > 0:
            match_per = match / len(tokens1)

        return match_per

    def block_similarity_retrieval_more_like_this (self, target_text, qr, similarity_threshold=0.25):

        """ Block similarity by token comparison. """

        #   will rank and order a list of query results using a target text as the reference point
        output = []

        for i, block in enumerate(qr):

            compare_text = block["text"]
            similarity = self.compare_text_blocks(target_text, compare_text)

            if similarity > similarity_threshold:
                block.update({"similarity": similarity})

                output.append(block)

        output = sorted(output, key=lambda x:x["similarity"], reverse=True)

        return output

    def build_doc_id_fn_list(self, qr):

        """ Utility function to build a doc_id and filename list. """
        doc_id_list = []
        fn_list = []

        for q in qr:
            if q["doc_ID"] not in doc_id_list:
                doc_id_list.append(q["doc_ID"])
                fn_list.append(q["file_source"])

        return doc_id_list, fn_list

    def expand_text_result_before(self, block, window_size=400):

        """ Expands text result before. """

        block_id = block["block_ID"] -1
        doc_id = block["doc_ID"]

        before_text = ""
        pre_blocks = []

        while len(before_text) < window_size and block_id >= 0:

            before_block = self.block_lookup(block_id, doc_id)

            if before_block:
                before_text += before_block["text"]
                pre_blocks.append(before_block)

        output = {"expanded_text": before_text, "results": pre_blocks}

        return output

    def expand_text_result_after(self, block, window_size=400):

        """ Expands text result after. """

        block_id = block["block_ID"] + 1
        doc_id = block["doc_ID"]

        after_text = ""
        post_blocks = []

        while len(after_text) < window_size:
            after_block = self.block_lookup(block_id, doc_id)
            if not after_block:
                break  # Break if no block is found

            after_text += after_block["text"]
            post_blocks.append(after_block)
            block_id += 1  # Increment block_id for next iteration

        output = {"expanded_text": after_text, "results": post_blocks}
        return output

    def generate_csv_report(self):

        """Generates a csv report from the current query status. """

        output = QueryState(self).generate_query_report_current_state()
        return output
    
    def filter_by_key_value_range(self, key, value_range, results_only=True):

        """ Executes a filter by key value range. """

        cursor = CollectionRetrieval(self.library_name,
                                     account_name=self.account_name).filter_by_key_value_range(key,value_range)

        query= ""
        result_dict = self._cursor_to_qr(query, cursor, exhaust_full_cursor=True)

        if results_only:
            return result_dict["results"]

        return result_dict

